Ga naar inhoud
·30 min leestijd

GitHub Actions CI/CD: Zero-Downtime Deployments Die Echt Werken

Mijn complete GitHub Actions-setup: parallelle testjobs, Docker build caching, SSH-deployment naar VPS, zero-downtime met PM2 reload, secrets management en de workflowpatronen die ik in twee jaar heb verfijnd.

Delen:X / TwitterLinkedIn

Elk project waar ik aan heb gewerkt bereikt uiteindelijk hetzelfde kantelpunt: het deployproces wordt te pijnlijk om handmatig te doen. Je vergeet de tests te draaien. Je bouwt lokaal maar vergeet de versie te verhogen. Je SSH't naar productie en realiseert je dat de laatste persoon die deployde een verouderd .env-bestand heeft achtergelaten.

GitHub Actions loste dit twee jaar geleden voor me op. Niet perfect op dag een — de eerste workflow die ik schreef was een 200-regels YAML-nachtmerrie die de helft van de tijd time-outte en niets cachete. Maar iteratie na iteratie kwam ik uit bij iets dat deze site betrouwbaar deployt, met nul downtime, in minder dan vier minuten.

Dit is die workflow, sectie voor sectie uitgelegd. Niet de docs-versie. De versie die het contact met productie overleeft.

De Bouwstenen Begrijpen#

Voordat we ingaan op de volledige pipeline, heb je een helder mentaal model nodig van hoe GitHub Actions werkt. Als je Jenkins of CircleCI hebt gebruikt, vergeet het meeste van wat je weet. De concepten komen losjes overeen, maar het uitvoeringsmodel verschilt genoeg om je te laten struikelen.

Triggers: Wanneer Je Workflow Draait#

yaml
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: "0 6 * * 1" # Every Monday at 6 AM UTC
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        default: "staging"
        type: choice
        options:
          - staging
          - production

Vier triggers, elk met een ander doel:

  • push naar main is je productie-deploytrigger. Code gemerged? Shippen.
  • pull_request draait je CI-checks bij elke PR. Hier leven lint, type checks en tests.
  • schedule is cron voor je repo. Ik gebruik het voor wekelijkse dependency audit-scans en opruimen van verouderde caches.
  • workflow_dispatch geeft je een handmatige "Deploy"-knop in de GitHub UI met invoerparameters. Onmisbaar wanneer je staging moet deployen zonder codewijziging — misschien heb je een omgevingsvariabele bijgewerkt of moet je een base Docker-image opnieuw pullen.

Een ding dat mensen bijt: pull_request draait tegen de merge commit, niet de HEAD van de PR-branch. Dit betekent dat je CI test hoe de code eruitziet na de merge. Dat is eigenlijk wat je wilt, maar het verrast mensen wanneer een groene branch rood wordt na een rebase.

Jobs, Steps en Runners#

yaml
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint

Jobs draaien standaard parallel. Elke job krijgt een verse VM (de "runner"). ubuntu-latest geeft je een redelijk krachtige machine — 4 vCPUs, 16 GB RAM per 2026. Dat is gratis voor openbare repo's, 2000 minuten/maand voor prive.

Steps draaien sequentieel binnen een job. Elke uses:-stap haalt een herbruikbare action op uit de marketplace. Elke run:-stap voert een shell-commando uit.

De --frozen-lockfile vlag is cruciaal. Zonder kan pnpm install je lockfile bijwerken tijdens CI, wat betekent dat je niet dezelfde afhankelijkheden test die je ontwikkelaar heeft gecommit. Ik heb gezien dat dit fantoom-testfouten veroorzaakt die lokaal verdwijnen omdat de lockfile op de machine van de ontwikkelaar al correct is.

Omgevingsvariabelen vs Secrets#

yaml
env:
  NODE_ENV: production
  NEXT_TELEMETRY_DISABLED: 1
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
        run: |
          echo "$SSH_PRIVATE_KEY" > key.pem
          chmod 600 key.pem
          ssh -i key.pem deploy@$DEPLOY_HOST "cd /var/www/app && ./deploy.sh"

Omgevingsvariabelen ingesteld met env: op workflow-niveau zijn platte tekst, zichtbaar in logs. Gebruik deze voor niet-gevoelige configuratie: NODE_ENV, telemetrievlaggen, feature toggles.

Secrets (${{ secrets.X }}) zijn versleuteld in rust, gemaskeerd in logs en alleen beschikbaar voor workflows in dezelfde repo. Ze worden ingesteld via Settings > Secrets and variables > Actions.

De environment: production regel is significant. GitHub Environments laten je secrets scopeen naar specifieke deployment-doelen. Je staging SSH-sleutel en je productie SSH-sleutel kunnen beide SSH_PRIVATE_KEY heten maar verschillende waarden bevatten afhankelijk van welke omgeving de job target. Dit ontgrendelt ook vereiste reviewers — je kunt productie-deploys achter een handmatige goedkeuring plaatsen.

De Volledige CI-Pipeline#

Zo structureer ik de CI-helft van de pipeline. Het doel: elke categorie fout opvangen in de snelst mogelijke tijd.

yaml
name: CI
 
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
 
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true
 
jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
 
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm tsc --noEmit
 
  test:
    name: Unit Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm test -- --coverage
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7
 
  build:
    name: Build
    needs: [lint, typecheck, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: .next/
          retention-days: 1

Waarom Deze Structuur#

Lint, typecheck en test draaien parallel. Ze hebben geen afhankelijkheden van elkaar. Een typefout blokkeert lint niet om te draaien, en een gefaalde test hoeft niet te wachten op de type checker. Bij een typische run voltooien alle drie in 30-60 seconden terwijl ze gelijktijdig draaien.

Build wacht op alle drie. De needs: [lint, typecheck, test] regel betekent dat de build-job alleen start als lint, typecheck EN test allemaal slagen. Het heeft geen zin om een project te bouwen dat lintfouten of typefouten heeft.

concurrency met cancel-in-progress: true is een enorme tijdsbesparing. Als je twee commits snel na elkaar pusht, wordt de eerste CI-run geannuleerd. Zonder dit heb je verouderde runs die je minutenbudget verbruiken en de checks-UI vervuilen.

Coverage-upload met if: always() betekent dat je het coveragerapport krijgt zelfs wanneer tests falen. Dit is nuttig voor debugging — je kunt zien welke tests faalden en wat ze dekten.

Fail-Fast vs. Laat Ze Allemaal Draaien#

Standaard annuleert GitHub de andere jobs als een job in een matrix faalt. Voor CI wil ik dit gedrag eigenlijk — als lint faalt, interesseren de testresultaten me niet. Fix eerst de lint.

Maar voor testmatrices (bijvoorbeeld testen over Node 20 en Node 22), wil je misschien alle fouten tegelijk zien:

yaml
test:
  strategy:
    fail-fast: false
    matrix:
      node-version: [20, 22]
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: pnpm/action-setup@v4
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ matrix.node-version }}
        cache: "pnpm"
    - run: pnpm install --frozen-lockfile
    - run: pnpm test

fail-fast: false laat beide matrixpoten voltooien. Als Node 22 faalt maar Node 20 slaagt, zie je die informatie onmiddellijk in plaats van opnieuw te moeten draaien.

Caching voor Snelheid#

De grootste verbetering die je kunt aanbrengen in CI-snelheid is caching. Een koude pnpm install op een middelgroot project duurt 30-45 seconden. Met een warme cache duurt het 3-5 seconden. Vermenigvuldig dat over vier parallelle jobs en je bespaart twee minuten bij elke run.

pnpm Store Cache#

yaml
- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: "pnpm"

Deze ene regel cachet de pnpm-store (~/.local/share/pnpm/store). Bij een cache hit hard-linkt pnpm install --frozen-lockfile vanuit de store in plaats van te downloaden. Dit alleen al verkort de installatietijd met 80% bij herhaalde runs.

Als je meer controle nodig hebt — bijvoorbeeld als je ook op basis van het besturingssysteem wilt cachen — gebruik dan actions/cache direct:

yaml
- uses: actions/cache@v4
  with:
    path: |
      ~/.local/share/pnpm/store
      node_modules
    key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
    restore-keys: |
      pnpm-${{ runner.os }}-

De restore-keys fallback is belangrijk. Als pnpm-lock.yaml verandert (nieuwe dependency), matcht de exacte sleutel niet, maar de prefix-match herstelt nog steeds het meeste van de gecachte pakketten. Alleen de diff wordt gedownload.

Next.js Build Cache#

Next.js heeft zijn eigen build-cache in .next/cache. Dit cachen tussen runs betekent incrementele builds — alleen gewijzigde pagina's en componenten worden opnieuw gecompileerd.

yaml
- uses: actions/cache@v4
  with:
    path: .next/cache
    key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
    restore-keys: |
      nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
      nextjs-${{ runner.os }}-

Deze drielaags-sleutelstrategie betekent:

  1. Exacte match: dezelfde dependencies EN dezelfde bronbestanden. Volledige cache hit, build is bijna instant.
  2. Gedeeltelijke match (dependencies): dependencies hetzelfde maar bron gewijzigd. Build hercompileert alleen gewijzigde bestanden.
  3. Gedeeltelijke match (alleen OS): dependencies gewijzigd. Build hergebruikt wat het kan.

Echte cijfers van mijn project: koude build duurt ~55 seconden, gecachte build duurt ~15 seconden. Dat is een reductie van 73%.

Docker Layer Caching#

Docker-builds zijn waar caching echt impactvol wordt. Een volledige Next.js Docker-build — OS-deps installeren, bron kopieren, pnpm install draaien, next build draaien — duurt 3-4 minuten koud. Met layer caching is het 30-60 seconden.

yaml
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

type=gha gebruikt GitHub Actions' ingebouwde cache-backend. mode=max cachet alle layers, niet alleen de laatste. Dit is cruciaal voor multi-stage builds waar tussenliggende layers (zoals pnpm install) het duurst zijn om opnieuw te bouwen.

Turborepo Remote Cache#

Als je in een monorepo zit met Turborepo, is remote caching transformatief. De eerste build uploadt taakuitvoer naar de cache. Volgende builds downloaden in plaats van opnieuw te berekenen.

yaml
- run: pnpm turbo build --remote-only
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Ik heb monorepo CI-tijden zien dalen van 8 minuten naar 90 seconden met Turbo remote cache. De valkuil: het vereist een Vercel-account of zelf-gehoste Turbo-server. Voor single-app repo's is het overkill.

Docker Build en Push#

Als je deployt naar een VPS (of welke server dan ook), geeft Docker je reproduceerbare builds. Hetzelfde image dat in CI draait is hetzelfde image dat in productie draait. Geen "it works on my machine" meer omdat de machine is het image.

Multi-Stage Dockerfile#

Voordat we bij de workflow komen, hier is de Dockerfile die ik gebruik voor Next.js:

dockerfile
# Stage 1: Dependencies
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false
 
# Stage 2: Build
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
 
# Stage 3: Production
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
 
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
 
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]

