|

Secrets in Docker-Builds absichern (Teil 7/7)

OK, Bonusrunde. Ich dachte, Teil 6 wäre das Ende, aber dann habe ich mir das Dockerfile aus Teil 5 nochmal angeschaut und gemerkt, dass mich etwas gestört hat.

Wir haben Contentful-Tokens als ARG und ENV im Dockerfile übergeben. Das funktioniert, aber aus Sicherheitssicht ist das nicht ideal. Lass mich erklären, warum und wie man es besser macht.

Das Problem mit Build-Args

So sah unser Dockerfile aus:

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

Das erledigt den Job, aber es gibt einen Haken: Build-Args werden in der Image-History gespeichert. Jeder mit Zugriff auf das Image kann docker history ausführen und die Werte sehen. Die ENV-Anweisung ist noch schlimmer — die bleibt im laufenden Container bestehen.

Für eine persönliche Seite mit nur-lesen Contentful-Tokens ist das Risiko gering. Aber wenn du etwas Sensibleres übergibst — einen API-Schlüssel zum E-Mail-Versand, ein Datenbank-Passwort — wird das zum echten Problem.

BuildKit Secrets

Docker BuildKit hat ein Feature namens Build Secrets. Statt sensible Werte in Image-Layer zu backen, werden sie als temporäre Dateien gemountet, die nur während eines bestimmten RUN-Befehls existieren. Sie landen nie in der Image-History.

So sieht das aktualisierte Dockerfile aus:

# 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

# Secrets mounten und bauen
# Die Secrets sind nur während dieses RUN-Schritts verfügbar
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

Die entscheidende Änderung ist die RUN --mount=type=secret-Zeile. Die Secrets werden als Dateien unter /run/secrets/<id> gemountet, mit cat in Umgebungsvariablen gelesen und für diesen einen RUN-Befehl verwendet. Danach sind sie weg. Nicht im Layer, nicht in der History, nirgends.

Lokal verwenden

Um Secrets mit docker build zu nutzen, brauchst du BuildKit (Standard in modernem Docker) und übergibst Secrets via --secret:

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 .

Jedes Secret verweist auf eine Datei mit dem Wert:

# .secrets/contentful_space_id
deine_space_id_hier

# .secrets/contentful_delivery_token
dein_delivery_token_hier

.secrets/ zur .gitignore und .dockerignore hinzufügen.

Oder wenn du lieber keine separaten Dateien erstellen möchtest, kannst du Werte aus Umgebungsvariablen pipen:

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

Obwohl separate Dateien für mehrere Secrets ehrlich gesagt sauberer sind.

Docker Compose mit Secrets

Für die lokale Entwicklung sieht die Syntax etwas anders aus:

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 liest die Werte aus deiner Umgebung (oder .env-Datei) und übergibt sie als Build-Secrets.

GitHub Actions mit Build Secrets

Hier wird es richtig praktisch. Der aktualisierte CI/CD-Workflow aus Teil 5, jetzt mit Secrets statt 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
          # Sensible Daten als Build-Secrets übergeben (nicht in Image-Layern gespeichert)
          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            

Statt build-args verwenden wir secrets. Die docker/build-push-action unterstützt das nativ. GitHub Actions Secrets werden an Docker BuildKit Secrets weitergereicht, die nur temporär während des Builds gemountet werden.

Noch sensiblere Secrets

Angenommen, du fügst ein Kontaktformular hinzu, das E-Mails über Resend verschickt. Jetzt hast du einen API-Schlüssel, der in deinem Namen E-Mails senden kann. Den willst du definitiv nicht leaken.

Gleiches Muster:

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

Und im 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 }}  

Jedes Secret folgt dem gleichen Muster. Mounten, lesen, verwenden, vergessen.

Build-Args vs. Secrets: Wann was verwenden?

Nicht alles muss ein Secret sein. Meine Faustregel:

Use ARG / build-argsUse BuildKit secrets
Öffentliche Config-WerteAPI-Schlüssel und Tokens
Node-Umgebung (production)Datenbank-Passwörter
Feature-FlagsManagement-Tokens
Basis-URLsPrivate Schlüssel

Wenn der Wert in docker history auftauchen würde und dich das nervös macht, verwende ein Secret. Wenn es eh nur Config ist, reicht ein Build-Arg.

Überprüfen, dass es funktioniert

Nach dem Build mit Secrets prüfen, ob nichts durchgesickert ist:

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 .

# History prüfen — keine Secrets sichtbar
docker history mysite

# Image inspizieren — kein ENV mit Secrets
docker inspect mysite | grep -i contentful

Wenn du deine Tokens in der Ausgabe siehst, stimmt etwas nicht. Mit dem --mount=type=secret-Ansatz solltest du sie nicht sehen.

Sicherheits-Checkliste

Wo wir schon dabei sind — ein paar Dinge, die ich vor dem Go-Live prüfen würde:

  • .env-Dateien in .gitignore und .dockerignore
  • Keine Secrets in docker-compose.yml — Umgebungsreferenzen verwenden
  • Nginx-Sicherheits-Header gesetzt (behandelt in Teil 5)
  • CSP-Header schränkt externe Ressourcen ein
  • Kein latest-Tag in Produktion — Image-Digests oder Versions-Tags verwenden
  • GitHub Actions Secrets als Repository-Secrets gesetzt, nicht hardcoded
  • Contentful Delivery Token ist nur-lesen (nicht Management-Token)
  • Preview Token wird nur in der Entwicklung verwendet, nie in Produktions-Builds

Sieben Posts, eine Architektur. Eine statische Seite, gespeist von einem Headless-CMS, zur Build-Zeit validiert, von Nginx in einem winzigen Docker-Container ausgeliefert, automatisch deployed, mit Secrets die nie das Image berühren.

Nicht schlecht für eine persönliche Website.

Weiter voran und genieße jeden Schritt deiner Coding-Reise.