Vai al contenuto
·32 min di lettura

GitHub Actions CI/CD: deploy a zero downtime che funzionano davvero

La mia configurazione completa di GitHub Actions: job di test paralleli, caching delle build Docker, deploy via SSH su VPS, zero downtime con PM2 reload, gestione dei secret e i pattern di workflow affinati in due anni.

Condividi:X / TwitterLinkedIn

Ogni progetto su cui ho lavorato prima o poi arriva allo stesso punto di svolta: il processo di deploy diventa troppo doloroso per farlo a mano. Ti dimentichi di lanciare i test. Fai la build in locale ma ti scordi di aggiornare la versione. Fai SSH in produzione e scopri che l'ultima persona che ha deployato ha lasciato un file .env obsoleto.

GitHub Actions ha risolto questo problema per me due anni fa. Non in modo perfetto dal primo giorno: il primo workflow che ho scritto era un incubo YAML di 200 righe che andava in timeout la metà delle volte e non cachava nulla. Ma iterazione dopo iterazione, sono arrivato a qualcosa che deploya questo sito in modo affidabile, con zero downtime, in meno di quattro minuti.

Questo è quel workflow, spiegato sezione per sezione. Non la versione della documentazione. La versione che sopravvive al contatto con la produzione.

Capire i componenti fondamentali#

Prima di entrare nella pipeline completa, ti serve un modello mentale chiaro di come funziona GitHub Actions. Se hai usato Jenkins o CircleCI, dimentica la maggior parte di quello che sai. I concetti si mappano vagamente, ma il modello di esecuzione è abbastanza diverso da trarti in inganno.

Trigger: quando si avvia il workflow#

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

Quattro trigger, ognuno con uno scopo diverso:

  • push su main è il trigger per il deploy in produzione. Codice mergiato? Rilascialo.
  • pull_request esegue i controlli CI su ogni PR. Qui vivono lint, type check e test.
  • schedule è il cron per il tuo repo. Lo uso per scan settimanali di audit delle dipendenze e pulizia delle cache obsolete.
  • workflow_dispatch ti dà un pulsante manuale "Deploy" nell'interfaccia di GitHub con parametri di input. Indispensabile quando devi deployare lo staging senza modificare il codice -- magari hai aggiornato una variabile d'ambiente o devi ripullare un'immagine Docker di base.

Una cosa che frega molti: pull_request viene eseguito sul merge commit, non sull'HEAD del branch della PR. Questo significa che il CI sta testando come apparirà il codice dopo il merge. In realtà è proprio quello che vuoi, ma sorprende quando un branch verde diventa rosso dopo un rebase.

Job, step e runner#

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

I job vengono eseguiti in parallelo di default. Ogni job ottiene una VM nuova (il "runner"). ubuntu-latest ti dà una macchina ragionevolmente potente -- 4 vCPU, 16 GB di RAM nel 2026. Gratuito per i repo pubblici, 2000 minuti al mese per quelli privati.

Gli step vengono eseguiti sequenzialmente all'interno di un job. Ogni step uses: importa un'action riutilizzabile dal marketplace. Ogni step run: esegue un comando shell.

Il flag --frozen-lockfile è cruciale. Senza, pnpm install potrebbe aggiornare il lockfile durante il CI, il che significa che non stai testando le stesse dipendenze che lo sviluppatore ha committato. Ho visto questo causare fallimenti fantasma dei test che svanivano in locale perché il lockfile sulla macchina dello sviluppatore era già corretto.

Variabili d'ambiente vs secret#

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"

Le variabili d'ambiente impostate con env: a livello di workflow sono in chiaro, visibili nei log. Usale per configurazioni non sensibili: NODE_ENV, flag di telemetria, feature toggle.

I secret (${{ secrets.X }}) sono crittografati a riposo, mascherati nei log e disponibili solo ai workflow nello stesso repo. Si configurano in Settings > Secrets and variables > Actions.

La riga environment: production è significativa. I GitHub Environments ti permettono di associare i secret a specifici target di deploy. La tua chiave SSH di staging e quella di produzione possono entrambe chiamarsi SSH_PRIVATE_KEY ma contenere valori diversi a seconda dell'environment che il job punta. Questo sblocca anche i required reviewer: puoi bloccare i deploy in produzione dietro un'approvazione manuale.

