Key Takeaway
How to store secrets and environment variables in Docker safely: never use ENV or ARG, because both end up in the image. Keep them in a .env file ignored by Git and .dockerignore, pass them at runtime with --env-file or env_file, use a secret mount for build credentials, and rotate any committed key.
Your app works on your laptop because a .env file holds the API key. Then you want to run it in a container and hit the question almost every beginner meets: where does the key go? The shortcut is obvious, writing ENV API_KEY=... in the Dockerfile. That is the route that ships the key to the registry together with the image, readable by anyone who can pull it.
This guide compares four places for a value: ARG, ENV, a runtime environment variable and a secret mount, then carries the idea through GitHub Actions and what to do when a key has already reached Git. Every command is matched against the official Docker and GitHub docs, and the deploy pattern is the one this site uses: secrets live in GitHub, get written to an owner-only .env file on the server, and are read by the container when it starts. Every value in the lab is made up.
Want the concept first, with an animated walkthrough? Read our free lesson: Docker env vars and secrets: build time vs runtime. This article is the hands-on side: you type the commands and see for yourself where a value leaks and where it stays safe.
What you need
Not much. Every step runs on Linux, macOS or Windows as long as Docker is running.
- Docker installed and running (Docker Desktop on macOS or Windows, Docker Engine on Linux).
- A terminal and a text editor. Docker Compose is used in Step 4; current Docker installs include it.
- About 30 minutes and an empty folder called secret-lab.
docker --version
# Expect one line starting with: Docker version ...
# "command not found" or "cannot connect to the Docker daemon" means
# Docker is not installed or not running yet. Fix that first.
mkdir secret-lab && cd secret-lab
# Every value below is made up. Never paste a real key into a lab.Use made-up values such as not-a-real-secret. Never paste a real key into a practice lab, least of all into a command that lands in your terminal history.
Step 1: See for yourself why ARG and ENV are not for secrets
Start with ARG. The Dockerfile reference says an ARG value is not embedded in the image or available in the final container, yet build arguments are visible in docker history, and Docker does not recommend them for secrets. Prove it yourself:
# Dockerfile.arg
FROM alpine:3
ARG DEMO_TOKEN
RUN test -n "$DEMO_TOKEN"
# in a terminal:
docker build --build-arg DEMO_TOKEN=not-a-real-secret -f Dockerfile.arg -t env-lab:arg .
docker history --no-trunc env-lab:arg | grep not-a-real-secret
# If this prints a line, the value is readable by anyone who can pull the image.Now ENV. The docs say ENV values are always persisted in the built image and carry into every container run from it. No docker history needed; just run the container:
# Dockerfile.env
FROM alpine:3
ENV DEMO_TOKEN=not-a-real-secret
# in a terminal:
docker build -f Dockerfile.env -t env-lab:env .
docker run --rm env-lab:env printenv DEMO_TOKEN
# not-a-real-secret <- it ships inside the image and every container started from itIf a key ever went into an ARG or ENV and the image was pushed, treat that key as leaked. Rotate it at the provider; deleting the image does not recall copies someone already pulled.
In short, ENV and ARG suit values anyone may read, such as NODE_ENV or a port number. This site's Dockerfile contains only ENV lines like that and declares no ARG at all.
Step 2: Keep the .env file out of Git and out of the image
Two files stop two different leaks. .gitignore keeps .env out of the repository. .dockerignore keeps it out of the build context, and Docker states that files matching its patterns are removed from the build context before it is sent to the builder, so they cannot be copied into the image with ADD, COPY or bind mounts. This site's repo lists .env and .env.* in both files.
# .gitignore (keeps the file out of Git)
.env
.env.*
# .dockerignore (keeps the file out of the build context)
.env
.env.*# Dockerfile
# Wrong: without a .dockerignore, this copies a local .env into a layer.
COPY . .
# Right: same line, but .dockerignore removed .env from the context first,
# so there is nothing for COPY to pick up.
COPY . .Note that .gitignore only helps for files that were never committed. If .env already reached Git, adding it to .gitignore now does not erase its history; that is Step 7.
Step 3: Pass the secret at runtime with --env-file or -e
The right pattern: build the image with no secrets, then hand the values to the container when it runs. With --env-file, each line is VAR=value and lines starting with # are comments. With -e VAR and no equals sign, the Docker CLI reads VAR from your shell environment.
# A file of KEY=value lines. Lines starting with # are comments.
printf 'DEMO_TOKEN=not-a-real-secret\n' > .env
docker run --rm --env-file .env alpine:3 printenv DEMO_TOKEN
# not-a-real-secret
# Or pass one variable straight from your shell: no "=" means "copy it from here".
export DEMO_TOKEN=not-a-real-secret
docker run --rm -e DEMO_TOKEN alpine:3 printenv DEMO_TOKEN
# not-a-real-secretOne small trap: the docs state that a # anywhere else on a line is part of the value, not a comment. A trailing comment therefore becomes part of your secret and makes authentication fail without a clear message.
# Wrong: the # is part of the value, not a comment.
DEMO_TOKEN=abc123 # my staging key
# Right: comment on its own line.
# my staging key
DEMO_TOKEN=abc123Use -e VAR with no value when you do not want to type a key into the command line. If the variable is not exported in your shell, the docs say it is unset in the container.
Step 4: Move it into Docker Compose with env_file
On a server you will almost certainly use Compose. The env_file attribute loads a .env file into the container's environment, with its path relative to the location of compose.yaml. Once the file exists, restrict it with chmod 600 so only the owner can read it, as this site's deploy does.
# compose.yaml
services:
web:
image: alpine:3
env_file: .env
# .env
GREETING=from-env-file
# in a terminal:
chmod 600 .env # owner-only, as this site does on its server
docker compose run --rm web printenv GREETING
# from-env-fileWhen the same name is set in two places, Compose follows a fixed order. Highest first: docker compose run -e, then environment or env_file with an interpolated value, then environment alone, then env_file alone, and last an ENV in the image. That is why the environment entry beats env_file in the example above.
services:
web:
image: alpine:3
env_file: .env
environment:
GREETING: from-compose-file # environment outranks env_file
# docker compose run --rm web printenv GREETING
# from-compose-file
# For a value that MUST exist, fail loudly instead of starting with a blank:
# image: ${IMAGE:?IMAGE is required}An honest note: the Compose docs advise against passing passwords through environment variables and offer Compose secrets instead. env_file remains reasonable for one container on one server with an owner-only file, as on this site. For a larger setup, study that secrets option.
Step 5: Use a secret mount when the build itself needs a credential
Sometimes the build needs a credential, for example to download a private package. Docker writes that build arguments and environment variables are inappropriate here because they persist in the final image, and recommends secret mounts. The value appears as a file at /run/secrets/ followed by its id, only during that one RUN instruction.
# Dockerfile.secret
FROM alpine:3
# The secret exists only during this one RUN, at /run/secrets/demo.
RUN --mount=type=secret,id=demo test -s /run/secrets/demo
# in a terminal:
printf 'not-a-real-secret' > demo.txt
docker build --secret id=demo,src=demo.txt -f Dockerfile.secret -t env-lab:secret .
docker history --no-trunc env-lab:secret | grep not-a-real-secret
# No output: the value is not in the history.
# From an environment variable instead of a file:
# docker build --secret id=DEMO_TOKEN .This site does not need one, so this part comes purely from the Docker docs. For secrets the running app needs, keep using Steps 3 and 4.
Step 6: Carry the secret from GitHub Actions to the server
Store the value in GitHub as a secret and read it through the secrets context. The GitHub docs recommend passing a secret to a command as an environment variable referenced in quotes, and warn against secrets on the command line because other users may see them with ps.
# Wrong: the secret sits in the command line, where other users of the
# machine can see it with ps.
- run: ./deploy.sh --token ${{ secrets.DEPLOY_TOKEN }}
# Right: hand it over as an environment variable and quote the reference.
- env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: ./deploy.sh "$DEPLOY_TOKEN"This site's deploy writes the .env file on the server over SSH with a quoted heredoc, then runs chmod 600. The shape is below, with names replaced by placeholders:
# Inside the SSH step of the deploy job (names are placeholders).
# The quotes around ENVEOF stop the shell expanding a $ inside a value.
cat > .env <<'ENVEOF'
API_KEY=${{ secrets.API_KEY }}
APP_PORT=${{ vars.APP_PORT || 3002 }}
ENVEOF
chmod 600 .envDo not rely on log redaction. GitHub does redact printed secrets, but its docs state this is not guaranteed because a value can be transformed, and the runner can only redact secrets used in the current job. Never print a secret with echo.
Step 7: If a secret was already committed or pushed
Order matters. The GitHub docs say the first step is to revoke or rotate the secret; once rotated, the old key can no longer be used, and rewriting history may not be necessary. If you still want to clean history, GitHub points to git-filter-repo version 2.47 or later with the --sensitive-data-removal option:
# 1. Revoke or rotate the key at the provider. Do this before anything else.
# 2. Optional: rewrite history to drop the file (git-filter-repo 2.47 or later).
git-filter-repo --sensitive-data-removal --invert-paths --path .env
# 3. Publish the rewritten history.
git push --force --mirror originRewriting history and force-pushing is not enough. GitHub warns the old commits may still be reachable in other clones and forks, through cached views by SHA-1 hash, and through pull requests that reference them. That is why rotating the key is always the first step.
After that, go back to Step 2 so it does not happen again. See the supporting article at the end for how secret scanning works.
Common errors and how to fix them
The six symptoms that most often confuse beginners, with their cause and fix.
| Symptom | Cause | Fix |
|---|---|---|
| The key shows in docker history output | The value was passed through ARG or build-arg | Rotate the key, then move to RUN --mount=type=secret |
| printenv in the container is empty although .env has it | Forgot --env-file or env_file, or used -e VAR that is not exported in the shell | Add --env-file .env or env_file: .env, or export the variable first |
| Authentication fails although the key is right | A trailing comment on a .env line became part of the value | Move the comment to its own line starting with # |
| The environment value is used instead of the .env one | In Compose precedence, environment ranks above env_file | Remove the duplicate entry, or use environment on purpose as the override |
| Compose starts with a blank value and no warning | The variable is unset, and plain interpolation uses an empty string | Use the required form with a question mark and an error message, so Compose stops with an error |
| A secret still appears in the GitHub Actions log | Redaction is not guaranteed for transformed values and only covers secrets used in that job | Never print a secret; rotate any key that was printed |
Every symptom above can be reproduced in this lab with made-up values, so try getting it wrong on purpose to learn what it looks like.
Checklist before you deploy
- .env and .env.* are listed in .gitignore and .dockerignore.
- The Dockerfile holds only non-sensitive ENV lines and no ARG for secrets.
- App secrets are handed over at runtime through env_file or --env-file.
- The .env file on the server is restricted with chmod 600.
- GitHub Actions secrets are referenced through env with quotes, never written on the command line or printed.
- You know the order if a key leaks: rotate first, clean history second.
Go deeper
For the big picture of where a secret lives at each stage of a deploy, follow the lesson Docker env vars and secrets: build time vs runtime, complete with a quiz and an animation. For good Dockerfile habits in general, read Docker containerization best practices. And if you want to step up to Docker secrets in production, continue with Docker secrets in production.
The rule to take home: build the image with no secrets, let secrets arrive at runtime, and treat any key someone else has seen as leaked and rotate it. The rest is picking the right place for each value.
Sources and references:

