Compose & env conventions
Two conventions do most of the work in docker/: one compose file per
service, plus a dev override, and three env layers under
docker/.config/. Together they are why make dev s=api can start a subset
without a per-project runbook.
Compose layering
Section titled “Compose layering”Every service gets two files. docker/compose/<service>.yml is the baseline,
describing the service as it ships. docker/compose/<service>.dev.yml is the
override, describing what it needs while you are working on it.
make dev # baseline + .dev.yml override, mergedmake dev s=api # just this service's filesmake dev s="api site" # a named subset: both services and their depsmake up # baseline alone — the same file that shipsThe quotes in s="api site" are required. Make splits on whitespace, so an
unquoted second name is read as a second goal rather than as part of the value.
dev, up, down, stop, restart and logs all take a list, because the
compose verb underneath accepts several service operands. build, run, exec
and sh/bash act on exactly one image or container, and they refuse a list out
loud rather than silently using the first name.
make dev brings the existing services down first, so it doubles as “restart
into a known state”.
A typical dev override adds four things and nothing else:
services: api: build: target: development # the dev stage of the same dockerfile environment: - RUST_LOG=debug volumes: - ../../apps/api:/app # source mount for hot reload healthcheck: disable: true # or a relaxed one with room to boot db: ports: - "${DB_HOST_PORT:-0}:5432" # host 0 = Docker picks a free portNote what is not there: the application service publishes no HTTP host port in
either file. That is the router’s job. Backing services
are the exception, because psql and Redis GUIs want a host port.
Host ports
Section titled “Host ports”Every host-side publish in a shipped compose file is ${VAR:-default}, in one of
two classes, and a conformance test refuses a bare number in either.
Ports tooling dials on demand default to 0, which tells Docker to allocate
a free one. A pinned 5432 is a claim on the whole workstation, and a second mx
stack booting into it dies on Bind for 0.0.0.0:5432 failed: port is already allocated. Host port 0 cannot collide. Discover the number when you need it:
docker compose -p "$COMPOSE_PROJECT_NAME" port db 5432# 0.0.0.0:32789Or pin one for a session while its siblings stay ephemeral:
DB_HOST_PORT=5432 make devThe names: DB_HOST_PORT, REDIS_HOST_PORT, NODE_DEBUG_HOST_PORT,
METRICS_HOST_PORT, VITE_HOST_PORT, INERTIA_SSR_HOST_PORT.
Ports a browser dials from a URL the page already holds cannot move
silently, so they keep today’s number as the default and gain an override:
LEPTOS_RELOAD_HOST_PORT (3001), ZOLA_LIVERELOAD_PORT (1024),
NGINX_HTTP_PORT / NGINX_HTTPS_PORT, TRAEFIK_HTTP_PORT /
TRAEFIK_HTTPS_PORT / TRAEFIK_DASHBOARD_PORT, MX_ROUTER_HTTP_PORT /
MX_ROUTER_HTTPS_PORT. A lone stack is unchanged; a second stack overrides
rather than failing to start.
The shape alone is not the fix. ${DB_HOST_PORT:-5432} is parameterized and
still collides out of the box, which is why the test asserts the default per
class as well as the syntax, and fails deliberately on a container port it has
never been told how to classify. Publishing a host port is a design call.
Composition, not duplication
Section titled “Composition, not duplication”Compose’s include: is how a service pulls in the backing services it needs
without owning them:
include: - path: ./db.yml - path: ./redis.ymlTwo services that both need Postgres include the same db.yml. One database,
declared once.
Include only a file the recipe actually ships. Compose’s long-form include
accepts path, project_directory and env_file and nothing else, so an
optional: true key is silently ignored rather than honoured, and a missing
sibling is a hard error instead of a skip. Every installed recipe’s assembled dev
config has to pass docker compose config straight after mx add, which is the
gate that now catches this class.
Container names
Section titled “Container names”Services are named by you; containers are named by Compose. No compose file mx
ships sets container_name, and a recipe may not either, so the name on disk is
always Compose’s own <COMPOSE_PROJECT_NAME>-<service>-<index>. In a project
directory called myproj, the database container is myproj-db-1.
The three env layers
Section titled “The three env layers”All under docker/.config/:
| File | Holds | Committed |
|---|---|---|
.env.shared | values every service needs: project name, ports, shared hostnames | yes |
.env.secrets | credentials, API keys, database passwords | no (gitignored) |
.env.<service> | one service’s settings | yes |
.env.secrets.template is committed, with the keys and no values. make init
copies it to .env.secrets if that file does not exist, and then fills it: it
generates a real development value for every credential that is still empty or
still one of the __GENERATE_*__ / CHANGE_ME placeholders the templates ship.
A value that is already real, whether an earlier run generated it or you typed
it, is never touched, so make init cannot rotate credentials out from under a
running database. make dev runs init.sh on every start, which is why a
service added with mx add after mx new comes up with working credentials
without a hand edit.
One key stays deliberately blank: REDIS_PASSWORD. The shipped Redis runs
without auth, and a generated password there would break every client that
builds redis://:$REDIS_PASSWORD@redis:6379 against a server that wants none.
make doctor reports on the same file, cheaply and without Docker. It names any
key that has no value yet, and any value still holding a ${VAR} reference,
which is a latent empty: Compose interpolates a ${…} inside an env_file
value from the compose project directory’s own .env, never from a sibling env
file, so such a value reaches the container as an empty string.
scripts/generate-secrets.sh resolves those references into literals for that
reason. Generate a one-off secret of your own with
make make-key BYTES=32 FORMAT=hex.
Precedence, precisely
Section titled “Precedence, precisely”Compose applies env_file entries in order and later entries win. Then the
environment: block wins over all of them, and a value already exported in your
shell wins over the compose file’s interpolation defaults.
Every compose file mx ships lists them in one order, and always has the same one:
env_file: - ../.config/.env.shared - ../.config/.env.secrets - ../.config/.env.<service>So the effective precedence, highest first, is environment: →
.env.<service> → .env.secrets → .env.shared. A credential in
.env.secrets beats the same key in .env.shared, and one service’s
.env.<service> beats both. That holds for application services and for the
backing services alike: db.yml and redis.yml follow it too.
This used to be true of some files and not others. The application services in
four recipes, plus all four of laravel’s compose fragments, listed .env.secrets
first, which inverted it for exactly the layer most likely to collide, and the
zola recipe omitted .env.secrets altogether. They were corrected, and the rule
is now held by a conformance test over every compose file in templates/ rather
than by authoring habit: every_env_file_list_follows_the_documented_precedence_order
and every_env_file_list_carrying_shared_also_carries_secrets in
crates/mx-lib/tests/templates_env_precedence.rs. You can read the order off any
shipped file and trust that the next one says the same thing.
A worked example
Section titled “A worked example”What mx add api --recipe rust-api lands in docker/compose/api.yml is a plain
instance of all of it:
include: - path: ./db.yml
services: api: build: context: ../.. dockerfile: docker/dockerfiles/api/app target: production env_file: - ../.config/.env.shared - ../.config/.env.secrets - ../.config/.env.api environment: - RUST_ENV=production - RUST_LOG=info,api=debug networks: - default - devmesh-traefik depends_on: db: condition: service_healthyThe database URL is not in that environment: block, and that placement is the
point. docker/.config/.env.api carries it:
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME}and scripts/generate-secrets.sh resolves those references into literals before
anything starts, reading the credentials it generated in .env.secrets. It has
to: Compose does not interpolate an env_file value from a sibling env file, so
a ${DB_PASSWORD} left as a reference here would reach the container empty and
Postgres would refuse to initialize. Literals are the only form that arrives.
One consequence worth knowing. Once resolved, a derived value is a copy, so
changing DB_PASSWORD in .env.secrets later does not re-derive DATABASE_URL
in .env.api. Blank the derived value and re-run make init to have it
regenerated. make doctor reports anything left unset.
Dockerfiles
Section titled “Dockerfiles”docker/dockerfiles/<service>/app is the development image and app.prod the
shipped one. Both are usually multi-stage, and the compose files select a stage
with build.target. That is how one dockerfile serves make dev and
make build s=api prod=1.
Further reading
Section titled “Further reading”- Docker assembly guide: the corpus document on building these files well
- The folder contract: where
docker/sits - Infra credentials: provider credentials, which live outside the project’s env files