La guida alle pipeline che non volevi configurare ma che ti salveranno dal deploy manuale alle 3 di notte.
Da "git push e prego" a "la pipeline fa tutto da sola"
Alla fine CI/CD non è poi così terribile, basta che qualcuno te lo spieghi senza il tono da "DevOps Engineer con 47 badge su Credly".
Spoiler: è solo un modo per smettere di deployare a mano
Immagina di lavorare in una fabbrica di biscotti. Ogni volta che inventi una ricetta nuova (codice), devi: impastare, cuocere, assaggiare, confezionare e spedire. CI/CD è il nastro trasportatore automatico: tu butti l'impasto, e lui fa tutto il resto. Se il biscotto è brutto (test fallito), il nastro si ferma prima di spedire porcherie ai clienti.
Ogni volta che fai push, il codice viene compilato e testato automaticamente. Se qualcosa si rompe, lo sai subito — non dopo 3 settimane quando il QA ti manda un'email passivo-aggressiva.
Delivery: il codice testato è pronto per andare in produzione (ma qualcuno preme il bottone). Deployment: va in produzione da solo. Sì, da solo. Terrificante? Un po'. Ma funziona.
Scrivi codice → "funziona da me" → zip via email → FTP sul server → 500 Internal Server Error → panico → "chi ha toccato il server?" → il capo ti guarda male
Push su Git → la pipeline builda → i test passano → deploy automatico → tutto funziona → tu vai a prendere un caffè → il capo pensa che sei un genio
Il vocabolario minimo per non fare figure di merda
L'intero processo automatizzato dal push al deploy. Come una catena di montaggio: ogni pezzo fa il suo lavoro.
Un gruppo di job che gira insieme. Es: "build", "test", "deploy". Gli stage vanno in ordine sequenziale.
Un singolo compito dentro uno stage. Es: "esegui i test unitari", "builda l'immagine Docker". I job nello stesso stage possono girare in parallelo.
Un file prodotto da un job e passato al successivo. Es: il JAR compilato, il binario, l'immagine Docker, il report dei test.
La macchina (vera o virtuale) che esegue i job. Può essere ospitata dal provider (GitHub/GitLab) o self-hosted (il tuo server triste in cantina).
L'evento che fa partire la pipeline. Push, merge request, cron, tag, o il tuo collega che clicca "Run pipeline" per sbaglio.
Ecco la gerarchia, dal più grande al più piccolo:
# La gerarchia CI/CD
Pipeline # L'intero processo (1 per push/evento)
→ Stage # Fase logica (build, test, deploy...)
→ Job # Singolo compito (compila, linta, testa...)
→ Step # Singola istruzione dentro un job
# Esempio concreto:
Pipeline "Deploy App"
Stage: Build
Job: build-frontend # npm run build
Job: build-backend # go build
Stage: Test
Job: unit-tests # pytest
Job: integration-tests # cypress
Stage: Deploy
Job: deploy-staging # kubectl apply
Job: deploy-prod # solo se staging OK
Il CI/CD di GitHub — quello che usi se il repo è su GitHub (duh)
GitHub Actions cerca i workflow in .github/workflows/. Ogni file YAML lì dentro è un workflow separato.
il-tuo-progetto/
├── .github/
│ └── workflows/
│ ├── ci.yml # pipeline principale
│ ├── deploy.yml # deploy in prod
│ └── nightly.yml # test notturni
├── src/
├── package.json
└── ...
Ecco un workflow completo per un progetto Node.js. Ogni riga ha il suo perché.
# Nome del workflow (appare nella tab Actions di GitHub)
name: CI Pipeline
# QUANDO gira questo workflow?
on:
push:
branches: [ main, develop ] # solo su questi branch
pull_request:
branches: [ main ] # PR verso main
# I JOB che compongono il workflow
jobs:
# --- JOB 1: Build e Test ---
build-and-test:
runs-on: ubuntu-latest # su quale macchina gira
strategy:
matrix:
node-version: [18, 20, 22] # testa su 3 versioni di Node
steps:
# Step 1: Scarica il codice
- uses: actions/checkout@v4 # action ufficiale di GitHub
# Step 2: Installa Node.js
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm' # cache le dipendenze
# Step 3: Installa dipendenze
- run: npm ci # ci = clean install (più veloce)
# Step 4: Linting
- run: npm run lint
# Step 5: Test
- run: npm test
# Step 6: Build
- run: npm run build
# Step 7: Salva gli artifact
- uses: actions/upload-artifact@v4
with:
name: build-output-${{ matrix.node-version }}
path: dist/
Un workflow più completo: build, test, e deploy su staging/produzione.
name: Build & Deploy
on:
push:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test
build-image:
needs: test # aspetta che test finisca
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
docker build -t myapp:${{ github.sha }} .
docker push myregistry/myapp:${{ github.sha }}
deploy-staging:
needs: build-image
runs-on: ubuntu-latest
environment: staging # protezione ambiente
steps:
- run: |
kubectl set image deployment/myapp \
myapp=myregistry/myapp:${{ github.sha }}
deploy-prod:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # richiede approvazione manuale
steps:
- run: |
kubectl set image deployment/myapp \
myapp=myregistry/myapp:${{ github.sha }}
needs definisce le dipendenze tra job. Senza needs, i job girano in parallelo. Con needs: test, il job aspetta che "test" finisca con successo.
# Le action che userai nel 90% dei workflow
actions/checkout@v4 # clona il repo
actions/setup-node@v4 # installa Node.js
actions/setup-python@v5 # installa Python
actions/setup-go@v5 # installa Go
actions/setup-java@v4 # installa Java
actions/cache@v4 # cache dipendenze (npm, pip, ecc)
actions/upload-artifact@v4 # salva file tra job
actions/download-artifact@v4 # scarica artifact da job precedente
docker/build-push-action@v5 # build + push immagine Docker
docker/login-action@v3 # login a Docker registry
L'alternativa — un solo file .gitlab-ci.yml nella root e via
GitLab CI è più lineare: un file, stage ordinati, job che li popolano.
# Definisci l'ordine degli stage
stages:
- build
- test
- deploy
# Variabili globali
variables:
NODE_VERSION: "20"
# Immagine Docker di default per tutti i job
default:
image: node:${NODE_VERSION}
# --- STAGE: BUILD ---
build:
stage: build
script:
- npm ci
- npm run build
artifacts: # passa i file allo stage successivo
paths:
- dist/
expire_in: 1 hour
# --- STAGE: TEST ---
unit-test:
stage: test
script:
- npm ci
- npm test
coverage: '/Statements\s*:\s*(\d+\.?\d*)%/'
lint:
stage: test # stesso stage = parallelo a unit-test
script:
- npm ci
- npm run lint
# --- STAGE: DEPLOY ---
deploy-staging:
stage: deploy
script:
- echo "Deploying to staging..."
- kubectl apply -f k8s/staging/
environment:
name: staging
url: https://staging.example.com
only:
- develop
deploy-prod:
stage: deploy
script:
- echo "Deploying to production..."
- kubectl apply -f k8s/production/
environment:
name: production
url: https://example.com
only:
- main
when: manual # richiede click manuale
| Concetto | GitHub Actions | GitLab CI |
|---|---|---|
| File config | .github/workflows/*.yml |
.gitlab-ci.yml |
| Trigger | on: push/pull_request |
only/rules |
| Macchina | runs-on: ubuntu-latest |
image: node:20 |
| Passi | steps: [run, uses] |
script: [...] |
| Dipendenze | needs: [job-name] |
stage: (ordine implicito) |
| Secrets | ${{ secrets.TOKEN }} |
$TOKEN (CI/CD Variables) |
| Cache | actions/cache@v4 |
cache: paths: [...] |
| Manuale | workflow_dispatch |
when: manual |
Quando far partire la pipeline (e quando NO)
on:
# Push su branch specifici
push:
branches: [ main, develop ]
paths: # SOLO se questi file cambiano
- 'src/**'
- 'package.json'
paths-ignore: # ignora questi
- 'docs/**'
- '**.md'
# Pull request
pull_request:
branches: [ main ]
types: [ opened, synchronize ] # solo apertura e nuovi push
# Manuale (bottone "Run workflow")
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
default: 'staging'
type: choice
options:
- staging
- production
# Cron (schedulato)
schedule:
- cron: '0 3 * * 1-5' # lun-ven alle 3:00 UTC
# Quando viene creato un tag
push:
tags:
- 'v*' # v1.0.0, v2.3.1, ecc.
# Quando un altro workflow finisce
workflow_run:
workflows: [ "CI Pipeline" ]
types: [ completed ]
paths e paths-ignore insieme, paths-ignore vince. Usane solo uno dei due per non impazzire.
Come NON committare la password del database nel repo
MAI, MAI, MAI nel codice. MAI nel YAML. MAI in un commento "tanto nessuno lo legge". I segreti vanno nei secret manager della piattaforma.
Settings → Secrets and variables → Actions → New repository secret
Settings → CI/CD → Variables → Add variable (Flag "Masked" + "Protected")
# GitHub Actions
steps:
- name: Deploy
run: |
echo "Deploying..."
kubectl apply -f k8s/
env:
KUBE_TOKEN: ${{ secrets.KUBE_TOKEN }} # dal secret store
REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }}
- name: Login Docker
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
# GitLab CI (più semplice, sono già env vars)
deploy:
script:
- docker login -u $DOCKER_USER -p $DOCKER_PASS
- kubectl --token=$KUBE_TOKEN apply -f k8s/
***). Ma se fai echo $SECRET | base64 o li metti in un artifact, li esponi comunque. Non fare il furbo.
Build dell'immagine, push al registry, deploy — tutto automatico
name: Docker Build & Push
on:
push:
branches: [ main ]
tags: [ 'v*' ]
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Login al registry
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Genera tag intelligenti
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha
# Build e push
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from/cache-to: type=gha usa la cache di GitHub Actions per i layer Docker. La build successiva sarà molto più veloce se il Dockerfile non è cambiato tanto.
Come mandare in produzione senza distruggere tutto
Togli il vecchio, metti il nuovo. Tutto insieme. Downtime garantito.
rischio altoQuando usarlo: mai in prod. Forse in dev se sei pigro.
Sostituisci le istanze una alla volta. Zero downtime. Default in K8s.
rischio bassoQuando usarlo: il 90% delle volte. È il default, fai bene a usarlo.
Due ambienti identici. Uno live (blue), uno con la nuova versione (green). Switch istantaneo del traffico.
rischio medioQuando usarlo: quando vuoi rollback istantaneo (basta tornare a blue).
Mandi il 5% del traffico alla nuova versione. Se va tutto bene, aumenti gradualmente a 100%.
rischio bassoQuando usarlo: per cambiamenti rischiosi. Vuoi vedere se esplode prima di mandare tutto.
Quando la pipeline è rossa e non sai perché
L'errore più generico dell'universo. Significa solo "qualcosa è fallito".
# 1. Leggi il log INTERO, non solo l'ultima riga
# 2. Cerca la PRIMA riga rossa/di errore
# 3. Cause comuni:
- Test fallito # leggi quale test e perché
- npm ci fallito # package-lock.json non aggiornato?
- Comando non trovato # manca un tool? Installalo nello step prima
- Permessi # chmod +x script.sh
Stai usando runs-on: macos-latest o windows-latest con un'action che richiede Docker.
# Cambia il runner a Linux:
runs-on: ubuntu-latest
Il GITHUB_TOKEN non ha i permessi giusti.
# Aggiungi i permessi nel workflow:
permissions:
contents: read
packages: write
pull-requests: write
Di solito il colpevole è npm install senza cache o Docker build senza layer cache.
# 1. Cache npm/pip/yarn
- uses: actions/setup-node@v4
with:
cache: 'npm'
# 2. Cache Docker layers
- uses: docker/build-push-action@v5
with:
cache-from: type=gha
cache-to: type=gha,mode=max
# 3. Usa npm ci invece di npm install
# 4. Parallelizza i job (togli needs se non serve)
# 5. Usa path filters per skippare pipeline inutili
Su GitHub, i runner gratuiti hanno dei limiti. Se li superi o GitHub ti segnala per mining:
# Verifica i limiti: Settings → Billing → Actions
# Free: 2000 min/mese (Linux)
# Soluzione: usa self-hosted runner o ottimizza le pipeline
# Self-hosted runner (il tuo server):
runs-on: self-hosted
Copia, incolla, funziona
| Cosa Vuoi Fare | Come |
|---|---|
| Runnare solo su main | on: push: branches: [main] |
| Skippare se cambi solo docs | paths-ignore: ['docs/**', '**.md'] |
| Runnare manualmente | on: workflow_dispatch |
| Runnare su cron | on: schedule: - cron: '0 3 * * *' |
| Job B dopo Job A | needs: [job-a] |
| Usare un secret | ${{ secrets.MY_SECRET }} |
| Cachare npm | setup-node con cache: 'npm' |
| Continuare se un job fallisce | continue-on-error: true |
| Runnare solo su tag | on: push: tags: ['v*'] |
| Matrice di versioni | strategy: matrix: node: [18, 20, 22] |
| Condizione if | if: github.ref == 'refs/heads/main' |
| Timeout per job | timeout-minutes: 10 |
| Cosa Vuoi Fare | Come |
|---|---|
| Runnare solo su main | only: [main] oppure rules: - if: $CI_COMMIT_BRANCH == "main" |
| Deploy manuale | when: manual |
| Passare file tra stage | artifacts: paths: [dist/] |
| Cache dipendenze | cache: paths: [node_modules/] |
| Job che può fallire | allow_failure: true |
| Variabile d'ambiente | variables: MY_VAR: "value" |
| Job solo su merge request | only: [merge_requests] |
| Timeout | timeout: 10 minutes |
name: Python CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install -r requirements.txt
- run: pytest --cov=app tests/
name: Go CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- run: go test ./...
- run: go build -o app .
name: Deploy to GitHub Pages
on:
push:
branches: [ main ]
permissions:
pages: write
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- uses: actions/configure-pages@v4
- uses: actions/upload-pages-artifact@v3
with:
path: '.'
- uses: actions/deploy-pages@v4
id: deployment
La pipeline è rotta, il deploy è fallito, il capo chiama. Cosa fai?
Respira. Non fare push random "per provare". Non fare git push --force. Non fare niente finché non hai capito cos'è rotto.
Vai nella tab Actions/Pipelines. Apri il job fallito. Leggi dall'inizio, non solo l'ultima riga. Il primo errore è quasi sempre la causa, il resto sono conseguenze.
Colpa tua: il codice non compila, test falliti, typo nel YAML. Infrastruttura: runner non disponibile, timeout, rate limit di npm/Docker Hub, disco pieno.
Se il deploy è andato male, rollback prima, debug dopo.
# Kubernetes
kubectl rollout undo deployment/myapp
# Docker Compose
docker compose up -d --force-recreate myapp:previous-tag
# Git: torna al commit precedente e re-deploy
git revert HEAD && git push
Fixxa il problema, pusha, aspetta che la pipeline passi. Se non sai cos'è rotto, prova a ri-runnare il job — a volte sono errori transienti (network timeout, runner pieno).
# === GITHUB ACTIONS (con gh CLI) ===
# Vedi l'ultimo run
gh run list --limit 5
# Vedi i log di un run fallito
gh run view <run-id> --log-failed
# Re-run un workflow fallito
gh run rerun <run-id> --failed
# Disabilita un workflow (emergenza)
gh workflow disable ci.yml
# Triggera manualmente un workflow
gh workflow run deploy.yml -f environment=staging
# === GITLAB CI ===
# Vedi le pipeline recenti
glab ci list
# Vedi i log di un job
glab ci trace <job-id>
# Retrigga una pipeline
glab ci retry <pipeline-id>