Drie stages, duidelijke scheiding. Het eindimage is ~150MB in plaats van de ~1.2GB die je zou krijgen als je alles kopieert. Alleen productie-artefacten komen in de runner-stage terecht.

De Build-en-Push Workflow#

yaml
name: Build and Push Docker Image
 
on:
  push:
    branches: [main]
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=ref,event=branch
            type=raw,value=latest,enable={{is_default_branch}}
 
      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Laat me de belangrijke beslissingen hier uitpakken.

GitHub Container Registry (ghcr.io)#

Ik gebruik ghcr.io in plaats van Docker Hub om drie redenen:

  1. Authenticatie is gratis. GITHUB_TOKEN is automatisch beschikbaar in elke workflow — geen Docker Hub-credentials nodig om op te slaan.
  2. Nabijheid. Images worden gepulld van dezelfde infrastructuur waar je CI op draait. Pulls tijdens CI zijn snel.
  3. Zichtbaarheid. Images zijn gekoppeld aan je repo in de GitHub UI. Je ziet ze in de Packages-tab.

Multi-Platform Builds#

yaml
platforms: linux/amd64,linux/arm64

Deze regel voegt misschien 90 seconden toe aan je build, maar het is het waard. ARM64-images draaien native op:

  • Apple Silicon Macs (M1/M2/M3/M4) tijdens lokale ontwikkeling met Docker Desktop
  • AWS Graviton-instances (20-40% goedkoper dan x86-equivalenten)
  • Oracle Cloud's gratis ARM-tier

Zonder dit draaien je ontwikkelaars op M-serie Macs x86-images via Rosetta-emulatie. Het werkt, maar het is merkbaar langzamer en brengt af en toe vreemde architectuurspecifieke bugs aan het licht.

QEMU biedt de cross-compilatielaag. Buildx orchestreert de multi-arch build en pusht een manifestlijst zodat Docker automatisch de juiste architectuur pullt.

Tagging-strategie#

yaml
tags: |
  type=sha,prefix=
  type=ref,event=branch
  type=raw,value=latest,enable={{is_default_branch}}

Elk image krijgt drie tags:

  • abc1234 (commit SHA): Onveranderlijk. Je kunt altijd een exacte commit deployen.
  • main (branchnaam): Veranderlijk. Wijst naar de laatste build van die branch.
  • latest: Veranderlijk. Alleen ingesteld op de standaardbranch. Dit is wat je server pullt.

Deploy latest nooit in productie zonder ook de SHA ergens vast te leggen. Wanneer iets breekt, moet je weten welke latest. Ik sla de gedeployde SHA op in een bestand op de server dat het health-endpoint uitleest.

SSH-Deployment naar VPS#

Dit is waar het allemaal samenkomt. CI slaagt, Docker-image is gebouwd en gepusht, nu moeten we de server vertellen het nieuwe image te pullen en te herstarten.

De SSH Action#

yaml
deploy:
  name: Deploy to Production
  needs: [build-and-push]
  runs-on: ubuntu-latest
  environment: production
 
  steps:
    - name: Deploy via SSH
      uses: appleboy/ssh-action@v1
      with:
        host: ${{ secrets.DEPLOY_HOST }}
        username: ${{ secrets.DEPLOY_USER }}
        key: ${{ secrets.SSH_PRIVATE_KEY }}
        port: ${{ secrets.SSH_PORT }}
        script_stop: true
        script: |
          set -euo pipefail
 
          APP_DIR="/var/www/akousa.net"
          IMAGE="ghcr.io/${{ github.repository }}:latest"
          DEPLOY_SHA="${{ github.sha }}"
 
          echo "=== Deploying $DEPLOY_SHA ==="
 
          # Pull the latest image
          docker pull "$IMAGE"
 
          # Stop and remove old container
          docker stop akousa-app || true
          docker rm akousa-app || true
 
          # Start new container
          docker run -d \
            --name akousa-app \
            --restart unless-stopped \
            --network host \
            -e NODE_ENV=production \
            -e DATABASE_URL="${DATABASE_URL}" \
            -p 3000:3000 \
            "$IMAGE"
 
          # Wait for health check
          echo "Waiting for health check..."
          for i in $(seq 1 30); do
            if curl -sf http://localhost:3000/api/health > /dev/null 2>&1; then
              echo "Health check passed on attempt $i"
              break
            fi
            if [ "$i" -eq 30 ]; then
              echo "Health check failed after 30 attempts"
              exit 1
            fi
            sleep 2
          done
 
          # Record deployed SHA
          echo "$DEPLOY_SHA" > "$APP_DIR/.deployed-sha"
 
          # Prune old images
          docker image prune -af --filter "until=168h"
 
          echo "=== Deploy complete ==="

Het Deployscript-alternatief#

Voor alles dat verder gaat dan een eenvoudige pull-en-herstart, verplaats ik de logica naar een script op de server in plaats van het in de workflow in te voegen:

bash
#!/bin/bash
# /var/www/akousa.net/deploy.sh
set -euo pipefail
 
APP_DIR="/var/www/akousa.net"
LOG_FILE="$APP_DIR/deploy.log"
IMAGE="ghcr.io/akousa/akousa-net:latest"
 
log() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
 
log "Starting deployment..."
 
# Login to GHCR
echo "$GHCR_TOKEN" | docker login ghcr.io -u akousa --password-stdin
 
# Pull with retry
for attempt in 1 2 3; do
  if docker pull "$IMAGE"; then
    log "Image pulled successfully on attempt $attempt"
    break
  fi
  if [ "$attempt" -eq 3 ]; then
    log "ERROR: Failed to pull image after 3 attempts"
    exit 1
  fi
  log "Pull attempt $attempt failed, retrying in 5s..."
  sleep 5