La pipeline CI completa#

Ecco come struttura la metà CI della pipeline. L'obiettivo: intercettare ogni categoria di errore nel minor tempo possibile.

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

Perché questa struttura#

Lint, typecheck e test vengono eseguiti in parallelo. Non hanno dipendenze tra loro. Un errore di tipo non blocca l'esecuzione del lint, e un test fallito non ha bisogno di aspettare il type checker. In un'esecuzione tipica, tutti e tre completano in 30-60 secondi girando contemporaneamente.

La build attende tutti e tre. La riga needs: [lint, typecheck, test] significa che il job di build parte solo se lint, typecheck E test passano tutti. Non ha senso buildare un progetto con errori di lint o fallimenti di tipo.

concurrency con cancel-in-progress: true è un enorme risparmio di tempo. Se fai push di due commit in rapida successione, la prima esecuzione CI viene cancellata. Senza questo, avresti esecuzioni obsolete che consumano il tuo budget di minuti e intasano l'interfaccia dei check.

Upload della coverage con if: always() significa che ottieni il report di coverage anche quando i test falliscono. Utile per il debug: puoi vedere quali test sono falliti e cosa coprivano.

Fail-fast vs. lasciarli tutti girare#

Di default, se un job in una matrix fallisce, GitHub cancella gli altri. Per il CI, in realtà questo comportamento mi va bene: se il lint fallisce, non mi importa dei risultati dei test. Prima correggi il lint.

Ma per le matrix di test (ad esempio, testare su Node 20 e Node 22), potresti voler vedere tutti i fallimenti insieme:

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 lascia completare entrambe le combinazioni della matrix. Se Node 22 fallisce ma Node 20 passa, vedi subito quell'informazione invece di dover rieseguire.

Caching per la velocità#

Il singolo miglioramento più grande che puoi apportare alla velocità del CI è il caching. Un pnpm install a freddo su un progetto medio richiede 30-45 secondi. Con la cache calda, ne servono 3-5. Moltiplicalo per quattro job paralleli e stai risparmiando due minuti su ogni esecuzione.

Cache dello store pnpm#

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

Questa singola riga cacha lo store pnpm (~/.local/share/pnpm/store). Con cache hit, pnpm install --frozen-lockfile fa semplicemente hard-link dallo store invece di scaricare. Questo da solo taglia i tempi di installazione dell'80% sulle esecuzioni successive.

Se hai bisogno di più controllo -- ad esempio, vuoi cachare in base al sistema operativo -- usa actions/cache direttamente:

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 }}-

Il fallback restore-keys è importante. Se pnpm-lock.yaml cambia (nuova dipendenza), la chiave esatta non farà match, ma il match per prefisso ripristinerà comunque la maggior parte dei pacchetti cachati. Viene scaricato solo il delta.

Cache della build Next.js#

Next.js ha la sua cache di build in .next/cache. Cacharla tra le esecuzioni significa build incrementali: vengono ricompilate solo le pagine e i componenti modificati.

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 }}-

Questa strategia di chiave a tre livelli significa:

  1. Match esatto: stesse dipendenze E stessi file sorgente. Cache hit completo, la build è quasi istantanea.
  2. Match parziale (dipendenze): stesse dipendenze ma sorgenti cambiati. La build ricompila solo i file modificati.
  3. Match parziale (solo SO): dipendenze cambiate. La build riutilizza ciò che può.

Numeri reali dal mio progetto: la build a freddo richiede ~55 secondi, quella cachata ~15 secondi. Una riduzione del 73%.

Caching dei layer Docker#

Le build Docker sono dove il caching diventa davvero impattante. Una build Docker completa di Next.js -- installazione delle dipendenze OS, copia dei sorgenti, esecuzione di pnpm install, esecuzione di next build -- richiede 3-4 minuti a freddo. Con il layer caching, 30-60 secondi.

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 usa il backend di cache integrato di GitHub Actions. mode=max cacha tutti i layer, non solo quelli finali. Questo è critico per le build multi-stage dove i layer intermedi (come pnpm install) sono i più costosi da ricostruire.

