ITSkillsCenter
Développement Web

Créer un service systemd : tutoriel 2026

15 min de lecture

📍 Cluster : Sysadmin systemd. Articles frères : journalctl, secrets, timers vs cron.

Introduction

Quand on déploie une application custom sur un VPS Linux — un script Python qui ingère des données toutes les nuits, un worker Node.js qui traite une queue Redis, un binaire Go qui sert une API REST — la question pratique est toujours la même : comment garantir qu’elle redémarre automatiquement après un reboot, qu’elle se relance après un crash, et que ses logs soient consultables proprement ? Sur Linux moderne, la réponse universelle est : en faire un service systemd. Plus question d’utiliser nohup ... &, screen ou tmux ; ce sont des outils interactifs qui ne survivent pas à un reboot et n’offrent aucun mécanisme de supervision.

Ce tutoriel détaille la création d’un service systemd robuste pour une application en production : structure d’un fichier .service, gestion des dépendances (réseau, base de données prête), permissions et utilisateur dédié, redémarrage automatique sur crash, limites de ressources, hardening (sandboxing), logs vers journald, secrets et environnement, et timers pour les tâches planifiées. À la fin, vous saurez packager n’importe quelle application en service système avec la même rigueur qu’un service distribué par votre distribution.

Prérequis

  • VPS Linux avec systemd ≥ 245 (Debian 11+, Ubuntu 22.04+, Rocky/Alma 9+)
  • Accès sudo
  • Une application à déployer : binaire compilé (Go, Rust, Java jar) ou script avec interpréteur (Python, Node, Ruby, Bash)
  • Si l’application a des dépendances réseau ou DB, ces services doivent être identifiables (ex: postgresql.service)
  • Niveau attendu : intermédiaire
  • Temps estimé : 25 à 40 minutes

Étape 1 — Créer un utilisateur système dédié

Une application en production ne doit jamais tourner en root, ni en tant qu’utilisateur applicatif partagé. Le principe : un utilisateur système dédié par application, sans shell de connexion, sans home directory utile, avec des permissions minimales. Cela limite la portée d’une compromission éventuelle (un attaquant qui exploite une faille dans l’app obtient un shell sous l’utilisateur de l’app, pas sous root) et simplifie l’audit.

# Création d'un utilisateur système (UID < 1000, pas de home, pas de shell)
sudo useradd --system --no-create-home --shell /usr/sbin/nologin api-reservation

# Vérifier
id api-reservation
# uid=998(api-reservation) gid=998(api-reservation) groups=998(api-reservation)

# Préparer les répertoires de l'application
sudo mkdir -p /opt/api-reservation
sudo mkdir -p /var/lib/api-reservation
sudo mkdir -p /var/log/api-reservation  # optionnel : on préférera journald

sudo chown -R api-reservation:api-reservation /opt/api-reservation
sudo chown -R api-reservation:api-reservation /var/lib/api-reservation

# Si l'application a besoin de lire un fichier secret partagé :
sudo install -d -m 0750 -o api-reservation -g api-reservation /etc/api-reservation
sudo install -m 0640 -o root -g api-reservation .env /etc/api-reservation/.env

Trois conventions à respecter. Le shell /usr/sbin/nologin empêche toute connexion interactive — un su - api-reservation retourne "This account is currently not available". Pour debug, on peut quand même exécuter une commande ponctuelle avec sudo -u api-reservation -s /bin/bash qui contourne le shell par défaut. Le pattern de répertoires (/opt/app pour les binaires, /var/lib/app pour les données persistantes, /etc/app pour la config) suit la Filesystem Hierarchy Standard et facilite la gestion (backups ciblés, dimensionnement disque). Les permissions des secrets (0640 root:groupe) permettent à l'app de lire sans autoriser l'écriture, ce qui empêcherait une exfiltration accidentelle.

Étape 2 — Écrire le fichier .service