done
 
# Health check function
health_check() {
  local port=$1
  local max_attempts=30
  for i in $(seq 1 $max_attempts); do
    if curl -sf "http://localhost:$port/api/health" > /dev/null 2>&1; then
      return 0
    fi
    sleep 2
  done
  return 1
}
 
# Start new container on alternate port
docker run -d \
  --name akousa-app-new \
  --env-file "$APP_DIR/.env.production" \
  -p 3001:3000 \
  "$IMAGE"
 
# Verify new container is healthy
if ! health_check 3001; then
  log "ERROR: New container failed health check. Rolling back."
  docker stop akousa-app-new || true
  docker rm akousa-app-new || true
  exit 1
fi
 
log "New container healthy. Switching traffic..."
 
# Switch Nginx upstream
sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
 
# Stop old container
docker stop akousa-app || true
docker rm akousa-app || true
 
# Rename new container
docker rename akousa-app-new akousa-app
 
log "Deployment complete."

De workflow wordt dan een enkel SSH-commando:

yaml
script: |
  cd /var/www/akousa.net && ./deploy.sh

Dit is beter omdat: (1) de deploylogica versiebeheerd is op de server, (2) je het handmatig via SSH kunt draaien voor debugging, en (3) je geen YAML hoeft te escapen binnen YAML binnen bash.

Zero-Downtime Strategieen#

"Zero downtime" klinkt als marketingtaal, maar het heeft een precieze betekenis: geen enkel verzoek krijgt een connection refused of een 502 tijdens deployment. Hier zijn drie echte benaderingen, van eenvoudigst tot meest robuust.

Strategie 1: PM2 Cluster Mode Reload#

Als je Node.js direct draait (niet in Docker), geeft PM2's cluster mode je het makkelijkste zero-downtime pad.

bash
# ecosystem.config.js already has:
#   instances: 2
#   exec_mode: "cluster"
 
pm2 reload akousa --update-env

pm2 reload (niet restart) doet een rolling restart. Het start nieuwe workers op, wacht tot ze klaar zijn en doodt dan oude workers een voor een. Op geen enkel moment zijn er nul workers die verkeer bedienen.

De --update-env vlag herlaadt omgevingsvariabelen uit de ecosysteemconfiguratie. Zonder blijven je oude omgevingsvariabelen bestaan, zelfs na een deploy die .env heeft gewijzigd.

In je workflow:

yaml
- name: Deploy and reload PM2
  uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.DEPLOY_HOST }}
    username: ${{ secrets.DEPLOY_USER }}
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    script: |
      cd /var/www/akousa.net
      git pull origin main
      pnpm install --frozen-lockfile
      pnpm build
      pm2 reload ecosystem.config.js --update-env

Dit is wat ik gebruik voor deze site. Het is eenvoudig, betrouwbaar, en de downtime is letterlijk nul — ik heb het getest met een load generator die 100 req/s draaide tijdens deploys. Niet een enkele 5xx.

Strategie 2: Blue/Green met Nginx Upstream#

Voor Docker-deployments geeft blue/green je een schone scheiding tussen de oude en nieuwe versie.

Het concept: draai de oude container ("blue") op poort 3000 en de nieuwe container ("green") op poort 3001. Nginx wijst naar blue. Je start green, verifieert dat het gezond is, schakelt Nginx naar green en stopt dan blue.

Nginx upstream-configuratie:

