Deux mauvaises façons d'apprendre que ton site est tombé
La première : un utilisateur te l'envoie par message. C'est gênant. Quelqu'un d'autre a découvert le problème avant toi, et il est probablement tombé depuis un moment. La deuxième : tu t'en rends compte toi-même, trois jours après. Pire.
Et il y a une seule façon d'apprendre que tes sauvegardes ne marchaient pas : le jour où tu en as besoin. Cette dernière leçon installe les deux filets qui séparent un projet qui tourne d'un projet bien géré : les sauvegardes hors-site et le monitoring externe. Ce sont les deux pièces qui manquent encore à la carte de la leçon 1. Après ça, la carte sera complète.
Les sauvegardes : la règle 3-2-1 et le minimum vital
La règle 3-2-1 : 3 copies de tes données, sur 2 supports différents, dont 1 hors-site. C'est la règle d'or du monde de la sauvegarde, indépendante de la technologie. En pratique pour un VPS : le dump local (copie 1, support 1), éventuellement une rétention sur le VPS (copie 2, même support), et une copie chez un autre fournisseur (copie 3, support 2, hors-site). Ce troisième exemplaire est le seul qui te sauve vraiment si le VPS ou le compte disparaît.
La priorité absolue : la base de données. Les fichiers statiques, tu peux les redéployer depuis git. Tes données utilisateurs, non.
Avec le fil rouge du cours, la base Postgres vit dans le conteneur db. Pour la sauvegarder, tu exécutes la commande directement dans ce conteneur :
# La commande se passe en trois temps
docker compose exec -T db pg_dump -U app -Fc app > /backup/db_20260603.dump
exec : exécute une commande à l'intérieur d'un conteneur qui tourne. -T : pas de terminal interactif (indispensable dans un cron, sinon le script se bloque en attendant un clavier). db : le nom du service dans ton compose.yml. Le > écrit le dump côté hôte : c'est tout l'intérêt, le fichier sort du conteneur et atterrit directement dans /backup/ sur le VPS.
Dans un cron, toutes les 6 heures ou chaque nuit (selon la criticité) :
# /etc/cron.d/backup-db
0 */6 * * * deploy docker compose -f /opt/app/compose.yml exec -T db pg_dump -U app -Fc app > /backup/db_$(date +\%Y\%m\%d_\%H\%M).dump
Le hors-site se fait avec rclone, un outil qui sync vers presque n'importe quel stockage cloud (S3, Backblaze B2, Google Drive, SFTP...). Une fois configuré avec rclone config, une seule commande suffit, chaque nuit :
rclone sync /backup remote:vps-backup
Si le VPS ou le compte hébergeur saute du jour au lendemain, les données survivent chez un fournisseur indépendant. C'est le point qui manque à la quasi-totalité des projets perso.
Une rétention simple à mettre en place : 7 sauvegardes quotidiennes, 4 hebdomadaires, 3 mensuelles. Un script de nettoyage avec find /backup -mtime +7 -name "*.dump" -delete suffit pour les quotidiennes.
Piège central : un backup jamais restauré n'est pas un backup. C'est un fichier qui te rassure. Le seul moyen de savoir qu'une sauvegarde vaut quelque chose, c'est de la restaurer. Répète une restauration de temps en temps, dans un environnement de test, pas en prod. Ouvre le dump, vérifie que les tables sont là, que les données sont lisibles. C'est le drill de restauration, et c'est non négociable.
Tes volumes Docker : trois endroits où vivent les données
Dans le compose Symfony/FrankenPHP du cours, tes données persistent dans trois volumes :
pgdata: les données Postgres. Couvert par lepg_dumpci-dessus.uploads(ou./uploads:/app/public/uploadssi tu as monté un dossier local) : les fichiers envoyés par les utilisateurs. Git ne les versionne pas. Il faut les inclure dans le sync rclone.caddy_data: les certificats TLS de Caddy. Pas critique (ils se redemandent automatiquement à Let's Encrypt), mais le garder évite de retomber dans les limites de renouvellement de Let's Encrypt.
Pour les uploads, si tu utilises un volume Docker nommé, docker volume inspect uploads te donne le chemin réel sur l'hôte (souvent /var/lib/docker/volumes/uploads/_data). Ajoute ce dossier au sync rclone :
# Sync base + uploads + caddy_data vers le stockage distant
rclone sync /backup remote:vps-backup/db
rclone sync /var/lib/docker/volumes/uploads/_data remote:vps-backup/uploads
Si tu as monté les uploads en bind mount (./uploads:/app/public/uploads), c'est encore plus simple : le dossier ./uploads est directement accessible sur l'hôte, tu le synces sans passer par docker volume inspect.
Ton hébergeur annonce que tes disques sont en RAID (données dupliquées sur plusieurs disques physiques). As-tu encore besoin de sauvegardes ?
Voir la réponse
Oui, absolument. Le RAID protège d'un seul scénario : un disque physique qui meurt. Il ne protège ni d'un DELETE FROM users raté (fidèlement répliqué sur tous les disques en temps réel), ni d'un ransomware, ni d'une corruption silencieuse, ni d'un compte hébergeur fermé ou piraté. Dans tous ces cas, l'erreur est dupliquée sur chaque disque du RAID aussi vite qu'elle se produit. Seule une copie hors-site, datée et testée, protège de ça.
À toi : dump → hors-site → restaurabilité
Le vrai test d'une sauvegarde ne s'arrête pas au dump. Il faut vérifier que le fichier produit est réellement lisible, et que le hors-site a bien fonctionné. Ces trois étapes font le tour complet.
Le monitoring minimal : un oeil externe et deux commandes
Le monitoring minimal, c'est un service externe qui appelle ton /health toutes les cinq minutes et t'alerte par mail si ça ne répond plus. UptimeRobot (service en ligne) ou Uptime Kuma (auto-hébergé, open source) font très bien ce travail.
Si tu choisis d'auto-héberger ton monitoring (Uptime Kuma) sur le même VPS que ton application, il tombera en même temps que lui. Un monitoring qui vit sur le serveur qu'il surveille, ce n'est pas du monitoring : c'est juste un processus de plus. Pour une vraie alerte, il faut un point de vue externe : soit un service en ligne (UptimeRobot, Betteruptime...), soit une petite instance sur un serveur séparé.
Pour l'endpoint Symfony, expose une route /health qui exécute un vrai SELECT 1 via Doctrine. UptimeRobot surveille que PHP répond, mais toi tu veux savoir quand la BDD tombe aussi. Les logs applicatifs passent par Monolog dans var/log/prod.log.
Pour les logs, deux commandes suffisent dans le contexte Compose :
# Logs du conteneur FrankenPHP (PHP + Caddy intégré)
docker compose logs php
# Logs du conteneur Messenger (workers Symfony en arrière-plan)
docker compose logs messenger
# Et pour les services hôte comme docker.service lui-même
journalctl -u docker.service -f
Les logs Docker peuvent remplir ton disque silencieusement si tu n'y prends pas garde. Limite leur taille dans ton compose.yml :
services:
php:
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
Trois fichiers de 10 Mo maximum, puis rotation automatique. Un disque plein à cause des logs, ça arrive, et c'est bête.
Un endpoint /health minimal dans ton app, qui renvoie 200 OK si tout va bien, est tout ce dont le monitoring a besoin pour savoir que ton service répond. Si le healthcheck renvoie autre chose ou ne répond plus du tout, l'alerte part.
La carte est complète, et une porte vers la suite
Tu sais maintenant tout faire à la main. Chaque brique a sa leçon, chaque leçon a son labo. Mais avant de fermer le cours, il y a une fonctionnalité native de FrankenPHP qui mérite d'être connue : le mode worker.
La carte de la leçon 1, complétée : au départ, un appartement nu avec une IP publique. À la fin : git push → tests automatiques → déploiement atomique SSH → healthcheck → HTTPS Caddy → sauvegardes hors-site chaque nuit → alerte si ça tombe. Le système d'un projet perso bien géré, brique par brique : durcissement, systemd, Docker Compose FrankenPHP, HTTPS auto, CI + image GHCR, deploy pull/up, rollback par tag, sauvegardes 3-2-1 et monitoring externe.
Étape 2 : le mode worker de FrankenPHP
Aujourd'hui, ton app démarre le framework à chaque requête. Le kernel Symfony se boot, charge la config, instancie les services, traite la requête, puis tout s'efface. C'est le comportement classique PHP : stateless par conception.
Le mode worker de FrankenPHP, que Symfony intègre nativement depuis sa version 7.4 (plus besoin de package externe), change ça. Le kernel se boot une seule fois au démarrage du conteneur. Il reste en mémoire. Chaque requête arrive dans une app déjà chaude : pas de boot, pas de lecture de config, pas d'injection à refaire. Le débit augmente, la latence baisse.
Mais il y a une contrepartie sérieuse à comprendre avant d'activer quoi que ce soit.
L'état survit entre les requêtes. En PHP classique, chaque requête repart d'un état propre. En mode worker, ce n'est plus vrai. Un service mal conçu peut fuir : mémoire qui gonfle progressivement, ou donnée d'un utilisateur qui traîne dans le contexte de la requête suivante. Les garde-fous : tes services à état doivent implémenter ResetInterface (Symfony les réinitialise entre chaque requête), et tu peux configurer max_requests pour recycler le worker tous les N requêtes. Avant d'activer le mode worker, audite ton app : cherche les services qui gardent de l'état entre appels.
Le conseil du cours : déploie d'abord en mode classique, ce que tu viens d'apprendre marche tel quel. Active le mode worker ensuite, quand l'app est stable et que tu as audité tes services. La doc de référence est sur frankenphp.dev/docs/worker/.
Et si un jour tu veux une interface web pour gérer tout ça : Coolify. Un CLI minimaliste depuis ta machine locale : Kamal. Tu sauras déboguer ce qu'ils automatisent : tu l'as fait à la main.
Two bad ways to find out your site went down
The first: a user messages you about it. Awkward — someone else discovered the problem before you did, and it probably went down a while ago. The second: you notice it yourself, three days later. Worse.
And there's only one way to find out your backups weren't working: the day you need them. This final lesson sets up the two safety nets that separate a project that runs from a project that is well managed: off-site backups and external monitoring. These are the two pieces still missing from the lesson 1 map — and after this, the map is complete.
Backups: the 3-2-1 rule and the vital minimum
The 3-2-1 rule: 3 copies of your data, on 2 different media, with 1 off-site. This is the golden rule of backup, technology-independent. In practice for a VPS: the local dump (copy 1, media 1), optionally retention on the VPS (copy 2, same media), and a copy at another provider (copy 3, media 2, off-site). That third copy is the only one that really saves you if the VPS or the account disappears.
The absolute priority: the database. Static files you can redeploy from git. User data, you can't.
Following the course's Symfony/FrankenPHP thread, the Postgres database lives inside the db container. To back it up, run the command directly inside that container:
# The command works in three parts
docker compose exec -T db pg_dump -U app -Fc app > /backup/db_20260603.dump
exec: runs a command inside a running container. -T: no interactive terminal (required in a cron, otherwise the script hangs waiting for a keyboard). db: the service name in your compose.yml. The > writes the dump on the host side — that's the whole point: the file leaves the container and lands directly in /backup/ on the VPS.
In a cron, every 6 hours or nightly (depending on criticality):
# /etc/cron.d/backup-db
0 */6 * * * deploy docker compose -f /opt/app/compose.yml exec -T db pg_dump -U app -Fc app > /backup/db_$(date +\%Y\%m\%d_\%H\%M).dump
Off-site with rclone — a tool that syncs to almost any cloud storage (S3, Backblaze B2, Google Drive, SFTP...). Once configured with rclone config, one command a night:
rclone sync /backup remote:vps-backup
If the VPS or the hosting account disappears overnight, the data survives at an independent provider. This is the piece missing from the vast majority of personal projects.
A simple retention policy: 7 daily backups, 4 weekly, 3 monthly. A cleanup script with find /backup -mtime +7 -name "*.dump" -delete handles the dailies.
The central trap: a backup that has never been restored is not a backup. It's a file that reassures you. The only way to know a backup is worth anything is to restore it. Run a restoration drill periodically — in a test environment, not production. Open the dump, check the tables are there, the data is readable. This is the restoration drill, and it's non-negotiable.
Your Docker volumes: three places where data lives
In the course's Symfony/FrankenPHP compose, your data persists across three volumes:
pgdata: the Postgres data. Covered by thepg_dumpabove.uploads(or./uploads:/app/public/uploadsif you used a bind mount): files uploaded by users. Git doesn't version them. Include them in the rclone sync.caddy_data: Caddy's TLS certificates. Not critical (they're automatically renewed from Let's Encrypt), but keeping this volume avoids falling into Let's Encrypt renewal rate limits.
For uploads, if you use a named Docker volume, docker volume inspect uploads gives you the real path on the host (usually /var/lib/docker/volumes/uploads/_data). Add that folder to the rclone sync:
# Sync database + uploads + caddy_data to remote storage
rclone sync /backup remote:vps-backup/db
rclone sync /var/lib/docker/volumes/uploads/_data remote:vps-backup/uploads
If you mounted uploads as a bind mount (./uploads:/app/public/uploads), it's even simpler: the ./uploads folder is directly accessible on the host, sync it without going through docker volume inspect.
Your hosting provider advertises that your disks are in RAID (data duplicated across several physical disks). Do you still need backups?
Show the answer
Yes, absolutely. RAID protects against one scenario only: a physical disk dying. It does not protect against a botched DELETE FROM users (faithfully replicated to every disk in real time), a ransomware attack, silent corruption, or a hosting account that gets closed or hacked. In all those cases, the error is duplicated to every disk in the RAID as fast as it happens. Only an off-site copy, dated and tested, protects against that.
Your turn: dump → off-site → restorability
A real backup check doesn't stop at the dump. You need to verify that the file produced is actually readable — and that the off-site transfer worked. These three steps cover the complete cycle.
Minimal monitoring: an external eye and two commands
Minimal monitoring is an external service that calls your /health endpoint every five minutes and emails you if it stops responding. UptimeRobot (online service) or Uptime Kuma (self-hosted, open source) both do this well.
If you choose to self-host your monitoring (Uptime Kuma) on the same VPS as your application, it will go down with it. Monitoring that lives on the server it watches isn't monitoring — it's just one more process. For a real alert, you need an external point of view: either an online service (UptimeRobot, Betteruptime...), or a small instance on a separate server.
For the Symfony endpoint, expose a real /health route that runs a SELECT 1 through Doctrine. UptimeRobot checks that PHP responds, but you want to know when the DB goes down too. Application logs go through Monolog in var/log/prod.log.
For logs, two commands cover the Compose setup:
# Logs from the FrankenPHP container (PHP + integrated Caddy)
docker compose logs php
# Logs from the Messenger container (Symfony background workers)
docker compose logs messenger
# And for host services like docker.service itself
journalctl -u docker.service -f
Docker logs can silently fill your disk if you're not careful. Cap their size in your compose.yml:
services:
php:
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
Three files of 10 MB max, then automatic rotation. A disk filled by logs does happen, and it's a silly failure.
A minimal /health endpoint in your app — returning 200 OK when everything is fine — is all the monitor needs to know your service is responding. If the healthcheck returns something else or goes silent, the alert fires.
The map is complete — and a door to what's next
You now know how to do everything by hand. Every brick has its lesson, every lesson has its lab. But before closing the course, there's one native FrankenPHP feature worth knowing: worker mode.
The lesson 1 map, completed: we started with a bare flat and a public IP. We end with: git push → automatic tests → atomic SSH deploy → healthcheck → Caddy HTTPS → off-site backups every night → alert if it goes down. The system of a well-managed personal project, brick by brick: hardening, systemd, Docker Compose FrankenPHP, automatic HTTPS, CI + GHCR image, deploy pull/up, rollback by tag, 3-2-1 backups and external monitoring.
Step 2: FrankenPHP worker mode
Right now, your app boots the framework on every request. The Symfony kernel starts, loads config, instantiates services, handles the request — then everything is discarded. That's classic PHP: stateless by design.
FrankenPHP's worker mode, natively integrated into Symfony since version 7.4 (no external package needed), changes that. The kernel boots once when the container starts. It stays in memory. Every request arrives in an already-warm app: no boot, no config loading, no injection to redo. Throughput increases, latency drops.
But there's a serious trade-off to understand before enabling anything.
State survives between requests. In classic PHP, each request starts from a clean slate. In worker mode, that's no longer true. A poorly designed service can leak: memory that slowly grows, or a user's data lingering into the next request's context. The safeguards: stateful services must implement ResetInterface (Symfony resets them between requests), and you can configure max_requests to recycle the worker every N requests. Before enabling worker mode, audit your app: look for services that keep state between calls.
The course advice: deploy in classic mode first — everything you just learned works as-is. Enable worker mode later, once the app is stable and you've audited your services. The reference doc is at frankenphp.dev/docs/worker/.
And if you ever want a web interface to manage all of this: Coolify. A minimalist CLI from your local machine: Kamal. You'll know how to debug what they automate — you did it by hand.
🎯 Pratique
S'entraîner (clique pour ouvrir) :
💬 Ré-explique sans regarder
Explique la règle 3-2-1 et pourquoi on répète des restaurations de temps en temps.
🧠 Rappel libre
De mémoire : cite les briques posées pendant ce cours, de la leçon 2 à la 9.
⚖️ Juge le conseil de l'IA
L'IA conclut : « ton hébergeur fait du RAID et des snapshots quotidiens, tu peux sauter les sauvegardes pour simplifier, ça fait un truc de moins à maintenir. » Tu acceptes, ou tu rejettes ?
rclone vers un autre fournisseur) + un drill de restauration régulier reste non négociable.docker compose exec -T db pg_dump .... À quoi sert le flag -T ?Your project is live, cleanly deployed, backed up, monitored. The logical next step: learning to think like the people who will attack it — the Web Security course, with its sandboxed attack labs.
Course: Web security →