compose-helper: compose file authoring and the build profile
compose-helper is a thin wrapper around docker compose that lives next to
docker-compose.yaml. Every invocation it makes is:
docker compose -p <project> -f <compose-file> [--env-file .env|.config/.env] <args>
<project>= the script's directory name, unless overridden byDCH_PROJECT_NAMEincompose-helper.env.- Compose file =
docker-compose.yaml, falling back todocker-compose.yml. - Env file =
.envif present, else.config/.env, else none.
Always go through the script (never raw docker compose) so the project
name stays consistent. Any call with 2+ arguments passes straight through to
docker compose with those pinned options — that is the escape hatch for
config, ps, exec, non-following logs, etc.
The --profile build convention (IMPORTANT)
The build, rebuild, and up commands run:
docker compose ... --profile build build --pull
while start/restart run plain up -d / down + up -d without the
build profile. This means the compose file must separate "services that
produce an image" from "services that run":
services:
# BUILDER: exists only to produce a local image. Never runs.
my-app-builder:
profiles: ["build"] # only visible to `--profile build build`
build:
context: ./my-app # dir containing the Dockerfile
image: my-app:local # REQUIRED: the tag the build produces
# RUNTIME: consumes the image. No build block.
my-app:
image: my-app:local # must match the builder's image: tag
restart: unless-stopped
env_file: .env # optional; see env section below
ports:
- "8080:8080"
# Pulled images need no profile and no builder.
postgres:
image: postgres:16
restart: unless-stopped
volumes:
- pg-data:/var/lib/postgresql/data
volumes:
pg-data:
Rules when creating or editing docker-compose.yaml
- Every service with a
build:block getsprofiles: ["build"]AND an explicitimage: <name>:localtag. Withoutimage:, compose derives a tag from the project name and the runtime service can't find it. - Runtime services never have a
build:block. They reference the builder'simage:tag exactly. If you add abuild:block to an unprofiled service,startmay trigger an implicit build and break the build/run separation. - Never
depends_ona builder service. Builder services are only ever built, never started; a runtime service depending on one will fail to start (the dependency never becomes "started"). - The profile name is exactly
build— the scripts hard-code--profile build. Do not invent other profile names for builders. - One builder can feed multiple runtime services: give them all the same
image:tag. - If nothing is built locally (all images pulled), skip the convention
entirely —
--profile buildtargeting zero services is harmless. - Suffix builder service names with
-builder(convention, not enforced) so intent is obvious.
Why this pattern exists
start/restartare guaranteed fast and side-effect-free: they never compile anything or touch the network for builds.build --pullrefreshes base images instead of serving stale layer-cache parents, and only touches services that opted into thebuildprofile.- Runtime config stays declarative: every runtime service is just
image: + settings, identical in shape whether the image is local or pulled.
Env files — two separate concerns
| File | Purpose |
|---|---|
.env (or .config/.env fallback) |
${VAR} substitution inside docker-compose.yaml; auto-passed as --env-file |
compose-helper.env |
Configures the script itself: DCH_PROJECT_NAME, DCH_STOP_TIMEOUT (default 30), DCH_LOGS_TAIL (default 10) |
When you reference ${VAR} in the compose file, define it in .env (or
.config/.env), never in compose-helper.env. To pass variables into a
container's environment, additionally use environment: or env_file: on
that service — --env-file alone only does substitution.
Edit → build → verify workflow
After creating or modifying docker-compose.yaml (Linux/macOS shown; use
.\compose-helper.ps1 on Windows — both scripts are feature-equivalent):
# 1. Validate syntax, including build-profile services (pass-through form):
./compose-helper.sh --profile build config --quiet
# 2. Build local images (only build-profile services are targeted):
./compose-helper.sh build
# 3. Start runtime services detached:
./compose-helper.sh start
# 4. Verify state and read bounded logs (pass-through; no -f, returns):
./compose-helper.sh ps
./compose-helper.sh logs --tail=100
./compose-helper.sh logs --tail=100 <service>
Steps 2+3 can be combined as ./compose-helper.sh rebuild.
Pitfalls
- Do not run
./compose-helper.sh upor... logs(single-arg) in an automated session — both end inlogs -fand block forever. Userebuild+ pass-throughlogs --tail=N. downdeletes named volumes. Only use it when the user explicitly wants a data wipe; otherwisestop.- "pull access denied" / "image not found" on
startusually means a builder's image was never built or itsimage:tag doesn't match the runtime service — run./compose-helper.sh buildand diff the two tags. - Changes to Dockerfiles or build contexts do nothing until
buildorrebuild—start/restartnever rebuild. - Two-or-more arguments always bypass the shorthand commands
(
up -d≠ theupshorthand). Single unknown arguments (likeps) also pass through.