The Mistake

# ❌ WRONG
COPY .env .env
RUN echo "API_KEY=$API_KEY" >> config.env
CMD ["python", "app.py"]

Or even simpler:

# ❌ WRONG  
ARG API_KEY
RUN curl https://api.example.com/validate?key=$API_KEY

You think the secret is deleted after the build? It’s not.

Why It Fails

Docker layer caching. Each RUN, COPY, and ADD creates a layer. Layers are immutable and can be inspected:

docker history your-image
docker save your-image | tar -x
# Extract any layer and grep for secrets

The secret persists in that layer’s filesystem, even if a later layer deletes the file.

Git history. Commit .env once by mistake:

git log -p -- .env
# Secret visible forever (even after deletion)

Registry access. Push the image anywhere — public registry, private registry, DockerHub, GitHub Container Registry:

docker pull ghcr.io/attacker/your-image
docker save your-image | tar -x
# Extract the layers, find the secret

Anyone with image access has the secret.

CI/CD logs. Docker build output appears in GitHub Actions, GitLab CI, Jenkins:

docker build --build-arg API_KEY=$API_KEY .
# Build log captures the secret

Build context leaks. Even if you don’t COPY .env, the build context includes it:

docker build .  # includes all files in current directory

Unless .dockerignore explicitly excludes it.

Real Attack Scenarios

Scenario 1: Shared repository

  • Dev accidentally commits .env
  • Coworker clones repo, has the secret
  • Later fired employee still has the secret
  • Attacker forks the repo, has historical secrets

Scenario 2: Registry compromise

  • Private registry gets breached
  • Attacker pulls all images
  • Every image is a key-extraction treasure hunt

Scenario 3: Shared CI/CD logs

  • Build log is visible to all developers
  • Secret is in plaintext in the log
  • Log retention is years, attacker has time

Scenario 4: Accidental push

  • You push to Docker Hub to test
  • You meant to delete it after testing
  • The image is cached/indexed, never truly deleted

The Right Way: Environment Variables

Build time: No secrets.

# ✅ CORRECT
FROM python:3.12
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY app.py .
CMD ["python", "app.py"]

No ARG, no .env, no secrets in the image. The image is generic and shareable.

Runtime: Inject secrets.

# Local dev
export API_KEY="dev-key-here"
docker run -e API_KEY=$API_KEY your-image

# Docker Compose
# .env file (gitignored) sits next to compose.yml
# compose.yml reads it
docker compose up -d

# Kubernetes
kubectl create secret generic api-key --from-literal=key=$API_KEY
# Pod mounts it as env var or volume

# Cloud (AWS, GCP, Azure)
# Secrets Manager, Parameter Store, Key Vault
# Platform injects at runtime

Why this works:

  • Image is reusable across environments (dev, staging, prod)
  • Secrets never in layers, git, or logs
  • Rotation doesn’t require a rebuild
  • Image is safe to push, share, or open-source

Checklist

  • No secrets in Dockerfile
  • No ARG for API keys, tokens, passwords
  • No COPY .env or COPY .local
  • .dockerignore includes *.env, .env*, secrets/
  • .env in .gitignore (not .env.example)
  • All config read from environment at runtime
  • CI/CD passes secrets via env vars, not in config files
  • No secrets in docker build –build-arg
  • docker history contains no keys/passwords

If You Already Baked It

  1. Rotate the key immediately. The old key is burned.
  2. If pushed to git: force-push to remove from history (or nuke the repo).
  3. If pushed to a registry: delete and rebuild.
  4. Check logs: grep CI/CD history for the leaked secret.
  5. Rebuild properly: inject at runtime instead.

Summary

Baking secrets into Docker images trades away:

  • Reusability (image is tied to one environment)
  • Security (layers are forever, logs are searchable)
  • Rotation flexibility (rebuild = redeploy = downtime)

Injecting at runtime costs nothing:

  • One extra docker run -e flag
  • One .env file (gitignored)
  • Platform-native secret management

Do it once, do it right.