
Il progetto è un'applicazione web Django per un cliente, ad uso operativo quotidiano. Django, Celery, PostgreSQL, Redis, S3. Niente di esotico. La parte interessante non è lo stack, è che sapevo fin dall'inizio che avrei dovuto replicare l'ambiente almeno due volte, e che il cliente avrebbe avuto bisogno di farlo senza di me.
Ho già scritto di Terraform come disciplina e di come isolare ambienti senza spendere una fortuna. Questo è il capitolo pratico: come l'ho fatto su un progetto reale, cosa ha funzionato, e dove ho preso decisioni che non avrei preso sei mesi fa.
Il problema
Il cliente vuole tre ambienti: sviluppo (locale), staging, produzione. Sviluppo è Docker Compose, nessun bisogno di AWS. Staging e produzione sono AWS, stessa architettura, dimensioni diverse. La domanda ovvia: come fai a garantire che siano strutturalmente identici senza copincollare centinaia di righe di .tf?
La risposta ovvia: moduli. Ma "usa i moduli" è un consiglio che tutti danno e pochi dettagliano. Quanti moduli? Dove tagli? Cosa parametrizzi e cosa lasci fisso?
Sei moduli, sei responsabilità
Ho tagliato così:
| Modulo | Cosa crea | Perché separato |
|---|---|---|
| networking | VPC, subnet, NAT, route tables | Cambia raramente, tocca tutto |
| database | RDS PostgreSQL, subnet group, security group | Ha il suo ciclo di vita (backup, scaling) |
| cache | ElastiCache Redis, subnet group | Stessa logica del database |
| storage | S3 bucket, policy | Stateful, non si ricrea mai leggermente |
| loadbalancer | ALB, listener, target group, ACM | Il punto di ingresso, regole sue |
| compute | ECS cluster, service, task definition, IAM | Cambia ad ogni deploy |
Il principio: ogni modulo gestisce risorse con lo stesso ciclo di vita. Se il database scala indipendentemente dal compute, devono essere moduli diversi. Se il load balancer sopravvive al redeploy del servizio, devono essere moduli diversi.
Il taglio non è ovvio come sembra. La prima versione aveva quattro moduli: networking, data (database + cache + storage), loadbalancer, compute. Ho separato database, cache e storage dopo la prima settimana perché mi sono trovato a fare terraform apply sul modulo "data" per cambiare una policy S3 e pregare che non toccasse il database. Moduli troppo larghi creano blast radius inutili.
Stessi moduli, variabili diverse
La struttura nel repo:
infra/
modules/
networking/
database/
cache/
storage/
loadbalancer/
compute/
environments/
stg/
main.tf # chiama i moduli con var.stg
variables.tf
terraform.tfvars
prd/
main.tf # stessi moduli, variabili diverse
variables.tf
terraform.tfvars
Ogni environment è una cartella con il suo state. stg/main.tf e prd/main.tf sono quasi identici, cambiano le variabili. Dimensione istanze, numero di repliche, dominio, tag di ambiente.
La tentazione è forte: "Perché non un solo main.tf con una variabile environment?" L'ho fatto in passato. Il problema emerge quando staging e produzione divergono per necessità, non per errore. Staging con single-AZ e istanze micro. Produzione con multi-AZ e istanze large. Un approval gate in produzione che staging non ha. Se tutto è nello stesso file, finisci con count = var.environment == "prd" ? 2 : 1 ovunque. Il codice diventa illeggibile nel giro di un mese.
Cartelle separate, moduli condivisi. La duplicazione è nei file di colla (i main.tf), non nella logica.
Il naming che ti salva la vita
Convenzione semplice: app-{env}-{risorsa}. Tutto. Cluster ECS, bucket S3, security group, secret SSM. Se vedi app-stg-db sai cos'è senza aprire la console. Se vedi app-prd-alb sai che non devi toccarlo per fare test.
L'unica eccezione: ECR. Un solo repository container, condiviso. Le immagini si differenziano per tag, non per repo. Duplicare ECR per ambiente non aggiunge isolamento, aggiunge costi e confusione.
Commitizen + release-please su un progetto cliente
Questa è la decisione che ha alzato più sopracciglia. Versioning semantico automatico, conventional commits forzati da hook, changelog generato da una GitHub Action. Su un progetto per un cliente che non leggerà mai il changelog.
Perché? Non è per il cliente. È per il deploy.
Il flusso:
- Feature branch → develop (CI testa, builda, deploya su staging)
release-pleaseapre una PR con version bump + changelog- Merge della PR → tag → CI deploya in produzione
Il deploy in produzione parte da un tag, non da una persona che decide "ok, ora mando in prod". Il tag è il risultato di commit convenzionali accumulati. Se hai pushato solo fix:, il bump è patch. Se hai un feat:, è minor. Nessuna decisione umana, nessuna dimenticanza.
Il costo: un .commitlintrc.yaml, un workflow release-please.yml, e la disciplina di scrivere commit message decenti. La disciplina è la parte difficile, ma commitizen ti aiuta: se il commit non rispetta il formato, l'hook lo rifiuta.
Tre mesi fa non l'avrei fatto su un progetto cliente. Avrei detto "troppo overhead per un team di una persona". Ho cambiato idea dopo aver perso un pomeriggio a capire quale commit aveva rotto il deploy: la history era un muro di "fix stuff", "update", "wip". Mai più.
Il deploy: Docker → ECR → ECS, con le migration nel mezzo
Il pezzo che ha richiesto più tentativi: le migration Django.
Il deploy classico: builda l'immagine Docker, pushala su ECR, aggiorna il task definition ECS, aspetta che il servizio sia stabile. Ma Django ha bisogno di python manage.py migrate prima che il nuovo codice giri. E quel comando deve girare con accesso al database, ai secret, alla rete (lo stesso contesto del servizio).
Soluzione: un task ECS one-off. Stessa task definition del servizio, ma con command override che esegue la migration. Il task parte, migra, muore. Se fallisce, il deploy si ferma. Se riesce, il servizio si aggiorna con la nuova immagine.
# Semplificato dal workflow reale
- name: Run migrations
run: |
aws ecs run-task \
--cluster app-stg \
--task-definition app-stg-web \
--overrides '{"containerOverrides":[{"name":"web","command":["python","manage.py","migrate","--noinput"]}]}' \
--network-configuration '...'
# Poi aspetta che il task finisca e controlla l'exit code
Il smoke test alla fine è un curl sull'health check dell'ALB. Se risponde 200, il deploy è riuscito. Se no, il workflow fallisce e nessuno deve andare a controllare manualmente.
I numeri
Staging, tutto compreso:
| Risorsa | Sizing | Costo stimato |
|---|---|---|
| RDS PostgreSQL | db.t4g.micro, single-AZ | ~$15/mese |
| ElastiCache Redis | cache.t4g.micro, single-AZ | ~$12/mese |
| NAT Gateway | 1, single-AZ | ~$35/mese |
| ECS Fargate | 0.25 vCPU, 0.5 GB | ~$10/mese |
| ALB | 1 condiviso | ~$20/mese |
| S3 + ECR | pay per use | ~$5/mese |
| Totale | ~$97/mese |
Produzione sarà circa 3x: multi-AZ, istanze più grandi, più repliche. Ma la struttura è identica. terraform apply con variabili diverse.
Cosa farei diversamente
- Moduli da subito, non dopo: la prima versione con quattro moduli troppo larghi mi è costata un refactor la seconda settimana. Meglio partire granulari e accorpare che viceversa.
- SSM Parameter Store, non Secrets Manager: per i segreti applicativi (API key, credenziali DB) uso SSM SecureString. Costa meno, fa lo stesso lavoro per questo caso d'uso. Secrets Manager ha senso quando serve rotazione automatica.
- Il NAT Gateway è il costo nascosto: $35/mese per staging, fissi. Se non serve uscire su internet dal task (e spesso non serve), mettilo in subnet pubblica con Security Group restrittivo e risparmiati il NAT. L'ho fatto su un altro progetto e funziona.
Il punto
Terraform ti obbliga a descrivere. I moduli ti obbligano a strutturare. Le variabili per ambiente ti obbligano a rendere esplicite le differenze. Il versioning automatico ti obbliga a scrivere commit sensati.
Nessuna di queste cose è gratuita. Ognuna ha un costo iniziale in tempo e disciplina. Ma il costo di non averle lo paghi ogni volta che staging e produzione divergono, ogni volta che un deploy va storto e nessuno sa cosa è cambiato, ogni volta che qualcuno chiede "come faccio a mettere su l'ambiente?" e la risposta è "chiedi a Marco".
Se la risposta è sempre "chiedi a Marco", Marco è il single point of failure. E Marco, a un certo punto, vuole andare in vacanza.

In sintesi (TL;DR)
- Sei moduli Terraform riutilizzabili (networking, database, cache, storage, loadbalancer, compute) coprono l'intera infrastruttura AWS. Staging e produzione si differenziano solo per le variabili.
- Commitizen + release-please automatizzano versioning e changelog su un progetto cliente. Il costo iniziale è un .commitlintrc e un workflow GitHub Actions. Il ritorno è un deploy in produzione che parte da un tag, non da una decisione umana.
- Il deploy end-to-end (Docker build, ECR push, DB migration, ECS Fargate, smoke test) gira in CI senza intervento. La migration Django è un task ECS one-off che muore dopo l'esecuzione.
- Il vero problema non è scrivere i moduli. È decidere cosa condividere e cosa separare tra ambienti, e avere la disciplina di non fare eccezioni.