Créer son propre service systemd et le lancer automatiquement au démarrage
Le fichier unit ligne par ligne : Type, User, WorkingDirectory, Restart et network-online.target. Avec les messages d'erreur au mot près et la preuve que le service survit vraiment au redémarrage.
Un programme qui ne revient pas tout seul après un redémarrage n'est pas un service sur un serveur, c'est un risque. nohup, screen et tmux maintiennent un processus en vie tant que rien ne se passe. Cet article écrit un fichier unit ligne par ligne, montre les messages d'erreur au mot près et explique comment s'en sortir quand le service reste coincé dans le carrousel de démarrage.
Toutes les commandes s'exécutent en tant que root. Testé sur Debian 13 (systemd 257), Debian 12 (252), Ubuntu 24.04 LTS (255) et Ubuntu 22.04 LTS (249). Là où ces quatre systèmes divergent, c'est signalé au fil du texte.
Ce qu'un service systemd apporte et que nohup et screen n'apportent pas
La différence n'est pas une question de confort, mais de responsabilité. systemd démarre le processus au boot dans un ordre défini, le place dans son propre cgroup, collecte stdout et stderr dans le journal, le relance après un crash et l'arrête proprement avec SIGTERM à l'extinction. C'est le cgroup qui manque le plus cruellement : un script lancé via nohup laisse derrière lui des processus enfants orphelins, alors qu'une unit nettoie son groupe entièrement.
Préparation : programme, utilisateur et répertoire
Avant de créer l'unit, il faut que ce qu'elle doit démarrer fonctionne déjà. L'exemple retenu est un script qui écrit sur stdout. C'est justement le point important : sous systemd, un service ne journalise pas lui-même dans un fichier, il écrit sur stdout et systemd se charge de déposer le tout dans le journal.
mkdir -p /opt/kh-demo
cat > /opt/kh-demo/run.sh <<'EOF'
#!/bin/bash
set -euo pipefail
while true; do
echo "kh-demo tourne, $(date --iso-8601=seconds), PWD=$PWD, GREETING=${GREETING:-non défini}"
sleep 10
done
EOF
chmod +x /opt/kh-demo/run.sh
Ensuite un utilisateur système dédié : --system attribue un UID inférieur à 1000, /usr/sbin/nologin empêche toute connexion interactive.
useradd --system --no-create-home --home-dir /opt/kh-demo --shell /usr/sbin/nologin khdemo
id khdemo
chown -R root:khdemo /opt/kh-demo
chmod 750 /opt/kh-demo
Le test décisif avant d'écrire le fichier unit : est-ce que le programme tourne sous cet utilisateur ? Sauter cette étape revient à déboguer systemd plus tard, alors que le problème se situe dans le programme.
timeout 3 runuser -u khdemo -- /opt/kh-demo/run.sh; echo "Exitcode $?"
Exitcode 124 est ici le résultat souhaité : timeout a interrompu un programme qui tournait. Toute autre valeur signifie que le script est mort de lui-même, et dans ce cas l'erreur ne vient pas de systemd.
Le fichier unit ligne par ligne
Les units maison vont dans /etc/systemd/system/, pas dans /lib/systemd/system/ ni dans /usr/lib/systemd/system/ : ces deux répertoires appartiennent au gestionnaire de paquets et seront écrasés au prochain apt upgrade.
cat > /etc/systemd/system/kh-demo.service <<'EOF'
[Unit]
Description=KernelHost Demo Worker
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=khdemo
Group=khdemo
WorkingDirectory=/opt/kh-demo
EnvironmentFile=-/etc/kh-demo.env
ExecStart=/opt/kh-demo/run.sh
Restart=on-failure
RestartSec=5s
TimeoutStopSec=20s
SyslogIdentifier=kh-demo
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
[Install]
WantedBy=multi-user.target
EOF
Section [Unit]
Description= est le texte affiché par systemctl status. Aucun effet technique, mais c'est lui qui décide si vous comprenez à trois heures du matin ce qui vient d'échouer. Une phrase, pas un mot.
After= ne fixe que l'ordre, pas la dépendance. After=network-online.target signifie : si cette cible est de toute façon démarrée pendant ce boot, attends-la. Cela ne signifie pas qu'elle sera démarrée.
Wants= la demande réellement, et ce sous forme de dépendance faible. Si la cible échoue, le service démarre quand même. Son pendant Requires= entraînerait le service dans l'échec. Pour la plupart des applications, Wants= est le bon choix, car un service qui ne démarre même pas à cause d'un timeout réseau est pire qu'un service qui tourne brièvement dans le vide et revient grâce à Restart=.
Section [Service]
User= et Group= définissent l'identité du processus. Sans ces lignes, le service tourne en tant que root. Sur Debian et Ubuntu, useradd crée par défaut un groupe du même nom, d'où Group=khdemo.
WorkingDirectory= est le répertoire de travail du processus. Sans cette indication, le service démarre dans /. Tout programme qui cherche ses fichiers de configuration, ses templates ou ses plugins relativement au répertoire courant coule alors immédiatement. Si le répertoire indiqué n'existe pas, le démarrage se termine par 200/CHDIR.
EnvironmentFile= charge des variables depuis un fichier. Le signe moins devant le chemin rend ce fichier facultatif. Sans ce moins, le démarrage échoue si le fichier manque. Le contenu se limite à de simples lignes KEY=VALUE, ce n'est pas du shell. Un export devant est une erreur, et PORT=$BASE_PORT ne sera pas résolu.
ExecStart= exige un chemin absolu vers l'exécutable. C'est la règle sur laquelle la plupart butent, d'où une section entière plus bas.
TimeoutStopSec= limite le temps que systemd attend après le SIGTERM avant d'envoyer SIGKILL. La valeur par défaut est de 90 secondes.
SyslogIdentifier= définit le nom sous lequel les lignes apparaissent dans le journal. Sans cette ligne, l'émetteur s'appelle run.sh, ce qui rend tout filtrage inutilisable.
NoNewPrivileges=true interdit au processus et à tous ses enfants de gagner des droits via des binaires setuid. PrivateTmp=true donne au service son propre /tmp. ProtectSystem=full monte /usr, /boot et /etc en lecture seule. Le niveau strict va plus loin et exige alors StateDirectory= ou ReadWritePaths= pour tout ce qui doit rester accessible en écriture.
Section [Install]
WantedBy=multi-user.target répond à la question de savoir quand le service doit démarrer automatiquement. multi-user.target correspond au fonctionnement multi-utilisateur normal sans interface graphique, c'est-à-dire à ce qu'atteint un serveur. Seul systemctl enable évalue cette section et crée le lien symbolique. Si [Install] manque, enable s'interrompt avec The unit files have no installation config (WantedBy=, RequiredBy=, Also=, Alias= settings in the [Install] section, and DefaultInstance= for template units). Le service peut alors être démarré à la main, mais il ne revient jamais après un redémarrage.
Type=simple, Type=exec et Type=forking
Le Type= répond à exactement une question : à quoi systemd reconnaît-il que le service a démarré ?
Avec Type=simple, le service est considéré comme démarré dès que le processus a été créé, donc après le fork() et avant même que le programme ne soit exécuté. C'est la valeur par défaut, et le bon choix pour la quasi-totalité des programmes modernes qui restent au premier plan.
Avec Type=exec, systemd attend en plus que execve() ait réussi. La différence pratique est considérable : avec Type=simple, systemctl start annonce un succès même si le binaire n'existe pas du tout, et l'erreur n'apparaît qu'ensuite dans le journal. Avec Type=exec, le démarrage échoue immédiatement dans ce cas précis. Disponible depuis systemd 240, donc sur les quatre distributions.
Avec Type=forking, systemd part du principe que le programme passe lui-même en arrière-plan : le processus lancé se termine, un enfant continue de tourner, et c'est cette fin qui vaut signal de démarrage. C'est le comportement classique d'un démon Unix. Qui utilise ce type a en règle générale besoin en plus de PIDFile= avec un chemin absolu, sinon systemd devine lequel des processus restants est le processus principal.
La recommandation est sans ambiguïté : Type=forking uniquement si le programme refuse absolument d'y renoncer. Presque tous les logiciels disposent d'une option pour cela, souvent --foreground, -D FOREGROUND, --no-daemon ou daemon off; dans la configuration. Au premier plan avec Type=simple, l'unit est plus courte, les logs atterrissent dans le journal et Restart= fonctionne de façon fiable.
La mauvaise combinaison typique : un programme qui passe lui-même en arrière-plan, sous Type=simple. systemd voit le processus de démarrage se terminer, considère le service comme arrêté et tue les enfants au passage. Le symptôme est Active: inactive (dead) juste après un systemctl start qui n'a signalé aucune erreur. Le cas inverse, un programme de premier plan sous Type=forking, laisse systemd attendre une fin qui n'arrive jamais : Job for foo.service failed because a timeout was exceeded, au bout de 90 secondes.
Câbler correctement After=network-online.target
C'est ici que presque tous les tutoriels s'arrêtent trop tôt. network.target signifie uniquement que la gestion du réseau a démarré, pas qu'une adresse IP est configurée. Si vous avez besoin d'une adresse joignable, par exemple parce que le programme se lie à une IP fixe, c'est network-online.target qu'il vous faut.
Mais cette cible n'est pas atteinte toute seule. Elle suppose qu'un service d'attente adapté soit activé, et celui-ci diffère selon la gestion du réseau :
systemctl list-unit-files 'systemd-networkd-wait-online.service' 'NetworkManager-wait-online.service' 'ifupdown-wait-online.service' --no-pager
Sur Ubuntu Server 22.04 et 24.04, le réseau passe par netplan avec systemd-networkd : systemd-networkd-wait-online.service y est actif et network-online.target a une vraie signification. Sur une installation Debian classique avec ifupdown, ifupdown-wait-online.service existe bien, mais n'est pas activé. La cible est alors considérée comme atteinte immédiatement, et le délai d'attente sur lequel vous comptez n'a jamais lieu.
Sur Debian 12 et 13 avec ifupdown, le service d'attente s'active une fois pour toutes en cas de besoin :
systemctl enable ifupdown-wait-online.service
Activer deux services d'attente en même temps n'est pas une bonne idée, car chacun attend alors sa propre gestion du réseau et l'un des deux part forcément dans le timeout de 90 secondes. C'est la cause la plus fréquente d'un serveur qui met soudain une minute et demie de plus à démarrer. De toute façon, plus robuste que n'importe quel ordre de démarrage : un programme qui supporte une connexion absente au lancement, combiné à Restart=on-failure.
Restart, RestartSec et le frein au démarrage
Restart=on-failure relance en cas de code de sortie différent de zéro, en cas de signal comme SIGSEGV et en cas de timeout du watchdog, mais pas après un exit 0 ni après un systemctl stop manuel. Restart=always relance en plus après une fin propre, et c'est le moyen le plus rapide de se construire une boucle infinie.
Contre cela, systemd dispose d'un frein intégré : par défaut, cinq tentatives de démarrage sont autorisées en dix secondes (StartLimitBurst=5, StartLimitIntervalSec=10s). Passé ce seuil, systemd abandonne et signale :
kh-demo.service: Start request repeated too quickly.
kh-demo.service: Failed with result 'exit-code'.
Failed to start kh-demo.service - KernelHost Demo Worker.
Le service reste alors dans l'état failed et ne réagit plus non plus à systemctl start, tant que le compteur n'est pas remis à zéro :
systemctl reset-failed kh-demo.service
Deux détails coûtent régulièrement du temps ici. Premièrement, StartLimitBurst= et StartLimitIntervalSec= appartiennent à la section [Unit], pas à [Service]. Dans la mauvaise section, ils sont ignorés, avec un avertissement dans le journal mais sans erreur. Deuxièmement, les appels manuels à systemctl restart comptent aussi : qui relance cinq fois pendant le débogage déclenche le frein lui-même.
Règle empirique : StartLimitIntervalSec supérieur à RestartSec multiplié par StartLimitBurst.
Sur Debian 13 et Ubuntu 24.04, il existe en plus RestartSteps= et RestartMaxDelaySec= (à partir de systemd 254) pour des temps d'attente croissant de façon exponentielle. Sur Debian 12 et Ubuntu 22.04, ces directives n'existent pas et sont ignorées.
Activer, démarrer et prouver que ça tourne
Avant le premier démarrage, une vérification de syntaxe vaut le détour. Elle trouve les fautes de frappe dans les noms de directives, les sections mal orthographiées et les programmes manquants, sans rien démarrer :
systemd-analyze verify /etc/systemd/system/kh-demo.service
Aucune sortie signifie que tout est en ordre. Une faute de frappe comme WorkingDirectiry= produit, selon la version de systemd, Unknown key name 'WorkingDirectiry' in section 'Service', ignoring ou Unknown key 'WorkingDirectiry' in section [Service], ignoring. C'est là que naissent les erreurs silencieuses : systemd ignore les clés inconnues, le service démarre, mais il se comporte autrement que prévu.
systemctl daemon-reload
daemon-reload relit les fichiers unit, mais ne relance rien. Si vous oubliez le reload, le prochain systemctl status vous répond : Warning: The unit file, source configuration file or drop-ins of kh-demo.service changed on disk. Run 'systemctl daemon-reload' to reload units. À retenir dans l'ordre : d'abord daemon-reload, ensuite restart.
systemctl enable kh-demo.service
systemctl start kh-demo.service
Les deux à la fois avec systemctl enable --now kh-demo.service.
Vient maintenant la partie que la plupart des tutoriels laissent de côté : la preuve que cela a vraiment fonctionné, et pas seulement qu'aucune erreur n'est apparue. Quatre preuves indépendantes.
Premièrement, le lien symbolique existe. Il constitue à lui seul tout le mécanisme derrière enable. S'il manque, le service ne démarre pas après le redémarrage, quoi que dise status sur le moment.
ls -l /etc/systemd/system/multi-user.target.wants/kh-demo.service
systemctl is-enabled kh-demo.service
Deuxièmement, l'état. Ce qui compte, c'est la parenthèse dans la ligne Active:. active (running) veut dire qu'un processus tourne. active (exited) veut dire que le programme a terminé et que plus personne n'est là. Pour un service permanent, c'est une erreur, même si l'affichage est vert.
systemctl status kh-demo.service --no-pager
systemctl is-active kh-demo.service
Troisièmement, le journal. Pas seulement de savoir si des lignes arrivent, mais si ce sont les bonnes.
journalctl -u kh-demo.service -n 20 --no-pager
Suivre en temps réel : journalctl -u kh-demo.service -f. Uniquement le dernier démarrage : -b. Une période donnée : --since "-1h". Plus de détails dans l'article journalctl : analyser les logs sous systemd.
Quatrièmement, la configuration effective. Pas le fichier, mais ce que systemd en a fait. La différence compte dès que des drop-ins entrent en jeu.
systemctl show kh-demo.service -p ExecStart -p User -p WorkingDirectory -p Restart -p MainPID
systemctl cat kh-demo.service
La preuve finale reste un vrai redémarrage. Ensuite, cette commande montre si quelque chose est resté sur le carreau :
systemctl list-units --type=service --state=failed --no-pager
Les erreurs les plus fréquentes, au mot près
systemd signale les erreurs de démarrage via ses propres codes de sortie au-dessus de 200. Le nombre affiché dans status indique déjà où chercher.
| Code | Signification | Cause |
|---|---|---|
| 200/CHDIR | EXIT_CHDIR | WorkingDirectory= n'existe pas ou n'est pas accessible pour l'utilisateur |
| 203/EXEC | EXIT_EXEC | ExecStart= introuvable, non exécutable ou mauvais interpréteur |
| 216/GROUP | EXIT_GROUP | Group= n'existe pas |
| 217/USER | EXIT_USER | User= n'existe pas |
| 219/CGROUP | EXIT_CGROUP | le cgroup n'a pas pu être créé |
| 238/STATE_DIRECTORY | EXIT_STATE_DIRECTORY | StateDirectory= existe déjà avec un mauvais propriétaire |
203/EXEC : le mauvais chemin dans ExecStart
Le journal contient à ce sujet une ligne de la forme kh-demo.service: Failed at step EXEC spawning /opt/kh-demo/run.sh: No such file or directory, suivie de Main process exited, code=exited, status=203/EXEC.
No such file or directory est trompeur, car ce message a trois causes. Premièrement : le fichier n'existe réellement pas, le plus souvent à cause d'une faute de frappe ou parce que le binaire se trouve dans /usr/local/bin et non dans /usr/bin. Deuxièmement : le bit exécutable manque, et le journal signale alors Permission denied. Troisièmement, le cas le plus vicieux : le fichier est exécutable, mais sa ligne shebang pointe dans le vide. Un script avec #!/usr/bin/python échoue exactement de la même manière sur Debian 12 et plus récent, car seul /usr/bin/python3 y existe. Le noyau signale l'absence de l'interpréteur, systemd la transmet comme une absence du script.
La vérification prend trois secondes :
ls -l /opt/kh-demo/run.sh
head -n 1 /opt/kh-demo/run.sh
Chemins relatifs et PATH
ExecStart=node server.js ne fonctionne pas, même avec un WorkingDirectory= défini. L'erreur au chargement est Neither a valid executable name nor an absolute path. Seule la première entrée doit être absolue, les arguments qui suivent peuvent rester relatifs. La forme correcte est ExecStart=/usr/bin/node server.js, où server.js est résolu relativement au WorkingDirectory. Le bon chemin est fourni par command -v :
command -v bash
Attention avec les gestionnaires de versions : sous nvm, cela renvoie un chemin comme /root/.nvm/versions/node/v22.14.0/bin/node, qui n'existe pas pour l'utilisateur du service. Les environnements d'exécution destinés à des services s'installent à l'échelle du système, par exemple via NodeSource ou Adoptium Temurin.
Variables d'environnement manquantes
Le grand classique : dans une session interactive le programme tourne, en tant que service non. La raison est que systemd ne démarre aucun shell de connexion. Ni /etc/profile, ni ~/.bashrc, ni ~/.profile ne sont lus, et tout ce qui y est défini par export manque dans le service. Le PATH d'un service système est un chemin minimal figé, généralement /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin.
Les variables se déclarent donc dans l'unit ou dans un fichier dédié :
cat > /etc/kh-demo.env <<'EOF'
GREETING=bonjour
LANG=fr_FR.UTF-8
EOF
chmod 640 /etc/kh-demo.env
chown root:khdemo /etc/kh-demo.env
systemctl restart kh-demo.service
journalctl -u kh-demo.service -n 5 --no-pager
Si la sortie affiche maintenant GREETING=bonjour au lieu de GREETING=non défini, le fichier est bien arrivé. Cela se contrôle aussi directement :
systemctl show kh-demo.service -p Environment -p EnvironmentFiles
Trois pièges dans ce genre de fichiers : la substitution de commande avec $(...) n'a pas lieu, un $ voulu au sens littéral doit s'écrire $$, et les guillemets se retrouvent dans la valeur, là où ils n'ont rien à faire.
Unit not found
Failed to start kh-demo.service: Unit kh-demo.service not found. signifie presque toujours l'une de ces trois choses : le fichier se trouve dans le mauvais répertoire, il porte la mauvaise extension (.services au lieu de .service) ou le daemon-reload manque. ls -l /etc/systemd/system/ règle les deux premiers cas.
Différences entre Debian 13, Debian 12, Ubuntu 24.04 et 22.04
Les bases, à savoir [Unit], [Service], [Install], Type=simple, Restart=, User= et WorkingDirectory=, sont identiques sur les quatre systèmes. Les différences se situent dans les marges.
Directives disponibles. Ubuntu 22.04 embarque systemd 249, Debian 12 systemd 252, Ubuntu 24.04 systemd 255 et Debian 13 systemd 257. Tout ce qui arrive à partir de 253 manque sur les deux systèmes les plus anciens : Type=notify-reload (253) ainsi que RestartSteps=, RestartMaxDelaySec= et RestartMode=direct (254). Ces directives ne sont pas signalées comme des erreurs, elles sont simplement ignorées. C'est ainsi que naissent des units qui font ce qu'on attend d'elles sur un système et tout autre chose, sans raison apparente, sur l'autre.
systemctl --version
Réseau. Ubuntu Server utilise netplan avec systemd-networkd, Debian utilise ifupdown dans son installation par défaut. network-online.target est donc parlant sur Ubuntu sans rien faire, et sur Debian seulement après l'activation de ifupdown-wait-online.service.
Chemins des interpréteurs et des environnements d'exécution. C'est là que naissent la plupart des erreurs 203/EXEC lorsqu'on transpose une unit d'un système à l'autre. Node.js est fourni en 20.19 sur Debian 13, en 18.20 sur Debian 12, en 18.19 sur Ubuntu 24.04 et en 12.22 sur Ubuntu 22.04. PHP est en 8.4 sur Debian 13, 8.2 sur Debian 12, 8.3 sur Ubuntu 24.04 et 8.1 sur Ubuntu 22.04. Pour Java, l'écart est le plus grand : Debian 13 ne livre que openjdk-21-jre-headless, Debian 12 uniquement openjdk-17-jre-headless, tandis qu'Ubuntu 24.04 et 22.04 proposent 8, 11, 17 et 21. Copier ExecStart=/usr/lib/jvm/java-17-openjdk-amd64/bin/java de Debian 12 vers Debian 13 donne à coup sûr un 203/EXEC.
Bases de données. Debian ne livre pas de mysql-server, c'est toujours MariaDB qui y tourne. Une unit avec After=mysql.service attend donc sur Debian un service qui n'existe pas, et démarre sans le moindre délai, en silence et sans avertissement. La forme correcte y est After=mariadb.service.
Modifier, revenir en arrière, nettoyer
Les units maison se modifient directement dans le fichier, suivi d'un daemon-reload. Pour les units issues d'un paquet, on utilise en revanche des drop-ins, afin que la prochaine mise à jour n'efface pas la personnalisation :
systemctl edit kh-demo.service
Cela crée /etc/systemd/system/kh-demo.service.d/override.conf, qui ne contient que les directives modifiées. Cas particulier : les directives de type liste comme ExecStart= ne sont pas remplacées, elles sont ajoutées. Pour les écraser, il faut d'abord vider la liste :
ExecStart=
ExecStart=/opt/kh-demo/run.sh --verbose
systemctl revert kh-demo.service supprime à nouveau tous les drop-ins. Démontage complet de l'exemple, exactement dans cet ordre :
systemctl disable --now kh-demo.service
rm -f /etc/systemd/system/kh-demo.service
rm -rf /etc/systemd/system/kh-demo.service.d
systemctl daemon-reload
systemctl reset-failed
Attention à la dernière étape : systemctl reset-failed sans argument réinitialise l'état d'erreur de toutes les unités, pas seulement celui du service d'exemple. Sur un système où d'autres services sont encore à l'état failed, leurs messages disparaissent eux aussi de systemctl list-units --state=failed. Pour ne nettoyer qu'ici, prenez la forme ciblée systemctl reset-failed kh-demo.service vue dans la section plus haut.
Supprimer le fichier sans avoir appelé disable au préalable laisse un lien symbolique mort dans /etc/systemd/system/multi-user.target.wants/, qui apparaît comme avertissement à chaque daemon-reload. Il se nettoie avec find /etc/systemd/system -xtype l -delete. Cet appel supprime toutefois chaque lien symbolique mort sous /etc/systemd/system, donc aussi les restes de services tiers dont vous avez peut-être encore besoin. Plus sûr : commencer par un coup d'œil sans -delete, ou passer directement par la variante ciblée find /etc/systemd/system -xtype l -name 'kh-demo*' -delete. En option, l'utilisateur et les fichiers :
userdel khdemo
rm -rf /opt/kh-demo /etc/kh-demo.env
Chemin absolu dans ExecStart, Type adapté, utilisateur dédié, WorkingDirectory défini, variables via EnvironmentFile= : avec cet ensemble en place, vous avez un service qui survit à un redémarrage et qui, en cas de problème, vous dit ce qui s'est passé.
Questions fréquentes
Dois-je exécuter systemctl daemon-reload après chaque modification du fichier unit ?
Que signifie status=203/EXEC ?
Pourquoi mon service ne démarre-t-il pas après le redémarrage alors que systemctl start fonctionne ?
Type=simple ou Type=forking ?
Pourquoi mon service ne trouve-t-il pas ses variables d'environnement ?
Le service ne réagit plus à systemctl start, que faire ?
2026 KernelHost GmbH. Tous droits réservés. Ce guide est protégé par le droit d'auteur. Sa republication sur d'autres sites web, même partielle ou sous une forme modifiée, n'est pas autorisée sans notre accord écrit. Les citations accompagnées de la source et d'un lien sont expressément les bienvenues.

