Pierre Laub endurance & creativity

Astro Production Deployment: Zero-Downtime CI/CD mit GitHub Actions und Docker

I. Zielsetzung und Architektur 🎯

Dieser Leitfaden beschreibt die Implementierung einer robusten Zero-Downtime CI/CD-Pipeline für eine statisch generierte Astro-Anwendung, bereitgestellt auf einem Linux-Host mittels Docker und GitHub Actions. Die Strategie basiert auf einem Blue/Green-ähnlichen Ansatz inklusive Health Check, um maximale Verfügbarkeit zu gewährleisten.

II. Phase 0: Vorbereitung der Infrastruktur 🔑

1. Host- und User-Setup (Linux Server)

Für maximale Sicherheit wird ein dedizierter Deployment-User (deploy) benötigt, der Zugriff auf Docker-Befehle hat.

# 1. Create a dedicated user
sudo adduser deploy

# 2. Grant Docker permissions
sudo usermod -aG docker deploy

# 3. Apply permissions (needs relog/reboot)

# 4. Generate SSH Key Pair (locally or on a secure client)
ssh-keygen -t rsa -b 4096 -f ~/.ssh/github_actions_rsa -N ""

# 5. Place the public key on the server:
sudo su - deploy
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "<INHALT DES github_actions_rsa.pub>" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

2. Projekt- und Registry-Setup

Alle sensiblen Daten werden als GitHub Secrets hinterlegt.

Secret NameWertZweck
SSH_HOST, SSH_USER, SSH_KEYServer-IP, deploy, Private KeySSH-Zugriff für CD
DOCKERHUB_USERNAME, DOCKERHUB_TOKENRegistry-CredentialsAuthentifizierung für Build/Push

III. Phase 1: Continuous Integration (CI)

Die CI-Phase ist verantwortlich für den isolierten Build-Prozess und das Tagging des Images.

1. Dockerfile (Multi-Stage Build)

Das finale Image wird mit NGINX als Webserver und nur den statischen Assets gebaut.

