3h du matin, ton terminal est fermé
Ton worker Symfony tourne. Tu l'as lancé à la main dans ta session SSH :
php bin/console messenger:consume async
Tu fermes le terminal : le worker meurt. Connexion interrompue, processus tué. Plus personne pour traiter les emails en file d'attente. Alors tu te dis : nohup. Tu relances :
nohup php bin/console messenger:consume async &
Il survit à la déconnexion. Jusqu'au premier crash à 3h du matin. Là, personne ne le relance. Les emails ne partent plus. Et au premier reboot du serveur après une mise à jour de sécurité ? Personne ne le redémarre non plus. Les logs, eux, partent dans un fichier nohup.out que personne ne lit.
Le serveur a besoin d'un concierge : quelqu'un qui est là 24h/24, qui sait que ton worker doit tourner, qui le relance s'il tombe, qui le démarre au boot, et qui note tout dans un journal. Ce concierge, c'est systemd. C'est l'init system de Linux, présent sur Debian, Ubuntu et l'immense majorité des distributions modernes.
Systemd est le premier processus lancé par le noyau Linux au démarrage (PID 1). Il gère tous les services du système, et les tiens. Si systemd ne tourne pas, rien d'autre ne tourne non plus.
Anatomie d'un fichier .service
Un service systemd se décrit dans un fichier texte posé dans /etc/systemd/system/. Voici messenger-worker.service dans son intégralité :
[Unit]
Description=Worker Messenger de mon app
After=network.target
[Service]
User=deploy
WorkingDirectory=/opt/app
ExecStart=/usr/bin/php bin/console messenger:consume async --time-limit=3600
Restart=always
RestartSec=5
Environment=APP_ENV=prod
[Install]
WantedBy=multi-user.target
Chaque directive a un rôle précis :
Description: le nom lisible du service, affiché parsystemctl status.After=network.target: systemd attend que le réseau soit prêt avant de démarrer le worker (utile s'il ouvre des connexions au démarrage).User=deploy: le process tourne sous l'utilisateurdeploy, pas root. Les droits sont cloisonnés.WorkingDirectory: le dossier de travail. Les chemins relatifs de ton code sont résolus depuis là.ExecStart: la commande exacte qui lance le worker (chemin absolu obligatoire). L'option--time-limit=3600fait sortir proprement le worker chaque heure.Restart=alwaysle relance frais aussitôt. Résultat : anti-fuite mémoire intégré, sans rien coder de plus.Restart=always: si le process meurt pour n'importe quelle raison (crash, kill, sortie normale), systemd le relance.RestartSec=5: délai avant relance, en secondes. Évite une boucle de relances instantanées si le worker crashe au démarrage.Environment: variables d'environnement injectées au démarrage (alternative :EnvironmentFile=pour un fichier.env).WantedBy=multi-user.target: démarre ce service quand le système atteint le mode multi-utilisateur normal, c'est-à-dire au boot.
Une fois le fichier écrit, trois commandes suffisent :
# Recharger la liste des services (obligatoire après tout ajout/modif)
systemctl daemon-reload
# Activer ET démarrer maintenant
systemctl enable --now messenger-worker
# Vérifier l'état
systemctl status messenger-worker
# Lire les logs en temps réel
journalctl -u messenger-worker -f
enable vs start vs enable --now : systemctl start messenger-worker démarre le service maintenant, mais rien ne le relancera au prochain reboot. systemctl enable messenger-worker crée le symlink qui le fera démarrer à chaque boot, mais ne le démarre pas maintenant. systemctl enable --now messenger-worker fait les deux en une commande : c'est celle-là qu'on utilise à l'installation.
Restart=always + RestartSec=5, c'est la combinaison minimale pour qu'un worker se relève tout seul de tout crash, avec un délai sain avant chaque nouvelle tentative. Et User=deploy, c'est la règle d'or : si un attaquant compromet ton app, il obtient les droits de deploy, pas ceux de root. Le périmètre de dégâts reste limité.
D'où vient /usr/bin/php ? Du magasin, comme tout le reste. Le runtime de ton app s'installe avec apt, exactement comme ufw et fail2ban à la leçon 2 : sudo apt install -y php8.3-cli pour PHP. Pour Node ce serait nodejs npm, pour Python, python3 est déjà là. Le numéro de version suit ta distrib : Ubuntu 24.04 donne PHP 8.3, Debian 12 donne PHP 8.2. Lance php -v pour vérifier ce que tu as. Rien de magique : un .service ne fait que LANCER un programme installé.
Quelle est la différence entre systemctl start messenger-worker et systemctl enable messenger-worker ? Et que fait enable --now ?
Voir la réponse
start : démarre le service maintenant, mais rien ne le relancera au prochain reboot. enable : crée le symlink qui le fera démarrer à chaque boot, mais ne le démarre pas maintenant. enable --now : les deux d'un coup, ce qui est presque toujours ce qu'on veut à l'installation.
À toi : simule le crash de 3h du matin
Le terminal ci-dessous est connecté à un VPS durci (leçon 2). Le fichier /etc/systemd/system/messenger-worker.service est déjà écrit, exactement comme dans la section précédente. À toi de l'activer, de vérifier qu'il tourne, puis d'observer ce qui se passe quand le process crashe.
journalctl -u messenger-worker -f (sans -n) suit les logs en temps réel, comme tail -f. Sur un vrai serveur, c'est la première commande à lancer quand tu ne comprends pas pourquoi ton service ne démarre pas.
Quand systemd suffit, et quand Docker Compose apporte plus
systemd est le bon outil pour une app mono-processus : un binaire Go, un script Python, un serveur Node. Un seul fichier .service de dix lignes, et tu as la gestion du cycle de vie plus un journal intégré. Pas besoin de configurer une lib de logs séparée : tout passe par journalctl.
Dès qu'il y a plusieurs processus qui dépendent les uns des autres, le décor change. Imagine une app, une BDD PostgreSQL et un cache Redis. Dans quel ordre les démarrer ? Comment redémarrer la BDD sans casser l'app ? Comment reproduire le même environnement sur ton laptop pour tester avant de pousser en prod ? systemd peut techniquement gérer ça, avec des dépendances entre services, mais ça devient vite verbeux et fragile.
C'est là que Docker Compose apporte la reproductibilité : un seul fichier docker-compose.yml décrit toute la stack, et elle se lance de façon identique sur ton laptop et sur le serveur. C'est le sujet de la prochaine leçon.
systemd et Docker, qui fait quoi ? À partir de la leçon 4, ton app vivra dans Docker. Le rôle de concierge y est joué par restart: unless-stopped, conteneur par conteneur. Mais systemd ne disparaît pas : c'est LUI qui garde docker.service (et sshd, et le firewall). Le concierge de l'immeuble surveille le gardien du parking. Et pour un script hors Docker (un binaire Go, un worker ponctuel), le .service reste l'outil parfait.
3 a.m., your terminal is closed
Your Symfony worker is running. You launched it by hand in your SSH session:
php bin/console messenger:consume async
You close the terminal — the worker dies. Connection dropped = process killed. Nobody to process the queued emails. So you think: nohup. You relaunch:
nohup php bin/console messenger:consume async &
It survives the disconnect. Until the first crash at 3 a.m. Nobody restarts it. Emails stop going out. Or until the first server reboot after a security update. Nobody restarts it then either. Logs pile up in a nohup.out file nobody reads.
The server needs a caretaker: someone on duty 24/7, who knows your worker must run, who restarts it if it dies, who starts it at boot, and who keeps a journal. That caretaker is systemd — Linux's init system, present on Debian, Ubuntu, and the vast majority of modern distributions.
Systemd is the first process launched by the Linux kernel at startup (PID 1). It manages all system services — and yours. If systemd isn't running, nothing else is either.
Anatomy of a .service file
A systemd service is described in a text file placed in /etc/systemd/system/. Here's a complete messenger-worker.service:
[Unit]
Description=Worker Messenger de mon app
After=network.target
[Service]
User=deploy
WorkingDirectory=/opt/app
ExecStart=/usr/bin/php bin/console messenger:consume async --time-limit=3600
Restart=always
RestartSec=5
Environment=APP_ENV=prod
[Install]
WantedBy=multi-user.target
Each directive plays a specific role:
Description— the human-readable name of the service, shown bysystemctl status.After=network.target— systemd waits until the network is up before starting the worker (useful if it opens connections on startup).User=deploy— the process runs asdeploy, not root. Rights are compartmentalised.WorkingDirectory— the working directory; relative paths in your code resolve from here.ExecStart— the exact command that starts the worker (absolute path required). The--time-limit=3600flag makes the worker exit cleanly every hour.Restart=alwaysbrings it back fresh immediately. Built-in anti-memory-leak, zero extra code.Restart=always— if the process dies for any reason (crash, kill, clean exit), systemd restarts it.RestartSec=5— delay before restart, in seconds. Prevents an instant restart loop if the worker crashes on startup.Environment— environment variables injected at startup (alternative:EnvironmentFile=for a.envfile).WantedBy=multi-user.target— start this service when the system reaches normal multi-user mode (i.e.: at boot).
Once the file is written, three commands are enough:
# Reload the service list (mandatory after any add/change)
systemctl daemon-reload
# Enable AND start now
systemctl enable --now messenger-worker
# Check status
systemctl status messenger-worker
# Follow logs in real time
journalctl -u messenger-worker -f
enable vs start vs enable --now: systemctl start messenger-worker starts the service now, but nothing will restart it at the next reboot. systemctl enable messenger-worker creates the symlink that will start it at every boot — but doesn't start it now. systemctl enable --now messenger-worker does both at once: that's the one to use at install time.
Restart=always + RestartSec=5 is the minimum combination for a worker to recover from any crash on its own, with a healthy delay before retry. And User=deploy is the golden rule: if an attacker compromises your app, they get deploy's rights — not root. The blast radius stays contained.
Where does /usr/bin/php come from? From the store, like everything else. Your app's runtime is installed with apt, exactly like ufw and fail2ban in lesson 2: sudo apt install -y php8.3-cli for PHP. For Node it would be nodejs npm, for Python, python3 is already there. The version number follows your distro: Ubuntu 24.04 gives PHP 8.3, Debian 12 gives PHP 8.2. Run php -v to check what you have. No magic: a .service only LAUNCHES an installed program.
What is the difference between systemctl start messenger-worker and systemctl enable messenger-worker? And what does enable --now do?
Show the answer
start: starts the service now, but nothing will restart it at the next reboot. enable: creates the symlink that will start it at every boot — but doesn't start it now. enable --now: both at once, which is almost always what you want at install time.
Your turn: simulate the 3 a.m. crash
The terminal below is connected to a hardened VPS (lesson 2). The file /etc/systemd/system/messenger-worker.service is already written — exactly as in the previous section. Your job: enable it, verify it's running, then watch what happens when the process crashes.
journalctl -u messenger-worker -f (without -n) follows logs in real time like tail -f. On a real server, it's the first command to run when you can't figure out why your service won't start.
When systemd is enough — and when Docker Compose adds more
systemd is the right tool for a single-process app: a Go binary, a Python script, a Node server. One .service file of ten lines, and you get lifecycle management + a built-in journal (no need to configure a separate logging lib — everything goes through journalctl).
As soon as you have multiple processes that depend on each other — an app + a PostgreSQL database + a Redis cache — the question becomes: in what order do you start them? How do you restart the database without breaking the app? How do you reproduce the same environment on your laptop to test before pushing to prod? systemd can technically handle this (with inter-service dependencies), but it quickly becomes verbose and brittle.
That's where Docker Compose brings reproducibility: a single docker-compose.yml describes the whole stack, and it spins up identically on your laptop and on the server. That's the subject of the next lesson.
systemd and Docker: who does what? From lesson 4, your app will live inside Docker. The caretaker role there is played by restart: unless-stopped, container by container. But systemd doesn't disappear: it's the one keeping docker.service alive (and sshd, and the firewall). The building caretaker watches the parking attendant. And for a script outside Docker (a Go binary, a one-off worker), the .service is still the perfect tool.
🎯 Pratique
S'entraîner (clique pour ouvrir) :
💬 Ré-explique sans regarder
Décris les 3 blocs d'un fichier .service systemd et 2 directives clés de chacun.
Description, After=network.target) ; [Service] → comment lancer et superviser le process (User=deploy, ExecStart, Restart=always, RestartSec=5) ; [Install] → quand démarrer au boot (WantedBy=multi-user.target).🧠 Rappel libre
Sans remonter : start, enable, enable --now : qui fait quoi ?
⚖️ Juge le conseil de l'IA
Tu demandes à l'IA comment faire tourner ton app en production. Elle répond : « Lance-la avec nohup php bin/console messenger:consume async & et c'est réglé. C'est suffisant pour un projet perso. » Tu acceptes, ou tu rejettes ?
nohup … & survit à la fermeture du terminal, et c'est tout. Il ne relance PAS l'app si elle crashe (rien ne surveille le process) ; il ne la démarre PAS au reboot (rien dans les services système) ; ses logs partent dans nohup.out, un fichier orphelin introuvable à 3h du matin. Un fichier .service de dix lignes règle les trois problèmes, et c'est la solution pro universelle sur Linux.You now know how to keep a Messenger worker alive with systemd. Lesson 4 moves to Docker: your Symfony app packaged with FrankenPHP, the same recipe running on your laptop and on the server.
Lesson 4: Docker Compose on a VPS →