Cache remota Turborepo#

Se sei in un monorepo con Turborepo, la cache remota è trasformativa. La prima build carica gli output dei task nella cache. Le build successive scaricano invece di ricalcolare.

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

Ho visto i tempi CI dei monorepo scendere da 8 minuti a 90 secondi con la cache remota di Turbo. Il compromesso: richiede un account Vercel o un server Turbo self-hosted. Per repo con una singola app, è eccessivo.

Build e push Docker#

Se stai deployando su un VPS (o qualsiasi server), Docker ti dà build riproducibili. La stessa immagine che gira nel CI è la stessa che gira in produzione. Niente più "funziona sulla mia macchina" perché la macchina è l'immagine.

Dockerfile multi-stage#

Prima di arrivare al workflow, ecco il Dockerfile che uso per 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"]

Tre stage, separazione netta. L'immagine finale è ~150MB invece dei ~1.2GB che otterresti copiando tutto. Solo gli artefatti di produzione arrivano allo stage runner.

Il workflow di build-and-push#

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

Vediamo le decisioni importanti qui.

GitHub Container Registry (ghcr.io)#

Uso ghcr.io invece di Docker Hub per tre motivi:

  1. L'autenticazione è gratuita. GITHUB_TOKEN è automaticamente disponibile in ogni workflow -- non serve salvare le credenziali Docker Hub.
  2. Prossimità. Le immagini vengono pullate dalla stessa infrastruttura su cui gira il tuo CI. I pull durante il CI sono veloci.
  3. Visibilità. Le immagini sono collegate al tuo repo nell'interfaccia GitHub. Le vedi nella tab Packages.

Build multi-piattaforma#

yaml
platforms: linux/amd64,linux/arm64

Questa riga aggiunge forse 90 secondi alla build, ma ne vale la pena. Le immagini ARM64 girano nativamente su:

  • Mac Apple Silicon (M1/M2/M3/M4) durante lo sviluppo locale con Docker Desktop
  • Istanze AWS Graviton (20-40% più economiche delle equivalenti x86)
  • Il tier gratuito ARM di Oracle Cloud

Senza questo, i tuoi sviluppatori su Mac serie M fanno girare immagini x86 tramite emulazione Rosetta. Funziona, ma è notevolmente più lento e occasionalmente fa emergere bug specifici dell'architettura.

QEMU fornisce il layer di cross-compilazione. Buildx orchestra la build multi-arch e fa push di un manifest list così Docker pulla automaticamente l'architettura giusta.

Strategia di tagging#

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

Ogni immagine riceve tre tag:

  • abc1234 (SHA del commit): Immutabile. Puoi sempre deployare un commit specifico.
  • main (nome del branch): Mutabile. Punta all'ultima build da quel branch.
  • latest: Mutabile. Impostato solo sul branch di default. È quello che il tuo server pulla.

Non deployare mai latest in produzione senza registrare anche lo SHA da qualche parte. Quando qualcosa si rompe, devi sapere quale latest. Salvo lo SHA deployato in un file sul server che l'endpoint di health legge.

Deploy via SSH su VPS#

Qui tutto si incastra. Il CI passa, l'immagine Docker è buildata e pushata, ora dobbiamo dire al server di pullare la nuova immagine e riavviare.

L'action SSH#

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 ==="

L'alternativa dello script di deploy#

Per qualsiasi cosa che vada oltre un semplice pull-e-riavvia, sposto la logica in uno script sul server piuttosto che inlinarla nel workflow:

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."

Il workflow diventa quindi un singolo comando SSH:

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

Questo è meglio perché: (1) la logica di deploy è versionata sul server, (2) puoi eseguirlo manualmente via SSH per il debug, e (3) non devi fare escape di YAML dentro YAML dentro bash.

Strategie a zero downtime#

"Zero downtime" sembra linguaggio di marketing, ma ha un significato preciso: nessuna richiesta riceve un connection refused o un 502 durante il deploy. Ecco tre approcci concreti, dal più semplice al più robusto.

Strategia 1: PM2 Cluster Mode Reload#

