# Compose convention The `compose.yaml` your repo ships. Copy it verbatim — it names nothing repo-specific, so the deploy layer can inject identity and host paths without collisions. ```yaml services: svc: # generic service key — ALWAYS svc, no container_name build: . user: "1337:1337" # the shared services account (required) restart: unless-stopped # required — the host unit is oneshot; this recovers crashes environment: HOME: /tmp volumes: - ${LOGS_DIR:-./logs}:/app/logs # output — host log dir, injected at deploy - ${CONFIG_DIR:-./config}:/app/config # input — host config dir, injected at deploy # - ${MOUNTS_DIR:-./mounts}:/app/data # optional — arbitrary host data (opt-in, see below) - cache:/app/cache # your data — ephemeral named volume volumes: cache: # bare name — deploy auto-prefixes it per service ``` ## The rules - **Service key is always `svc`.** Never a repo-specific name, never a `container_name` — the ops tooling keys off `svc`. This is what makes services collision-proof on the fleet. - **`user: "1337:1337"` and `restart: unless-stopped` are required.** The first runs as the shared `services` account (uid/gid 1337, fixed fleet-wide); the second lets Docker recover a crashed container (the host-side unit is oneshot). - **Host paths come from `${...}` variables, never hardcoded.** Write to `${LOGS_DIR}` and `${CONFIG_DIR}`; `${MOUNTS_DIR}` is optional. - **Named volumes use bare names** (`cache`, not `myapp_cache`) — deploy auto-prefixes them per service so they can't collide. !!! tip "The container path is where your code reads and writes" `WORKDIR` is `/app`, so the **right-hand side** of each volume line is the path your code targets. If your app writes to `./cache` (i.e. `/app/cache`), that's the `cache` mount; logs go to `/app/logs`, config is read from `/app/config`. Match the container path to what your code actually uses. ## Three tiers of storage - **`logs`** (output) and **`config`** (input you edit) — **we handle these**: injected and managed at deploy time. - **`mounts`** (optional) — a host dir for arbitrary read/write data. The deploy layer **injects `MOUNTS_DIR` and auto-creates the dir**, but the mount line is **opt-in**: uncomment it and choose the container path yourself (it's app-specific, so it can't be auto-mounted). - **Named volumes** (`cache`, …) — **yours**: anything else your service persists. Docker owns them, no host paths to manage. !!! tip "The `${VAR:-./default}` pattern" Host paths use a variable with a local fallback. Locally, `docker compose up` needs nothing set — it uses `./logs`, `./config` (and `./mounts` if you enable it). When deployed, the ops tooling sets the real values, so the same file works both places. ## How deploy fills it in At deploy time the tooling **generates** the environment your compose reads, so the `${...}` variables resolve without anything from you: ``` LOGS_DIR # your host log dir -> ${LOGS_DIR} CONFIG_DIR # your host config dir -> ${CONFIG_DIR} MOUNTS_DIR # optional host data dir -> ${MOUNTS_DIR} (if you enable the mount) ``` Write your logs and config to those mount points and you're set. If a service won't come up or its logs aren't persisting, that's a host-side detail on our end — flag it and we'll sort it. ## Subprocess and browser workloads Bots that spawn Chrome, Xvfb, ffmpeg, or other child processes need three extra knobs: ```yaml services: svc: build: . user: "1337:1337" restart: unless-stopped init: true # tini as PID 1 — reaps zombie subprocesses, forwards signals shm_size: "2gb" # Chrome/headless browsers crash on Docker's default 64 MB /dev/shm mem_limit: "4g" # bound memory — raise as worker/browser count grows ``` !!! warning "The PID-1 gotcha with shell-wrapper CMDs" If your `CMD` is a shell-script wrapper (e.g. `xvfb-run ...`), it must **not** be PID 1, or the real process dies on startup. `init: true` is exactly what fixes this — tini takes PID 1, and your wrapper runs as a normal child. ## Checklist - service key `svc` — **no `container_name`**, no repo-specific names - `user: "1337:1337"` — the shared `services` account - `restart: unless-stopped` - host paths via `${LOGS_DIR}` / `${CONFIG_DIR}` (and optional `${MOUNTS_DIR}`) — never hardcoded - named volumes with **bare names** (`cache`, not `myapp_cache`) - for browser/subprocess workloads: `init: true`, `shm_size`, `mem_limit` See **[Dockerfile & build](dockerfile.md)** for the image, and **[Secrets](secrets.md)** for keeping credentials out of it.