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-args | Use BuildKit secrets |
|---|---|
| Öffentliche Config-Werte | API-Schlüssel und Tokens |
Node-Umgebung (production) | Datenbank-Passwörter |
| Feature-Flags | Management-Tokens |
| Basis-URLs | Private 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.gitignoreund.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.