Se stai eseguendo Node.js direttamente (non in Docker), la modalità cluster di PM2 ti dà il percorso più semplice verso lo zero downtime.

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

pm2 reload (non restart) fa un riavvio rolling. Avvia nuovi worker, aspetta che siano pronti, poi termina i vecchi worker uno alla volta. In nessun momento ci sono zero worker che servono traffico.

Il flag --update-env ricarica le variabili d'ambiente dalla configurazione dell'ecosystem. Senza, il vecchio env persiste anche dopo un deploy che ha cambiato .env.

Nel tuo 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

Questo è ciò che uso per questo sito. È semplice, affidabile, e il downtime è letteralmente zero: l'ho testato con un generatore di carico che faceva 100 req/s durante i deploy. Nemmeno un singolo 5xx.

Strategia 2: Blue/Green con Nginx Upstream#

Per i deploy Docker, il blue/green ti dà una separazione netta tra vecchia e nuova versione.

Il concetto: esegui il vecchio container ("blue") sulla porta 3000 e il nuovo container ("green") sulla porta 3001. Nginx punta a blue. Avvii green, verifichi che sia sano, switchi Nginx su green, poi fermi blue.

Configurazione upstream di Nginx:

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;
    }
}

Lo script di switch:

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"

La pausa di 5 secondi dopo il reload di Nginx non è pigrizia: è tempo di grazia. Il reload di Nginx è graceful (le connessioni esistenti vengono mantenute), ma alcune connessioni long-polling o risposte in streaming hanno bisogno di tempo per completarsi.

Strategia 3: Docker Compose con health check#

Per un approccio più strutturato, Docker Compose può gestire lo swap blue/green:

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"

La riga order: start-first è la chiave. Significa "avvia il nuovo container prima di fermare il vecchio." Combinata con parallelism: 1, ottieni un aggiornamento rolling -- un container alla volta, mantenendo sempre la capacità.

Deploy con:

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

Docker Compose monitora l'healthcheck e non instrada traffico al nuovo container finché non lo supera. Se l'healthcheck fallisce, failure_action: rollback ripristina automaticamente la versione precedente. Questo è il più vicino possibile ai rolling deployment stile Kubernetes che puoi ottenere su un singolo VPS.

Gestione dei secret#

La gestione dei secret è una di quelle cose facili da fare "quasi bene" e catastroficamente sbagliate nei casi limite rimanenti.

GitHub Secrets: le basi#

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 maschera automaticamente i valori dei secret dall'output dei log. Se il tuo secret è p@ssw0rd123 e qualsiasi step stampa quella stringa, i log mostrano ***. Funziona bene, con un'avvertenza: se il tuo secret è corto (tipo un PIN a 4 cifre), GitHub potrebbe non mascherarlo perché potrebbe corrispondere a stringhe innocue. Mantieni i secret ragionevolmente complessi.

Secret con scope per environment#

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

Stesso nome del secret, valori diversi per environment. Il campo environment del job determina quale set di secret viene iniettato.

Gli environment di produzione dovrebbero avere i required reviewer abilitati. Questo significa che un push su main avvia il workflow, il CI gira automaticamente, ma il job di deploy si ferma e aspetta che qualcuno clicchi "Approve" nell'interfaccia GitHub. Per un progetto in solitaria, potrebbe sembrare overhead. Per qualsiasi cosa con utenti, ti salva la vita la prima volta che mergi accidentalmente qualcosa di rotto.

OIDC: basta credenziali statiche#

Le credenziali statiche (chiavi di accesso AWS, file JSON di service account GCP) salvate nei GitHub Secrets sono un rischio. Non scadono, non possono essere limitate a una specifica esecuzione del workflow, e se vengono compromesse, devi ruotarle manualmente.

OIDC (OpenID Connect) risolve il problema. GitHub Actions funge da identity provider, e il tuo cloud provider gli dà fiducia per emettere credenziali a breve durata al volo:

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

Nessuna access key. Nessuna secret key. L'action configure-aws-credentials richiede un token temporaneo da AWS STS usando il token OIDC di GitHub. Il token ha scope specifico per repo, branch e environment. Scade al termine dell'esecuzione del workflow.

