Automatizza build e deploy con GitHub Actions

Pipeline CI/CD con GitHub Actions per un’app Node e React: test, Docker, staging e produzione

Una pipeline CI/CD con GitHub Actions su tre ambienti: test su ogni push, un'unica immagine Docker su GHCR etichettata per SHA, deploy automatico su staging via SSH, promozione manuale in produzione, rollback, concurrency e healthcheck.

Una pipeline CI/CD con GitHub Actions per un’app Node con frontend React fa quattro cose: testa ogni push (install, lint, test, build), costruisce una sola immagine Docker e la pubblica su GitHub Container Registry, la installa da sola su staging e la porta in produzione solo quando lo decidi tu. In produzione arriva esattamente l’immagine provata in staging, identificata dal suo digest sha256 e mai ricostruita, e il rollback consiste nel ridistribuire il digest precedente.

Il flusso di base è quello che seguo anche io. Faccio quasi tutto con Claude Code e uso spesso Codex per testare quello che ha scritto. Provo le modifiche in un’immagine Docker locale che mi fa da ambiente di dev, mentre staging e live stanno su VPS. In mezzo c’è una pipeline su GitHub Actions: ogni commit con push passa da dev e viene testato, poi va su staging, e il passaggio da staging a live lo avvio io a mano.

I file che trovi qui sotto non sono quelli dei miei progetti: sono un esempio che riproduce quel flusso. Anche le scelte di dettaglio (immagine unica, deploy per digest, script di deploy) appartengono all’esempio, e i nomi di comodo (acme/shop, example.com) vanno sostituiti con i tuoi. Workflow e script sono stati validati con actionlint e shellcheck.

Indice


Lo schema: un’immagine, tre ambienti

L’errore più comune è ricostruire per ogni ambiente, o fare git pull e npm install sul server: in produzione finisce un artefatto mai provato. Lo schema della guida:

  1. Dev: lavori in locale in un container; ogni push su qualunque branch fa partire i test.
  2. Build: su main, a test passati, la pipeline pubblica un’immagine e ne registra il digest.
  3. Staging: la pipeline scarica quell’immagine sulla VPS di staging via SSH, la avvia e la verifica.
  4. Produzione: avvii tu il deploy dello stesso digest sulla VPS di produzione.

Il repository usa npm workspaces: client/ per React (build con Vite in client/dist), server/ per l’API Node, e un package.json alla radice con gli script lint, test e build. Con un’altra struttura cambiano i percorsi, non la logica; per il frontend c’è la guida sull’architettura di un’app React.

