Lesson 3/9 10 min

systemd, the caretaker

Your Messenger worker must survive a 3 a.m. crash without waking you up. systemd is the caretaker that restarts it, logs it, and starts it at every reboot.

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é par systemctl 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'utilisateur deploy, 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=3600 fait sortir proprement le worker chaque heure. Restart=always le 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é.

Prédis avant de lire

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.

🖥️ Terminal simulé · systemd en action
root@vps:~#

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.

Timeline de la nuit du crash. Avec systemd : à 03:00:12 l'app tombe, à 03:00:17 systemd la relance (RestartSec=5), à 03:00:18 elle est de nouveau active. Sans systemd : à 03:00:12 l'app tombe, la ligne reste plate jusqu'à 08:30 où le dev se réveille, café et panique. Avec systemd 03:00:12 03:00:17 03:00:18 app tombe RestartSec=5 app up · nouveau PID Sans systemd 03:00:12 08:30 app tombe toi, café, panique
Avec systemd, le crash de 3h se corrige tout seul en 5 secondes. Sans lui, l'app reste morte jusqu'à ce que quelqu'un (toi) le remarque.

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 by systemctl 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 as deploy, 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=3600 flag makes the worker exit cleanly every hour. Restart=always brings 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 .env file).
  • 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.

Predict before reading on

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.

🖥️ Simulated terminal · systemd in action
root@vps:~#

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.

Timeline of the crash night. With systemd: at 03:00:12 the app goes down, at 03:00:17 systemd restarts it (RestartSec=5), at 03:00:18 it's active again. Without systemd: at 03:00:12 the app goes down, the line stays flat until 08:30 when the dev wakes up, coffee and panic. With systemd 03:00:12 03:00:17 03:00:18 app goes down RestartSec=5 app up · new PID Without systemd 03:00:12 08:30 app goes down you, coffee, panic
With systemd, the 3 a.m. crash fixes itself in 5 seconds. Without it, the app stays dead until someone (you) notices.

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
Ré-explique sans regarder

Décris les 3 blocs d'un fichier .service systemd et 2 directives clés de chacun.

Une bonne explication dit : [Unit] → metadata + ordre de démarrage (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
Rappel libre

Sans remonter : start, enable, enable --now : qui fait quoi ?

start : démarre maintenant, mais rien au prochain reboot. enable : crée le symlink pour démarrer à chaque boot, mais pas maintenant. enable --now : les deux à la fois, c'est la commande à utiliser à l'installation.
⚖️ Juge le conseil de l'IA
Accepter ou rejeter 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 ?

À rejeter. 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.
Que garantit Restart=always dans un fichier .service ?
Pourquoi User=deploy dans le fichier .service ?
Où lis-tu les logs de ton service systemd ?
Ton worker lancé avec nohup php bin/console messenger:consume async & vs un service systemd : la vraie différence ?
Next step

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 →
Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement