Docker Compose interpolation is the substitution that turns expressions such as ${IMAGE_TAG} and ${HOST_PORT:-8080} into a resolved application model. It happens before Compose creates a container. A service-level env_file, by contrast, contributes values to the container environment represented by that model. The two mechanisms can use the same variable name without choosing the same value.
That distinction is easy to miss when a project has .env, --env-file, env_file, and environment in one directory. The reliable preflight is to render the model, assert the fields that control deployment, and save a small receipt before any pull, build, or up command.
A reproduced Debian 13.6 lab used Docker Compose 5.4.0 to prove automatic .env, an explicit environment file, two ordered files, and a shell override. It also showed that service.env populated the future container environment without supplying interpolation, and that a missing required image tag stopped the render with exit 1. No Docker daemon was available or contacted.
Interpolation asks the first question: which source substitutes the Compose file? Docker documents the source order in its current variable interpolation guide: a value already present in the shell has priority, followed by an explicitly supplied --env-file or the applicable local environment file, then the automatic project .env behavior described for the command. When several --env-file options are named, Compose reads them in order and a later file can override an earlier one.
Container assembly asks a different question: which value will exist inside the container? It is governed by service environment, service env_file, image ENV, and run-time overrides. Docker maintains a separate container environment precedence reference for that question.
| Layer being decided | Inputs in this lab | Field to inspect |
|---|---|---|
| Compose-file interpolation | shell, explicit --env-file, automatic .env |
resolved image, ports, and config --environment |
| Container environment assembly | service environment, service env_file |
resolved services.app.environment |
One practical consequence matters: an image can resolve to example.invalid/voxfor/compose-demo:from-dotenv while the model also says the future container’s IMAGE_TAG variable will equal from-service-env-file. That is not a contradiction. Those values act at different stages.
If the broader choice between individual Docker commands and a multi-service model is still unclear, start with Docker versus Docker Compose. This preflight assumes a Compose model is already the intended deployment unit.
Run the following commands on a disposable Linux workspace with curl, sha256sum, and jq. They create only a random directory under /tmp; the example registry hostname is deliberately invalid, because this test must never pull the image.
Input one pins the standalone Compose binary used for the receipt and verifies the checksum published alongside that release. Adjust the architecture only if the host is not x86-64. DOCKER_HOST points at a socket that does not exist, making accidental daemon dependence visible.
set -euo pipefail
umask 077
LAB_DIR=$(mktemp -d /tmp/voxfor-compose-interpolation.XXXXXX)
MARKER_FILE="$LAB_DIR/.voxfor-compose-interpolation-lab"
PROJECT_DIR="$LAB_DIR/project"
BIN_DIR="$LAB_DIR/bin"
COMPOSE_VERSION='v5.4.0'
COMPOSE_URL="https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-linux-x86_64"
COMPOSE_BIN="$BIN_DIR/docker-compose-linux-x86_64"
install -d -m 0700 "$BIN_DIR" "$PROJECT_DIR"
install -m 0600 /dev/null "$MARKER_FILE"
cleanup_lab() {
case "$LAB_DIR" in
/tmp/voxfor-compose-interpolation.*)
if [[ -f "$MARKER_FILE" ]]; then
rm -rf -- "$LAB_DIR"
printf 'cleanup=marker-owned-lab-removed\n'
fi
;;
*)
printf 'cleanup=refused-unexpected-path\n' >&2
return 1
;;
esac
}
trap cleanup_lab EXIT
curl -fsSLo "$COMPOSE_BIN" "$COMPOSE_URL"
curl -fsSLo "$BIN_DIR/docker-compose.sha256" "$COMPOSE_URL.sha256"
chmod 0700 "$COMPOSE_BIN"
(cd "$BIN_DIR" && sha256sum -c docker-compose.sha256)
export DOCKER_HOST="unix://$LAB_DIR/no-docker.sock"
"$COMPOSE_BIN" version --short
test ! -S "$LAB_DIR/no-docker.sock"
printf 'daemon_socket_absent=yes\n'
Every source in the fixture has a conspicuous, non-secret value. The image and ports fields require interpolation. The service-level file and environment attribute affect the later container environment. ${IMAGE_TAG:?...} uses Compose’s required-value form so an absent release tag cannot quietly become an empty string.
cat > "$PROJECT_DIR/compose.yaml" <<'YAML'
services:
app:
image: "example.invalid/voxfor/compose-demo:${IMAGE_TAG:?set IMAGE_TAG before deployment}"
ports:
- "${HOST_PORT:-8080}:8080"
env_file:
- ./service.env
environment:
DEPLOY_MARKER: "${DEPLOY_MARKER:-from-compose-default}"
RUNTIME_WINNER: "from-environment-attribute"
YAML
cat > "$PROJECT_DIR/.env" <<'ENV'
IMAGE_TAG=from-dotenv
HOST_PORT=18080
DEPLOY_MARKER=from-dotenv
ENV
cat > "$PROJECT_DIR/release.env" <<'ENV'
IMAGE_TAG=from-explicit-env-file
HOST_PORT=28080
DEPLOY_MARKER=from-explicit-env-file
ENV
cat > "$PROJECT_DIR/service.env" <<'ENV'
IMAGE_TAG=from-service-env-file
RUNTIME_WINNER=from-service-env-file
SERVICE_ONLY=loaded-from-service-env-file
ENV
install -m 0600 /dev/null "$PROJECT_DIR/empty.env"
sha256sum "$PROJECT_DIR/compose.yaml" "$PROJECT_DIR/.env" \
"$PROJECT_DIR/release.env" "$PROJECT_DIR/service.env"
Synthetic values keep the lab safe, but the confidentiality warning is real: a fully rendered Compose model can expose interpolated or container-bound secrets. Keep CI logs private, avoid printing complete production models, and extract only the fields needed for admission.
Docker’s docker compose config reference defines the command that parses, resolves, and renders the model. --format json makes field assertions deterministic, while config --environment reports the values Compose used for interpolation. Neither operation starts a service.
Define one helper that clears the three possibly inherited shell variables. It fixes the project directory and Compose file so the source test does not depend on the caller’s working directory.
.env and the service boundarycompose_config_json() {
env -u IMAGE_TAG -u HOST_PORT -u DEPLOY_MARKER \
"$COMPOSE_BIN" --project-directory "$PROJECT_DIR" \
-f "$PROJECT_DIR/compose.yaml" "$@" config --format json
}
compose_config_json > "$LAB_DIR/default.json"
env -u IMAGE_TAG -u HOST_PORT -u DEPLOY_MARKER \
"$COMPOSE_BIN" --project-directory "$PROJECT_DIR" \
-f "$PROJECT_DIR/compose.yaml" config --environment \
> "$LAB_DIR/default.environment"
jq -e '
.services.app.image == "example.invalid/voxfor/compose-demo:from-dotenv" and
(.services.app.ports[0].published | tostring) == "18080" and
.services.app.environment.DEPLOY_MARKER == "from-dotenv" and
.services.app.environment.IMAGE_TAG == "from-service-env-file" and
.services.app.environment.RUNTIME_WINNER == "from-environment-attribute" and
.services.app.environment.SERVICE_ONLY == "loaded-from-service-env-file"
' "$LAB_DIR/default.json" >/dev/null
grep -q '^IMAGE_TAG=from-dotenv$' "$LAB_DIR/default.environment"
! grep -q '^SERVICE_ONLY=' "$LAB_DIR/default.environment"
Automatic .env supplied the resolved image and port in this control. In the same JSON model, container IMAGE_TAG=from-service-env-file proves that service env_file has a separate destination. RUNTIME_WINNER comes from the service environment attribute, which overrides the same name in service.env.
An explicit --env-file release.env does not mean “merge this over whatever automatic .env happened to load.” In this reproduced command it selects the explicit file instead of automatic default loading. The assertions fail if any automatic value leaks through.
compose_config_json --env-file "$PROJECT_DIR/release.env" \
> "$LAB_DIR/explicit.json"
jq -e '
.services.app.image == "example.invalid/voxfor/compose-demo:from-explicit-env-file" and
(.services.app.ports[0].published | tostring) == "28080" and
.services.app.environment.DEPLOY_MARKER == "from-explicit-env-file" and
.services.app.environment.IMAGE_TAG == "from-service-env-file"
' "$LAB_DIR/explicit.json" >/dev/null
When an intentional merge is needed, name both files. Docker’s current documentation allows multiple --env-file options; the later occurrence has precedence over the earlier one.
compose_config_json \
--env-file "$PROJECT_DIR/.env" \
--env-file "$PROJECT_DIR/release.env" \
> "$LAB_DIR/multiple.json"
jq -e '
.services.app.image == "example.invalid/voxfor/compose-demo:from-explicit-env-file" and
(.services.app.ports[0].published | tostring) == "28080"
' "$LAB_DIR/multiple.json" >/dev/null
That ordered control distinguishes “explicit file replaces automatic loading” from “several files were deliberately merged.” It also makes the reviewable source order visible in the command itself.
Shell values sit at the top of this reproduced path. Supply all three names on the command invocation, keep release.env present as a lower-priority control, and assert the resolved fields.
IMAGE_TAG=from-shell HOST_PORT=38080 DEPLOY_MARKER=from-shell \
"$COMPOSE_BIN" --project-directory "$PROJECT_DIR" \
--env-file "$PROJECT_DIR/release.env" \
-f "$PROJECT_DIR/compose.yaml" config --format json \
> "$LAB_DIR/shell.json"
jq -e '
.services.app.image == "example.invalid/voxfor/compose-demo:from-shell" and
(.services.app.ports[0].published | tostring) == "38080" and
.services.app.environment.DEPLOY_MARKER == "from-shell"
' "$LAB_DIR/shell.json" >/dev/null
A resolved tag is still mutable text. Before a production pull, inspect the OCI platform digest and record the immutable identity appropriate to the target architecture.
A default such as ${IMAGE_TAG:-latest} may be convenient for a local sandbox, but it can admit the wrong artifact in a release path. The fixture instead makes IMAGE_TAG mandatory. Supply an empty explicit file, clear the shell, and require both a nonzero exit and the exact operator-facing message.
set +e
env -u IMAGE_TAG -u HOST_PORT -u DEPLOY_MARKER \
"$COMPOSE_BIN" --project-directory "$PROJECT_DIR" \
--env-file "$PROJECT_DIR/empty.env" \
-f "$PROJECT_DIR/compose.yaml" config \
> "$LAB_DIR/missing.stdout" 2> "$LAB_DIR/missing.stderr"
MISSING_RC=$?
set -e
printf 'missing_required_rc=%s\n' "$MISSING_RC"
test "$MISSING_RC" -ne 0
grep -F 'set IMAGE_TAG before deployment' "$LAB_DIR/missing.stderr" |
sed -E 's/^.*(set IMAGE_TAG before deployment).*$/missing_required_message=\1/'
Exit 1 was the reproduced result. A pipeline must test that status; merely searching stderr is insufficient because warnings and successful output can coexist in other tools.
Hash the three admitted JSON models and write only the small facts needed for a release receipt. These hashes identify the exact normalized files tested in this lab; they are not signatures of an image, a container, or the source repository.
DEFAULT_HASH=$(sha256sum "$LAB_DIR/default.json" | awk '{print $1}')
EXPLICIT_HASH=$(sha256sum "$LAB_DIR/explicit.json" | awk '{print $1}')
SHELL_HASH=$(sha256sum "$LAB_DIR/shell.json" | awk '{print $1}')
printf 'default_model_sha256=%s\n' "$DEFAULT_HASH"
printf 'explicit_model_sha256=%s\n' "$EXPLICIT_HASH"
printf 'shell_model_sha256=%s\n' "$SHELL_HASH"
printf '%s\n' \
'default_dotenv=from-dotenv explicit_file=from-explicit-env-file shell=from-shell' \
'container_boundary=service-env-file runtime_override=environment-attribute' \
'required_value_failed_closed=yes daemon_contact_required=no'
Representative output from the reproduced run:
docker-compose-linux-x86_64: OK
5.4.0
daemon_socket_absent=yes
default_dotenv=from-dotenv explicit_file=from-explicit-env-file shell=from-shell
container_boundary=service-env-file runtime_override=environment-attribute
missing_required_rc=1
missing_required_message=set IMAGE_TAG before deployment
default_model_sha256=3fe635ef9ea5f86f2f40e41610dc584a401c3d49b02d811864d31f40c4af803b
explicit_model_sha256=1e5eb8e12dc7e2faecbdc8be45ba929117a71b84f722b8b9378bc27de1e571d2
shell_model_sha256=c76965a404479c8ec6c96493f1b3ce9564c2ce2daf8f4427785de386dfe9af2f
required_value_failed_closed=yes daemon_contact_required=no
cleanup=marker-owned-lab-removed
Accept the preflight only when the release binary checksum passes; the daemon socket is absent; every JSON assertion succeeds; SERVICE_ONLY is absent from config --environment; default, explicit, ordered-file, and shell winners match their controls; the missing value returns nonzero with the required message; all three model hashes are nonempty; and marker-owned cleanup is recorded. Any difference blocks promotion rather than being normalized as “another Compose behavior.”
CI can run these admission commands because model rendering does not need a Docker daemon. Pin the Compose release, verify its checksum, use an isolated project directory, explicitly control inherited variables, and retain only selected assertions or hashes. A self-hosted runner with scoped credentials is one place to run that gate, but the runner’s own environment is an interpolation source, so clear or allowlist it deliberately.
docker compose config is not a deployment test. It does not prove that a registry permits the pull, the image supports the host architecture, volumes are writable, ports are available, dependencies become ready, or the main process remains alive. After a controlled start, use Docker healthcheck and restart-policy boundaries to separate application health from process exit behavior.
Promotion into a VPS hosting environment should begin only after the model is approved; repeat the Compose version and config checks on that host before starting containers. Preserve the approved env-source files and model receipt as release inputs, not as public build artifacts.
For an existing application, a safe rollback is configuration-first. Stop before up, restore the previously reviewed Compose file or environment source, rerun the same render assertions, and compare its receipt. Do not run docker compose down against an unverified project name merely to undo a failed preflight: no container was created by this procedure.
Docker Compose interpolation replaces variable expressions in the Compose file before the application model is used. Examples include ${IMAGE_TAG}, ${HOST_PORT:-8080}, and ${IMAGE_TAG:?message}. Inspect the substituted model with docker compose config and its interpolation inputs with docker compose config --environment.
env_file provide values for Compose-file interpolation?No. A service-level env_file contributes variables to the container environment assembled in the resolved service model. It does not supply a value for ${IMAGE_TAG} in the Compose file. Use the shell, an applicable .env, or --env-file for interpolation.
.env?For the reproduced path, a value present in the command’s shell wins over the same name in an explicit environment file or automatic .env. The shell-control render kept release.env in the command and still resolved all three tested fields from the shell.
--env-file override the default .env file?Naming an explicit --env-file selects that file instead of relying on automatic .env loading. If both files should participate, name both explicitly in the intended order. That is clearer than describing the behavior as an implicit merge.
--env-file files?Yes. Current Compose accepts multiple --env-file options and processes them in order. In the lab, .env came first and release.env came second, so the release values won for names present in both.
Use docker compose config --environment to inspect the interpolation environment, then use docker compose config --format json to assert the resolved fields. Do not publish the complete output when real environment values may contain secrets.
docker compose config run without a Docker daemon?Yes for this model-rendering path. Every positive and negative control succeeded while DOCKER_HOST pointed to an absent Unix socket. Commands that pull, build, create, start, inspect runtime state, or read daemon-managed objects still require a reachable engine.
Use the required form, for example ${IMAGE_TAG:?set IMAGE_TAG before deployment}, in a field that must never be empty. Treat the nonzero exit from docker compose config as a blocked release and surface the message to the operator.
Cleanup must be narrower than the operation it follows. The final input accepts only the random lab prefix and requires the marker created at setup. It refuses an unexpected path rather than deleting it.
case "$LAB_DIR" in
/tmp/voxfor-compose-interpolation.*)
test -f "$MARKER_FILE"
rm -rf -- "$LAB_DIR"
printf 'cleanup=marker-owned-lab-removed\n'
;;
*)
printf 'cleanup=refused-unexpected-path\n' >&2
exit 1
;;
esac
trap - EXIT
If any assertion fails, keep only secret-free terminal evidence, remove the marker-owned lab with the guarded block, and correct the source value rather than weakening the assertion. In production, restore the last reviewed Compose or env input and render again before any up command. The completed receipt proves interpolation precedence, the service-environment boundary, fail-closed input, daemon independence, and cleanup—nothing beyond those stated gates.
Continue with related DevOps guides only after preserving the exact model receipt used for the release decision.