nginx
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
    server 127.0.0.1:3000;
}
nginx
# /etc/nginx/sites-available/akousa.net
server {
    listen 443 ssl http2;
    server_name akousa.net;
 
    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

Het wisselscript:

bash
#!/bin/bash
set -euo pipefail
 
CURRENT_PORT=$(grep -oP 'server 127\.0\.0\.1:\K\d+' /etc/nginx/conf.d/upstream.conf)
 
if [ "$CURRENT_PORT" = "3000" ]; then
  NEW_PORT=3001
  OLD_PORT=3000
else
  NEW_PORT=3000
  OLD_PORT=3001
fi
 
echo "Current: $OLD_PORT -> New: $NEW_PORT"
 
# Start new container on the alternate port
docker run -d \
  --name "akousa-app-$NEW_PORT" \
  --env-file /var/www/akousa.net/.env.production \
  -p "$NEW_PORT:3000" \
  "ghcr.io/akousa/akousa-net:latest"
 
# Wait for health
for i in $(seq 1 30); do
  if curl -sf "http://localhost:$NEW_PORT/api/health" > /dev/null; then
    echo "New container healthy on port $NEW_PORT"
    break
  fi
  [ "$i" -eq 30 ] && { echo "Health check failed"; docker stop "akousa-app-$NEW_PORT"; docker rm "akousa-app-$NEW_PORT"; exit 1; }
  sleep 2
done
 
# Switch Nginx
sudo sed -i "s/server 127.0.0.1:$OLD_PORT/server 127.0.0.1:$NEW_PORT/" /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
 
# Stop old container
sleep 5  # Let in-flight requests complete
docker stop "akousa-app-$OLD_PORT" || true
docker rm "akousa-app-$OLD_PORT" || true
 
echo "Switched from :$OLD_PORT to :$NEW_PORT"

De 5 seconden sleep na de Nginx-reload is geen luiheid — het is wachttijd. Nginx's reload is graceful (bestaande verbindingen worden opengehouden), maar sommige long-polling verbindingen of streaming responses hebben tijd nodig om te voltooien.

Strategie 3: Docker Compose met Health Checks#

Voor een meer gestructureerde aanpak kan Docker Compose de blue/green-wissel beheren:

yaml
# docker-compose.yml
services:
  app:
    image: ghcr.io/akousa/akousa-net:latest
    restart: unless-stopped
    env_file: .env.production
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s
    deploy:
      replicas: 2
      update_config:
        parallelism: 1
        delay: 10s
        order: start-first
        failure_action: rollback
      rollback_config:
        parallelism: 0
        order: stop-first
    ports:
      - "3000:3000"

De order: start-first is de sleutelregel. Het betekent "start de nieuwe container voordat je de oude stopt." Gecombineerd met parallelism: 1 krijg je een rolling update — een container per keer, altijd met behoud van capaciteit.

Deploy met:

bash
docker compose pull
docker compose up -d --remove-orphans

Docker Compose bewaakt de healthcheck en routeert geen verkeer naar de nieuwe container totdat deze slaagt. Als de healthcheck faalt, reverteert failure_action: rollback automatisch naar de vorige versie. Dit is zo dicht bij Kubernetes-stijl rolling deployments als je kunt komen op een enkele VPS.

Secrets Management#

Secrets management is een van die dingen die makkelijk "grotendeels goed" te doen zijn en catastrofaal fout in de resterende randgevallen.

GitHub Secrets: De Basis#

yaml
# Set via GitHub UI: Settings > Secrets and variables > Actions
 
steps:
  - name: Use a secret
    env:
      DB_URL: ${{ secrets.DATABASE_URL }}
    run: |
      # The value is masked in logs
      echo "Connecting to database..."
      # This would print "Connecting to ***" in the logs
      echo "Connecting to $DB_URL"

GitHub redigeert automatisch secret-waarden uit loguitvoer. Als je secret p@ssw0rd123 is en een stap die string afdrukt, tonen de logs ***. Dit werkt goed, met een kanttekening: als je secret kort is (zoals een 4-cijferige PIN), maskeert GitHub het misschien niet omdat het onschuldige strings zou kunnen matchen. Houd secrets redelijk complex.

Omgevingsgebonden Secrets#

yaml
jobs:
  deploy-staging:
    environment: staging
    steps:
      - run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = staging.akousa.net
 
  deploy-production:
    environment: production
    steps:
      - run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = akousa.net

Dezelfde secretnaam, verschillende waarden per omgeving. Het environment-veld op de job bepaalt welke set secrets wordt geïnjecteerd.

Productieomgevingen zouden vereiste reviewers ingeschakeld moeten hebben. Dit betekent dat een push naar main de workflow triggert, CI draait automatisch, maar de deploy-job pauzeert en wacht tot iemand op "Approve" klikt in de GitHub UI. Voor een soloproject voelt dit misschien als overhead. Voor alles met gebruikers is het een redding de eerste keer dat je per ongeluk iets kapots merged.

OIDC: Geen Statische Credentials Meer#

Statische credentials (AWS access keys, GCP service account JSON-bestanden) opgeslagen in GitHub Secrets zijn een risico. Ze verlopen niet, ze kunnen niet worden beperkt tot een specifieke workflowrun, en als ze lekken moet je ze handmatig roteren.

OIDC (OpenID Connect) lost dit op. GitHub Actions fungeert als identiteitsprovider en je cloudprovider vertrouwt het om kortlevende credentials on the fly uit te geven:

yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # Required for OIDC
      contents: read
 
    steps:
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: eu-central-1
 
      - name: Push to ECR
        run: |
          aws ecr get-login-password --region eu-central-1 | \
            docker login --username AWS --password-stdin 123456789012.dkr.ecr.eu-central-1.amazonaws.com

Geen access key. Geen secret key. De configure-aws-credentials action vraagt een tijdelijk token aan bij AWS STS met GitHub's OIDC-token. Het token is beperkt tot de specifieke repo, branch en omgeving. Het verloopt na de workflowrun.

Dit opzetten aan de AWS-kant vereist een IAM OIDC-identiteitsprovider en een role trust policy:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:akousa/akousa-net:ref:refs/heads/main"
        }
      }
    }
  ]
}

De sub-voorwaarde is cruciaal. Zonder kan elke repo die op de een of andere manier de details van je OIDC-provider verkrijgt de rol aannemen. Met kan alleen de main-branch van je specifieke repo dat.

GCP heeft een equivalente setup met Workload Identity Federation. Azure heeft gefedereerde credentials. Als je cloud OIDC ondersteunt, gebruik het. Er is geen reden om in 2026 statische cloud-credentials op te slaan.

Deployment SSH-sleutels#

Voor VPS-deployments via SSH, genereer een speciaal sleutelpaar:

bash
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""

Voeg de publieke sleutel toe aan de ~/.ssh/authorized_keys van de server met beperkingen:

restrict,command="/var/www/akousa.net/deploy.sh" ssh-ed25519 AAAA... github-actions-deploy

Het restrict-prefix schakelt port forwarding, agent forwarding, PTY-allocatie en X11-forwarding uit. Het command=-prefix betekent dat deze sleutel alleen het deployscript kan uitvoeren. Zelfs als de prive-sleutel wordt gecompromitteerd, kan de aanvaller je deployscript draaien en verder niets.

Voeg de prive-sleutel toe aan GitHub Secrets als SSH_PRIVATE_KEY. Dit is de ene statische credential die ik accepteer — SSH-sleutels met geforceerde commando's hebben een zeer beperkte blast radius.

PR Workflows: Preview Deployments#

Elke PR verdient een preview-omgeving. Het vangt visuele bugs die unit tests missen, laat designers reviewen zonder code uit te checken en maakt het leven van QA dramatisch makkelijker.

Deploy een Preview bij PR Open#

