Alcuni link in questa pagina sono link di affiliazione: se ti registri tramite questi link, il sito riceve una commissione senza costi aggiuntivi per te. Prezzi e consigli non cambiano.
Questo sito gira su un solo VPS di piccole dimensioni. Sulla stessa macchina: un blog statico, uno stack di analytics self-hosted, due API interne e un’applicazione protetta da password. Un unico container Caddy termina il TLS per tutti, rinnova i certificati senza che glielo si chieda e dalla messa in opera non ha richiesto un solo intervento manuale.
Questo articolo è esattamente quella configurazione, file inclusi, più i due errori che abbiamo commesso strada facendo. Se prima vi serve la parte hardware, abbiamo confrontato le opzioni nel miglior VPS per il self-hosting: tutto ciò che segue gira comodamente sul piano più economico di quella lista.
Perché Caddy e non nginx
nginx è ottimo, e infatti lo teniamo ancora dentro questo stack come server di file statici. Ma sul bordo, dove arrivano le richieste, Caddy vince su una cosa che a questa scala conta più di tutte: il TLS smette di essere un progetto. Niente certbot, niente cron di rinnovo, niente prove con --dry-run alle due di notte. Scrivete un hostname, Caddy ottiene il certificato alla prima richiesta e lo rinnova in background.
Il compromesso è reale e va conosciuto: meno manopole di regolazione, un ecosistema più piccolo di configurazioni già pronte e un linguaggio di configurazione tutto suo, al posto della sintassi nginx che già conoscete. Per una singola macchina lo scambio è nettamente a vostro favore.
Il file Compose
Cinque servizi, una rete, nessuna orchestrazione:
services:
blog:
image: nginx:alpine
container_name: blog
restart: unless-stopped
volumes:
- ./site:/usr/share/nginx/html:ro
analytics-db:
image: postgres:17-alpine
container_name: analytics-db
restart: unless-stopped
env_file: [.env.analytics]
volumes:
- analytics_db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U analytics"]
interval: 10s
timeout: 5s
retries: 10
analytics:
image: ghcr.io/umami-software/umami:postgresql-latest
container_name: analytics
restart: unless-stopped
env_file: [.env.analytics]
depends_on:
analytics-db:
condition: service_healthy
caddy:
image: caddy:2
container_name: caddy
restart: unless-stopped
ports: ["80:80", "443:443"]
volumes:
- ./caddy:/etc/caddy:ro
- caddy_data:/data
- caddy_config:/config
volumes:
analytics_db:
caddy_data:
caddy_config:
Quattro dettagli lì dentro sono portanti.
Solo Caddy pubblica porte. Nient’altro si lega all’host. Blog e analytics sono raggiungibili sulla rete Compose per nome del container e da nessun’altra parte. È il maggior vantaggio di sicurezza di questo schema: non potete esporre per sbaglio un database a Internet se non ha porte pubblicate.
caddy_data è un volume con nome, non un bind-mount. Contiene i certificati e la chiave dell’account ACME. Perderlo significa riemettere tutto al riavvio successivo: sopravvivibile, ma se capita spesso durante il debug finirete contro i rate limit.
Il database ha un healthcheck e l’applicazione lo aspetta. Senza condition: service_healthy il container dell’app parte per primo, non riesce a connettersi e si riavvia in loop finché Postgres non è pronto. Alla fine converge comunque, ma i log diventano illeggibili e un primo deploy sembra rotto senza esserlo.
restart: unless-stopped, non always. La differenza emerge quando fermate di proposito un container per esaminarlo e poi riavviate l’host: always lo rimette in piedi contro la vostra intenzione.
Il Caddyfile
Un blocco per hostname. Questa è la struttura reale, con i nostri nomi sostituiti da segnaposto:
blog.example.com {
encode zstd gzip
header {
X-Content-Type-Options nosniff
Referrer-Policy no-referrer-when-downgrade
-Server
}
# gli asset con hash nel nome si possono cachare a lungo, l'html resta da rivalidare
@immutable path /assets/* /favicon*.png /apple-touch-icon.png /og-default.png
header @immutable Cache-Control "public, max-age=31536000, immutable"
header /favicon.ico Cache-Control "public, max-age=604800"
reverse_proxy blog:80
}
stats.example.com {
encode zstd gzip
header -Server
reverse_proxy analytics:3000
}
app.example.com {
basic_auth {
admin $2a$14$...hash bcrypt qui...
}
encode zstd gzip
reverse_proxy app:3000
}
Questa è tutta la configurazione TLS: non c’è. Ogni hostname ottiene un certificato alla prima richiesta, rinnovato in automatico, con il reindirizzamento da HTTP a HTTPS incluso.
Tre cose vale la pena copiare.
-Server rimuove l’header del server. Dettaglio, ma gratuito: per impostazione predefinita Caddy si presenta.
La separazione della cache conta più di quanto sembri. I nomi di file degli asset con hash si possono cachare per un anno, perché un cambio di contenuto produce un nome nuovo. L’HTML no, altrimenti i visitatori continuano a leggere la pagina vecchia dopo la pubblicazione. Invertire le due regole è il classico bug «ho fatto il deploy e non è cambiato niente», invisibile a voi perché il vostro browser rivalida al ricaricamento.
basic_auth vuole un hash bcrypt, non una password. Generatelo con docker exec caddy caddy hash-password. La password in chiaro non va mai nel file.
I redirect sono una questione di configurazione
I nostri link di affiliazione non puntano mai direttamente al partner. Ognuno passa da un redirect definito nella configurazione di bordo:
redir /go/vendor-a* https://www.vendor-a.example/ 302
redir /go/vendor-b* https://www.vendor-b.example/ 302
redir /go/* /affiliate-disclosure/ 302
Due motivi, e al secondo non pensa nessuno finché non morde:
- Gli URL dei partner cambiano. I parametri di tracciamento vengono riemessi, i programmi cambiano dominio. Con questo schema modificate una riga e ricaricate Caddy; senza, riscrivete ogni articolo che ha mai linkato lì.
- Il catch-all vi protegge. Far cadere
/go/*sulla pagina di informativa significa che un refuso o un programma chiuso producono una pagina vera, non un 404 in mezzo a un articolo monetizzato.
Tenete l’asterisco finale in ogni pattern, così le query string sopravvivono al salto, e restate su 302 invece che 301: un redirect permanente verso un URL partner che prima o poi cambierete resta in cache in browser che non controllate.
Due errori che abbiamo fatto davvero
Montare un singolo file ha inchiodato un inode morto. Il Caddyfile era montato come ./Caddyfile:/etc/caddy/Caddyfile. Le modifiche sull’host smettevano di arrivare nel container: Docker monta un file per inode, e la maggior parte degli editor scrive un file nuovo e lo rinomina sopra il vecchio, lasciando al container l’originale cancellato. Il sito ha servito una configurazione vecchia per giorni, senza una riga nei log. Rimedio: montare la directory, non il file, come nel Compose qui sopra.
Una chiave di deploy senza restrizioni, tuttora in uso. La chiave di deploy del blog è vincolata a un’unica destinazione rsync, ma una chiave CI più vecchia, di un’altra applicazione sulla stessa macchina, è stata creata senza alcuna restrizione: chi la possiede ottiene una shell interattiva, non il permesso di scrivere in una directory. Funziona benissimo da mesi, ed è proprio per questo che nessuno l’ha guardata. Rivedete ogni riga di authorized_keys, non solo l’ultima che avete aggiunto: il prossimo articolo spiega come farlo bene.
Gestione quotidiana
L’intera superficie di manutenzione sono tre comandi:
docker compose up -d # applicare le modifiche
docker exec caddy caddy reload --config /etc/caddy/Caddyfile # solo configurazione, senza interruzione
docker compose logs -f caddy # guardare la danza ACME di un nuovo hostname
Un sito nuovo si aggiunge con un servizio e un blocco hostname, poi reload. La prima richiesta al nome nuovo impiega un secondo o due, il tempo di emettere il certificato; dopo è indistinguibile dalle altre.
Un avvertimento sui certificati: se il vostro DNS sta dietro un proxy che termina il TLS da sé, la challenge ACME preferita da Caddy può fallire in un modo che sembra un bug di Caddy. Durante la configurazione puntate il record direttamente alla macchina, poi rimettete il proxy se vi serve.
Prossimamente
L’articolo gemello: fare deploy su questa macchina da GitHub Actions senza dare una shell alla CI - la configurazione rsync ristretta che avremmo dovuto usare dal primo giorno.