Configurarlo lato AWS richiede un OIDC identity provider IAM e una policy di trust per il ruolo:

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"
        }
      }
    }
  ]
}

La condizione sub è cruciale. Senza, qualsiasi repo che in qualche modo ottenga i dettagli del tuo OIDC provider potrebbe assumere il ruolo. Con essa, solo il branch main del tuo repo specifico può farlo.

GCP ha un setup equivalente con Workload Identity Federation. Azure ha le credenziali federate. Se il tuo cloud supporta OIDC, usalo. Non c'è ragione per salvare credenziali cloud statiche nel 2026.

Chiavi SSH per il deploy#

Per i deploy su VPS via SSH, genera una coppia di chiavi dedicata:

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

Aggiungi la chiave pubblica al file ~/.ssh/authorized_keys del server con restrizioni:

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

Il prefisso restrict disabilita port forwarding, agent forwarding, allocazione PTY e X11 forwarding. Il prefisso command= significa che questa chiave può solo eseguire lo script di deploy. Anche se la chiave privata viene compromessa, l'attaccante può eseguire il tuo script di deploy e nient'altro.

Aggiungi la chiave privata ai GitHub Secrets come SSH_PRIVATE_KEY. Questa è l'unica credenziale statica che accetto -- le chiavi SSH con forced command hanno un raggio d'azione molto limitato.

Workflow per le PR: deploy di preview#

Ogni PR merita un ambiente di preview. Cattura bug visivi che gli unit test non rilevano, permette ai designer di fare review senza fare checkout del codice, e rende la vita del QA drammaticamente più facile.

Deploy di una preview all'apertura della PR#

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,
              });
            }

Il calcolo della porta (4000 + PR_NUM) è un hack pragmatico. La PR #42 ottiene la porta 4042. Finché non hai più di qualche centinaio di PR aperte, non ci sono collisioni. Una configurazione wildcard di Nginx instrada pr-*.preview.akousa.net alla porta giusta.

Cleanup alla chiusura della PR#

Gli ambienti di preview che non vengono puliti mangiano disco e memoria. Aggiungi un job di cleanup:

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',
              });
            }

Required status check#

Nelle impostazioni del tuo repository (Settings > Branches > Branch protection rules), richiedi questi check prima del merge:

  • lint -- Nessun errore di lint
  • typecheck -- Nessun errore di tipo
  • test -- Tutti i test passano
  • build -- Il progetto builda con successo

Senza questo, qualcuno mergerà una PR con check falliti. Non per cattiveria -- vedranno "2 di 4 check passati" e assumeranno che gli altri due stiano ancora girando. Bloccalo.

Abilita anche "Require branches to be up to date before merging." Questo forza una riesecuzione del CI dopo il rebase sull'ultimo main. Cattura il caso in cui due PR passano individualmente il CI ma vanno in conflitto se combinate.

Notifiche#

Un deploy di cui nessuno sa è un deploy di cui nessuno si fida. Le notifiche chiudono il loop di feedback.

Webhook Slack#

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 }}"
              }
            ]
          }
        ]
      }

L'if: always() è critico. Senza, lo step di notifica viene saltato quando il deploy fallisce -- che è esattamente quando ne hai più bisogno.

API Deployments di GitHub#

Per un tracking più ricco dei deploy, usa l'API Deployments di GitHub. Questo ti dà una cronologia dei deploy nell'interfaccia del repo e abilita i badge di stato:

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',
      });

Ora la tab Environments di GitHub mostra una cronologia completa dei deploy: chi ha deployato cosa, quando e se ha avuto successo.

Email solo in caso di fallimento#

Per i deploy critici, attivo anche un'email in caso di fallimento. Non tramite l'email integrata di GitHub Actions (troppo rumorosa), ma tramite un webhook mirato:

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 }}"
      }'

Questa è la mia ultima linea di difesa. Slack è fantastico ma è anche rumoroso: le persone silenziano i canali. Un'email "DEPLOY FAILED" con un link all'esecuzione cattura l'attenzione.

Il file workflow completo#

Ecco tutto collegato in un unico workflow pronto per la produzione. È molto vicino a quello che effettivamente deploya questo sito.

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

Analisi del flusso#

