Docker + Nginx für statische Seiten (Teil 5/7)
Fast geschafft. Wir haben eine Astro-Seite, die von Contentful zieht und optimierte statische Dateien baut. Jetzt packen wir das Ganze ein und machen es live.
Meine Hugo-Seite läuft bereits mit Docker und Nginx, also ist das Muster vertraut — einfach die Build-Stage von Hugo auf Node.js umstellen.
Das Dockerfile
Zwei Stages: Node baut die Seite, Nginx liefert sie aus. Das finale Image hat kein Node.js, keine node_modules, keinen Quellcode. Nur HTML und Nginx.
# 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
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
# 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
Im Vergleich zu meinem aktuellen Setup:
npm cistattapk add hugo. Deterministische Installationen, keine Überraschungen.- Build-Args für Contentful-Tokens. Existieren nur in der Build-Stage, die verworfen wird.
- Healthcheck. Etwas, das ich vorher hätte hinzufügen sollen. Docker kann den Container neu starten, wenn Nginx ausfällt.
- Gleiche Nginx-Alpine-Basis. Finales Image bleibt unter 30MB.
Nginx-Konfiguration
Hier würde ich von meinem aktuellen „einfach Dateien ausliefern"-Ansatz auf etwas Produktionswürdiges upgraden:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Sicherheits-Header
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' https://images.ctfassets.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self'" always;
# Gzip
gzip on;
gzip_vary on;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types text/plain text/css text/xml text/javascript
application/javascript application/json application/xml
application/rss+xml image/svg+xml;
# Astro hasht Dateinamen — für immer cachen
location /_astro/ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Schriften und Icons
location ~* \.(ico|svg|woff|woff2|ttf|eot)$ {
expires 6M;
add_header Cache-Control "public";
access_log off;
}
# Bilder
location ~* \.(jpg|jpeg|png|gif|webp|avif)$ {
expires 1M;
add_header Cache-Control "public";
access_log off;
}
# Root auf Standard-Locale weiterleiten
location = / {
return 301 /en/;
}
# Saubere URLs
location / {
try_files $uri $uri/ $uri/index.html =404;
}
error_page 404 /en/404.html;
# Keine versteckten Dateien
location ~ /\. {
deny all;
access_log off;
}
}
Die Highlights:
Sicherheits-Header. Schutz vor Clickjacking, MIME-Sniffing und Einschränkung externer Ressourcen.
Aggressives Caching. Astro legt gehashte Dateinamen in /_astro/ ab — die ändern sich nie. Ein Jahr cachen. Schriften 6 Monate. Bilder 1 Monat. HTML Standard-Verhalten.
try_files. Macht saubere URLs ohne Server-seitiges Routing möglich.
Docker Compose
Fast identisch zu meinem aktuellen Setup:
version: "3.9"
services:
website:
build:
context: .
dockerfile: docker/Dockerfile
args:
CONTENTFUL_SPACE_ID: ${CONTENTFUL_SPACE_ID}
CONTENTFUL_DELIVERY_TOKEN: ${CONTENTFUL_DELIVERY_TOKEN}
ports:
- "80:80"
restart: unless-stopped
docker compose up --build -d
Gleicher Workflow. Einzige Änderung: die Contentful Build-Args.
CI/CD mit GitHub Actions
Deploy bei jedem Push auf main und wenn in Contentful Inhalte veröffentlicht werden:
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
build-args: |
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
Contentful-Webhook. Webhook erstellen, der den GitHub-Dispatch auslöst:
URL: https://api.github.com/repos/DEIN_USER/DEIN_REPO/dispatches
Method: POST
Headers:
Accept: application/vnd.github.v3+json
Authorization: Bearer DEIN_GITHUB_PAT
Body:
{ "event_type": "contentful_publish" }
In Contentful veröffentlichen → Webhook feuert → GitHub Action baut → neues Docker-Image → deployed. Etwa 2 Minuten von Anfang bis Ende.
SSL falls nötig
Wenn du nicht hinter einem Load Balancer stehst, kann ein Host-Level-Nginx SSL übernehmen:
server {
listen 80;
server_name oltionzefi.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name oltionzefi.com;
ssl_certificate /etc/letsencrypt/live/oltionzefi.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/oltionzefi.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000" always;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Host-Nginx kümmert sich um SSL und leitet zum Docker-Container weiter.
Womit wir am Ende dastehen
├── astro.config.mjs
├── package.json
├── tsconfig.json
├── docker/
│ ├── Dockerfile
│ └── nginx.conf
├── docker-compose.yml
├── .github/workflows/deploy.yml
├── public/
│ ├── favicon.svg
│ └── robots.txt
├── src/
│ ├── components/
│ ├── layouts/
│ ├── lib/
│ ├── pages/
│ ├── styles/
│ └── types/
└── scripts/
└── migrate-to-contentful.ts
Was kommt als Nächstes
Die Deployment-Story steht. Noch zwei Posts — Teil 6 deckt Zod-Schemas ab, um Contentful-Daten zur Build-Zeit zu validieren, und Teil 7 kümmert sich um die sichere Handhabung von Secrets in Docker-Builds.
Weiter voran und genieße jeden Schritt deiner Coding-Reise.