yaml
name: Preview Deploy
 
on:
  pull_request:
    types: [opened, synchronize, reopened]
 
jobs:
  preview:
    runs-on: ubuntu-latest
    environment:
      name: preview-${{ github.event.number }}
      url: ${{ steps.deploy.outputs.url }}
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Build preview image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:pr-${{ github.event.number }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
      - name: Deploy preview
        id: deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            PORT=$((4000 + PR_NUM))
            IMAGE="ghcr.io/${{ github.repository }}:pr-${PR_NUM}"
 
            docker pull "$IMAGE"
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
 
            docker run -d \
              --name "preview-${PR_NUM}" \
              --restart unless-stopped \
              -e NODE_ENV=preview \
              -p "${PORT}:3000" \
              "$IMAGE"
 
            echo "url=https://pr-${PR_NUM}.preview.akousa.net" >> "$GITHUB_OUTPUT"
 
      - name: Comment PR with preview URL
        uses: actions/github-script@v7
        with:
          script: |
            const url = `https://pr-${{ github.event.number }}.preview.akousa.net`;
            const body = `### Preview Deployment
 
            | Status | URL |
            |--------|-----|
            | :white_check_mark: Deployed | [${url}](${url}) |
 
            _Last updated: ${new Date().toISOString()}_
            _Commit: \`${{ github.sha }}\`_`;
 
            // Find existing comment
            const comments = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
 
            const botComment = comments.data.find(c =>
              c.user.type === 'Bot' && c.body.includes('Preview Deployment')
            );
 
            if (botComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body,
              });
            }

De poortberekening (4000 + PR_NUM) is een pragmatische hack. PR #42 krijgt poort 4042. Zolang je niet meer dan een paar honderd open PR's hebt, zijn er geen botsingen. Een Nginx-wildcardconfiguratie routeert pr-*.preview.akousa.net naar de juiste poort.

Opruimen bij PR Sluiting#

Preview-omgevingen die niet worden opgeruimd vreten schijfruimte en geheugen. Voeg een opruimjob toe:

yaml
name: Cleanup Preview
 
on:
  pull_request:
    types: [closed]
 
jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Remove preview container
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
            docker rmi "ghcr.io/${{ github.repository }}:pr-${PR_NUM}" || true
            echo "Preview for PR #${PR_NUM} cleaned up."
 
      - name: Deactivate environment
        uses: actions/github-script@v7
        with:
          script: |
            const deployments = await github.rest.repos.listDeployments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              environment: `preview-${{ github.event.number }}`,
            });
 
            for (const deployment of deployments.data) {
              await github.rest.repos.createDeploymentStatus({
                owner: context.repo.owner,
                repo: context.repo.repo,
                deployment_id: deployment.id,
                state: 'inactive',
              });
            }

Vereiste Statuscontroles#

In je repository-instellingen (Settings > Branches > Branch protection rules), vereis deze controles voordat gemerged wordt:

  • lint — Geen lintfouten
  • typecheck — Geen typefouten
  • test — Alle tests slagen
  • build — Project bouwt succesvol

Zonder dit zal iemand een PR mergen met falende checks. Niet kwaadaardig — ze zien "2 van 4 checks geslaagd" en nemen aan dat de andere twee nog draaien. Vergrendel het.

Schakel ook "Require branches to be up to date before merging" in. Dit dwingt een herrun van CI af na het rebasen op de laatste main. Het vangt het geval op waar twee PR's individueel CI slagen maar conflicteren wanneer gecombineerd.

Notificaties#

Een deployment waar niemand van weet is een deployment die niemand vertrouwt. Notificaties sluiten de feedbackloop.

Slack Webhook#

yaml
- name: Notify Slack
  if: always()
  uses: slackapi/slack-github-action@v2
  with:
    webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
    webhook-type: incoming-webhook
    payload: |
      {
        "blocks": [
          {
            "type": "header",
            "text": {
              "type": "plain_text",
              "text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
            }
          },
          {
            "type": "section",
            "fields": [
              {
                "type": "mrkdwn",
                "text": "*Repository:*\n${{ github.repository }}"
              },
              {
                "type": "mrkdwn",
                "text": "*Branch:*\n${{ github.ref_name }}"
              },
              {
                "type": "mrkdwn",
                "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>"
              },
              {
                "type": "mrkdwn",
                "text": "*Triggered by:*\n${{ github.actor }}"
              }
            ]
          },
          {
            "type": "actions",
            "elements": [
              {
                "type": "button",
                "text": {
                  "type": "plain_text",
                  "text": "View Run"
                },
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
              }
            ]
          }
        ]
      }

Het if: always() is cruciaal. Zonder wordt de notificatiestap overgeslagen wanneer de deploy faalt — wat precies het moment is dat je het het hardst nodig hebt.

GitHub Deployments API#

Voor rijkere deployment-tracking, gebruik de GitHub Deployments API. Dit geeft je een deploymentgeschiedenis in de repo-UI en maakt statusbadges mogelijk:

yaml
- name: Create GitHub Deployment
  id: deployment
  uses: actions/github-script@v7
  with:
    script: |
      const deployment = await github.rest.repos.createDeployment({
        owner: context.repo.owner,
        repo: context.repo.repo,
        ref: context.sha,
        environment: 'production',
        auto_merge: false,
        required_contexts: [],
        description: `Deploying ${context.sha.substring(0, 7)} to production`,
      });
      return deployment.data.id;
 
- name: Deploy
  run: |
    # ... actual deployment steps ...
 