Quando faccio push su main:

  1. Lint, Type Check e Test partono contemporaneamente. Tre runner, tre job paralleli. Se uno fallisce, la pipeline si ferma.
  2. Build gira solo se tutti e tre passano. Verifica che l'applicazione compili e produca output funzionante.
  3. Docker builda l'immagine di produzione e la pusha su ghcr.io. Multi-piattaforma, con layer caching.
  4. Deploy fa SSH nel VPS, pulla la nuova immagine, avvia un nuovo container, fa health check, switcha Nginx e pulisce.
  5. Notifiche partono a prescindere dall'esito. Slack riceve il messaggio. I GitHub Deployments vengono aggiornati. Se ha fallito, parte un'email di alert.

Quando apro una PR:

  1. Lint, Type Check e Test girano. Stessi gate di qualità.
  2. Build gira per verificare che il progetto compili.
  3. Docker e Deploy vengono saltati (le condizioni if li limitano solo al branch main).

Quando serve un deploy d'emergenza (saltare i test):

  1. Clicca "Run workflow" nella tab Actions.
  2. Seleziona skip_tests: true.
  3. Lint e typecheck girano comunque (non puoi saltare quelli -- non mi fido di me stesso a tal punto).
  4. I test vengono saltati, la build gira, Docker builda, il deploy parte.

Questo è il mio workflow da due anni. Ha superato migrazioni di server, upgrade di versioni major di Node.js, pnpm che ha sostituito npm, e l'aggiunta di 15 tool a questo sito. Il tempo totale end-to-end dal push alla produzione: 3 minuti e 40 secondi in media. Lo step più lento è la build Docker multi-piattaforma a ~90 secondi. Tutto il resto è cachato fino a essere quasi istantaneo.

Lezioni da due anni di iterazioni#

Chiudo con gli errori che ho fatto così tu non devi ripeterli.

Fissa le versioni delle action. uses: actions/checkout@v4 va bene, ma per la produzione, considera uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 (lo SHA completo). Un'action compromessa potrebbe esfiltrare i tuoi secret. L'incidente tj-actions/changed-files nel 2025 ha dimostrato che non è teoria.

Non cachare tutto. Una volta ho cachato direttamente node_modules (non solo lo store pnpm) e ho passato due ore a debuggare un fallimento fantasma della build causato da binding nativi obsoleti. Cacha lo store del package manager, non i moduli installati.

Imposta i timeout. Ogni job dovrebbe avere timeout-minutes. Il default è 360 minuti (6 ore). Se il tuo deploy si blocca perché la connessione SSH è caduta, non vuoi scoprirlo sei ore dopo quando hai consumato tutti i minuti mensili.

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

Usa concurrency con saggezza. Per le PR, cancel-in-progress: true è sempre giusto -- a nessuno importa del risultato CI di un commit su cui è già stato fatto force-push. Per i deploy in produzione, impostalo a false. Non vuoi che un commit successivo cancelli un deploy che è a metà del rollout.

Testa il tuo file workflow. Usa act (https://github.com/nektos/act) per eseguire i workflow in locale. Non cattura tutto (i secret non sono disponibili e l'ambiente del runner è diverso), ma cattura errori di sintassi YAML e bug logici ovvi prima che tu faccia push.

Monitora i costi del CI. I minuti di GitHub Actions sono gratuiti per i repo pubblici e a basso costo per quelli privati, ma si accumulano. Le build Docker multi-piattaforma costano il doppio dei minuti (uno per piattaforma). Le strategie matrix di test moltiplicano il tempo di esecuzione. Tieni d'occhio la pagina di fatturazione.

La migliore pipeline CI/CD è quella di cui ti fidi. La fiducia nasce dall'affidabilità, dall'osservabilità e dal miglioramento incrementale. Parti con una semplice pipeline lint-test-build. Aggiungi Docker quando hai bisogno di riproducibilità. Aggiungi il deploy SSH quando hai bisogno di automazione. Aggiungi le notifiche quando hai bisogno di sicurezza. Non costruire la pipeline completa dal primo giorno -- sbaglierai le astrazioni.

Costruisci la pipeline di cui hai bisogno oggi, e lascia che cresca con il tuo progetto.

Articoli correlati