|

Securing Secrets in Docker Builds (Part 7/7)

Okay, bonus round. I thought Part 6 was the end, but then I looked at the Dockerfile from Part 5 again and realized something was bothering me.

We were passing Contentful tokens as ARG and ENV in the Dockerfile. That works, but it’s not great from a security perspective. Let me explain why and how to fix it.

The problem with build args

Here’s what our Dockerfile had:

ARG CONTENTFUL_SPACE_ID
ARG CONTENTFUL_DELIVERY_TOKEN
ENV CONTENTFUL_SPACE_ID=$CONTENTFUL_SPACE_ID
ENV CONTENTFUL_DELIVERY_TOKEN=$CONTENTFUL_DELIVERY_TOKEN

RUN npm run build

This gets the job done, but there’s a catch: build args are stored in the image history. Anyone with access to the image can run docker history and see the values. The ENV instruction is even worse — those persist into the running container.

For a personal site with read-only Contentful tokens, the risk is low. But if you’re passing something more sensitive — an API key for sending emails, a database password, a management token — this becomes a real problem.

Enter BuildKit secrets

Docker BuildKit has a feature called build secrets. Instead of baking sensitive values into image layers, you mount them as temporary files that exist only during a specific RUN instruction. They never end up in the image history.

Here’s what the updated Dockerfile looks like:

# Stage 1: Build
FROM node:22-alpine AS build
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --production=false

COPY astro.config.mjs tsconfig.json ./
COPY src ./src
COPY public ./public

# Mount secrets and build
# The secrets are available as files during this RUN step only
RUN --mount=type=secret,id=contentful_space_id \
    --mount=type=secret,id=contentful_delivery_token \
    CONTENTFUL_SPACE_ID=$(cat /run/secrets/contentful_space_id) \
    CONTENTFUL_DELIVERY_TOKEN=$(cat /run/secrets/contentful_delivery_token) \
    npm run build

# Stage 2: Serve
FROM nginx:1.27-alpine

RUN rm /etc/nginx/conf.d/default.conf
RUN rm -rf /usr/share/nginx/html/*

COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html

EXPOSE 80

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget -qO- http://localhost/ || exit 1

The key change is the RUN --mount=type=secret line. The secrets are mounted as files at /run/secrets/<id>, read into environment variables with cat, and used for that single RUN command. Once the command finishes, they’re gone. Not in the layer, not in the history, not anywhere.

How it works locally

To use secrets with docker build, you need BuildKit enabled (it’s the default in modern Docker) and you pass secrets via --secret:

docker build \
  --secret id=contentful_space_id,src=.env.contentful_space_id \
  --secret id=contentful_delivery_token,src=.env.contentful_delivery_token \
  -f docker/Dockerfile .

Each secret points to a file containing the value. I’d keep these as single-value files:

# .secrets/contentful_space_id
your_space_id_here

# .secrets/contentful_delivery_token
your_delivery_token_here

Add .secrets/ to .gitignore and .dockerignore.

Or if you’d rather not create separate files, you can pipe values from environment variables:

echo "$CONTENTFUL_SPACE_ID" | docker build \
  --secret id=contentful_space_id,src=/dev/stdin \
  -f docker/Dockerfile .

Though honestly, separate files are cleaner for multiple secrets.

Docker Compose with secrets

For local development with docker compose, the syntax is slightly different:

version: "3.9"

services:
  website:
    build:
      context: .
      dockerfile: docker/Dockerfile
      secrets:
        - contentful_space_id
        - contentful_delivery_token
    ports:
      - "80:80"
    restart: unless-stopped

secrets:
  contentful_space_id:
    environment: CONTENTFUL_SPACE_ID
  contentful_delivery_token:
    environment: CONTENTFUL_DELIVERY_TOKEN

Docker Compose reads the values from your environment (or .env file) and passes them as build secrets. Same security benefit, same familiar workflow.

GitHub Actions with build secrets

This is where it gets really practical. Here’s the updated CI/CD workflow from Part 5, now using secrets instead of build-args:

name: Build and Deploy

on:
  push:
    branches: [main]
  repository_dispatch:
    types: [contentful_publish]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v5
        with:
          context: .
          file: docker/Dockerfile
          push: true
          tags: ghcr.io/${{ github.repository }}:latest
          # Pass sensitive data as build secrets (not stored in image layers)
          secrets: |
            contentful_space_id=${{ secrets.CONTENTFUL_SPACE_ID }}
            contentful_delivery_token=${{ secrets.CONTENTFUL_DELIVERY_TOKEN }}            
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_KEY }}
          script: |
            docker pull ghcr.io/${{ github.repository }}:latest
            docker compose -f /opt/website/docker-compose.yml up -d            

Notice the difference: instead of build-args, we use secrets. The docker/build-push-action supports this natively. GitHub Actions secrets are passed to Docker BuildKit secrets, which are mounted temporarily during the build and never stored in the image.

What about more sensitive secrets?

Let’s say you add a contact form that sends emails via Resend. Now you’ve got an API key that can send emails on your behalf. Definitely don’t want that leaking.

Same pattern:

RUN --mount=type=secret,id=resend_api_key \
    --mount=type=secret,id=contentful_space_id \
    --mount=type=secret,id=contentful_delivery_token \
    RESEND_API_KEY=$(cat /run/secrets/resend_api_key) \
    CONTENTFUL_SPACE_ID=$(cat /run/secrets/contentful_space_id) \
    CONTENTFUL_DELIVERY_TOKEN=$(cat /run/secrets/contentful_delivery_token) \
    npm run build

And in the GitHub Actions workflow:

secrets: |
  contentful_space_id=${{ secrets.CONTENTFUL_SPACE_ID }}
  contentful_delivery_token=${{ secrets.CONTENTFUL_DELIVERY_TOKEN }}
  resend_api_key=${{ secrets.RESEND_API_KEY }}  

Every secret follows the same pattern. Mount it, read it, use it, forget it.

Build-args vs secrets: when to use which

Not everything needs to be a secret. Here’s my rule of thumb:

Use ARG / build-argsUse BuildKit secrets
Public config valuesAPI keys and tokens
Node environment (production)Database passwords
Feature flagsManagement tokens
Base URLsPrivate keys

If the value showing up in docker history would make you uncomfortable, use a secret. If it’s just config that anyone could guess anyway, a build arg is fine.

Verifying it works

After building with secrets, check that nothing leaked:

# Build the image
docker build --secret id=contentful_space_id,src=.secrets/contentful_space_id \
             --secret id=contentful_delivery_token,src=.secrets/contentful_delivery_token \
             -f docker/Dockerfile -t mysite .

# Check the history — no secrets visible
docker history mysite

# Inspect the image — no ENV with secrets
docker inspect mysite | grep -i contentful

If you see your tokens in either output, something’s wrong. With the --mount=type=secret approach, you shouldn’t.

Quick security checklist

While we’re at it, here are a few more things I’d check before going live:

  • .env files are in .gitignore and .dockerignore
  • No secrets in docker-compose.yml — use environment references
  • Nginx security headers are set (covered in Part 5)
  • CSP header restricts external resource loading
  • No latest tag in production — use image digests or version tags
  • GitHub Actions secrets are set as repository secrets, not hardcoded
  • Contentful delivery token is read-only (not management token)
  • Preview token is only used in dev, never in production builds

Seven posts, one architecture. A static site backed by a headless CMS, validated at build time, served by Nginx in a tiny Docker container, deployed automatically, with secrets that never touch the image.

Not bad for a personal website.

Keep pushing forward and savor every step of your coding journey.