Prerequisiti sui server: Docker con Compose, curl, jq (per leggere la risposta JSON dell’healthcheck), un utente dedicato al deploy che possa usare Docker e la cartella /opt/app con compose.yaml e un .env con le credenziali, scritto una volta a mano. L’healthcheck gira sul server, verso 127.0.0.1:3000, quindi il deploy non richiede che l’app sia raggiungibile da internet. Gli URL pubblici degli environment (https://staging.example.com, https://example.com) presuppongono invece un reverse proxy sulla VPS, con DNS e certificato TLS già configurati.

I file sono tre: .github/workflows/pipeline.yml (test, immagine, staging), .github/workflows/promote.yml (produzione e rollback) e scripts/deploy.sh, condiviso dai due workflow.

Il job di test su ogni push

Il primo job gira su ogni push e non ha segreti. I permessi del workflow sono in sola lettura (contents: read); i job che ne hanno bisogno li alzano da soli.

name: pipeline

on:
  push:
    branches: ["**"]

permissions:
  contents: read

env:
  IMAGE: ghcr.io/acme/shop

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm test
      - run: npm run build

branches: ["**"] include i branch con lo slash (feature/login) ed esclude i push dei soli tag. cache: npm salva la cache di npm con chiave l’hash di package-lock.json, e npm ci installa esattamente il lockfile. Il build qui serve solo a trovare subito gli errori: quello che va sui server nasce nel Dockerfile. Se lavori con pull request, aggiungi pull_request tra gli eventi e rendi obbligatorio il job test nelle regole di protezione di main.

Un’immagine Docker unica su GHCR

Il secondo job parte solo su main e solo se test è passato. Pubblica su GitHub Container Registry con il GITHUB_TOKEN del workflow, senza token personali, e per questo dichiara packages: write.

  image:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@v7
      - uses: docker/setup-buildx-action@v4
      - uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v6
        with:
          images: ${{ env.IMAGE }}
          tags: type=sha,format=long
      - id: build
        uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
      - name: Digest nel riepilogo
        env:
          DIGEST: ${{ steps.build.outputs.digest }}
        run: |
          echo "Immagine pubblicata: \`$IMAGE@$DIGEST\`" >> "$GITHUB_STEP_SUMMARY"

Qui c’è la distinzione che regge la pipeline. docker/metadata-action con type=sha,format=long crea un tag leggibile (sha- più il commit), ma un tag si sposta: se rilanci la pipeline sullo stesso commit, il build riparte, magari su una node:24-alpine aggiornata, e lo stesso tag finisce su un’immagine diversa. Il digest è invece l’hash del contenuto: docker/build-push-action lo espone come output digest, il job lo passa avanti con outputs e lo scrive nel riepilogo della run. Ogni deploy usa il riferimento ghcr.io/acme/shop@sha256:…, che punta sempre e solo a quei byte.

Niente latest: con un riferimento che si sposta non sai cosa gira su un server. Il nome dell’immagine va in minuscolo. La cache type=gha riusa i layer Docker tra una run e l’altra. Il Dockerfile è multi-stage: il primo stadio compila client e server, il secondo tiene solo le dipendenze di produzione, il codice compilato e i file statici di React.

FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY client/package.json client/
COPY server/package.json server/
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
COPY client/package.json client/
COPY server/package.json server/
RUN npm ci --omit=dev --workspace server
COPY --from=build /app/server/dist server/dist
COPY --from=build /app/client/dist server/public
USER node
EXPOSE 3000
CMD ["node", "server/dist/index.js"]

Aggiungi un .dockerignore con almeno node_modules, .git e i file .env, altrimenti COPY . . si porta dietro anche quelli. Il container gira come utente node.

Deploy automatico su staging via SSH

Sulla VPS l’app gira con Docker Compose e due file di variabili. .env contiene le credenziali, si scrive una volta a mano e la pipeline non lo tocca. deploy.env contiene solo APP_IMAGE, il riferimento per digest, ed è l’unico file che la pipeline scrive. Lo script li passa entrambi con docker compose --env-file .env --env-file deploy.env, perché con --env-file Compose non legge più .env da solo. Se APP_IMAGE manca, Compose si ferma invece di avviare qualcosa a caso.

services:
  app:
    image: ${APP_IMAGE:?scrivi APP_IMAGE in deploy.env}
    environment:
      DATABASE_URL: ${DATABASE_URL:?imposta DATABASE_URL nel file .env}
      PORT: 3000
    ports:
      - "127.0.0.1:3000:3000"
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(async (r) => process.exit(r.status === 200 && (await r.json()).status === 'ok' ? 0 : 1)).catch(() => process.exit(1))"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 10s
    restart: unless-stopped

Questo compose.yaml è ridotto all’osso ed è un esempio separato da quello della guida su Docker Compose con Node e PostgreSQL, ma usa gli stessi nomi: APP_IMAGE, .env per le credenziali, deploy.env per il riferimento all’immagine. Come lì, il servizio ha un healthcheck del container, così up --wait aspetta un’app che risponde. Per questo lo script funziona anche con il compose.yaml di quella guida senza adattamenti: lì l’app pubblica già la porta su 127.0.0.1:3000 e /health risponde con "status":"ok".

Se il package su GHCR è privato, sul server serve un docker login ghcr.io fatto una volta con un token in sola lettura sui package. Su GitHub crei l’environment staging (Settings → Environments) con quattro segreti: SSH_HOST, SSH_USER, SSH_KEY (la chiave privata dell’utente di deploy) e SSH_KNOWN_HOSTS (l’output di ssh-keyscan, controllato a mano). Gli stessi nomi vanno nell’environment production, con i valori dell’altra VPS.

  deploy-staging:
    needs: image
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.example.com
    concurrency:
      group: deploy-staging
      cancel-in-progress: false
    outputs:
      skipped: ${{ steps.latest.outputs.skipped }}
    steps:
      - uses: actions/checkout@v7
      - name: Salta se main è già più avanti
        id: latest
        run: |
          head="$(git ls-remote origin refs/heads/main | cut -f1)"
          if [ "$head" != "$GITHUB_SHA" ]; then
            echo "main è a $head: il deploy di $GITHUB_SHA è superato" >> "$GITHUB_STEP_SUMMARY"
            echo "skipped=true" >> "$GITHUB_OUTPUT"
          fi
      - name: Deploy su staging
        if: steps.latest.outputs.skipped != 'true'
        env:
          REF: ${{ env.IMAGE }}@${{ needs.image.outputs.digest }}
          SSH_HOST: ${{ secrets.SSH_HOST }}
          SSH_USER: ${{ secrets.SSH_USER }}
          SSH_KEY: ${{ secrets.SSH_KEY }}
          SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
        run: ./scripts/deploy.sh staging "$REF"

Il primo passo salta i deploy superati (vedi concurrency). Per l’SSH basta il client ssh dei runner Ubuntu: nessuna action di terze parti a cui affidare la chiave del server.

#!/usr/bin/env bash
# Uso: deploy.sh <ambiente> <immagine@sha256:digest>
# Legge SSH_HOST, SSH_USER, SSH_KEY e SSH_KNOWN_HOSTS dall'ambiente.
set -euo pipefail

TARGET="$1"
REF="$2"

# Accetta solo riferimenti per digest: ghcr.io/<nome>@sha256:<64 caratteri esadecimali>
if [[ ! "$REF" =~ ^ghcr\.io/[a-z0-9._/-]+@sha256:[0-9a-f]{64}$ ]]; then
  echo "Riferimento non valido: $REF" >&2
  exit 1
fi

install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts

LOG="$(mktemp)"

# Il blocco tra i due REMOTE gira sul server, con arresto sugli errori anche lì
ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" bash -s -- "$REF" <<'REMOTE' | tee "$LOG"
set -euo pipefail
REF="$1"
cd /opt/app

# .env contiene le credenziali: si scrive una volta sul server, la pipeline non lo tocca
if [ ! -f .env ]; then
  echo "Manca /opt/app/.env" >&2
  exit 1
fi

# Con --env-file Compose non legge più .env da solo: si passano entrambi i file
compose() {
  docker compose --env-file .env --env-file "$1" -f compose.yaml "${@:2}"
}

# Versione attiva e verificata; deploy.env manca solo al primo deploy
ACTIVE=""
if [ -f deploy.env ]; then
  ACTIVE="$(sed -n 's/^APP_IMAGE=//p' deploy.env)"
fi

# Riuscito solo se curl esce con 0, il codice è 200 e il corpo è un solo oggetto JSON con status "ok"
healthy() {
  local body code
  body="$(mktemp)"
  for _ in 1 2 3 4 5 6 7 8 9 10 11 12; do
    if code="$(curl --silent --output "$body" --write-out '%{http_code}' \
         --connect-timeout 2 --max-time 5 http://127.0.0.1:3000/health)" \
       && [ "$code" = 200 ] \
       && jq -se 'length == 1 and (.[0] | type == "object" and .status == "ok")' "$body" > /dev/null 2>&1; then
      rm -f "$body"
      return 0
    fi
    sleep 5
  done
  rm -f "$body"
  return 1
}

# 1. Scarica la candidata: se fallisce, sul server non è cambiato nulla
printf 'APP_IMAGE=%s\n' "$REF" > deploy.env.next
if ! compose deploy.env.next pull >&2; then
  rm -f deploy.env.next
  exit 1
fi

# 2. Avvia la candidata e verificala; se non risponde, torna alla versione attiva
if ! compose deploy.env.next up -d --no-build --wait >&2 || ! healthy; then
  echo "Deploy fallito per $REF" >&2
  if [ -f deploy.env ]; then
    if compose deploy.env up -d --no-build --wait >&2 && healthy; then
      echo "Ripristinata $ACTIVE" >&2
    else
      echo "Ripristino fallito: intervieni sul server" >&2
    fi
  else
    # Primo deploy: non c'è una versione a cui tornare. Si ferma solo app, i volumi restano
    if compose deploy.env.next stop app >&2; then
      echo "Primo deploy fallito: app fermata, nessuna versione per il rollback" >&2
    else
      echo "Primo deploy fallito e app non fermata: la candidata può essere attiva, intervieni sul server" >&2
    fi
  fi
  rm -f deploy.env.next
  exit 1
fi

# 3. Solo ora aggiorna i riferimenti; ridistribuire lo stesso digest non tocca .previous
if [ -n "$ACTIVE" ] && [ "$ACTIVE" != "$REF" ]; then
  cp deploy.env deploy.env.previous
fi
mv deploy.env.next deploy.env
echo "BEFORE=${ACTIVE:-nessuna}"
echo "ROLLBACK=$(sed -n 's/^APP_IMAGE=//p' deploy.env.previous 2>/dev/null || true)"
REMOTE

BEFORE="$(sed -n 's/^BEFORE=//p' "$LOG")"
ROLLBACK="$(sed -n 's/^ROLLBACK=//p' "$LOG")"
{
  echo "### Deploy su $TARGET"
  echo "- Distribuita: \`$REF\`"
  echo "- Attiva prima di questa esecuzione: \`$BEFORE\`"
  echo "- Per il rollback (deploy.env.previous): \`${ROLLBACK:-nessuna}\`"
} >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}"

