Environment Variables and Secrets in Docker Compose
Table of contents
- Key takeaways
- How do you pass configuration into a container?
- What does the .env file and variable interpolation do?
- env_file versus environment: which one wins?
- Docker Compose secrets: why not use variables for passwords
- Best practices and common mistakes
- Frequently asked questions
- Is the .env file injected automatically into the container?
- Can I see a Docker secret with docker inspect?
- Do I need Docker Swarm to use secrets?
- Conclusion
- Sources
Docker Compose gives you three ways to pass configuration into a container: the environment key, the env_file attribute and the .env file for interpolation. For sensitive data, do not use environment variables; Docker Compose secrets are mounted as read-only files under /run/secrets/, away from logs and the process environment.
Passing a database password to a container with environment: works, but anyone who runs docker inspect will see it in plain text. That is the trap that separates ordinary configuration from sensitive data. In this guide you will see the three ways to inject configuration with Docker Compose (the environment key, the env_file attribute and the .env file), how their precedence is ordered, and why passwords and API keys should go through the secrets mechanism instead of environment variables. The same guide is available in Spanish.
Key takeaways
- Docker Compose resolves variables with a five-level precedence:
docker compose run -ewins, then the interpolated shell value, thenenvironment, thenenv_file, and last the image’sENV. - The
.envfile is for interpolating values insidedocker-compose.yml(${VAR}), not for injecting them automatically into the container: that is still the job ofenvironmentorenv_file. - When the same key appears in both
environmentandenv_file,environmentwins. - Docker Compose secrets are defined as files and mounted read-only at
/run/secrets/<name>, outside the process environment and outsidedocker inspectoutput. - Official images (PostgreSQL, MariaDB) support the
_FILEconvention:POSTGRES_PASSWORD_FILE=/run/secrets/db_passwordreads the password from the file rather than a variable. - Since Compose v2.24.0 you can mark an
env_fileas optional withrequired: false.
How do you pass configuration into a container?
A container starts knowing nothing about your server: it does not know the port you want to expose, the database name, or the time zone. Docker Compose gives you three ways to tell it, and you should avoid mixing them by accident.
The first is the environment key inside the service, where you write key-value pairs directly in docker-compose.yml. The second is env_file, which points to one or more files holding those same pairs, one per line, so the Compose file stays clean. The third is the .env file in the project directory, and this is where the most common confusion sits: that .env is not injected into the container. Compose uses it to interpolate values inside docker-compose.yml itself. If you define APP_PORT=8080 in .env, you can write "${APP_PORT}:8080" in the ports section, but the container only receives APP_PORT if you also declare it under environment.
This distinction causes most beginner problems. Before going further, if you do not have the engine installed yet, review how to install Docker on Ubuntu 24.04.
What does the .env file and variable interpolation do?
Compose automatically loads a file called .env from the directory where you run the command. Its values become available for interpolation: any ${VARIABLE} or $VARIABLE that appears in docker-compose.yml is substituted with the matching value before the containers are created. It supports both the braced form ${VAR} and the short form $VAR, plus default values with ${VAR:-value}.
A typical .env for the example below looks like this:
APP_PORT=8080
LOG_LEVEL=info
POSTGRES_VERSION=16.4
With that you can reference ${APP_PORT} or ${POSTGRES_VERSION} in the Compose file and change the port or the image version without touching the YAML. The key point: use .env to parameterise docker-compose.yml, not as a password store, because it is a plain-text file that easily ends up in the repository. Always add it to your .gitignore.
env_file versus environment: which one wins?
Both keys inject variables into the container, but they behave differently. environment is written in the Compose file and suits non-sensitive, rarely changing values. env_file externalises those pairs into a separate file, handy when you have many variables or want one file per environment (development, testing, production).
When the same key sits in both places, environment wins. And the global precedence, highest to lowest, follows these five rules per Docker’s documentation:
- What you pass with
docker compose run -eon the command line. - An
environmentorenv_filevalue interpolated from the shell or an environment file. - The value set only in
environment. - The value set in
env_file. - The
ENVdefined in the image (theDockerfile).
Since Compose v2.24.0 you can declare an optional env_file with required: false: if the file is missing, Compose silently ignores the entry instead of failing. And since v2.30.0 the format attribute allows alternative file formats. These improvements sit alongside the Compose v2 hallmark: you no longer write the version: key at the top of the file.
Docker Compose secrets: why not use variables for passwords
Here is the shift in mindset. An environment variable is visible to any process in the container, shows up in docker inspect, is inherited by child processes and often ends up printed in debug logs. Docker’s own documentation puts it plainly: "If you’re injecting passwords and API keys as environment variables, you risk unintentional information exposure." Its recommendation is clear: use the secrets mechanism.
A secret in Compose is, in its simplest form, a file whose contents are mounted read-only at /run/secrets/<name> inside the container. It is not in the process environment, docker inspect does not list it, and you control access with ordinary file permissions. Official images close the loop with the _FILE convention: instead of POSTGRES_PASSWORD you pass POSTGRES_PASSWORD_FILE=/run/secrets/db_password, and the image reads the password from the file.
Here is a complete, copy-paste docker-compose.yml with a database and an application that share a password as a secret:
services:
db:
image: postgres:16.4
restart: unless-stopped
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 10s
timeout: 5s
retries: 5
app:
image: nginx:1.27.2
restart: unless-stopped
env_file:
- path: .env
required: true
environment:
DB_HOST: db
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
depends_on:
db:
condition: service_healthy
ports:
- "${APP_PORT}:80"
secrets:
db_password:
file: ./secrets/db_password.txt
volumes:
db_data:
Before bringing it up, create the secret file and the .env, then start the stack:
mkdir -p secrets
openssl rand -base64 24 > secrets/db_password.txt
chmod 600 secrets/db_password.txt
printf 'APP_PORT=8080\n' > .env
docker compose up -d
docker compose exec db cat /run/secrets/db_password
The last command confirms the secret is mounted inside the container. If you compare the output of docker inspect db with a service that used POSTGRES_PASSWORD under environment, you will see the difference: with the secret, the password appears nowhere. To persist that data properly, review how volumes and bind mounts in Docker work, and to make the app wait for a healthy database, lean on healthchecks and restart policies.
One important nuance: Compose secrets outside Swarm are bind mounts of local files. Docker Swarm adds encrypted external secrets in its Raft log, which you create with docker secret create and reference with external: true. For a single-host homelab, the file version is enough and far simpler.
Best practices and common mistakes
After standing up a few services, these are the most repeated slip-ups and how to avoid them:
- Do not pin
:latestin production. The:latesttag is not reproducible: a silent update can break your startup. Pin a specific version such aspostgres:16.4and use:latestonly as a warning of what not to do. - Ignore sensitive files. Add
.env,secrets/and any password*.txtto your.gitignorebefore the first commit. A secret in Git history is already compromised. - Do not confuse
.envwith container variables. The.envinterpolates in the YAML; for a variable to reach the container, declare it inenvironmentorenv_file. - Give the secret file strict permissions. A
chmod 600stops other host users from reading it. - Rotate your secrets. If you are going to manage many credentials, a manager like Vaultwarden helps you generate and store them outside the repository.
Storing configuration in the environment, not in the code, is a classic principle (formalised by the Twelve-Factor App methodology). Secrets are the practical exception to it: they go in mounted files, not variables.
Frequently asked questions
Is the .env file injected automatically into the container?
No. Compose loads .env to interpolate ${VARIABLE} inside docker-compose.yml, but the container does not receive those variables unless you declare them in environment or env_file. It is the most common confusion: .env parameterises the Compose file, it does not fill the container environment on its own.
Can I see a Docker secret with docker inspect?
No, and that is exactly the point. Unlike an environment variable, which docker inspect shows in plain text, a secret is mounted as a read-only file at /run/secrets/<name> and is not part of the configuration the Docker API exposes.
Do I need Docker Swarm to use secrets?
Not for the file-based version. Docker Compose mounts secrets from local files without Swarm. Swarm is only required for external secrets encrypted in the Raft log, meant for multi-node clusters.
Conclusion
The rule is simple: ordinary configuration through environment or env_file, Compose parameterisation with .env, and passwords and keys always as secrets mounted at /run/secrets/. With that separation your docker-compose.yml stays clean, reproducible and free of credentials in the open. The natural next step is chaining services with healthy dependencies: review healthchecks and restart policies in Docker Compose so your app does not start before its database.