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.
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#
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
- productionQuattro trigger, ognuno con uno scopo diverso:
pushsumainè il trigger per il deploy in produzione. Codice mergiato? Rilascialo.pull_requestesegue 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_dispatchti 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#
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 lintI 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#
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.
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: 1Perché 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:
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 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#
- 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:
- 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.
- 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:
- Match esatto: stesse dipendenze E stessi file sorgente. Cache hit completo, la build è quasi istantanea.
- Match parziale (dipendenze): stesse dipendenze ma sorgenti cambiati. La build ricompila solo i file modificati.
- 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.
- 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 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.
- 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:
# 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#
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=maxVediamo le decisioni importanti qui.
GitHub Container Registry (ghcr.io)#
Uso ghcr.io invece di Docker Hub per tre motivi:
- L'autenticazione è gratuita.
GITHUB_TOKENè automaticamente disponibile in ogni workflow -- non serve salvare le credenziali Docker Hub. - Prossimità. Le immagini vengono pullate dalla stessa infrastruttura su cui gira il tuo CI. I pull durante il CI sono veloci.
- Visibilità. Le immagini sono collegate al tuo repo nell'interfaccia GitHub. Le vedi nella tab Packages.
Build multi-piattaforma#
platforms: linux/amd64,linux/arm64Questa 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#
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#
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:
#!/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:
script: |
cd /var/www/akousa.net && ./deploy.shQuesto è 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.
# ecosystem.config.js already has:
# instances: 2
# exec_mode: "cluster"
pm2 reload akousa --update-envpm2 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:
- 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-envQuesto è 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:
# /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;
}
}Lo script di switch:
#!/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:
# 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:
docker compose pull
docker compose up -d --remove-orphansDocker 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#
# 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#
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.netStesso 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:
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.comNessuna 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:
{
"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:
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#
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:
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 linttypecheck-- Nessun errore di tipotest-- Tutti i test passanobuild-- 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#
- 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:
- 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:
- 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.
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 }}"
}' || trueAnalisi del flusso#
Quando faccio push su main:
- Lint, Type Check e Test partono contemporaneamente. Tre runner, tre job paralleli. Se uno fallisce, la pipeline si ferma.
- Build gira solo se tutti e tre passano. Verifica che l'applicazione compili e produca output funzionante.
- Docker builda l'immagine di produzione e la pusha su ghcr.io. Multi-piattaforma, con layer caching.
- Deploy fa SSH nel VPS, pulla la nuova immagine, avvia un nuovo container, fa health check, switcha Nginx e pulisce.
- 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:
- Lint, Type Check e Test girano. Stessi gate di qualità.
- Build gira per verificare che il progetto compili.
- Docker e Deploy vengono saltati (le condizioni
ifli limitano solo al branchmain).
Quando serve un deploy d'emergenza (saltare i test):
- Clicca "Run workflow" nella tab Actions.
- Seleziona
skip_tests: true. - Lint e typecheck girano comunque (non puoi saltare quelli -- non mi fido di me stesso a tal punto).
- 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.
jobs:
deploy:
timeout-minutes: 15
runs-on: ubuntu-latestUsa 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.