Le fichier /etc/systemd/system/api-reservation.service définit comment systemd démarre, redémarre et supervise votre application. Sa structure suit trois sections : [Unit] (métadonnées et dépendances), [Service] (commandes et politique d'exécution), [Install] (cible de boot). Voici un exemple production-ready commenté.

# /etc/systemd/system/api-reservation.service

[Unit]
Description=API Réservation Hotel Saint-Louis
Documentation=https://docs.hotel-saint-louis.com/api
After=network-online.target postgresql.service
Wants=network-online.target
Requires=postgresql.service

[Service]
Type=simple
User=api-reservation
Group=api-reservation
WorkingDirectory=/opt/api-reservation
EnvironmentFile=/etc/api-reservation/.env

# Pour un binaire Go/Rust/Java
ExecStart=/opt/api-reservation/bin/api-server

# Pour un script Python avec virtualenv
# ExecStart=/opt/api-reservation/.venv/bin/python -m uvicorn app:app --host 127.0.0.1 --port 8080

# Pour Node.js avec npm
# ExecStart=/usr/bin/node /opt/api-reservation/server.js

# Politique de redémarrage
Restart=on-failure
RestartSec=5s
StartLimitIntervalSec=60s
StartLimitBurst=5

# Limites de ressources (anti-runaway)
MemoryMax=512M
CPUQuota=100%
TasksMax=200

# Hardening (sandboxing)
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=true
ReadWritePaths=/var/lib/api-reservation

# Logs vers journald avec identifier custom
SyslogIdentifier=api-reservation
StandardOutput=journal
StandardError=journal

# Timeout de démarrage et arrêt
TimeoutStartSec=30s
TimeoutStopSec=20s

[Install]
WantedBy=multi-user.target

Quatre choix essentiels à comprendre. Type=simple est le plus simple : systemd considère le service démarré dès que ExecStart est lancé. Pour un fork, utiliser Type=forking ; pour un service qui se déclare prêt explicitement, Type=notify avec sd_notify. Restart=on-failure redémarre seulement si le code de retour est non-zéro ou en cas de SIGSEGV — pas si l'app sort proprement avec exit 0. StartLimitBurst=5 évite le crash loop : si l'app crashe 5 fois en 60 secondes, systemd abandonne et passe en failed. Les directives Protect* sont les sandboxing systemd : ProtectSystem=strict rend tout le système de fichiers en lecture seule pour le service, sauf les paths listés dans ReadWritePaths. Une compromission de l'app ne peut donc pas modifier /etc ou /usr. RestrictAddressFamilies=AF_INET AF_INET6 n'autorise que les sockets IPv4/IPv6 — bloque AF_NETLINK, AF_PACKET, AF_UNIX (sauf si nécessaires). Combinées, ces directives offrent un niveau de hardening équivalent à un conteneur sans la complexité.

Étape 3 — Activer et démarrer le service

Une fois le fichier .service écrit, il faut indiquer à systemd qu'il existe (daemon-reload), puis l'activer pour qu'il démarre au boot (enable) et le démarrer maintenant (start), ou les deux d'un coup (enable --now).

# Recharger la config systemd (lecture des nouveaux .service)
sudo systemctl daemon-reload

# Activer ET démarrer en une commande
sudo systemctl enable --now api-reservation

# Vérifier l'état
sudo systemctl status api-reservation

# État attendu :
# ● api-reservation.service - API Réservation Hotel Saint-Louis
#      Loaded: loaded (/etc/systemd/system/api-reservation.service; enabled)
#      Active: active (running) since Mon 2026-04-27 14:32:18 GMT; 12s ago
#    Main PID: 8421 (api-server)
#       Tasks: 8 (limit: 200)
#      Memory: 47.2M (max: 512M)
#         CPU: 145ms
#      CGroup: /system.slice/api-reservation.service
#              └─8421 /opt/api-reservation/bin/api-server

# Si Active=failed, voir les logs pour comprendre
sudo journalctl -u api-reservation -n 50
sudo journalctl -u api-reservation -p err -n 20

Le statut active (running) confirme que l'application tourne. Si Active=failed, plusieurs causes possibles : binaire absent ou non-exécutable, dépendance manquante (lib partagée, port déjà utilisé, base de données non prête malgré Requires=postgresql), permission refusée par les directives Protect*, syntaxe .env invalide. Les logs journalctl -u api-reservation contiennent toujours la cause exacte : ne pas chercher ailleurs avant de les avoir lus en détail.

Étape 4 — Tester le redémarrage automatique

L'intérêt principal de systemd vs un simple nohup est la supervision : votre app crashe → systemd la redémarre. Ce comportement doit être testé avant la mise en production, sinon on découvre en plein incident que Restart=on-failure ne couvre pas le cas observé.

# Test 1 : tuer le process avec SIGTERM (arrêt propre)
sudo systemctl status api-reservation     # noter le PID
sudo kill PID                              # SIGTERM
sleep 1
sudo systemctl status api-reservation     # doit être de nouveau "active (running)" avec un nouveau PID

# Test 2 : tuer avec SIGKILL (simule crash brutal)
sudo systemctl status api-reservation
sudo kill -9 PID
sleep 1
sudo systemctl status api-reservation

# Test 3 : déclencher une fuite mémoire qui dépasse MemoryMax
# (depuis l'app : alloue 600 Mo) → systemd OOM-kill puis redémarre

# Test 4 : reboot du VPS et vérifier qu'au démarrage le service revient
sudo reboot
# Après reconnexion :
sudo systemctl status api-reservation     # doit être "active (running)" sans intervention

# Voir l'historique de redémarrages
sudo systemctl show api-reservation --property=NRestarts
# NRestarts=3

Un service bien configuré redémarre dans tous les cas de crash dans les 5-10 secondes (selon RestartSec) et survit aux reboots. Si le test 4 (reboot) ne ramène pas le service, c'est que WantedBy=multi-user.target manque dans [Install] ou que systemctl enable n'a pas été exécuté. Si le test 3 (memory) ne provoque pas d'OOM-kill mais swap massif, c'est que MemoryMax n'a pas été appliqué — vérifier avec systemctl show api-reservation -p MemoryMax.

Étape 5 — Logs et debug

Le service écrit ses sorties (stdout/stderr) vers journald grâce à StandardOutput=journal. Cela évite les fichiers texte locaux à gérer (rotation, permissions) et permet d'utiliser tout l'écosystème journalctl (filtrage par priorité, JSON, forwarding Loki). Pour les logs structurés, l'application peut écrire en JSON sur stderr et journald les capture automatiquement.

# Suivre les logs en temps réel
sudo journalctl -u api-reservation -f

# Logs depuis le dernier démarrage
sudo journalctl -u api-reservation -b

# Erreurs uniquement
sudo journalctl -u api-reservation -p err

# Logs JSON pour analyse jq
sudo journalctl -u api-reservation -o json --since "1 hour ago" | \
    jq -r 'select(.PRIORITY=="3") | .MESSAGE'

# Si l'app écrit du JSON sur stderr (recommandé en production) :
sudo journalctl -u api-reservation -o json --since "1 hour ago" | \
    jq -r '.MESSAGE' | jq 'select(.level=="error")'

# Quand le service est en boucle de crash
sudo systemctl status api-reservation
sudo journalctl -u api-reservation --since "10 minutes ago"
# Lire chaque démarrage et identifier la cause de l'exit

Pour les applications custom, fortement recommandé : émettre les logs en JSON structuré sur stderr avec a minima les champs level, message, timestamp, et un identifiant de corrélation (request_id, user_id). Cela facilite drastiquement le debug en production : journalctl -u api-reservation -o json | jq 'select(.MESSAGE | fromjson | .request_id == "abc-123")' retrouve toute la chaîne d'exécution d'une requête en une commande.

Étape 6 — Variables d'environnement et secrets

La configuration applicative passe typiquement par des variables d'environnement (12-factor app). Trois méthodes pour les fournir au service systemd, par ordre croissant de sécurité : directive Environment= dans le .service, fichier EnvironmentFile=, ou les LoadCredential de systemd 250+ (sujet du tutoriel frère "secrets et environnement").

# Méthode 1 : directement dans le .service (uniquement valeurs non sensibles)
[Service]
Environment="LOG_LEVEL=info"
Environment="DATABASE_HOST=localhost"
Environment="PORT=8080"

# Méthode 2 : EnvironmentFile (recommandé pour la majorité des cas)
[Service]
EnvironmentFile=/etc/api-reservation/.env

# /etc/api-reservation/.env (permissions 0640 root:api-reservation)
LOG_LEVEL=info
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=apiuser
DATABASE_PASSWORD=secret-postgres-password
JWT_SECRET=ma-cle-jwt-32-octets-base64
SMTP_PASSWORD=secret-mailgun

# Méthode 3 : LoadCredential systemd 250+ (le plus secure)
# Voir tutoriel frère : systemd-secrets-environnement

# Vérifier qu'un service a bien chargé ses variables
sudo systemctl show api-reservation -p Environment
# Environment= LOG_LEVEL=info DATABASE_HOST=localhost ...

Ne JAMAIS committer le fichier .env dans Git — c'est l'erreur la plus fréquente. Pour le déploiement, utiliser un mécanisme out-of-band : Ansible Vault, Hashicorp Vault, GitOps avec sealed secrets, ou copie manuelle au moment du déploiement initial. Vérifier les permissions : ls -la /etc/api-reservation/.env doit montrer -rw-r----- 1 root api-reservation. Si le fichier est lisible par tous (-rw-r--r--), n'importe quel utilisateur du VPS peut récupérer les secrets.

Étape 7 — Mise à jour du service et déploiement zéro downtime

Pour mettre à jour l'application, deux scénarios : redémarrage simple (downtime 1-5 secondes) suffisant pour la plupart des cas, ou stratégie blue-green / canary pour les services à haute disponibilité. Pour un site PME hôtelier ou EdTech, un downtime de 3 secondes en heures creuses est invisible — la simplicité prime.

# Déploiement classique simple (downtime 2-5 secondes)
# 1. Copier le nouveau binaire ou code
sudo cp /tmp/api-server-new /opt/api-reservation/bin/api-server.new
sudo chown api-reservation: /opt/api-reservation/bin/api-server.new
sudo chmod +x /opt/api-reservation/bin/api-server.new

# 2. Switch atomique (mv est atomique sur le même filesystem)
sudo mv /opt/api-reservation/bin/api-server /opt/api-reservation/bin/api-server.old
sudo mv /opt/api-reservation/bin/api-server.new /opt/api-reservation/bin/api-server

# 3. Redémarrer le service
sudo systemctl restart api-reservation

# 4. Vérifier que tout va bien
sleep 3
sudo systemctl status api-reservation
curl -fsS http://localhost:8080/health || echo "FAIL"

# 5. Si OK, supprimer la version précédente
sudo rm /opt/api-reservation/bin/api-server.old

# Pour Zéro downtime : utiliser systemd socket activation + plusieurs instances
# OU déployer derrière un reverse proxy (Caddy, Nginx) qui bascule entre 2 ports
# C'est un autre tutoriel — la plupart des cas ne le nécessitent pas

Pour automatiser ce flow, écrire un script shell ou utiliser Ansible avec le module systemd et copy. Pour les déploiements via CI/CD (GitLab, Forgejo, GitHub Actions), un runner SSH peut exécuter le script à chaque tag git. Le binaire peut être pré-construit dans le pipeline et téléchargé via scp ou rsync. Le rollback en cas d'échec : conserver les 5 dernières versions et garder un script rollback.sh qui remet la version précédente.

Erreurs fréquentes

ErreurCauseSolution
"Failed to start" et logs videsExecStart binaire absent ou path incorrect, ou utilisateur sans permissions execfile /chemin/binaire ; ls -la sur le path ; tester en lançant manuellement avec sudo -u api-reservation /chemin
"Permission denied" malgré file mode 755ProtectSystem=strict bloque l'écriture où l'app essaie d'écrireAjouter le path à ReadWritePaths= ou supprimer ProtectSystem temporairement
Service tourne mais inaccessibleBind à 127.0.0.1 mais accès depuis IP externe ; ou port firewalléBind à 0.0.0.0 ou ajouter un reverse proxy ; ouvrir le port firewall
EnvironmentFile non chargéSyntaxe .env invalide (espaces, quotes mal placés)Format strict : KEY=value sans espaces autour du =, sans quotes sauf si valeur contient des espaces
"Start request repeated too quickly"App crashe immédiatement ; StartLimitBurst atteintFixer la cause root du crash (logs) avant tout ; systemctl reset-failed pour reset le compteur
Service ne s'arrête pas avec stopApp ignore SIGTERM ; pas de handler de signalImplémenter graceful shutdown sur SIGTERM ; ou KillSignal=SIGINT ; TimeoutStopSec=10 force SIGKILL

Adaptation au contexte ouest-africain

Trois aspects pratiques. Premièrement, dimensionner les limites au plus juste : les VPS abordables type Hetzner CX21/CX22 disposent de 4 Go RAM. Un service mal configuré sans MemoryMax peut consommer toute la mémoire et faire crasher le système entier (OOM-killer aléatoire qui prend Postgres ou Nginx). Définir explicitement MemoryMax selon le profil de l'app (Java avec heap 512M → MemoryMax=768M ; Node.js typique → 256M ; Go → 128-256M) protège l'ensemble du VPS.

Deuxièmement, gérer les coupures réseau : sur un VPS ouest-africain ou un VPS européen accédé depuis l'Afrique en SSH, les déploiements à distance peuvent être interrompus à mi-parcours par des micro-coupures Internet. Toujours utiliser tmux ou screen côté SSH pour persister la session ; tout déploiement sensible (db migration, package install) doit pouvoir reprendre de façon idempotente. Pour systemctl restart spécifiquement, l'opération est atomique et instantanée — pas de risque sur ce point.

Troisièmement, alternative pour applications très légères : pour un script Python qui tourne 30 secondes par jour (job de nettoyage, stats), inutile de créer un service systemd permanent — utiliser un systemd timer qui déclenche le service à intervalle. Cf. tutoriel frère "systemd timer vs cron". C'est plus moderne et puissant que cron, parfaitement intégré aux logs journald, et permet des conditions de déclenchement plus fines (après le boot, randomisé sur 30 minutes, etc.).

Tutoriels frères

FAQ

Type=simple ou Type=notify ?

simple dans 90 % des cas — c'est le plus simple et systemd considère le service "ready" dès qu'il démarre. notify est plus précis : l'app utilise sd_notify(0, "READY=1") pour signaler qu'elle est vraiment prête à servir. Utile quand l'app a un démarrage long (> 5s, ex: charger un gros modèle ML) et que des dépendances doivent attendre. Pour Python, le paquet python-systemd ; pour Go, github.com/coreos/go-systemd.

systemd vs Docker, lequel choisir ?

Pour une app simple sur un VPS unique, systemd est plus léger, plus rapide à démarrer, et offre un sandboxing comparable à un conteneur via les directives Protect*. Pour multi-services (app + DB + cache + reverse proxy) ou besoin de portabilité (dev/staging/prod), Docker Compose est plus pratique. Cohabitation classique : Docker pour les services tiers (Postgres, Redis, MinIO), systemd pour l'app custom. Coolify combine les deux élégamment.

Comment limiter le nombre de threads / connexions ouvertes ?

Plusieurs directives selon le besoin. TasksMax=200 limite le nombre de threads/processes total. LimitNOFILE=10000 définit le nombre max de file descriptors ouverts (utile pour les apps qui ouvrent beaucoup de connexions). LimitNPROC=100 limite le nombre de processes pour l'utilisateur du service. Ces limites complètent MemoryMax et CPUQuota pour un encadrement complet.

Où placer le fichier .service : /etc/systemd/system ou /lib/systemd/system ?

/etc/systemd/system/ pour vos services custom. /lib/systemd/system/ est réservé aux services fournis par les paquets de la distribution (Nginx, Postgres) — toute modification y serait écrasée à la prochaine mise à jour du paquet. Pour overrider un service système (ex: ajouter Restart=always à nginx), utiliser systemctl edit nginx qui crée un drop-in dans /etc/systemd/system/nginx.service.d/override.conf.

Pour aller plus loin

Mots-clés secondaires : systemd service, .service file, daemon Linux, ExecStart, Restart=on-failure, MemoryMax, ProtectSystem, hardening sandboxing, EnvironmentFile, journald, supervision processus.

Besoin d'un site web ?

Confiez-nous la Création de Votre Site Web

Site vitrine, e-commerce ou application web — nous transformons votre vision en réalité digitale. Accompagnement personnalisé de A à Z.

À partir de 250.000 FCFA
Parlons de Votre Projet
Publicité