Add an App to a Docker Host
Apps live in docker/<host>/NN-<app>/docker-compose.yaml, deployed GitOps-style by doco-cd (config: docker/<host>/.doco-cd.yaml): it polls this repo's main hourly, auto-discovers stacks one directory deep, and delete: true means removing (or renaming) a directory deletes the running stack. Mirror an existing app rather than inventing structure:
| Host | Proxy | Network | Reference apps |
|---|---|---|---|
gladius (TrueNAS) |
traefik (01-traefik, docker provider, exposedByDefault: false, wildcard *.bjw-s.dev) |
apps, or network_mode: host |
03-garage (bind-mount storage), 02-exporters (host network) |
icarus |
caddy-l4 (03-caddy-l4, Caddyfile) |
external edge (no published ports for HTTP) |
04-gatus (config file, secrets), 02-towonel (published non-HTTP ports) |
Steps
Directory: next free
NN-prefix on that host (ls docker/<host>/). The number is just ordering; keep it stable — renaming triggers delete+recreate.Compose file — conventions from existing apps:
- Top-level
name: <app>andcontainer_name: <app>,restart: unless-stopped. - Registry-qualified image with a pinned version tag. Never write a tag from memory — look up the upstream project's current release first. Plain tags are fine; Renovate manages digests/updates. Keep a
# renovate: datasource=docker depName=...hint comment only if copying from an app that has one (e.g.01-crowdsec,02-towonel). - Set
user:where the image supports it (see garage/towonel). - Config files:
config/subdirectory, wired via aconfigs:block (04-gatus) or a read-only bind volume (03-garage). - Persistent data: on gladius, named volume with
driver_optsbind to/mnt/tank/apps/<app>/<vol>(see garage); on icarus, host path (towonel uses/opt/<app>) or a named volume.
- Top-level
Secrets: add
VAR_NAME: op://<vault>/<item>/<field>underexternal_secretsindocker/<host>/.doco-cd.yaml, reference as${VAR_NAME}in the compose file. Use the item's real field names (ask the user; never guess) — doco-cd injects these as env vars at deploy time.Expose it (only if it serves HTTP):
- icarus: join the
edgenetwork (networks: default: {name: edge, external: true}), publish no HTTP ports, then registericarus-<app>.bjw-s.devin two places:VPS_LOCAL_HOSTSin03-caddy-l4/docker-compose.yamlAND areverse_proxy <container>:<port>site block in03-caddy-l4/config/Caddyfile. - gladius: traefik is label-based but no current app uses labels (garage/exporters run host-network). Confirm the intended exposure with the user instead of inventing label conventions.
- Only publish
ports:directly for non-HTTP protocols (see towonel: 22, 51820/udp).
- icarus: join the
Verify:
docker compose -f docker/<host>/NN-<app>/docker-compose.yaml config --quiet(unset${VAR}warnings are expected). Show the user the files before committing. Commit style:feat(<app>): Deploy to NAS.
Common mistakes
- Inventing an image tag — check the upstream release; a hallucinated tag deploys nothing or the wrong thing.
- Secret in compose but not in
.doco-cd.yaml— the${VAR}silently resolves empty. - Publishing HTTP ports on icarus — everything HTTP goes through caddy-l4 over
edge; published ports bypass TLS and auth. - Registering the hostname in only one of Caddyfile /
VPS_LOCAL_HOSTS— both are required. - Renaming/renumbering an existing app directory casually —
delete: truetears the old stack down, losing anonymous volumes.