Docker Compose Env Interpolation - Empty Default Yields Wrong Values in CI
Compose interpolates ${VAR} from the environment and .env. When a variable is unset it defaults to an empty string (with a warning), which can silently produce a wrong image tag or config. The :-, -, and :? modifiers control defaults and required-variable behavior.
What this error means
A docker compose up warns The "TAG" variable is not set. Defaulting to a blank string and then uses an empty value (e.g. image: myorg/api: → :latest or invalid). Or a ${VAR:?msg} fails the run because a required variable is missing.
WARN[0000] The "TAG" variable is not set. Defaulting to a blank string.
# image: myorg/api:${TAG} -> myorg/api: (empty tag)
# or required: error: required variable "DB_PASSWORD" is missing a value: set it in .envDiagnose it: read the resolved config, not the file you wrote
Compose merges override files, interpolates variables, and applies defaults before it does anything. Most Compose failures in CI are visible in the resolved configuration and invisible in the source file, because the value you are debugging came from an unset variable that quietly became an empty string.
# the fully merged, interpolated configuration Compose will actually run
docker compose config
# fail loudly on unset variables instead of silently interpolating empty
docker compose --env-file .env config --quiet || echo "invalid"
# which override files were picked up
docker compose config --services
Common causes
Unset variable defaults to empty
Compose substitutes an unset ${VAR} with an empty string and only warns. An empty tag or value flows through silently, producing a wrong reference or config.
Confusing :- vs - default syntax
${VAR:-default} uses the default when VAR is unset OR empty; ${VAR-default} only when VAR is unset (an empty VAR stays empty). Choosing the wrong one yields surprising values.
Required variable not provided
A ${VAR:?error message} is meant to fail loudly when VAR is missing. The failure is intentional - provide the value.
How to fix it
Provide explicit defaults with :-
Default required-but-optional variables so an unset value does not become empty.
services:
api:
image: myorg/api:${TAG:-latest} # default to latest if unset/emptyMake truly-required variables fail fast
Use :? so a missing required value stops the run with a clear message.
environment:
DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in the environment or .env}Bind mounts behave differently on a runner
- A relative bind source is resolved against the compose file location, not the working directory of the shell that invoked it.
- The host path must exist before
up. Compose creates missing directories for named volumes but not for bind mounts, and the failure surfaces as a mount error rather than a missing-path error. - On a CI runner the workspace path differs from your machine, so any absolute host path in a compose file is a portability bug waiting for its first CI run.
- Prefer named volumes for anything that does not genuinely need to be read from the host. They remove the whole class of problem.
How to prevent it
- Default optional variables with
${VAR:-default}. - Mark required variables with
${VAR:?message}to fail fast. - Run
docker compose configto verify interpolated values before running.