CertGrid CertGrid
Configuration·Docker

Docker Compose Environment and Secrets

Four different things all called environment, doing four different jobs. Which one substitutes into the file, which one reaches the process, and where a password should actually live - resolved by running all of them at once and reading the result.

Docker Compose Guide 27 of 46 Intermediate

Tested on the versions above. Nothing here depends on host specifics. The values shown are the ones the commands produced.

One Docker host is all this guide needs. Nothing here depends on a second machine, and the hardware above is modest on purpose - a 2 core, 4 GB VM runs everything in this path.
Server NameIP AddressOSRolesCPURAMHDD
DOCKER01192.168.0.21Ubuntu 26.04 LTSDocker Host2 Core4 GB50 GB

Before you start

  1. Four mechanisms, one file

    The confusion here is worth naming up front. .env supplies values that are substituted INTO the Compose file. environment: sets variables in the container. env_file: loads a file of variables into the container. secrets: mounts a file at /run/secrets/. This project uses all four at once so the difference is visible rather than theoretical.

    bash
    cat .envGREETING=from-dotenvTAG=3.22cat compose.yamlservices:  app:    image: alpine:${TAG}    environment:      GREETING: ${GREETING}    env_file:      - app.env    secrets:      - api_token secrets:  api_token:    file: ./secret.txt

    Expected resultOne service using variable substitution, an inline variable, an env file and a secret.

    Success conditionYou can point at which line does which job. Note ${TAG} is used in the IMAGE NAME - that only works because .env substitutes before the file is parsed.

  2. Resolve the file before running it

    docker compose config prints the file with every substitution applied. This is the single most useful debugging command in Compose: it shows what Compose actually understood, rather than what you think you wrote. Run it whenever a value is not what you expected.

    bash
    docker compose configname: cg-cfgservices:  app:    environment:      EXTRA: from-envfile      GREETING: from-dotenv    image: alpine:3.22    secrets:      - source: api_token        target: /run/secrets/api_token

    Expected result${TAG} resolved to a real tag, and both variables merged into one environment block.

    Success conditionThe image line reads alpine:3.22. Note the env_file variable and the inline one end up in the same place - by the time the container starts they are indistinguishable.

  3. See what the process actually got

    The proof is in the container. Both variables arrived, from two different sources, and the secret is readable as a FILE rather than as a variable - which is the whole point of the secrets mechanism.

    bash
    docker compose up -d Container cg-cfg-app-1 Starteddocker compose logs appapp-1  | greeting=from-dotenv extra=from-envfileapp-1  | secret=s3cr3t-value

    Expected resultBoth variables present, and the secret read from /run/secrets/api_token.

    Success conditionAll three values arrived by their own route. The secret was never an environment variable at any point.

  4. Why a secret is a file, not a variable

    Environment variables leak. They appear in docker inspect, in compose config output, in crash reports and in any child process's environment, and they are trivial to dump accidentally into a log. A secret is mounted as a file readable by the process and nothing else, so the value never appears in the container's metadata. For a local file-backed secret the protection is modest but the habit is the right one, and it is the same syntax a managed secret store plugs into later.

    bash
    # an environment variable is visible to anyone who can inspect the containerdocker compose exec app printenv GREETINGhello from the environmentdocker compose exec app cat /run/secrets/api_token

    Expected resultThe variable printed from the environment; the secret only readable from its path.

    Success conditionYou understand why passwords belong in secrets:. Also: never commit the secret file - add it to .gitignore alongside .env.

  5. Precedence, from weakest to strongest

    When the same variable is set in several places, the strongest wins. From weakest: values in env_file, then environment: in the Compose file, then a variable exported in your shell, then docker compose run -e. The rule that surprises people is that your shell's environment beats the .env file - a stale exported variable in one terminal will quietly override the project default in that terminal only.

    bash
    GREETING=from-the-shell docker compose config | grep GREETING# the shell value wins over .envdocker compose config | grep GREETING# back to the .env value in a clean shell

    Expected resultThe same command reporting different values depending on the shell environment.

    Success conditionYou know to check your shell before believing a config file is wrong. docker compose config is the arbiter.

  6. Keep .env out of version control

    .env holds per-machine values and frequently ends up holding credentials too. The convention is to commit a documented example and ignore the real one, so a new contributor knows which variables exist without receiving anyone's keys.

    bash
    cat .gitignore.envsecret.txtdocker compose down

    Expected resultBoth the env file and the secret file ignored.

    Success conditiongit status does not offer to commit your credentials.

Troubleshooting

Official sources