Sign in
← Docker: advanced
11 min · medium · Lesson 2 of 3

Secrets in containers

Why ENV and build args leak, how to hand secrets to a container properly, and BuildKit secrets at build time.

Why

A container almost always needs a secret: a database password, an API token, a key. The simplest ways to pass it are exactly the ones that leak. The password ends up in the image, and everyone with registry access can pull that image.

In this lesson we'll look at where secrets leak, how to hand them to a container at startup, and how to pass a secret into a build without it ending up in the layers.

Concepts

Secret — any value that grants access: a password, a token, a private key. Anyone else who sees it gets that access too.

Image layer — an immutable filesystem snapshot after one Dockerfile instruction. You can delete a file in the next layer, but it stays in the previous one. More detail in the lesson on images.

Image history — the list of instructions an image was built from. docker history shows it, and it travels with the image to the registry.

Where secrets leak

A secret in ENV. A password written into Dockerfile through ENV ends up in the image config. Two commands show it right away:

docker history --no-trunc myimage:1.0
docker image inspect -f '{{json .Config.Env}}' myimage:1.0
["PATH=/usr/local/sbin:...","DB_PASSWORD=env-lab-only-password"]

A secret in a build arg. ARG looks safer: the value isn't in Dockerfile, it's passed in from outside through --build-arg. But the value still lands in the history. Every RUN instruction after the ARG declaration keeps all the arguments in scope in the history, even ones the command doesn't use:

RUN |1 API_TOKEN=arg-lab-only-token /bin/sh -c echo hello > /hello.txt # buildkit

A secret in a file that got deleted later. COPY id_rsa followed by RUN rm id_rsa doesn't help. The file sits in the layer from COPY, and anyone who unpacks the image with docker save can get it out.

Rule: a secret must never reach the layers or history of an image. Images get distributed, so there can be no secrets in them.

Runtime secrets: variables and files

Hand a secret to a container at startup, not during the build. The simple way: an environment variable.

docker run -e DB_PASSWORD="$DB_PASSWORD" myapp:1.0

The secret is no longer in the image, it only exists in the running container. But the variable is visible to everyone with Docker access:

docker inspect -f '{{json .Config.Env}}' <container>
["DB_PASSWORD=run-lab-only",...]

On top of that, every child process inherits the variables, and they sit in /proc/<pid>/environ. Any debug dump of the environment, or a crashed process that logs its env, carries the secret away with it.

Handing the secret over as a file is more reliable. Compose has secrets: for that:

services:
  app:
    image: myapp:1.0
    secrets:
      - db_password
secrets:
  db_password:
    file: ./db_password.txt

Compose places the file in the container at the path /run/secrets/db_password. The app reads the password from the file, not from a variable. docker inspect shows no content, only the path to the source file on the host:

bind /home/user/project/db_password.txt -> /run/secrets/db_password

Without Swarm, a secret like this is just a bind mount of a single file. So the db_password.txt file on the host itself needs protecting: 600 permissions and a line in .gitignore.

If a secret leaks

A secret that has ever landed in an image, in Git, or in a log counts as leaked. Deleting the image from the registry isn't enough: someone may have pulled it earlier. The value has to change.

Rotating a secret is done once the old value is no longer accepted anywhere, and everyone who uses the secret is on the new one. For a redis password, that's redis itself and every service that connects to it. Change it on only one side, and the service stops authenticating.

Build-time secrets: BuildKit

Sometimes a secret is needed specifically during the build, say, a token to download a private dependency. ARG doesn't fit here, and COPY even less so. For cases like this there are BuildKit secrets: the file is mounted only for the duration of one RUN instruction and never reaches the layers.

# syntax=docker/dockerfile:1
FROM node:22.11.0-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmtoken \
    NPM_TOKEN=$(cat /run/secrets/npmtoken) npm ci
docker build --secret id=npmtoken,src=./npm_token -t myapp:1.0 .

Inside this RUN, the token sits at /run/secrets/npmtoken. In the next instruction the file is already gone, the history shows only the command itself, and the docker save dump has no token value in it.

BuildKit is on by default in Docker Desktop and in Docker Engine 23.0 and newer. On Linux with an older Engine and no BuildKit, the build fails with the --mount option requires BuildKit. In that case, build with the DOCKER_BUILDKIT=1 variable.

Common mistakes

Using ARG for a token "because it's not in Dockerfile." The value isn't in the file, but it is in docker history. And in every RUN after the ARG, not just the one that uses the token.

Deleting the secret in the next layer. RUN rm secret.txt creates a new layer, the old layer with the file stays in the image. Same with RUN ... && rm split across instructions.

Password in environment: in docker-compose.yml, file in Git. The image is clean, but the secret ended up in the repository. Keep values in a file outside Git, and reference it in compose through secrets: or env_file:.

The app reads only a variable. The password moved to /run/secrets/..., but the code still expects DB_PASSWORD and fails. Many official images understand a _FILE-suffixed variant (POSTGRES_PASSWORD_FILE=/run/secrets/db_password). Teach your own app to read the file.

--mount=type=secret without BuildKit. The classic builder doesn't know the --mount flag and fails. Check that the build is going through BuildKit.

Where to store secrets centrally

A file on the server and secrets: in compose work for a single host. Once there are many servers and services, secrets live in an external store (HashiCorp Vault, cloud secret managers) or get encrypted right in the repository through sops. The container still gets the value handed to it at startup. That's a topic for the ops track.

What you'll need for the tasks

Done reading?

Mark the lesson as read to move your track progress forward.

Go through it with an AI agent

The agent explains anything unclear in plain words, gives you 3 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.

Get access for $49

Tell the agent: use MCP izi, give me lesson docker-advanced-02-secrets