Il blocco tra i due REMOTE viaggia sullo standard input di ssh e gira sul server con bash -s, con il suo set -euo pipefail: quello del runner non vale sulla shell remota, e senza di esso un cd /opt/app fallito lascerebbe proseguire il deploy nella cartella sbagliata. L’ordine dei passi protegge il punto di rollback:

  1. Controlla che .env esista e legge da deploy.env la versione attiva e verificata; deploy.env manca solo al primo deploy, caso gestito in modo esplicito.
  2. Scrive la candidata in deploy.env.next e la scarica. Se il pull fallisce, cancella quel file e si ferma: sul server non cambia niente.
  3. Avvia la candidata con up -d --no-build --wait e la verifica. Se fallisce, riavvia la versione di deploy.env e scrive “Ripristinata” solo se anche quella passa la verifica, altrimenti “Ripristino fallito”. Al primo deploy ferma il solo servizio app (i volumi restano), e se nemmeno questo riesce avvisa che la candidata può essere ancora attiva. Esce sempre con errore.
  4. Solo a verifica riuscita copia deploy.env in deploy.env.previous e mette la candidata al suo posto. Un deploy fallito non modifica nessuno dei due file, e .env non viene mai toccato.

L’healthcheck fa fino a 12 richieste a /health, a 5 secondi di distanza, ognuna con --connect-timeout 2 e --max-time 5. Una richiesta conta solo se curl esce con 0 (una risposta troncata no), il codice è 200 e il corpo è un solo oggetto JSON con status uguale a "ok", verificato con jq -se. Passa {"status": "ok"}, con o senza spazi; vengono scartati redirect, testo libero, {"status":"error","dependency":{"status":"ok"}} e due oggetti JSON di fila. L’healthcheck del container in compose.yaml chiede a sua volta un 200 con status uguale a "ok". /health deve verificare le dipendenze reali, come il database.

