Docker Compose secrets without a .env file
How-to
The usual Compose setup is a .env file next to docker-compose.yml, either
read by env_file: or interpolated into environment: with ${VAR}. Both
work, and both leave a plaintext copy of every credential on the host, in the
repo if someone forgets .gitignore, and in the output of two commands people
run all the time.
The shortest fix is to wrap Compose in a command that resolves the values and puts them in its environment, and then delete the file:
seekrit run -- docker compose up
Compose reads ${DATABASE_URL} from the shell environment it was started
with, so every interpolation in the file resolves as before. There is no
.env on disk. The rest of this post covers what that does and does not fix,
and the two stronger shapes.
Where do Compose environment values end up?
Three places, regardless of whether they came from env_file, environment,
or ${VAR} interpolation:
docker compose configprints the fully resolved file, values included. It is the standard debugging command and the standard way to leak a key into a terminal recording or a support ticket.docker inspect <container>showsConfig.Envfor every container. Anyone with Docker socket access can read every service's credentials.- The container's process environment, which is where the app actually reads them, and which any code in the container can read too.
Wrapping Compose with seekrit run removes the file and the copy in the repo.
It does not change the other three. If those matter, keep reading.
Option 1: resolve on the host, interpolate as usual
services:
api:
image: my-api
environment:
DATABASE_URL: ${DATABASE_URL}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
seekrit run --app storefront --env development -- docker compose up
If you had a .env, import it first, then delete it:
seekrit secrets import .env --app storefront --env development
rm .env
Good for local development. The values are in your shell for the duration of
the command and nowhere on disk. docker compose config will still print
them, because Compose resolved them, so don't paste its output anywhere.
Note that Compose also reads a .env file beside the compose file for
interpolation, silently, if one exists. After you delete it there is nothing
to read. A COMPOSE_ENV_FILES override, or --env-file, works the same way.
Option 2: resolve inside the container at start
Put the static seekrit-run binary in the image and make it the entrypoint.
The container gets one variable, SEEKRIT_TOKEN, and fetches and decrypts
its own secrets when it starts:
FROM seekritdev/run:latest AS seekrit
FROM gcr.io/distroless/static
COPY --from=seekrit /seekrit-run /usr/local/bin/seekrit-run
COPY --from=build /app /app
ENTRYPOINT ["seekrit-run", "--"]
CMD ["/app/server"]
services:
api:
build: .
environment:
SEEKRIT_TOKEN: ${SEEKRIT_TOKEN}
Now docker compose config and docker inspect show a token, not the
credentials. The token is bound to one app environment and can be revoked
from the dashboard, which a leaked DATABASE_URL cannot. The binary is about
2 MB, fully static, and runs in scratch, alpine, and distroless images
without Node or a CA bundle. The launcher guide has a
version that downloads and checksum-verifies it instead of pulling from Docker
Hub.
For the token itself, ${SEEKRIT_TOKEN} from your shell is fine locally. On
a server, put it in the host's environment or a Compose secret (next
section) rather than a file in the repo.
Option 3: use Compose secrets: for the token
Compose's secrets: mounts a file at /run/secrets/<name> inside the
container instead of setting an environment variable. That keeps the token out
of docker inspect entirely:
services:
api:
build: .
secrets:
- seekrit_token
entrypoint: ["/bin/sh", "-c", "SEEKRIT_TOKEN=$(cat /run/secrets/seekrit_token) exec seekrit-run -- /app/server"]
secrets:
seekrit_token:
environment: SEEKRIT_TOKEN # read from the host shell at `compose up`
The environment: source for a secret needs a recent Compose (v2.23 or
later). Older versions only take file:, in which case point it at a file
holding only the token, with 0600 permissions, outside the repo. Either way
it's one short-lived credential, not the whole environment.
This needs a shell in the image, so it doesn't work on distroless/static.
Use distroless/base or alpine, or skip this option and pass the token as
an environment variable.
Which option should I use?
| Situation | Use |
|---|---|
Local development, you run compose up yourself | Option 1: seekrit run -- docker compose up |
| A server or CI where the compose file is deployed | Option 2: seekrit-run entrypoint, token via environment |
The host's Docker socket is shared, or docker inspect output is collected somewhere | Option 3: token via secrets: |
Options 2 and 3 also mean each service can have its own token bound to its own seekrit environment. A worker that needs one queue URL doesn't get the payment key just because it's in the same compose file.
Rotating a value
With options 2 and 3, docker compose restart api picks up the new value on
the next start. Nothing in the compose file changes. With option 1, re-run
seekrit run -- docker compose up -d, which re-resolves and recreates the
services whose environment changed.
Where the plaintext still exists
Every option above puts the decrypted value in the container's process
environment, because that's where the app reads it. Anything running in the
container can read it too. That's the same boundary as a .env file minus
the file, and it's the honest description of what's being replaced. If the
code in the container is something you don't trust, look at
placeholders and an egress proxy instead.
More on tokens: Service tokens. The container patterns in full: CI/CD & containers.