Skip to content
Go back

Stop Putting Passwords in Docker ENV

By SumGuy 5 min read
Stop Putting Passwords in Docker ENV

You need to pass a secret (API key, database password, SSH key) to your Docker build. You do this:

Dockerfile
FROM node:18
ENV DATABASE_PASSWORD=super_secret_123
RUN 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.

Terminal window
$ docker history myapp:latest
IMAGE CREATED CREATED BY SIZE
abc123def456 2 minutes ago /bin/sh -c #(nop) ENV DATABASE_PASSWORD=s... 0B
def456ghi789 2 minutes ago /bin/sh -c npm install 50MB
...
# Or inspect it directly
$ docker inspect myapp:latest | grep DATABASE_PASSWORD

This 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 layers
ENV API_KEY=sk-12345...
ENV DATABASE_URL=postgres://user:password@host/db
ENV PRIVATE_SSH_KEY="-----BEGIN RSA..."

Don’t use ARG with secrets:

# Bad: ARG is visible in build history
ARG 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.

Dockerfile
FROM node:18
WORKDIR /app
COPY package*.json ./
# Mount the secret as a temporary file during build
RUN --mount=type=secret,id=npm_token \
npm ci --legacy-peer-deps
COPY . .
RUN npm run build
CMD ["npm", "start"]

Build with:

Terminal window
$ docker build --secret npm_token=/path/to/.npmrc .

Or from stdin:

Terminal window
$ docker build --secret npm_token=- . < /home/user/.npmrc

The 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:

Dockerfile
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y git openssh-client
# Mount SSH key as a secret
RUN --mount=type=ssh \
git clone git@github.com:company/private-repo.git /app
COPY . /app
CMD ["./startup.sh"]

Build with:

Terminal window
$ 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:

Dockerfile
FROM node:18
COPY . /app
WORKDIR /app
CMD ["node", "index.js"]

Don’t hardcode secrets. Pass them at runtime:

Terminal window
# 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-loaded

Or in compose:

docker-compose.yml
services:
api:
image: myapp:latest
environment:
- DATABASE_PASSWORD=${DATABASE_PASSWORD} # Interpolated from host env
- API_KEY=${API_KEY}

Run with:

Terminal window
$ DATABASE_PASSWORD=secret API_KEY=key123 docker-compose up

The secrets never enter the image. They’re injected at runtime from your environment.

.env Files (Development)

For development, use a .env file (git-ignored):

.env
DATABASE_PASSWORD=dev_secret_123
DATABASE_URL=postgresql://localhost/dev_db
API_KEY=dev_key_456

Compose reads it automatically:

docker-compose.yml
services:
db:
image: postgres:15
environment:
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
api:
build: .
environment:
DATABASE_URL: ${DATABASE_URL}
API_KEY: ${API_KEY}
Terminal window
$ docker-compose up # Reads .env, injects vars

The .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.):

Terminal window
# 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.3

Or with Kubernetes (which has built-in secrets):

apiVersion: v1
kind: Pod
metadata:
name: api
spec:
containers:
- name: api
image: myapp:v1.2.3
env:
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: password

Kubernetes injects the secret at pod startup. It’s never in the image.

Complete Example: Secure Dockerfile + Compose

Dockerfile
FROM node:18-slim AS builder
WORKDIR /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 secrets
FROM node:18-slim
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package*.json ./
# Secrets injected at runtime, not in image
CMD ["node", "dist/index.js"]
docker-compose.yml
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:

Terminal window
$ DATABASE_URL=postgres://... API_KEY=sk-... docker-compose up

Secrets never touch the image. Secure. Clean. Auditable.

Checklist

Hardcoding secrets is how breaches happen. It’s also often how compliance audits fail. Use BuildKit secrets for build time, environment variables for runtime.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it may appear here.


Previous Post
The Linux OOM Killer: Why It's Killing Your App
Next Post
find Flags You Keep Forgetting

Related Posts