Layers are forever

Every ARG. Every ENV. Every COPY. Baked into layers. Pushed to registries. Cached on CI runners. Pulled by machines you'll never audit. Forever.

the reveal
docker history myapp:latest
IMAGE CREATED BY
abc123 ENV DATABASE_URL=postgres://prod:s3cr3t@...
def456 ARG STRIPE_KEY=sk_live_xxxxxxxxxx
ghi789 COPY .env /app/.env
// Anyone with image access can see this.

The Docker Secret Problem

Build-time secrets become permanent artifacts.

Layers are immutable

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.

Registries cache everything

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.

CI runners keep copies

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.

Build-time is the wrong time.

Secrets belong at runtime. Inject, don't bake.

The Pattern

Encrypted file in image. Decryption at container start.

Dockerfile — wrong

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

Dockerfile — right

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

Docker Compose

Mount the decryption key. Never bake it.

docker-compose.yml

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
run
docker compose build
docker compose up
app_1 | Server running on :3000

Docker Traps

Multi-stage "cleanup"

# 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.

Runtime only

# 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.

Decrypt then COPY

# 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

Encrypted in image

# .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

CI/CD

Test with secrets. Push without them.

First: Create a service account. CI needs a dedicated identity with its own keypair. The private key goes in GitHub Secrets, the public key is registered with envv. See the docs →

1. One-time setup (admin)

# 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

2. Store in GitHub Secrets

# 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

3. GitHub Actions workflow

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

Kubernetes

Same principle. Secret mounted, not baked.

1. Create K8s secret from service account

# 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

2. Mount in deployment

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

Audit Your Images

Right now. Before you continue reading.

the check
# Check what's in your image history
docker history your-app:latest --no-trunc
# Look for these patterns
ARG SECRET=...
ENV API_KEY=...
COPY .env ...
RUN echo $SECRET...
# If you see secrets, your image is compromised
Every registry that has it
Every CI cache that pulled it
Every machine that ran it

.dockerignore

Your first line of defense.

.dockerignore

# 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.

Setup

migrate to runtime secrets
# Install envv
curl -fsSL https://getenvv.com/envv | sh
# Encrypt your .env
envv push .env --env dev
rm .env
# Update .dockerignore
echo ".env" >> .dockerignore
# Add envv to Dockerfile
RUN curl -fsSL https://getenvv.com/install.sh | sh
CMD ["envv", "run", "--", "node", "server.js"]
# Rebuild and verify
docker build -t myapp .
docker history myapp --no-trunc | grep -i secret
# No matches. You're clean.

Build clean. Run secure.

Secrets at runtime. Never in layers. Never in history.

curl -fsSL https://getenvv.com/envv | sh