- name: Update deployment status
  if: always()
  uses: actions/github-script@v7
  with:
    script: |
      const deploymentId = ${{ steps.deployment.outputs.result }};
      await github.rest.repos.createDeploymentStatus({
        owner: context.repo.owner,
        repo: context.repo.repo,
        deployment_id: deploymentId,
        state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
        environment_url: 'https://akousa.net',
        log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
        description: '${{ job.status }}' === 'success'
          ? 'Deployment succeeded'
          : 'Deployment failed',
      });

Nu toont je Environments-tab in GitHub een complete deploymentgeschiedenis: wie wat deployde, wanneer en of het succesvol was.

Alleen-bij-fout E-mail#

Voor kritieke deployments trigger ik ook een e-mail bij fout. Niet via GitHub Actions' ingebouwde e-mail (te luidruchtig), maar via een gerichte webhook:

yaml
- name: Alert on failure
  if: failure()
  run: |
    curl -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
      -H "Content-Type: application/json" \
      -d '{
        "subject": "DEPLOY FAILED: ${{ github.repository }}",
        "body": "Commit: ${{ github.sha }}\nActor: ${{ github.actor }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
      }'

Dit is mijn laatste verdedigingslinie. Slack is geweldig maar het is ook luidruchtig — mensen dempen kanalen. Een "DEPLOY FAILED" e-mail met een link naar de run trekt de aandacht.

Het Complete Workflowbestand#

Hier is alles samengevoegd tot een enkel, productie-klaar workflowbestand. Dit is heel dicht bij wat deze site daadwerkelijk deployt.

yaml
name: CI/CD Pipeline
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
    inputs:
      skip_tests:
        description: "Skip tests (emergency deploy)"
        required: false
        type: boolean
        default: false
 
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
 
env:
  NODE_VERSION: "22"
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  # ============================================================
  # CI: Lint, type check, and test in parallel
  # ============================================================
 
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run ESLint
        run: pnpm lint
 
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run TypeScript compiler
        run: pnpm tsc --noEmit
 
  test:
    name: Unit Tests
    if: ${{ !inputs.skip_tests }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run tests with coverage
        run: pnpm test -- --coverage
 
      - name: Upload coverage report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7
 
  # ============================================================
  # Build: Only after CI passes
  # ============================================================
 
  build:
    name: Build Application
    needs: [lint, typecheck, test]
    if: always() && !cancelled() && needs.lint.result == 'success' && needs.typecheck.result == 'success' && (needs.test.result == 'success' || needs.test.result == 'skipped')
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Cache Next.js build
        uses: actions/cache@v4
        with:
          path: .next/cache
          key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
          restore-keys: |
            nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
            nextjs-${{ runner.os }}-
 
      - name: Build Next.js application
        run: pnpm build
 
  # ============================================================
  # Docker: Build and push image (main branch only)
  # ============================================================
 
  docker:
    name: Build Docker Image
    needs: [build]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      image_tag: ${{ steps.meta.outputs.tags }}
 
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Set up QEMU for multi-platform builds
        uses: docker/setup-qemu-action@v3
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract image metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest,enable={{is_default_branch}}
 
      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
  # ============================================================
  # Deploy: SSH into VPS and update
  # ============================================================
 
  deploy:
    name: Deploy to Production
    needs: [docker]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://akousa.net
 
    steps:
      - name: Create GitHub Deployment
        id: deployment
        uses: actions/github-script@v7
        with:
          script: |
            const deployment = await github.rest.repos.createDeployment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              ref: context.sha,
              environment: 'production',
              auto_merge: false,
              required_contexts: [],
              description: `Deploy ${context.sha.substring(0, 7)}`,
            });
            return deployment.data.id;
 
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script_stop: true
          command_timeout: 5m
          script: |
            set -euo pipefail
 
            APP_DIR="/var/www/akousa.net"
            IMAGE="ghcr.io/${{ github.repository }}:latest"
            SHA="${{ github.sha }}"
 
            echo "=== Deploy $SHA started at $(date) ==="
 
            # Pull new image
            docker pull "$IMAGE"
 
            # Run new container on alternate port
            docker run -d \
              --name akousa-app-new \
              --env-file "$APP_DIR/.env.production" \
              -p 3001:3000 \
              "$IMAGE"
 
            # Health check
            echo "Running health check..."
            for i in $(seq 1 30); do
              if curl -sf http://localhost:3001/api/health > /dev/null 2>&1; then
                echo "Health check passed (attempt $i)"
                break
              fi
              if [ "$i" -eq 30 ]; then
                echo "ERROR: Health check failed"
                docker logs akousa-app-new --tail 50
                docker stop akousa-app-new && docker rm akousa-app-new
                exit 1
              fi
              sleep 2
            done
 
            # Switch traffic
            sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
            sudo nginx -t && sudo nginx -s reload
 
            # Grace period for in-flight requests
            sleep 5
 
            # Stop old container
            docker stop akousa-app || true
            docker rm akousa-app || true
 
            # Rename and reset port
            docker rename akousa-app-new akousa-app
            sudo sed -i 's/server 127.0.0.1:3001/server 127.0.0.1:3000/' /etc/nginx/conf.d/upstream.conf
            # Note: we don't reload Nginx here because the container name changed,
            # not the port. The next deploy will use the correct port.
 
            # Record deployment
            echo "$SHA" > "$APP_DIR/.deployed-sha"
            echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $SHA" >> "$APP_DIR/deploy.log"
 
            # Cleanup old images (older than 7 days)
            docker image prune -af --filter "until=168h"
 
            echo "=== Deploy complete at $(date) ==="
 
      - name: Update deployment status
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const deploymentId = ${{ steps.deployment.outputs.result }};
            await github.rest.repos.createDeploymentStatus({
              owner: context.repo.owner,
              repo: context.repo.repo,
              deployment_id: deploymentId,
              state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
              environment_url: 'https://akousa.net',
              log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
            });
 
      - name: Notify Slack
        if: always()
        uses: slackapi/slack-github-action@v2
        with:
          webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
          webhook-type: incoming-webhook
          payload: |
            {
              "blocks": [
                {
                  "type": "header",
                  "text": {
                    "type": "plain_text",
                    "text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
                  }
                },
                {
                  "type": "section",
                  "fields": [
                    {
                      "type": "mrkdwn",
                      "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|`${{ github.sha }}`>"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*Actor:*\n${{ github.actor }}"
                    }
                  ]
                },
                {
                  "type": "actions",
                  "elements": [
                    {
                      "type": "button",
                      "text": { "type": "plain_text", "text": "View Run" },
                      "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
                    }
                  ]
                }
              ]
            }
 
      - name: Alert on failure
        if: failure()
        run: |
          curl -sf -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
            -H "Content-Type: application/json" \
            -d '{
              "subject": "DEPLOY FAILED: ${{ github.repository }}",
              "body": "Commit: ${{ github.sha }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
            }' || true

