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.
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#
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
- productionVier triggers, elk met een ander doel:
pushnaarmainis je productie-deploytrigger. Code gemerged? Shippen.pull_requestdraait je CI-checks bij elke PR. Hier leven lint, type checks en tests.scheduleis cron voor je repo. Ik gebruik het voor wekelijkse dependency audit-scans en opruimen van verouderde caches.workflow_dispatchgeeft 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#
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 lintJobs 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#
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.
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: 1Waarom 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:
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 testfail-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#
- 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:
- 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.
- 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:
- Exacte match: dezelfde dependencies EN dezelfde bronbestanden. Volledige cache hit, build is bijna instant.
- Gedeeltelijke match (dependencies): dependencies hetzelfde maar bron gewijzigd. Build hercompileert alleen gewijzigde bestanden.
- 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.
- 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=maxtype=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.
- 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:
# 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#
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=maxLaat me de belangrijke beslissingen hier uitpakken.
GitHub Container Registry (ghcr.io)#
Ik gebruik ghcr.io in plaats van Docker Hub om drie redenen:
- Authenticatie is gratis.
GITHUB_TOKENis automatisch beschikbaar in elke workflow — geen Docker Hub-credentials nodig om op te slaan. - Nabijheid. Images worden gepulld van dezelfde infrastructuur waar je CI op draait. Pulls tijdens CI zijn snel.
- Zichtbaarheid. Images zijn gekoppeld aan je repo in de GitHub UI. Je ziet ze in de Packages-tab.
Multi-Platform Builds#
platforms: linux/amd64,linux/arm64Deze 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#
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#
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:
#!/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:
script: |
cd /var/www/akousa.net && ./deploy.shDit 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.
# ecosystem.config.js already has:
# instances: 2
# exec_mode: "cluster"
pm2 reload akousa --update-envpm2 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:
- 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-envDit 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:
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
server 127.0.0.1:3000;
}# /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:
#!/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:
# 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:
docker compose pull
docker compose up -d --remove-orphansDocker 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#
# 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#
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.netDezelfde 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:
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.comGeen 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:
{
"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:
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#
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:
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 lintfoutentypecheck— Geen typefoutentest— Alle tests slagenbuild— 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#
- 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:
- 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:
- 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.
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 }}"
}' || trueDe Flow Doorlopen#
Wanneer ik naar main push:
- Lint, Type Check en Test starten gelijktijdig. Drie runners, drie parallelle jobs. Als er een faalt, stopt de pipeline.
- Build draait alleen als alle drie slagen. Het valideert dat de applicatie compileert en werkende output produceert.
- Docker bouwt het productie-image en pusht het naar ghcr.io. Multi-platform, layer-gecachet.
- Deploy SSH't naar de VPS, pullt het nieuwe image, start een nieuwe container, doet een health-check, schakelt Nginx en ruimt op.
- 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:
- Lint, Type Check en Test draaien. Dezelfde kwaliteitseisen.
- Build draait om te verifieren dat het project compileert.
- Docker en Deploy worden overgeslagen (de
if-voorwaarden beperken ze tot alleen demain-branch).
Wanneer ik een nooddeploy nodig heb (tests overslaan):
- Klik op "Run workflow" in de Actions-tab.
- Selecteer
skip_tests: true. - Lint en typecheck draaien nog steeds (je kunt die niet overslaan — ik vertrouw mezelf niet zoveel).
- 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.
jobs:
deploy:
timeout-minutes: 15
runs-on: ubuntu-latestGebruik 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.