# PBL-26 Deployment Context for AI Agents > **TL;DR:** Help the student deploy `Plant Disease` on a shared Linux server. > Their container MUST bind to `127.0.0.1:9005` (not `9005` alone, not `0.0.0.0:9005`) > — a DOCKER-USER iptables rule actively drops external traffic to ports 9001-9006. > Memory cap: 512m. No sudo. Use `docker compose` (v2, space), not `docker-compose`. > Push code from the laptop, only `git pull` + `docker compose up` on the server. ## Project environment | Field | Value | |---|---| | Project | Plant Disease | | Public URL | https://plant-disease-26.microlab.club | | GitHub | https://github.com/MicroLabClub/pbl-26-plant-disease | | Linux user | `plant-disease-26_admin` — no sudo, in `docker` group | | Reserved host:port | `127.0.0.1:9005` (container-side port is your choice) | | Server | Ubuntu 22.04 at 194.180.191.175 (shared with 5 other teams + Hestia-managed services) | | RAM | 3.8 GB total, no swap → keep `mem_limit: 512m` per service | | Disk | ~22 GB free on root | ## The single hard constraint nginx reverse-proxies `https://plant-disease-26.microlab.club/` to `127.0.0.1:9005`. The container's host-side port binding **must** be exactly `127.0.0.1:9005`. A `DOCKER-USER` iptables rule drops any external traffic to ports 9001-9006, so a wrong binding will look like it works (container starts, local `curl` succeeds) but the public URL stays on the welcome page. Correct: ports: - "127.0.0.1:9005:80" # 80 = whatever the app listens on inside Wrong (publicly leaked + iptables-blocked): ports: - "9005:80" - "0.0.0.0:9005:80" For internal services (databases, brokers) use no `ports:` block at all — they only need to be reachable on the docker network, not the host. ## Available tools - `docker` and `docker compose` (v2, space-separated) — no sudo - `git` — clone and pull work over HTTPS; push needs SSH key or Personal Access Token - `curl`, GNU coreutils, standard editor (`nano`, `vim`) - ✗ NO `sudo`, NO `apt install`, NO `systemctl`, NO modifying `/etc/*` ## Workflow ### First deploy cd ~ git clone https://github.com/MicroLabClub/pbl-26-plant-disease.git cd pbl-26-plant-disease cp ~/docker-compose.example.yml ./docker-compose.yml # edit docker-compose.yml; keep "127.0.0.1:9005" host binding and mem_limit: 512m docker compose up -d --build curl -s http://127.0.0.1:9005/ # local sanity check first # then open https://plant-disease-26.microlab.club/ ### Redeploy after a code change git pull docker compose up -d --build docker compose logs -f ### Push code from the server HTTPS push prompts for username/password (no token = fails). Recommended path: switch the remote to SSH once, then pushes work via key auth. ssh-keygen -t ed25519 -C "plant-disease-26_admin@plant-disease-26" cat ~/.ssh/id_ed25519.pub # paste this at https://github.com/settings/keys git remote set-url origin git@github.com:MicroLabClub/pbl-26-plant-disease.git Better practice: do code edits + git push on a laptop, only run `git pull && docker compose up -d --build` on the server. ## Multi-service stacks (when the team has more than just one app) Only `127.0.0.1:9005` is publicly reachable per team. If the stack has multiple services (e.g. web + api + database + S3-like storage), route them all through a single internal reverse proxy that's the only thing binding the host port. [browser] → nginx (host, HTTPS, port 443) → 127.0.0.1:9005 → edge container (nginx:alpine inside docker-compose) → / → web service (no host ports) → /api/ → api service (no host ports) → /media/ → minio / storage (no host ports) Containers inside the compose file resolve each other via docker's built-in DNS — use the service name as the hostname: `http://api:8080`, `http://db:5432`, `http://minio:9000`. Do **not** expose internal services with `ports:` — they only need to be reachable on the docker network, not the public internet. If a service needs a public URL (e.g. MinIO pre-signed URLs, OAuth callback, webhook from an external service), it goes through the edge proxy at a known path (e.g. `/media/`) and the service is configured to know its public URL via env vars. ## Troubleshooting | Symptom | Likely cause | Fix | |---|---|---| | Welcome page still showing on the public URL | container is down OR not bound to `127.0.0.1:9005` | `docker compose ps`; `curl -v http://127.0.0.1:9005/`; `docker compose logs --tail=50 app` | | "port is already allocated" | something else has port 9005 | `docker ps --filter "publish=9005"`, then `docker stop ` | | "permission denied" running docker | docker-group membership only loaded on a fresh login | log out and SSH back in | | OOMKilled / app dies under load | hit the 512MB `mem_limit` | `docker stats --no-stream`; either reduce memory in the app or ask the instructor | | "could not read Username for github" | HTTPS push isn't authenticated | switch remote to SSH (see "Push code" above) or use a PAT | | Disk full | docker images/build cache piled up | `docker system prune -af`; `du -sh /var/lib/docker/*` (on host) | ### Clean slate docker compose down -v # stop + drop named volumes (wipes DB) docker system prune -af # remove unused containers, images, networks git fetch && git reset --hard origin/main ## What's already in the home dir - `~/docker-compose.example.yml` — starter compose with correct binding and memory limits - `~/README.txt` — quick-start one-pager - `~/llms.txt` (this file) — full deployment context - `~/CLAUDE.md` → `llms.txt` (Claude Code convention) - `~/AGENTS.md` → `llms.txt` (general AI agent convention) ## Help Telegram: @prodiuscristian. Include the subdomain (`plant-disease-26.microlab.club`) and any error output. ## Patterns to AVOID - ✗ `sudo` — the student has no sudo on this box. - ✗ `apt install` — same. - ✗ Binding the app to host port 80 or 443 — those are nginx's, shared across all teams. - ✗ Publishing database / broker / cache ports with `ports:` — keep them on the docker network only. - ✗ Mounting `/var/run/docker.sock` or `/:/host` — this box has production services for other tenants. - ✗ `docker-compose` (with hyphen) — that's v1; on this server use `docker compose` (space, v2). - ✗ Editing nginx, certbot, /etc/* — out of scope, no access anyway. ## Canonical answer when the student says "deploy this" Assuming the initial setup (clone + compose) is done: ssh plant-disease-26_admin@plant-disease-26.microlab.club cd ~/pbl-26-plant-disease git pull docker compose up -d --build docker compose logs -f ## Plant-disease-specific architecture The team is building a multi-service stack: a web frontend, an API backend, MinIO (S3-compatible storage for plant images and 3D models), and a callback path that an external ML model service POSTs predictions back to. ### Service layout | Service | Image / build | Container port | Public route | |---|---|---|---| | `edge` | `nginx:alpine` | 80 | `127.0.0.1:9005` (only host-published port) | | `web` | build from `./web` | whatever the framework uses | `https://plant-disease-26.microlab.club/` | | `api` | build from `./api` | 8080 (or similar) | `https://plant-disease-26.microlab.club/api/` | | `minio` | `minio/minio:latest` | 9000 (S3 API), 9001 (console) | `/media/`, `/minio-console/` | The `edge` service is a tiny nginx whose only job is path-based routing to the other three. Nothing else publishes a host port. ### Internal nginx config — keep this at `deploy/edge.conf` in the repo server { listen 80; client_max_body_size 100m; # 3D models can be large # API backend location /api/ { proxy_pass http://api:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; proxy_read_timeout 120s; } # MinIO S3 API (uploads, downloads, pre-signed URLs) location /media/ { proxy_pass http://minio:9000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; # MinIO uses chunked transfers; don't buffer proxy_buffering off; proxy_request_buffering off; } # MinIO web console (admin UI — protect with a strong root password) location /minio-console/ { proxy_pass http://minio:9001/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; } # Web frontend (catch-all goes last) location / { proxy_pass http://web:80; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; } } ### docker-compose.yml outline (adapt to your real services) services: edge: image: nginx:alpine container_name: pbl26-plant-disease-26-edge restart: unless-stopped ports: - "127.0.0.1:9005:80" # the only host binding volumes: - ./deploy/edge.conf:/etc/nginx/conf.d/default.conf:ro depends_on: [web, api, minio] mem_limit: 64m web: build: ./web container_name: pbl26-plant-disease-26-web restart: unless-stopped mem_limit: 256m # no ports: — reachable only via edge api: build: ./api container_name: pbl26-plant-disease-26-api restart: unless-stopped environment: # Internal docker DNS — NOT the public URL MINIO_ENDPOINT: minio:9000 MINIO_ACCESS_KEY: ${MINIO_ROOT_USER} MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD} MINIO_BUCKET: plants # Shared secret for the external ML cloud webhook ML_SHARED_SECRET: ${ML_SHARED_SECRET} depends_on: [minio] mem_limit: 512m # no ports: minio: image: minio/minio:latest container_name: pbl26-plant-disease-26-minio restart: unless-stopped command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: admin MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} # These two are CRITICAL — make pre-signed URLs + browser redirects # use the public hostname instead of the docker-internal one. MINIO_SERVER_URL: https://plant-disease-26.microlab.club/media MINIO_BROWSER_REDIRECT_URL: https://plant-disease-26.microlab.club/minio-console volumes: - minio_data:/data mem_limit: 256m # no ports: volumes: minio_data: Use a `.env` file (gitignored) for the secrets — `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`, `ML_SHARED_SECRET`. Commit a `.env.example` with placeholder values so teammates know the keys. ### Frontend code: where to point HTTP calls When the React/Vue/etc. bundle runs in the user's browser, it cannot resolve docker-internal hostnames like `http://api:8080`. The frontend must use either: - **Relative URLs** — `fetch("/api/predict")`, ``. Best option since it works in any environment. - Or the full public URL — `https://plant-disease-26.microlab.club/api/predict`. Avoid hardcoding; read it from a build-time env var. ### MinIO pre-signed URLs When the API hands out a pre-signed URL for the frontend to fetch a 3D model, MinIO must sign with the public hostname: MINIO_SERVER_URL=https://plant-disease-26.microlab.club/media Without this, MinIO will sign URLs like `http://minio:9000/...` which the browser cannot reach. The above env var fixes it permanently. ### External ML cloud → API webhook Have the external service POST results to: https://plant-disease-26.microlab.club/api/inference-result Authenticate the webhook with a shared secret in an `Authorization: Bearer ` header — don't rely on IP allowlists (the external service's egress IP can change). The same secret lives in the api container's `ML_SHARED_SECRET` env var; the api code rejects requests whose bearer token doesn't match. The external cloud doesn't need any firewall change on our side — it's just another HTTPS client hitting the public URL. nginx handles TLS, the edge container routes `/api/` to the api service. ### Memory budget Default `mem_limit` per service is 512m, but 4 services × 512m = 2 GB on a 4 GB host shared with 5 other teams. Use the per-service limits from the compose example above: | edge | web | api | minio | Total | |---|---|---|---|---| | 64m | 256m | 512m | 256m | ~1.1 GB | If the team adds a database (postgres, etc.), add another 256m — but watch `docker stats --no-stream` while load testing. ### Disk usage MinIO data lives in the `minio_data` named volume → `/var/lib/docker/volumes/_minio_data/_data` on the host. Plant 3D models at ~50 MB × 200 plants ≈ 10 GB. Host has ~21 GB free; fine for a few hundred plants. Beyond that, coordinate with the instructor. ### Common pitfalls in this stack 1. **Frontend hardcodes `localhost:8080` or `http://api:8080`** → fails in the browser. Use relative paths. 2. **MinIO `MINIO_SERVER_URL` missing** → pre-signed URLs unfetchable. 3. **Default `client_max_body_size`** (1 MB) blocks 3D model uploads. The edge config above sets it to 100 MB. 4. **CORS errors fetching from `/media/`** → shouldn't happen because everything is same-origin (same hostname, different paths). If you split MinIO onto its own subdomain later, you'll need explicit CORS config. 5. **`ports:` block on db/minio/api** — anything other than `127.0.0.1:9005` on `edge` violates the one rule. Remove `ports:` from everything else.