Nel riepilogo della run (GITHUB_STEP_SUMMARY) lo script scrive il riferimento distribuito, quello attivo prima dell’esecuzione e quello disponibile per il rollback (deploy.env.previous): gli ultimi due differiscono solo se ridistribuisci lo stesso digest. I segreti arrivano come variabili d’ambiente, mai interpolati nel comando (vale lo stesso per le API: c’è la checklist di sicurezza per le REST API). Rendi eseguibile lo script con chmod +x prima del commit.

Promozione in produzione avviata a mano

Per avviare a mano la produzione GitHub offre due strade, con requisiti diversi.

Opzione A: un workflow con workflow_dispatch

Un workflow con workflow_dispatch mostra il pulsante Run workflow nella scheda Actions e chiede il digest da promuovere, copiato dal riepilogo del deploy su staging. Da terminale: gh workflow run promote.yml -f digest=sha256:…. Parte solo se il file esiste sul branch di default.

name: promote

on:
  workflow_dispatch:
    inputs:
      digest:
        description: "Digest dell'immagine già verificata in staging (sha256:...)"
        required: true
        type: string

permissions:
  contents: read

env:
  IMAGE: ghcr.io/acme/shop

jobs:
  deploy-production:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com
    concurrency:
      group: deploy-production
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@v7
      - name: Deploy in produzione
        env:
          DIGEST: ${{ inputs.digest }}
          SSH_HOST: ${{ secrets.SSH_HOST }}
          SSH_USER: ${{ secrets.SSH_USER }}
          SSH_KEY: ${{ secrets.SSH_KEY }}
          SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
        run: ./scripts/deploy.sh production "$IMAGE@$DIGEST"

