Certains liens de cette page sont des liens d'affiliation : si vous vous inscrivez via ces liens, ce site touche une commission, sans surcoût pour vous. Les prix et les recommandations ne changent pas.
Ce site tourne sur un seul petit VPS. Sur la même machine : un blog statique, une pile d’analytics auto-hébergée, deux API internes et une application protégée par mot de passe. Un unique conteneur Caddy termine le TLS pour tout ce monde, renouvelle les certificats sans qu’on le lui demande, et n’a exigé aucune intervention manuelle depuis sa mise en place.
Cet article, c’est cette configuration, fichiers compris, plus les deux erreurs commises en chemin. Si vous voulez d’abord le matériel, nous avons comparé les options dans le meilleur VPS pour l’auto-hébergement : tout ce qui suit tourne sans peine sur l’offre la moins chère de ce comparatif.
Pourquoi Caddy plutôt que nginx
nginx est excellent, et nous le gardons d’ailleurs dans cette pile comme serveur de fichiers statiques. Mais en bordure, là où arrivent les requêtes, Caddy gagne sur un point qui compte à cette échelle : le TLS cesse d’être un projet. Pas de certbot, pas de cron de renouvellement, pas de répétition en --dry-run à deux heures du matin. Vous écrivez un nom d’hôte, Caddy obtient le certificat à la première requête et le renouvelle en arrière-plan.
Le compromis est réel : moins de réglages fins, un écosystème de configurations toutes faites plus petit, et un langage de configuration qui n’est pas la syntaxe nginx que vous connaissez déjà. Pour une machine unique, l’échange est nettement à votre avantage.
Le fichier Compose
Cinq services, un réseau, aucune orchestration :
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:
Quatre détails y sont porteurs.
Seul Caddy publie des ports. Rien d’autre ne se lie à l’hôte. Le blog et l’analytics sont joignables sur le réseau Compose par leur nom de conteneur, et nulle part ailleurs. C’est le plus grand gain de sécurité de cette disposition : vous ne pouvez pas exposer une base de données à Internet par accident si elle n’a aucun port publié.
caddy_data est un volume nommé, pas un bind-mount. Il contient les certificats et la clé du compte ACME. Le perdre, c’est tout réémettre au prochain démarrage : survivable, mais pénalisant si vous recommencez souvent pendant un débogage.
La base a un healthcheck et l’application l’attend. Sans condition: service_healthy, le conteneur applicatif démarre en premier, échoue à se connecter et redémarre en boucle jusqu’à ce que Postgres soit prêt. Cela finit par converger, mais les journaux sont illisibles et un premier déploiement semble cassé alors qu’il ne l’est pas.
restart: unless-stopped, pas always. La différence apparaît quand vous arrêtez volontairement un conteneur pour l’inspecter puis redémarrez la machine : always le relance contre votre intention.
Le Caddyfile
Un bloc par nom d’hôte. Voici la structure réelle, nos noms remplacés par des exemples :
blog.example.com {
encode zstd gzip
header {
X-Content-Type-Options nosniff
Referrer-Policy no-referrer-when-downgrade
-Server
}
# les fichiers au nom haché se cachent longtemps, le HTML reste revalidé
@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$...empreinte bcrypt ici...
}
encode zstd gzip
reverse_proxy app:3000
}
Voilà toute la configuration TLS : il n’y en a pas. Chaque nom d’hôte reçoit un certificat à la première requête, renouvelé automatiquement, avec la redirection HTTP vers HTTPS offerte.
Trois choses valent d’être reprises.
-Server supprime l’en-tête du serveur. Détail, mais gratuit : par défaut, Caddy s’annonce.
La séparation du cache compte plus qu’il n’y paraît. Les noms de fichiers d’assets hachés peuvent être mis en cache un an, puisqu’un changement de contenu produit un nouveau nom. Le HTML, non, sinon vos visiteurs continuent de lire l’ancienne page après publication. Inverser les deux, c’est le bug classique « j’ai déployé et rien n’a changé », invisible pour vous parce que votre propre navigateur revalide au rechargement.
basic_auth attend une empreinte bcrypt, pas un mot de passe. Générez-la avec docker exec caddy caddy hash-password. Jamais de mot de passe en clair dans le fichier.
Les redirections relèvent de la configuration
Nos liens d’affiliation ne pointent jamais directement vers un partenaire. Chacun passe par une redirection définie dans la configuration de bordure :
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
Deux raisons, et à la seconde personne ne pense avant d’être mordu :
- Les URL des partenaires changent. Les paramètres de suivi sont réémis, les programmes déménagent. Avec cette disposition, vous modifiez une ligne et rechargez Caddy ; sans elle, vous réécrivez chaque article qui a pointé là.
- Le catch-all vous protège. Faire tomber
/go/*sur la page de divulgation signifie qu’une faute de frappe ou un programme fermé donne une vraie page, et pas un 404 au milieu d’un article monétisé.
Gardez l’astérisque final sur chaque motif pour que les paramètres de requête survivent au saut, et restez en 302 plutôt qu’en 301 : une redirection permanente vers une URL partenaire que vous finirez par changer reste en cache dans des navigateurs qui ne vous appartiennent pas.
Deux erreurs que nous avons réellement commises
Monter un fichier unique a figé un inode mort. Le Caddyfile était monté en ./Caddyfile:/etc/caddy/Caddyfile. Les modifications faites sur l’hôte n’arrivaient plus dans le conteneur : Docker monte un fichier par son inode, et la plupart des éditeurs écrivent un nouveau fichier puis le renomment par-dessus l’ancien, ce qui laisse le conteneur avec l’original supprimé. Le site a servi une vieille configuration pendant des jours, sans rien dans les journaux. Correctif : montez le répertoire, pas le fichier, comme dans le Compose ci-dessus.
Une clé de déploiement sans restriction, toujours utilisée. La clé de déploiement du blog est verrouillée sur une seule cible rsync, mais une clé CI plus ancienne, pour une autre application sur la même machine, a été créée sans aucune restriction : qui la détient obtient un shell interactif, pas un droit d’écriture sur un répertoire. Elle fonctionne parfaitement depuis des mois, et c’est exactement pour cela que personne ne l’a regardée. Passez en revue chaque ligne de authorized_keys, pas seulement la dernière que vous avez ajoutée - l’article suivant explique comment faire cela proprement.
Exploitation
Toute la surface de maintenance tient en trois commandes :
docker compose up -d # appliquer les changements
docker exec caddy caddy reload --config /etc/caddy/Caddyfile # configuration seule, sans coupure
docker compose logs -f caddy # observer la danse ACME d'un nouveau nom d'hôte
Un nouveau site s’ajoute avec un service et un bloc de nom d’hôte, puis un reload. La première requête vers ce nom prend une seconde ou deux, le temps d’émettre le certificat ; ensuite, rien ne la distingue des autres.
Une réserve sur les certificats : si votre DNS passe par un proxy qui termine lui-même le TLS, le challenge ACME que Caddy préfère peut échouer d’une façon qui ressemble à un bug de Caddy. Pointez l’enregistrement directement sur la machine le temps de l’installation, puis remettez le proxy si vous y tenez.
La suite
Le pendant de cet article : déployer sur cette machine depuis GitHub Actions sans donner de shell à la CI - la configuration rsync restreinte que nous aurions dû adopter dès le premier jour.