Environment Files, Secrets and What Not to Commit
Separate .env, env_file and environment, see what docker inspect reveals, and use Compose secrets and build secrets. After this, you can keep real secrets out of images, Git and logs.
Course content
Sign in to save your progress.
-
Dockerfiles That Don't Rot
-
.dockerignore and What Belongs in the Build Context
-
ENTRYPOINT, CMD and When to Override Them
-
-
-
Data That Outlives Containers
-
Bind Mounts vs Named Volumes: Which One When
-
Permissions, Ownership and Why the Container Can't Write
-
-
-
Networks Beyond Defaults
-
Bridge Networks, Aliases and DNS
-
Publish Ports Without Exposing Everything
-
-
-
Compose for Real Apps
-
Services, depends_on and Healthchecks
-
Environment Files, Secrets and What Not to Commit
-
-
-
Images Across Machines
-
Tag Images So You Can Find Them Later
-
Push and Pull From a Registry Without Surprises
-
-
- Final Check: Docker Intermediate
Skip ahead: If you already know how .env, env_file and environment differ, skip to "Secrets are files, not variables", then go on to the next lesson.
Configuration is where projects leak. A password in a Compose file, a token in an image, a .env file in Git. This lesson separates three things that look alike and are not, shows what Docker reveals about variables to anyone who can run docker inspect, and then uses file-based secrets and build secrets. All passwords and tokens here are made up.
Three places a value can come from
Compose has three mechanisms, and people mix them up.
| Where | What it does |
|---|---|
.env file next to compose.yaml | Fills ${NAME} placeholders in the Compose file itself. It does not put anything into a container. |
env_file: on a service | Loads a file of NAME=value lines into that container's environment. |
environment: on a service | Sets variables for that container, written in the Compose file. |
Make a small project that uses all three. GREETING is only in .env, and the service never references it.
IMAGE_TAG=3.24
GREETING=hello from dot env
API_URL=http://api.example.test
services:
app:
image: alpine:${IMAGE_TAG}
env_file: app.env
environment:
MODE: demo
command: sh -c 'echo "MODE=$$MODE API_URL=$$API_URL GREETING=$$GREETING"'
Set-Location conf-stack
docker compose run --rm app 2>$null
MODE=demo API_URL=http://api.example.test GREETING=
MODE came from environment: and API_URL from env_file:. GREETING is empty, although it is in .env, because .env only fills placeholders in the Compose file. The only thing it set was IMAGE_TAG, inside image:. To see what Compose built from all of that, ask it.
docker compose config --images
$env:IMAGE_TAG = "3.23"
docker compose config --images
Remove-Item Env:IMAGE_TAG
alpine:3.24
alpine:3.23
A variable set in your shell wins over .env. That is useful for CI, and a surprise when an old IMAGE_TAG is still set in your terminal from last week.
⚠ Warning: docker compose config prints the resolved values, including every password in environment: and env_file:. Do not paste its output into a ticket, a chat or a CI log without reading it first.
Variables are not hidden
Anyone who can run Docker on the machine can read a container's variables. They are part of the container's configuration.
docker run -d --name conf-env -e DB_PASSWORD=not-a-real-password alpine:3.24 sleep 60 | Out-Null
docker inspect --format "{{range .Config.Env}}{{println .}}{{end}}" conf-env | Select-String "DB_PASSWORD" | ForEach-Object { $_.ToString().Trim() }
docker rm -f conf-env | Out-Null
DB_PASSWORD=not-a-real-password
Variables also leak in other ways: a crash dump, a debug page that prints the environment, a child process that inherits them. For a throwaway database password on your laptop that is acceptable. For anything real it is not.
Secrets are files, not variables
A Compose secret is mounted into the container as a read-only file under /run/secrets/. It is not part of the container's environment, and docker inspect does not show its value.
not-a-real-password
services:
app:
image: alpine:3.24
command: sleep 60
secrets:
- db_password
secrets:
db_password:
file: ./db_password.txt
docker compose -f compose.secret.yaml up -d 2>&1 | Out-Null
docker compose -f compose.secret.yaml exec app cat /run/secrets/db_password
"matches in the environment: " + @(docker inspect --format "{{range .Config.Env}}{{println .}}{{end}}" conf-stack-app-1 | Select-String "not-a-real").Count
docker compose -f compose.secret.yaml down 2>&1 | Out-Null
not-a-real-password
matches in the environment: 0
The program reads the file, and the value is not in the environment. Many official images support this directly: the Postgres image accepts POSTGRES_PASSWORD_FILE=/run/secrets/db_password in place of the plain variable.
Secrets at build time
The same trap exists in builds. A build argument looks private and is not: it is recorded in the image history.
FROM alpine:3.24
ARG TOKEN
RUN echo "building with a token" > /dev/null && test -n "$TOKEN"
docker build -q -f Dockerfile.arg --build-arg TOKEN=not-a-real-token -t arg-demo:1 . | Out-Null
docker history --no-trunc arg-demo:1 | Select-String "not-a-real-token" | ForEach-Object { $_.ToString() -replace '^.*?(ARG TOKEN=\S+|RUN \|1 TOKEN=\S+).*$', '$1' }
RUN |1 TOKEN=not-a-real-token
ARG TOKEN=not-a-real-token
The token is in the history of the image twice, once as the ARG line and once in the RUN line that used it. Anyone who can pull the image can read it. Use a build secret instead. It is mounted for one RUN step only and never stored in a layer.
not-a-real-token
FROM alpine:3.24
RUN --mount=type=secret,id=token test -s /run/secrets/token && echo "the secret was available" > /dev/null
RUN echo "build finished"
docker build -q -f Dockerfile.secret --secret id=token,src=token.txt -t secret-demo:1 . | Out-Null
"build exit code: $LASTEXITCODE"
"token in history: " + @(docker history --no-trunc secret-demo:1 | Select-String "not-a-real-token").Count
build exit code: 0
token in history: 0
The build passed, so the secret was readable in that step, and the history has no copy of it.
What not to commit
.envand any*.envfile with real values, and every secret file. Put them in.gitignore.- Commit a
.env.examplewith the same names and fake values, so a new person knows what to fill in. - Put the same names in
.dockerignore, so aCOPY . .cannot take them into an image. That was the first lesson in this course.
.env
*.env
!.env.example
db_password.txt
token.txt
If a real secret was committed once, deleting the file is not enough. The secret is in the history. Rotate it, and treat the old one as public.
Open your own project. Which values in its Compose file would you be uncomfortable pasting into a chat, and where do they live today?
About
Separate .env, env_file and environment, see what docker inspect reveals, and use Compose secrets and build secrets. After this, you can keep real secrets out of images, Git and logs.
- Duration
- 5 min
- Last update
- 08/08/2026
Comments (0)
Sign in to commentNo comments yet.