Il digest arriva allo script tramite la variabile DIGEST, non scritto nel comando con ${{ inputs.digest }}: un input malformato non diventa codice shell, e lo script lo scarta se non è @sha256: più 64 caratteri esadecimali.

Opzione B: environment production con required reviewers

In alternativa il job di produzione sta nella pipeline, dopo staging, e l’environment production ha la regola Required reviewers: il job aspetta che uno dei revisori (fino a sei, tra utenti e team) prema Approve and deploy, e solo allora riceve i segreti. Basta aggiungere in coda a pipeline.yml:

  deploy-production:
    needs: [image, deploy-staging]
    if: needs.deploy-staging.outputs.skipped != 'true'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com
    concurrency:
      group: deploy-production
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@v7
      - name: Deploy in produzione
        env:
          REF: ${{ env.IMAGE }}@${{ needs.image.outputs.digest }}
          SSH_HOST: ${{ secrets.SSH_HOST }}
          SSH_USER: ${{ secrets.SSH_USER }}
          SSH_KEY: ${{ secrets.SSH_KEY }}
          SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
        run: ./scripts/deploy.sh production "$REF"

Il digest è quello del job image della stessa run, appena verificato su staging; la condizione if esclude i deploy che staging ha saltato.

workflow_dispatchRequired reviewers
Chi avviachiunque abbia accesso in scrittura al repositoryla pipeline; un revisore approva
Quale immagineil digest che inserisci tuquella della run appena passata da staging
Rollbackstesso workflow con il digest precedenteserve comunque un workflow manuale
Disponibilitàtutti i piani, repository pubblici e privatisui piani Free, Pro e Team solo per i repository pubblici

Per un progetto privato su un piano personale l’opzione A è quella che funziona sempre, e serve comunque per il rollback. È quella che uso io: i miei repository sono privati, e quando una versione ha girato bene su staging il passaggio a live lo avvio a mano, con il digest copiato dal riepilogo della run. In entrambi i casi in produzione non si ricostruisce nulla. Un dettaglio della pagina Deployments: con workflow_dispatch il deployment viene associato al commit da cui lanci il workflow, non a quello dell’immagine indicata. Per sapere cosa gira fai fede al riepilogo della run.

Repository privato con piano Free

Su un repository privato di un account Free gli environment non si possono creare: per i repository privati la documentazione li riserva a GitHub Pro e alle organizzazioni Team (o Enterprise). La pipeline funziona lo stesso togliendo il blocco environment: e usando segreti di repository con un prefisso per ambiente:

  deploy-staging:
    needs: image
    runs-on: ubuntu-latest
    concurrency:
      group: deploy-staging
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@v7
      - name: Salta se main è già più avanti
        id: latest
        run: |
          head="$(git ls-remote origin refs/heads/main | cut -f1)"
          if [ "$head" != "$GITHUB_SHA" ]; then
            echo "skipped=true" >> "$GITHUB_OUTPUT"
          fi
      - name: Deploy su staging
        if: steps.latest.outputs.skipped != 'true'
        env:
          REF: ${{ env.IMAGE }}@${{ needs.image.outputs.digest }}
          SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
          SSH_USER: ${{ secrets.STAGING_SSH_USER }}
          SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
          SSH_KNOWN_HOSTS: ${{ secrets.STAGING_SSH_KNOWN_HOSTS }}
        run: ./scripts/deploy.sh staging "$REF"

