Docker Compose env files and secrets
Three different things get called an env file in Docker Compose. See how .env, env_file and environment differ, which one wins, and where secrets belong.
The three things people call an env file
Docker Compose has three separate mechanisms with confusingly similar names. The .env file fills in ${VARIABLE} placeholders inside compose.yaml itself, before Compose even parses the file. The env_file: attribute loads a file of key/value pairs into the container's environment. The environment: attribute sets variables on the container directly, written in the compose file. They are not interchangeable, and when two of them set the same key, the winner is fixed by a documented precedence order.
This guide shows each one working, proves the precedence with a command you can run, then covers the part that matters more: environment variables are readable by anyone who can run docker inspect, so passwords do not belong in them. If you are new to compose files in general, start with Docker Compose basics on a VPS and come back here for configuration.
The .env file is for the compose file, not the container
Create a directory and put two files in it.
mkdir -p ~/envdemo && cd ~/envdemo
printf 'ALPINE_TAG=3.20\n' > .envservices:
demo:
image: alpine:${ALPINE_TAG}
command: printenv ALPINE_TAGNow ask Compose what it actually parsed.
docker compose configThe output shows image: alpine:3.20. The placeholder is gone, because interpolation happened at parse time. Compose looks for .env in the project directory, which is the directory holding the compose file, and substitutes every ${NAME} it finds.
Then run the service.
docker compose run --rm demoprintenv ALPINE_TAG exits with status 1 and prints nothing. The variable does not exist inside the container. That is the single most common misunderstanding: .env configured the compose file, not the process. A .env file with POSTGRES_PASSWORD=hunter2 in it does nothing at all for your database unless some part of the compose file references it.
${NAME:-default} supplies a fallback when the variable is unset or empty. ${NAME:?message} makes Compose refuse to start and print your message, which is the right choice for a value that has no safe default.
env_file loads variables into the container
The env_file: attribute names one or more files whose contents become container environment variables.
printf 'GREETING=from_env_file\nAPP_MODE=production\n' > app.envservices:
demo:
image: alpine:3.20
command: printenv GREETING
env_file:
- ./app.envdocker compose run --rm demoThis prints from_env_file. The file format is plain KEY=value lines, one per line, with # starting a comment. It is not shell. Quotes are kept as part of the value in most cases, and export prefixes are not needed. Do not put spaces around the = sign, because KEY = value produces a variable literally named KEY with a leading space in its value.
A missing env_file path is an error and Compose stops. Mark it optional if the file may legitimately be absent:
env_file:
- path: ./app.env
required: falseenvironment sets variables inline
services:
demo:
image: alpine:3.20
command: printenv GREETING
environment:
GREETING: from_environmentTwo syntaxes are accepted, the mapping form above and a list form using - GREETING=from_environment. They behave identically. The list form has one extra trick: a bare key with no value passes the variable through from the shell where you ran docker compose.
environment:
- GREETINGGREETING=from_my_shell docker compose run --rm demoThat prints from_my_shell. Run it without setting GREETING in the shell and Compose sets nothing, with no warning. Silent pass-through failures are worth knowing about, because a service that starts with an empty password variable often starts successfully and is simply wide open.
Which one wins
Docker documents the precedence order, highest first: docker compose run -e on the command line, then environment or env_file whose value is interpolated from your shell or from an env file, then plain environment in the compose file, then env_file, then the ENV directive baked into the image.
The short version for daily work: environment: beats env_file:, and -e on the command line beats both. Prove it in one file.
services:
demo:
image: alpine:3.20
command: printenv GREETING
env_file:
- ./app.env
environment:
GREETING: from_environmentdocker compose run --rm demo
docker compose run --rm -e GREETING=from_cli demo printenv GREETINGThe first prints from_environment, so environment: overrode the value in app.env. The second prints from_cli. Nothing in the compose file overrides the command line.
When a container is behaving as though your config never applied, do not guess. docker compose config prints the fully resolved file, and docker compose config --environment prints the interpolation variables Compose is working from. Most "my env file is ignored" reports turn out to be a value set twice at two different levels.
Why environment variables leak
Set a password in environment: and it is stored in the container's configuration on disk, visible to any user in the docker group.
docker compose run -d --name leaky -e DB_PASSWORD=hunter2 demo sleep 300
docker inspect leaky --format '{{json .Config.Env}}'The output contains "DB_PASSWORD=hunter2" in plain text. Three more paths expose the same value. docker compose config prints it to the terminal, which is how it ends up pasted into a support forum. Any process inside the container can read /proc/1/environ and every child process inherits the variable. And application crash handlers routinely dump the whole environment into a log or an error report.
Membership of the docker group is effectively root on the host, so this is not a privilege boundary you can lean on. The guide on least privilege user accounts on a VPS explains why that group is worth restricting on any shared box.
Compose secrets keep the value in a file
Compose supports file based secrets. The value is mounted into the container as a file rather than injected into the environment.
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./db_password.txtThe secret is mounted at /run/secrets/db_password inside the container. The name after the slash is the secret name from the top level secrets: block.
The _FILE suffix is a convention used by the Docker Official Images, including postgres, mysql and mariadb. Those entrypoint scripts check for VARNAME_FILE, read the file, and use its contents. It is not a Docker feature, so it only works where the image implements it. Check the image documentation before assuming SOMETHING_FILE will be honoured. Applications that do not support it can often read the file themselves at startup, or you can pass the path and let your own entrypoint do it.
Verify from inside the running container:
docker compose exec db cat /run/secrets/db_password
docker compose exec db printenv POSTGRES_PASSWORDThe first prints the password. The second prints nothing, because the value never entered the environment. That is the whole point: docker inspect on this container shows only the harmless path.
Protect the source file on the host, because the secret is only as private as the file behind it:
chmod 600 db_password.txtThe pragmatic middle ground on a VPS
Many self hosted images do not support _FILE variables, so environment variables are the only way in. On a single administrator VPS the realistic goal is to stop the values sitting in a world readable file in your project directory, and to keep them out of git.
sudo install -o root -g root -m 600 /dev/null /etc/myapp/app.env
sudo nano /etc/myapp/app.env env_file:
- /etc/myapp/app.envinstall -m 600 creates the file with the mode already set, so there is no window where it is readable by everyone. Root owns it, so a non root user on the box cannot read it, though anyone who can run docker can still read the value out of the container. Add *.env and .env to .gitignore and commit a app.env.example holding the key names with empty values instead. A committed password is a rotated password.
Rotating a value means restarting the service. Environment variables are read once when the container process starts, so editing the file changes nothing until you run docker compose up -d --force-recreate db. This is the same pattern used in the n8n behind HTTPS on a VPS guide, where the encryption key lives outside the compose file.
Splitting configuration per environment
Compose reads .env from the project directory by default. Point it somewhere else with --env-file.
docker compose --env-file .env.staging configMultiple files are read in order, and later files override earlier ones. Keep the non secret defaults in a committed file and the secrets in a file that never leaves the server. The same applies to env_file:, where the last file listed wins for a duplicated key.
FAQ
Why is my .env file ignored inside the container?
It is not ignored. The .env file only substitutes ${NAME} placeholders in the compose file. It never sets variables inside a container. To get the value into the container, reference it: environment: { KEY: "${NAME}" }, or use env_file: ./that-file.env instead.
Does environment override env_file, or the other way round?
environment: wins. Docker's documented order puts the environment attribute above the env_file attribute, and both are below docker compose run -e on the command line. If a key is set in both places, the value in env_file is silently unused.
How do I see the final value Compose will use?
Run docker compose config to print the fully resolved compose file with all interpolation applied. For a container that is already running, docker inspect <container> --format '{{json .Config.Env}}' shows exactly what its process received.
Are Compose secrets encrypted?
No. A file based secret is mounted into the container as a plain file at /run/secrets/<name>, and the source file sits on the host disk unencrypted. The benefit is scope, not encryption: the value stays out of the container environment, out of docker inspect output, and out of crash dumps that print the environment.
Can I use quotes and spaces in an env file?
Use KEY=value with spaces and leave the quotes off. Compose treats the whole rest of the line as the value, so quotes usually end up as literal characters in the value. Never put spaces around the =, since the key then carries a trailing space and nothing matches it.