# Stage 1: Build Phase
FROM node:20-alpine as builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# Stage 2: Serve Phase (Final Image)
FROM nginx:alpine
RUN rm -rf /usr/share/nginx/html/*
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

2. GitHub Actions Job: build-and-push

Der Workflow wird manuell über workflow_dispatch gestartet und taggt das Image mit dem latest Tag und dem Commit SHA (${{ github.sha }}) zur Auditierbarkeit.

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: 🐳 Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - name: 🏗️ Build and Push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ secrets.DOCKERHUB_USERNAME }}/astro-app:latest
            ${{ secrets.DOCKERHUB_USERNAME }}/astro-app:${{ github.sha }}

IV. Phase 2: Continuous Deployment (CD)

Der Deployment-Job führt die Blue/Green-Strategie über SSH aus. Der Live-Port ist 4321, der Staging-Port 4322.

1. GitHub Actions Job: deploy

Wichtig: Der Deployment-Job verwendet den SHA-spezifischen Tag (${{ github.sha }}), um sicherzustellen, dass bei jedem Deployment das exakte neue Image verwendet wird. Dies verhindert Caching-Probleme, die beim Einsatz von latest auftreten können.

deploy:
  needs: build-and-push
  runs-on: ubuntu-latest
  steps:
    - name: 💻 Deploy new Docker image via SSH
      uses: appleboy/ssh-action@v1.0.1
      with:
        host: ${{ secrets.SSH_HOST }}
        username: ${{ secrets.SSH_USER }}
        key: ${{ secrets.SSH_KEY }}
        script: |
          IMAGE_TAG=${{ secrets.DOCKERHUB_USERNAME }}/astro-app:${{ github.sha }}
          IMAGE_TAG_LATEST=${{ secrets.DOCKERHUB_USERNAME }}/astro-app:latest
          LIVE_CONTAINER_NAME=astro_website_live
          NEW_CONTAINER_NAME=astro_website_staging
          LIVE_PORT=4321
          STAGING_PORT=4322

          echo "0. Logging into Docker Hub..."
          echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin

          echo "1. Pulling new image from Docker Hub..."
          # Use SHA-specific tag to ensure we get the exact new build
          docker pull $IMAGE_TAG
          docker pull $IMAGE_TAG_LATEST

          echo "2. Starting new container on $STAGING_PORT..."
          docker run -d \
            --name $NEW_CONTAINER_NAME \
            -p $STAGING_PORT:80 \
            --restart on-failure \
            $IMAGE_TAG

          echo "3. Health check (via $STAGING_PORT)..."
          sleep 10
          if ! wget --spider -q http://localhost:$STAGING_PORT; then
            echo "❌ Health check failed! Rolling back..."
            docker stop $NEW_CONTAINER_NAME || true
            docker rm $NEW_CONTAINER_NAME || true
            exit 1
          fi
          echo "✅ Health check passed!"

          echo "4. Stopping old container ($LIVE_PORT) and switching traffic..."
          docker stop $LIVE_CONTAINER_NAME || true
          docker rm $LIVE_CONTAINER_NAME || true

          # Rerun the proven healthy image on the LIVE_PORT
          docker stop $NEW_CONTAINER_NAME
          docker rm $NEW_CONTAINER_NAME
          docker run -d \
            --name $LIVE_CONTAINER_NAME \
            -p $LIVE_PORT:80 \
            --restart always \
            $IMAGE_TAG

          echo "5. Cleaning up old Docker images..."
          docker image prune -af --filter "until=24h"

          echo "6. Logging out from Docker Hub..."
          docker logout

          echo "✅ Deployment finished successfully!"
          echo "📦 Deployed image: $IMAGE_TAG"

2. Kritische Deployment-Optimierungen

Problem beim zweiten Deployment: Wenn nur der latest-Tag verwendet wird, kann Docker gecachte Images verwenden und nicht das neue Image pullen, wodurch der Content nicht aktualisiert wird.

Lösung:

  • SHA-basiertes Tagging: Jedes Build wird mit dem Commit-SHA getaggt (${{ github.sha }}), was eindeutige Image-Versionen garantiert
  • Docker Login auf dem Server: Der Deployment-Server muss sich bei Docker Hub authentifizieren, um private Images zu pullen
  • Aggressives Cleanup: docker image prune -af --filter "until=24h" entfernt alte Images und verhindert Cache-Probleme
  • Explizites Logout: Nach dem Deployment wird der Server aus Sicherheitsgründen wieder abgemeldet

V. Lokale Entwicklung (Dev Environment) 🧑‍💻

Für die lokale Entwicklung wird eine separate docker-compose.yml ohne CI/CD-Logik verwendet, die den Code live mountet (volumes) und den Astro Development Server startet.

services:
  astro-dev:
    image: node:20-alpine
    container_name: astro_local_dev
    ports:
      - "4321:4321"
    volumes:
      - .:/app
      - /app/node_modules
    command: sh -c "npm install && npm run dev -- --host 0.0.0.0"

VI. Fazit und Vorteile 📈

Dieses Setup gewährleistet ein hohes Maß an Betriebssicherheit:

  • Zero Downtime: Die Umschaltung erfolgt erst, nachdem der neue Container als gesund validiert wurde.
  • Immutability: Jeder Deployment-Vorgang basiert auf einem unveränderlichen Docker Image mit eindeutigem SHA-Tag.
  • Security: Trennung von Deployment-User und SSH-Keys von der Root-Umgebung, plus automatisches Logout nach Deployment.
  • Zuverlässigkeit: SHA-basiertes Tagging verhindert Cache-Probleme und garantiert, dass immer die neueste Version deployed wird.
  • Nachvollziehbarkeit: Jedes Image ist mit dem Commit-SHA getaggt, was vollständige Traceability ermöglicht.

Die Architektur ist somit hochgradig skalierbar, wartungsarm und produktionsreif.