In promote.yml togli allo stesso modo environment: e usi i segreti PROD_SSH_*. Cosa perdi:

  • la separazione dei segreti: quelli di produzione sono leggibili da qualunque job del repository;
  • le regole di protezione: niente required reviewers, wait timer o limiti sui branch che fanno deploy;
  • la cronologia in Deployments e il link all’URL dell’ambiente nella run.

Chi può avviare la produzione resta deciso da workflow_dispatch: serve l’accesso in scrittura al repository.

Rollback: ridistribuire il digest precedente

Il rollback è un deploy del digest precedente: lanci promote con quel valore e lo script fa il resto, healthcheck compreso, senza revert né nuovo build. Il digest lo trovi alla voce “Per il rollback” del riepilogo dell’ultimo deploy riuscito e sul server in deploy.env.previous. Un tentativo fallito non li aggiorna, quindi indicano sempre una versione verificata. Il rollback manuale serve quando il problema emerge dopo il deploy.

Due limiti. Il rollback dell’immagine non annulla le migrazioni del database: rendile compatibili con la versione precedente del codice (prima aggiungi colonne, rimuovi le vecchie in un rilascio successivo). E se pulisci in automatico le vecchie versioni su GHCR, tieni almeno le ultime release.

Concurrency e deploy superati

Il blocco concurrency fa girare un solo job di deploy alla volta per gruppo, quindi due deploy non si sovrappongono sullo stesso server. Con cancel-in-progress: false quello in corso finisce: interrompere docker compose up a metà è peggio che aspettare. In coda resta al massimo un job: se ne arriva un altro, quello in attesa da più tempo viene annullato. Staging e produzione hanno gruppi diversi, così un deploy su staging non blocca un rollback urgente.

Il gruppo però ordina i job per arrivo, non per età del commit. Se il build del commit A è più lento di quello di B, successivo, il deploy di A può arrivare dopo e riportare staging indietro. Per questo il primo passo del job, quando il job è già nel gruppo, confronta GITHUB_SHA con la punta di main letta con git ls-remote: se non coincidono, il deploy è superato e viene saltato, con una riga nel riepilogo.

Dove entra l’AI

Quello che i test non coprono va guardato prima del merge, e qui l’AI è utile: chiedere a Claude Code una review del diff, con il contesto del progetto, e far scrivere o eseguire a Codex i test sulle parti toccate, prima che il codice arrivi su main e quindi su staging. È lo stesso abbinamento che uso io: Claude Code scrive, Codex mette alla prova quello che ha scritto. Nessuno dei due sostituisce il job test né decide il deploy in produzione, che resta un’azione umana.

Come impostare prompt, review automatica sulle pull request e limiti di permessi è spiegato nella guida su l’AI nel workflow di sviluppo; per il resto della cassetta degli attrezzi c’è la pagina degli strumenti per sviluppatori.

Checklist prima del primo deploy

  1. Alla radice ci sono gli script lint, test, build e il package-lock.json.
  2. Il Dockerfile è multi-stage, non gira come root e c’è un .dockerignore.
  3. Il nome dell’immagine è in minuscolo; solo il job image ha packages: write.
  4. I deploy usano il riferimento per digest (@sha256:…), mai un tag o latest.
  5. Su ogni VPS ci sono Docker con Compose, curl, jq e un utente di deploy con la sua chiave; il known_hosts è nei segreti.
  6. Sul server, .env con le credenziali esiste ed è scritto a mano; la pipeline scrive solo deploy.env con APP_IMAGE; l’app pubblica la porta su 127.0.0.1:3000.
  7. Per gli URL pubblici di staging e produzione, reverse proxy, DNS e TLS sono pronti.
  8. /health controlla le dipendenze reali e risponde 200 con "status":"ok".
  9. Se il piano lo consente, gli environment staging e production hanno gli stessi nomi di segreti; su un privato Free, niente environment: e segreti di repository con prefisso.
  10. Ogni ambiente ha il suo gruppo concurrency e staging salta i commit superati.
  11. Hai scelto come promuovere: workflow_dispatch o required reviewers, se il piano li offre.
  12. Hai provato su staging un deploy con healthcheck fallito e un rollback.
  13. Le migrazioni restano compatibili con la release precedente e i workflow passano actionlint.

Fonti e documentazione