| name | add-container |
| description | Add a new container/service to the ansible-hms-docker stack — container_map entry, compose Jinja template, Traefik/Authentik/Homepage wiring, prereq/postreq tasks, env vars, docs and CI. Use when asked to add, wire up, or onboard a service (e.g. "add byparr", "add <app> to the stack"), or when creating/editing a file under roles/hmsdocker/templates/containers/. |
Adding a container to hms-docker
Two files are always required. Everything else is conditional, and adding conditional
files that nothing consumes is the most common failure mode here — see
Anti-patterns.
| File |
|---|
| Required | roles/hmsdocker/defaults/main/container_map.yml — the map entry |
| Required | roles/hmsdocker/templates/containers/<key>.yml.j2 — the compose fragment |
There is no edit to docker-compose.yml.j2, no Traefik dynamic-config entry, and no
task-file registration. The map is filtered into derived facts in
tasks/main.yml (lines 6–143), the render task
loops those facts (lines 292–303), and the top-level compose builds its include: list
from compose_files_created.results.
1. Gather inputs
Before writing anything, know:
- service key — lowercase, hyphens ok. This one string is the map key, the compose
service name,
container_name, and the template filename. They must all match.
- image — full ref including registry (
lscr.io/linuxserver/…, ghcr.io/…).
- container UI port — the port the app listens on inside the container.
- config dir? — does it need
${HMSD_APPS_PATH}/<key>/config?
- media access? — does it need
${HMSD_MOUNT_PATH}:/data?
- Traefik? — anything with a web UI: yes. Daemons with no UI (flaresolverr,
watchtower, cloudflare-ddns): no.
- Homepage widget? — check the Homepage widget docs
for whether the app has a widget type and what credential it takes (
key, or
username/password).
- secrets/tunables — anything the user must supply.
If the image or port is not known, ask the user rather than guessing. A wrong port
silently produces a 502 through Traefik.
2. Pick a host port that does not collide
75 host ports are already claimed. Enumerate them — the "? is load-bearing, five mappings
are quoted (cleanuparr, cross-seed, fileflows, n8n, tracearr) and a regex without it
silently misses ports 2468, 3002, 5678, 11011 and 19200:
grep -rhoE '^\s+- "?[0-9]+:[0-9]+' roles/hmsdocker/templates/containers/ | tr -d '" -' | sort -t: -k1 -n
Convention: keep the container port on the right and shift the host port left-side
when it collides — 8080:8080 is taken, so scrutiny uses 8087:8080, dockhand uses
3003:3000, beszel uses 8091:8090.
3. Map entry — defaults/main/container_map.yml
Key meanings are documented in that file's own header (lines 3–13). Rules:
- Insert alphabetically by key. Read the two entries that will surround yours before
editing, and confirm afterwards that neither was clobbered. Getting this wrong is a
recorded regression (see Anti-patterns).
- Keys within an entry are also alphabetical.
enabled: false for anything new. Never ship a service on by default.
- Emit only the keys that apply:
directory — omit for containers with no config dir (watchtower, tailscale, netdata,
flaresolverr, cloudflare-*).
homepage / homepage_stats — omit when there is no Homepage integration.
authentik_provider_type — only when the service genuinely uses OAuth2. Default is
proxy and is left implicit on almost every entry.
proxy_host_rule is the subdomain. Usually equals the key; shorten when the key is long
(speedtest-tracker → speedtest, tinymediamanager → tmm).
Standard shape:
<key>:
authentik: false
directory: true
enabled: false
expose_ports: false
expose_to_public: false
homepage: true
homepage_stats: false
proxy_host_rule: <key>
traefik: true
4. Compose template — templates/containers/<key>.yml.j2
Read reference/template-anatomy.md for the annotated
skeleton, and reference/variants.md when the service is not a
plain web app (GPU, VPN, host networking, sidecar DB, no config dir, no PUID support).
The best in-repo model to copy is
scrutiny.yml.j2 — it has
port gating, the full label block, a device loop, and a healthcheck.
Non-negotiables:
- A map key with no matching template file fails the play. Always create both.
- Gate on the derived list facts, never the raw map. The real names are
enabled_containers, expose_ports_enabled_containers, traefik_enabled_containers,
homepage_enabled_containers, homepage_stats_enabled_containers,
expose_public_enabled_containers, authentik_enabled_containers.
${...} is a shell var from the .env, rendered by
env.j2 — HMSD_APPS_PATH, HMSD_MOUNT_PATH,
HMSD_DOMAIN, PUID, PGID, TIMEZONE, RESTART_POLICY, COMPOSE_PROJECT. Ansible
vars use {{ }}. Do not confuse them.
- Rendering sets
trim_blocks: yes + lstrip_blocks: yes, so {% %} indentation is
cosmetic — match the surrounding file (most use 4 spaces, nested stats block uses 8).
- The four Traefik label lines are near-verbatim boilerplate. Every router and service name
carries the
-${COMPOSE_PROJECT} suffix.
security_opt: no-new-privileges:true is the default but is not universal. Images
that escalate privileges at runtime break under it (tdarr, fileflows). When it must go,
comment the block out in place with the reason — see
reference/template-anatomy.md.
- Authentik needs no block in the template. It is only the conditional
,authentik-proxy-${COMPOSE_PROJECT}-<key>-midware@docker suffix on the middlewares
label. Outposts are generated by tasks/container_prereqs/authentik.yml looping
authentik_proxy_enabled_containers.
5. Conditional wiring — add only when the service needs it
| File | Add only if |
|---|
tasks/container_prereqs/<key>.yml | dirs/files/keys must exist before the container starts. Model: n8n.yml |
tasks/container_postreqs/<key>.yml | config must be patched after first start. Model: sabnzbd.yml |
tasks/app_inits/<key>.yml | the app is bootstrapped over its API (arr-style only) |
templates/env.j2 | the template already references a ${VAR} that does not exist yet |
defaults/main/service_misc.yml | a user-tunable var the template actually consumes |
defaults/main/homepage_api_keys.yml | a Homepage widget needs a user-supplied credential |
tasks/app_api_key_reader.yml | the key can be scraped from the app's own config file on disk |
handlers/main.yml | some task genuinely notify:s a restart |
Prereq/postreq/app_init files are auto-discovered by filename — fileglob over the
directory, intersected with enabled_containers. Dropping the file in is the whole
wiring step; there is no list to register it in.
Order matters for env vars: add the ${VAR} reference in the template first, then the
line in env.j2, then the default in service_misc.yml or homepage_api_keys.yml. Never
the reverse — that is how orphan vars get committed.
Homepage credential styles:
- API key:
- homepage.widget.key=${<KEY>:-apikeyapikeyapikey}
- user/pass:
- homepage.widget.username=${X_USER:-} / .password=${X_PASS:-}
(see dockhand, beszel)
6. Docs, version, CI
-
docs-astro/src/content/docs/docs/container-list.mdx — two edits:
- A bullet under the right
## category (Media Servers / Media Management Systems /
Download Clients / Analytics-Dashboards / Networking / Misc), format
- [Name](upstream-url): short description <Badge text="New" variant="success"/>.
- A row appended to the
### Map table: Service Name, Enabled by Default, Container
Name, Host Port (if enabled), Container Port, Accessible via Traefik, Homepage
Integration — using ☑ for checked and ☐ for unchecked.
-
docs-astro/src/content/docs/docs/release-notes/v<major>.<minor>.md — add a
## v<version> section at the top of the file. The header must match
hmsd_current_version exactly; the release workflow extracts everything between it and
the next ## .
-
hms-docker.yml — bump hmsd_current_version (3-part semver). Tell the user this
cuts a GitHub Release when merged to master.
-
.github/extra-vars.yml — add an entry to the duplicated CI container map so the
deployment test exercises the service. It is JSON-ish with unquoted scalar values and a
different key order from the real map:
"<key>": {
"enabled": true,
"proxy_host_rule": <key>,
"directory": true,
"traefik": true,
"authentik": false,
"authentik_provider_type": proxy,
"expose_to_public": false,
"homepage": true,
"homepage_stats": false
},
enabled: true here is correct and intentional — it is the opposite of the real map.
If the service is enabled in CI and Traefik-routed, also add its subdomain to the
/etc/hosts line in .github/workflows/run-playbook.yml.
Do not touch README.md unless the service adds a headline capability.
7. Verify (static — nothing mutates the host)
diff <(grep -oP '^ \K[a-z0-9-]+(?=:)' roles/hmsdocker/defaults/main/container_map.yml | sort) \
<(ls roles/hmsdocker/templates/containers/ | sed 's/\.yml\.j2$//' | sort)
grep -rhoE '^\s+- "?[0-9]+:[0-9]+' roles/hmsdocker/templates/containers/ | tr -d '" -' \
| cut -d: -f1 | sort -n | uniq -d
grep -oE 'in [a-z_]+_containers' roles/hmsdocker/templates/containers/<key>.yml.j2 | sort -u
./bin/yq '.hms_docker_container_map.<key>' roles/hmsdocker/defaults/main/container_map.yml
Then read the two container_map.yml entries adjacent to the new one and confirm they are
intact.
make check (Ansible dry-run) will catch Jinja syntax errors but needs a populated
inventory/group_vars/all/ and installs galaxy requirements — offer it, let the user
decide. make apply mutates the host; never run it unprompted.
Anti-patterns
Every item below is a real correction from commit b241ceb ("fix: claudes mistakes"),
which reverted most of an agent-authored service addition:
- A restart handler nothing notifies. Do not add to
handlers/main.yml unless a task
you also wrote has notify: Restart <key>.
- A
service_misc.yml var the template never reads. Same for env.j2 lines.
enabled: true in container_map.yml. New services ship disabled. Only
.github/extra-vars.yml gets enabled: true.
- An alphabetical insert that overwrites the neighbouring entry. The librariarr add
rewrote the adjacent
lidarr block's key and proxy_host_rule.
- Editing
README.md for a service that is not a headline feature.