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-args | Use BuildKit secrets |
|---|---|
| Public config values | API keys and tokens |
Node environment (production) | Database passwords |
| Feature flags | Management tokens |
| Base URLs | Private 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:
-
.envfiles are in.gitignoreand.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
latesttag 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.
