You need to pass a secret (API key, database password, SSH key) to your Docker build. You do this:
FROM node:18ENV DATABASE_PASSWORD=super_secret_123RUN npm install...Congratulations, you’ve baked a plaintext secret into your image. Anyone with access to the image can read it. It’s visible in docker history, in the image layers, in your registry, everywhere.
$ docker history myapp:latestIMAGE CREATED CREATED BY SIZEabc123def456 2 minutes ago /bin/sh -c #(nop) ENV DATABASE_PASSWORD=s... 0Bdef456ghi789 2 minutes ago /bin/sh -c npm install 50MB...
# Or inspect it directly$ docker inspect myapp:latest | grep DATABASE_PASSWORDThis is how secrets get leaked. Build secrets don’t belong in ENV, and they definitely don’t belong in image layers.
What NOT to Do
Don’t use ENV for secrets:
# Bad: Visible in layersENV API_KEY=sk-12345...ENV DATABASE_URL=postgres://user:password@host/dbENV PRIVATE_SSH_KEY="-----BEGIN RSA..."Don’t use ARG with secrets:
# Bad: ARG is visible in build historyARG API_KEY=sk-12345...ARG looks slightly better (it’s not persisted in the image), but it’s still visible in docker history and in your build logs.
The Right Way: BuildKit Secrets
Docker BuildKit (the modern build backend) supports a --secret flag that passes secrets without baking them into layers.
FROM node:18WORKDIR /app
COPY package*.json ./
# Mount the secret as a temporary file during buildRUN --mount=type=secret,id=npm_token \ npm ci --legacy-peer-deps
COPY . .RUN npm run build
CMD ["npm", "start"]Build with:
$ docker build --secret npm_token=/path/to/.npmrc .Or from stdin:
$ docker build --secret npm_token=- . < /home/user/.npmrcThe secret is mounted at /run/secrets/npm_token (a tmpfs, not persisted) during the RUN, then discarded. It never appears in the image.
BuildKit Secrets Example: Private Git Repo
Clone a private repo during build:
FROM ubuntu:22.04RUN apt-get update && apt-get install -y git openssh-client
# Mount SSH key as a secretRUN --mount=type=ssh \ git clone git@github.com:company/private-repo.git /app
COPY . /appCMD ["./startup.sh"]Build with:
$ docker build --ssh default ~/.ssh/id_rsa .The SSH key is mounted during the git clone, then cleaned up. It’s never in the image.
Runtime Environment Variables
For secrets that your app needs at runtime (not build time), use environment variables:
FROM node:18COPY . /appWORKDIR /appCMD ["node", "index.js"]Don’t hardcode secrets. Pass them at runtime:
# Via -e flag$ docker run -e DATABASE_PASSWORD=secret myapp
# Via --env-file$ docker run --env-file .env myapp
# Via compose$ docker-compose up # .env file is auto-loadedOr in compose:
services: api: image: myapp:latest environment: - DATABASE_PASSWORD=${DATABASE_PASSWORD} # Interpolated from host env - API_KEY=${API_KEY}Run with:
$ DATABASE_PASSWORD=secret API_KEY=key123 docker-compose upThe secrets never enter the image. They’re injected at runtime from your environment.
.env Files (Development)
For development, use a .env file (git-ignored):
DATABASE_PASSWORD=dev_secret_123DATABASE_URL=postgresql://localhost/dev_dbAPI_KEY=dev_key_456Compose reads it automatically:
services: db: image: postgres:15 environment: POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
api: build: . environment: DATABASE_URL: ${DATABASE_URL} API_KEY: ${API_KEY}$ docker-compose up # Reads .env, injects varsThe .env file stays on your machine, never committed, never in the image.
Production Pattern: Secrets Manager
For production, use a secrets manager (Vault, AWS Secrets Manager, 1Password, etc.):
# At deployment time, fetch secret and inject$ DB_PASSWORD=$(aws secretsmanager get-secret-value --secret-id prod/db/password | jq -r .SecretString) \ docker run -e DATABASE_PASSWORD=$DB_PASSWORD myapp:v1.2.3Or with Kubernetes (which has built-in secrets):
apiVersion: v1kind: Podmetadata: name: apispec: containers: - name: api image: myapp:v1.2.3 env: - name: DATABASE_PASSWORD valueFrom: secretKeyRef: name: db-credentials key: passwordKubernetes injects the secret at pod startup. It’s never in the image.
Complete Example: Secure Dockerfile + Compose
FROM node:18-slim AS builderWORKDIR /app
COPY package*.json ./
# Mount npm token as secret during install (not in image)RUN --mount=type=secret,id=npm_token \ npm ci --production
COPY . .RUN npm run build
# Runtime stage: no secretsFROM node:18-slimWORKDIR /app
COPY --from=builder /app/node_modules ./node_modulesCOPY --from=builder /app/dist ./distCOPY package*.json ./
# Secrets injected at runtime, not in imageCMD ["node", "dist/index.js"]version: '3.8'
services: api: build: context: . secrets: - npm_token environment: DATABASE_URL: ${DATABASE_URL} API_KEY: ${API_KEY} ports: - "3000:3000"
secrets: npm_token: file: ${HOME}/.npmrc
networks: default:Build and run:
$ DATABASE_URL=postgres://... API_KEY=sk-... docker-compose upSecrets never touch the image. Secure. Clean. Auditable.
Checklist
- No secrets in Dockerfile ENV or ARG
- Build secrets use
RUN --mount=type=secret - Runtime secrets use environment variables or compose env
- Development uses
.env(git-ignored) - Production uses a secrets manager
-
docker historydoesn’t leak secrets -
.envis in.gitignore - Test: build, inspect image, no plaintext secrets visible
Hardcoding secrets is how breaches happen. It’s also often how compliance audits fail. Use BuildKit secrets for build time, environment variables for runtime.