|

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 ci statt apk 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.