De Flow Doorlopen#

Wanneer ik naar main push:

  1. Lint, Type Check en Test starten gelijktijdig. Drie runners, drie parallelle jobs. Als er een faalt, stopt de pipeline.
  2. Build draait alleen als alle drie slagen. Het valideert dat de applicatie compileert en werkende output produceert.
  3. Docker bouwt het productie-image en pusht het naar ghcr.io. Multi-platform, layer-gecachet.
  4. Deploy SSH't naar de VPS, pullt het nieuwe image, start een nieuwe container, doet een health-check, schakelt Nginx en ruimt op.
  5. Notificaties vuren ongeacht de uitkomst. Slack krijgt het bericht. GitHub Deployments worden bijgewerkt. Als het mislukte, gaat er een alert-e-mail uit.

Wanneer ik een PR open:

  1. Lint, Type Check en Test draaien. Dezelfde kwaliteitseisen.
  2. Build draait om te verifieren dat het project compileert.
  3. Docker en Deploy worden overgeslagen (de if-voorwaarden beperken ze tot alleen de main-branch).

Wanneer ik een nooddeploy nodig heb (tests overslaan):

  1. Klik op "Run workflow" in de Actions-tab.
  2. Selecteer skip_tests: true.
  3. Lint en typecheck draaien nog steeds (je kunt die niet overslaan — ik vertrouw mezelf niet zoveel).
  4. Tests worden overgeslagen, build draait, Docker bouwt, deploy vuurt.

Dit is mijn workflow al twee jaar. Het heeft servermigraties overleefd, Node.js major version-upgrades, pnpm die npm verving en de toevoeging van 15 tools aan deze site. De totale end-to-end tijd van push tot productie: 3 minuten en 40 seconden gemiddeld. De langzaamste stap is de multi-platform Docker-build met ~90 seconden. Al het andere is gecachet tot bijna instant.

Lessen Uit Twee Jaar Iteratie#

Ik sluit af met de fouten die ik heb gemaakt zodat jij dat niet hoeft.

Pin je action-versies. uses: actions/checkout@v4 is prima, maar overweeg voor productie uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 (de volledige SHA). Een gecompromitteerde action kan je secrets exfiltreren. Het tj-actions/changed-files incident in 2025 bewees dat dit niet theoretisch is.

Cache niet alles. Ik cachete een keer node_modules direct (niet alleen de pnpm-store) en besteedde twee uur aan het debuggen van een fantoom-buildfout veroorzaakt door verouderde native bindings. Cache de package manager-store, niet de geinstalleerde modules.

Stel timeouts in. Elke job zou timeout-minutes moeten hebben. De standaard is 360 minuten (6 uur). Als je deploy hangt omdat de SSH-verbinding wegviel, wil je dat niet zes uur later ontdekken wanneer je je maandelijkse minuten hebt opgebrand.

yaml
jobs:
  deploy:
    timeout-minutes: 15
    runs-on: ubuntu-latest

Gebruik concurrency verstandig. Voor PR's is cancel-in-progress: true altijd juist — niemand geeft om het CI-resultaat van een commit die al is overschreven door een force-push. Voor productie-deploys, zet het op false. Je wilt niet dat een snelle opvolgcommit een deploy annuleert die halverwege de uitrol is.

Test je workflowbestand. Gebruik act (https://github.com/nektos/act) om workflows lokaal te draaien. Het vangt niet alles (secrets zijn niet beschikbaar en de runner-omgeving verschilt), maar het vangt YAML-syntaxfouten en voor de hand liggende logicabugs voordat je pusht.

Monitor je CI-kosten. GitHub Actions-minuten zijn gratis voor openbare repo's en goedkoop voor prive, maar ze tellen op. Multi-platform Docker-builds zijn 2x de minuten (een per platform). Matrixtest-strategieen vermenigvuldigen je runtime. Houd de factureringspagina in de gaten.

De beste CI/CD-pipeline is degene die je vertrouwt. Vertrouwen komt van betrouwbaarheid, observeerbaarheid en incrementele verbetering. Begin met een eenvoudige lint-test-build pipeline. Voeg Docker toe wanneer je reproduceerbaarheid nodig hebt. Voeg SSH-deployment toe wanneer je automatisering nodig hebt. Voeg notificaties toe wanneer je vertrouwen nodig hebt. Bouw niet de volledige pipeline op dag een — je krijgt de abstracties verkeerd.

Gerelateerde artikelen