
Most leaked credentials aren't the result of a clever attack. Someone ran COPY . . with a .env file in the folder and pushed the image. Someone pasted the output of docker compose config into a GitHub issue. Someone logged process.env while debugging and forgot to remove it.
Docker makes it very easy to pass configuration into a container, and just as easy to pass it in the wrong way. This post walks through where secrets tend to leak, and what a safer setup looks like for a typical Node.js app with a Postgres database, a background worker, and a few other services around it.
Config vs. Secrets
Not everything in your .env file is sensitive. It helps to separate the two:
Config is harmless if it leaks:
NODE_ENV,PORT,LOG_LEVEL, feature flags. Put it wherever is convenient.Secrets are things that would force you to rotate them if someone saw them: database passwords, API keys, JWT signing keys, webhook secrets, and tunnel tokens.
A simple test: if exposing this value means you'd have to change it right away, treat it as a secret. Everything below is about that second group.
How Environment Variables Leak
Environment variables are the default way to configure a container, and they're fine for config. For secrets, they have some uncomfortable properties:
They're visible to anyone who can inspect the container.
docker inspectprints every variable in plain text, and so does/proc/<pid>/environinside the container.They're inherited by child processes. Any script or tool your app spawns gets the full environment.
They end up in logs and error reports. A debug statement, a crash dump, or an error tracker that captures the environment will happily include them.
They show up in tooling output.
docker compose configprints the fully resolved file, including any secrets passed as variables.
Try it yourself on a running container:
docker inspect --format '{{.Config.Env}}' my-apiIf your database password is in that output, anyone with access to the Docker socket on that machine can read it. That's the trade-off you accept with env vars, so decide on purpose rather than by accident.
Never Bake Secrets into an Image
This is the most damaging mistake, because images get copied, pushed, cached, and shared far more widely than running containers.
# Don't do this
FROM node:24-alpine
ENV DATABASE_URL=postgres://app:supersecret@db:5432/app
ARG NPM_TOKEN
COPY . .There are three separate problems here:
ENVis permanent. The value is stored in the image metadata, and anyone who can pull the image can read it.ARGis not a safe hiding place. Build arguments can show up in the image history, so passing a token with--build-argoften just moves the leak.COPY . .copies everything, including a.envfile sitting in your project folder.
Fix the last one with a .dockerignore:
.env
.env.*
!.env.example
.git
*.pem
secrets/And remember that deleting a file in a later layer doesn't remove it from the earlier one. If a secret was ever baked into an image that left your machine, rotate it.
Compose: environment, env_file, and the .env Gotcha
In Docker Compose there are several ways to pass variables, and people regularly mix them up:
services:
api:
image: my-api
env_file:
- .env.production
environment:
NODE_ENV: production
DATABASE_URL: ${DATABASE_URL}environmentsets variables directly in the Compose file. If the same key appears inenv_file, theenvironmentvalue wins.env_fileloads variables from a file and injects them into the container.${VAR}interpolation fills in values inside the Compose file itself, taken from your shell or from a file named.envnext to it.
The gotcha is that the .env file Compose reads automatically is used for interpolation only. Its contents are not sent to your containers unless you reference them through environment or env_file. Plenty of people assume the opposite and spend an hour wondering why a variable is undefined.
Keep a committed .env.example listing every variable name with fake values, and keep the real .env out of Git. New teammates (or a new server) can copy the example and fill in the blanks.
File-Based Secrets: A Better Default
For real secrets, mount them as files instead of passing them as variables. Compose has first-class support for this:
services:
api:
build: .
environment:
NODE_ENV: production
DATABASE_URL_FILE: /run/secrets/database_url
JWT_SECRET_FILE: /run/secrets/jwt_secret
secrets:
- database_url
- jwt_secret
depends_on:
- db
db:
image: postgres:17
environment:
POSTGRES_USER: app
POSTGRES_DB: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- pgdata:/var/lib/postgresql/data
secrets:
database_url:
file: ./secrets/database_url.txt
jwt_secret:
file: ./secrets/jwt_secret.txt
db_password:
file: ./secrets/db_password.txt
volumes:
pgdata:Each secret appears inside the container as a read-only file at /run/secrets/<name>. The values no longer show up in docker inspect, and they aren't inherited by every child process.
The official Postgres image supports this pattern out of the box through the _FILE suffix, as shown with POSTGRES_PASSWORD_FILE. Many other popular images follow the same convention.
Be realistic about what this gives you. In plain Compose (without Swarm), a file secret is a bind mount of a file on the host. It isn't encrypted, so the host's file permissions still matter. Keep the secrets/ folder out of Git, outside any web-served directory, and restrict it with something like chmod 600. Just make sure the user your container runs as can still read the files, because a file locked to your host user causes permission errors inside the container. Docker Swarm secrets go further by storing them encrypted and mounting them in memory, but that's a different setup.
Reading _FILE Secrets in Node.js
Your own app can follow the same convention with a tiny helper:
// secrets.ts
import { readFileSync } from "node:fs";
export function readSecret(name: string): string | undefined {
const path = process.env[`${name}_FILE`];
if (path) return readFileSync(path, "utf8").trim();
return process.env[name];
}It prefers the file when DATABASE_URL_FILE is set and falls back to a plain DATABASE_URL variable, which keeps local development simple. Pair it with startup validation so a missing secret stops the app immediately instead of failing on the first request:
import * as z from "zod";
import { readSecret } from "./secrets";
const Env = z.object({
DATABASE_URL: z.url(),
JWT_SECRET: z.string().min(32),
});
export const env = Env.parse({
DATABASE_URL: readSecret("DATABASE_URL"),
JWT_SECRET: readSecret("JWT_SECRET"),
});If you run a background worker from the same image, give it the same helper and only the secrets it actually needs. A worker that sends emails has no business holding your JWT signing key.
When a Tool Only Accepts Environment Variables
Some software gives you no choice. Many tunnel and agent tools expect a token in an environment variable, for example a Cloudflare tunnel token. In that case:
Check the tool's docs first, since some support reading a token from a file.
Keep the value in a root-owned file outside Git and reference it with
env_filerather than writing it into the Compose file.Restrict who can run Docker commands on the host, because that access is effectively access to the variable.
Give each token the narrowest scope the provider allows, so a leak has a small blast radius.
It's not perfect, but it's a reasonable compromise when the alternative is not using the tool.
Build-Time Secrets
Sometimes the secret is needed during the build, for instance an npm token to install a private package. Use BuildKit secret mounts. The secret is available only for that single RUN step and is never written into a layer:
# syntax=docker/dockerfile:1
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
COPY . .And pass it when building:
docker build --secret id=npmrc,src=$HOME/.npmrc -t my-api .Compare that to ARG NPM_TOKEN, which leaves the token in the build history.
Keep Secrets Out of Your Logs
If you read container logs with docker logs or a web viewer like Dozzle, remember that those tools show everything your app prints, to everyone who can open them. A secret that reaches stdout is a secret that's on a dashboard.
The usual ways it gets there:
Logging the whole environment or config object at startup
Logging full request objects, including
Authorizationheaders and cookiesPrinting connection strings in error messages
If you use Pino, the redact option masks sensitive paths before they're written:
import pino from "pino";
export const logger = pino({
redact: ["req.headers.authorization", "req.headers.cookie", "password", "*.password", "token", "*.token"],
});Also be careful where you paste the output of docker compose config or docker inspect. They're great for debugging and terrible for sharing.
Git Hygiene and What to Do After a Leak
Prevention is mostly boring habits:
Add
.env,.env.*, and yoursecrets/folder to.gitignore, and keep.env.examplecommitted.Run a secret scanner such as gitleaks or TruffleHog as a pre-commit hook or in CI.
Never put real credentials in a Dockerfile, a Compose file you commit, or a README.
If a secret does leak, the order matters:
Rotate it first. Create a new credential and revoke the old one.
Then clean up. Remove it from the repo, history, and images.
Check for misuse. Look at access logs from the provider for the period it was exposed.
Removing a secret from Git history without rotating it doesn't help. Assume anyone who could have seen it already copied it.
Rotation and Growing Beyond Files
With environment variables, changing a secret means recreating the container, and docker compose up -d does that for you when the configuration changes. File-based secrets need one extra step: Compose doesn't notice when a secret file's contents change, so restart or recreate the service (for example with docker compose up -d --force-recreate) so the app reads the new value.
Postgres has its own catch. The official image only reads POSTGRES_PASSWORD_FILE when it initializes an empty data volume, so changing the secret later doesn't change the password inside an existing database. Rotate it inside Postgres first (with ALTER ROLE), then update the secret and restart the services that use it.
For a single server running a handful of services, file-based secrets with tight permissions are a solid baseline. As things grow, such as multiple servers, teams, audit requirements, or frequent rotation, look at a proper secrets manager. Options include Doppler, Infisical, HashiCorp Vault, SOPS for encrypted files in Git, 1Password's CLI, and the managers built into the major cloud providers. They add access control, audit logs, and rotation, which flat files can't give you.
A Quick Checklist
Separate config from secrets, and treat anything you'd have to rotate after a leak as a secret.
Never use
ENV,ARG, orCOPY .envto put secrets into an image, and keep a.dockerignore.Remember that
.envin Compose is for interpolation, whileenv_fileis what injects variables.Prefer file-based secrets (
/run/secrets/...and_FILEvariables) for real credentials.Use BuildKit secret mounts for build-time secrets.
Validate secrets at startup so the app fails fast when one is missing.
Redact sensitive fields in logs, especially if you use a log viewer.
Scan for secrets in Git, and rotate first if one ever leaks.
None of this requires a big platform or a budget. A handful of habits, applied consistently, removes the most common ways credentials escape. What does your current setup do with its database password? It's a useful thing to check today.
Comments(0)
Join the discussion by logging into your account.
No comments yet. Be the first to comment!