Every ARG. Every ENV. Every COPY. Baked into layers. Pushed to registries. Cached on CI runners. Pulled by machines you'll never audit. Forever.
Build-time secrets become permanent artifacts.
Once a layer is created, it can't be modified. Delete the file in the next layer? The previous layer still has it. Every intermediate step is preserved.
Push to Docker Hub, ECR, GCR—every layer is stored. Even "private" registries are accessible to everyone with repo access. That's more people than you think.
GitHub Actions, GitLab CI, CircleCI—they cache layers for speed. Your secrets live on machines you don't control, in build caches you can't clear.
Secrets belong at runtime. Inject, don't bake.
Encrypted file in image. Decryption at container start.
FROM node:20-alpine
# Secrets in build args - VISIBLE IN HISTORY
ARG DATABASE_URL
ARG STRIPE_KEY
ENV DATABASE_URL=$DATABASE_URL
ENV STRIPE_KEY=$STRIPE_KEY
# Or worse - copying plaintext
COPY .env /app/.env
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "server.js"]
# docker history reveals everything
# Even after you delete the image
FROM node:20-alpine
# Install envv
RUN apk add --no-cache curl && \
curl -fsSL https://getenvv.com/install.sh | sh
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
# .env.encrypted is ciphertext - safe to COPY
# Decryption happens at runtime
CMD ["envv", "run", "--", \
"node", "server.js"]
# docker history shows nothing sensitive
Mount the decryption key. Never bake it.
services:
app:
build: .
ports:
- "3000:3000"
volumes:
# Age key mounted at runtime, not copied into image
- ~/.config/sops/age/keys.txt:/root/.config/sops/age/keys.txt:ro
# The image contains:
# - Your code
# - .env.encrypted (ciphertext)
# - envv binary
#
# The image does NOT contain:
# - Your secrets
# - Your age key
# - Anything sensitive in any layer
# People think this works
FROM node:20 AS builder
ARG SECRET_KEY
RUN echo $SECRET_KEY > /tmp/key
RUN npm run build
FROM node:20-alpine
# "Clean" image, right?
COPY --from=builder /app/dist /app
# Wrong. The builder stage is
# cached and often pushed.
# docker history builder shows it all.
# Secrets never touch the build
FROM node:20-alpine
RUN curl -fsSL https://getenvv.com/install.sh | sh
WORKDIR /app
COPY . .
RUN npm ci
# Decryption happens when container starts
CMD ["envv", "run", "--", \
"node", "server.js"]
# No secrets in any stage. Ever.
# In your CI script
envv pull --decrypt
docker build -t myapp .
# .env gets COPY'd into the image
# because it's in the build context
# because you just decrypted it
# because you forgot about .dockerignore
# .dockerignore
.env
*.key
keys/
# Only .env.encrypted enters the image
# It's ciphertext - useless without the key
# Key is mounted at runtime
# Never part of any layer
Test with secrets. Push without them.
# Create service account for CI
envv service-account create \
--name="ci-prod" \
--org-id=org_abc123 \
--role=reader
# Outputs private key - save it
# Re-encrypt secrets for new recipient
envv push --env prod
# Go to your repo:
# Settings → Secrets → Actions
# Create secret:
# Name: SOPS_AGE_KEY
# Value: AGE-SECRET-KEY-1...
# This is the private key from step 1
# CI will use it to decrypt secrets
name: Build and Push
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup age key
run: |
mkdir -p ~/.config/sops/age
echo "${{ secrets.SOPS_AGE_KEY }}" > ~/.config/sops/age/keys.txt
chmod 600 ~/.config/sops/age/keys.txt
- name: Build image
run: docker build -t myapp .
# Image contains .env.encrypted (ciphertext) - safe
- name: Test with secrets
run: |
docker run --rm \
-v ~/.config/sops/age/keys.txt:/root/.config/sops/age/keys.txt:ro \
myapp npm test
# Key mounted at runtime, never in image
- name: Push to registry
run: docker push myapp
# No secrets in any layer
Same principle. Secret mounted, not baked.
# Create a service account for K8s
envv service-account create \
--name="k8s-prod" \
--org-id=org_abc123 \
--role=reader
# Create K8s secret from the private key
kubectl create secret generic age-private-key \
--from-literal=keys.txt="AGE-SECRET-KEY-1..."
# Re-encrypt secrets
envv push --env prod
spec:
containers:
- name: app
image: myapp:latest
volumeMounts:
- name: age-key
mountPath: /root/.config/sops/age
readOnly: true
volumes:
- name: age-key
secret:
secretName: age-private-key
items:
- key: keys.txt
path: keys.txt
mode: 0600
Right now. Before you continue reading.
Your first line of defense.
# Exclude ALL sensitive files from build context
.env
.env.*
!.env.encrypted
# Keys should never enter the context
*.key
*.pem
keys/
.config/
# Git history might have secrets
.git/
# Node modules (rebuild in container)
node_modules/
The build context is everything docker build can access.
If it's not in .dockerignore, it could end up in a layer.
Secrets at runtime. Never in layers. Never in history.
curl -fsSL https://getenvv.com/envv | sh