Eigenen systemd-Service erstellen und beim Start automatisch ausführen

Veröffentlicht am 15 Min. Lesezeit

Unit-Datei Zeile für Zeile: Type, User, WorkingDirectory, Restart und network-online.target. Mit den Fehlermeldungen im Wortlaut und dem Nachweis, dass der Dienst den Reboot wirklich übersteht.

Ein Programm, das nach einem Reboot nicht von allein wiederkommt, ist auf einem Server kein Dienst, sondern ein Risiko. nohup, screen und tmux halten einen Prozess am Leben, solange nichts passiert. Dieser Artikel schreibt eine eigene Unit-Datei Zeile für Zeile, zeigt die Fehlermeldungen im Wortlaut und beschreibt, wie man wieder herauskommt, wenn der Dienst im Startkarussell hängt.

Alle Befehle laufen als root. Geprüft auf Debian 13 (systemd 257), Debian 12 (252), Ubuntu 24.04 LTS (255) und Ubuntu 22.04 LTS (249). Wo sich die vier unterscheiden, steht es dabei.

Was ein systemd-Service leistet, was nohup und screen nicht leisten

Der Unterschied ist nicht Bequemlichkeit, sondern Zuständigkeit. systemd startet den Prozess beim Boot in definierter Reihenfolge, legt ihn in eine eigene cgroup, sammelt stdout und stderr im Journal, startet nach einem Absturz neu und beendet ihn beim Shutdown sauber mit SIGTERM. Die cgroup vermisst man am schmerzhaftesten: ein per nohup gestartetes Skript hinterlässt verwaiste Kindprozesse, eine Unit räumt ihre Gruppe komplett ab.

Vorbereitung: Programm, Benutzer und Verzeichnis

Bevor die Unit entsteht, muss laufen, was sie starten soll. Als Beispiel dient ein Skript, das auf stdout schreibt. Genau das ist der Punkt: ein Dienst unter systemd loggt nicht selbst in eine Datei, sondern schreibt auf stdout, und systemd legt es ins Journal.

mkdir -p /opt/kh-demo
cat > /opt/kh-demo/run.sh <<'EOF'
#!/bin/bash
set -euo pipefail
while true; do
  echo "kh-demo lebt, $(date --iso-8601=seconds), PWD=$PWD, GREETING=${GREETING:-nicht gesetzt}"
  sleep 10
done
EOF
chmod +x /opt/kh-demo/run.sh

Dann ein eigener Systembenutzer: --system vergibt eine UID unter 1000, /usr/sbin/nologin verhindert interaktive Logins.

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

Der entscheidende Test vor der Unit-Datei: läuft das Programm unter diesem Benutzer? Wer das überspringt, debuggt später systemd, obwohl das Problem im Programm liegt.

timeout 3 runuser -u khdemo -- /opt/kh-demo/run.sh; echo "Exitcode $?"

Exitcode 124 ist hier das gewünschte Ergebnis: timeout hat ein laufendes Programm abgebrochen. Jeder andere Wert bedeutet, dass das Skript von selbst gestorben ist, und dann liegt der Fehler nicht bei systemd.

Die Unit-Datei Zeile für Zeile

Eigene Units gehören nach /etc/systemd/system/, nicht nach /lib/systemd/system/ oder /usr/lib/systemd/system/: die beiden gehören dem Paketmanager und werden beim nächsten apt upgrade überschrieben.

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

Abschnitt [Unit]

Description= ist der Text in systemctl status. Ohne technische Wirkung, aber er entscheidet darüber, ob man um drei Uhr nachts versteht, was da failed ist. Ein Satz, kein Wort.

After= legt nur die Reihenfolge fest, nicht die Abhängigkeit. After=network-online.target bedeutet: falls dieses Target in diesem Bootvorgang ohnehin gestartet wird, warte darauf. Es bedeutet nicht, dass es gestartet wird.

Wants= fordert es tatsächlich an, und zwar als schwache Abhängigkeit. Scheitert das Target, startet der Dienst trotzdem. Das Gegenstück Requires= würde den Dienst mit in den Fehler reißen. Für die meisten Anwendungen ist Wants= richtig, denn ein Dienst, der wegen eines Netzwerk-Timeouts gar nicht erst startet, ist schlimmer als einer, der kurz ins Leere läuft und dank Restart= wiederkommt.

Abschnitt [Service]

User= und Group= setzen die Identität des Prozesses. Ohne diese Zeilen läuft der Dienst als root. Auf Debian und Ubuntu legt useradd standardmäßig eine gleichnamige Gruppe an, deshalb passt Group=khdemo.

WorkingDirectory= ist das Arbeitsverzeichnis des Prozesses. Ohne diese Angabe startet der Dienst in /. Jedes Programm, das Konfigurationsdateien, Templates oder Plugins relativ zum aktuellen Verzeichnis sucht, geht dann sofort unter. Existiert das angegebene Verzeichnis nicht, endet der Start mit 200/CHDIR.

EnvironmentFile= lädt Variablen aus einer Datei. Das führende Minuszeichen vor dem Pfad macht die Datei optional. Ohne das Minus scheitert der Start, wenn die Datei fehlt. Der Inhalt sind schlichte KEY=VALUE-Zeilen, keine Shell. export davor ist falsch, und PORT=$BASE_PORT wird nicht aufgelöst.

ExecStart= braucht einen absoluten Pfad zur ausführbaren Datei. Das ist die Regel, an der die meisten scheitern, deshalb weiter unten ein eigener Abschnitt.

TimeoutStopSec= begrenzt, wie lange systemd nach dem SIGTERM wartet, bevor SIGKILL kommt. Standard sind 90 Sekunden.

SyslogIdentifier= setzt den Namen, unter dem die Zeilen im Journal auftauchen. Ohne diese Zeile heißt der Absender run.sh, was beim Filtern unbrauchbar ist.

NoNewPrivileges=true verbietet dem Prozess und allen Kindern, über setuid-Binaries Rechte zu gewinnen. PrivateTmp=true gibt dem Dienst ein eigenes /tmp. ProtectSystem=full mountet /usr, /boot und /etc nur lesbar. Die Stufe strict geht weiter und verlangt dann StateDirectory= oder ReadWritePaths= für alles Schreibbare.

Abschnitt [Install]

WantedBy=multi-user.target beantwortet die Frage, wann der Dienst automatisch starten soll. multi-user.target ist der normale Mehrbenutzerbetrieb ohne grafische Oberfläche, also das, was ein Server erreicht. Erst systemctl enable wertet diesen Abschnitt aus und legt den Symlink an. Fehlt [Install], bricht enable ab mit The unit files have no installation config (WantedBy=, RequiredBy=, Also=, Alias= settings in the [Install] section, and DefaultInstance= for template units). Der Dienst lässt sich dann von Hand starten, kommt aber nach einem Reboot nie wieder.

Type=simple, Type=exec und Type=forking

Der Type= beantwortet genau eine Frage: woran erkennt systemd, dass der Dienst gestartet ist?

Bei Type=simple gilt der Dienst als gestartet, sobald der Prozess erzeugt wurde, also nach dem fork() und noch bevor das Programm ausgeführt wird. Das ist der Standard und für fast alle modernen Programme richtig, die im Vordergrund bleiben.

Bei Type=exec wartet systemd zusätzlich, bis execve() erfolgreich war. Der praktische Unterschied ist erheblich: mit Type=simple meldet systemctl start Erfolg, selbst wenn das Binary gar nicht existiert, und der Fehler taucht erst danach im Journal auf. Mit Type=exec schlägt der Start in genau diesem Fall sofort fehl. Verfügbar seit systemd 240 und damit auf allen vier Distributionen.

Bei Type=forking geht systemd davon aus, dass sich das Programm selbst in den Hintergrund verabschiedet: der gestartete Prozess endet, ein Kind läuft weiter, und erst dieses Ende gilt als Startsignal. Das ist klassisches Unix-Daemon-Verhalten. Wer diesen Typ nutzt, braucht in aller Regel zusätzlich PIDFile= mit absolutem Pfad, sonst rät systemd, welcher der übriggebliebenen Prozesse der Hauptprozess ist.

Die Empfehlung ist eindeutig: Type=forking nur, wenn das Programm sich partout nicht davon abbringen lässt. Fast jede Software hat einen Schalter dafür, oft --foreground, -D FOREGROUND, --no-daemon oder daemon off; in der Konfiguration. Im Vordergrund plus Type=simple ist die Unit kürzer, das Logging landet im Journal und Restart= funktioniert zuverlässig.

Die typische Fehlkombination: ein Programm, das sich selbst in den Hintergrund legt, unter Type=simple. systemd sieht den Startprozess enden, hält den Dienst für beendet und killt die Kinder mit. Symptom ist Active: inactive (dead) direkt nach einem systemctl start, das keinen Fehler gemeldet hat. Der umgekehrte Fall, ein Vordergrundprogramm unter Type=forking, lässt systemd auf ein Ende warten, das nie kommt: Job for foo.service failed because a timeout was exceeded, nach 90 Sekunden.

After=network-online.target richtig verdrahten

Hier hört fast jede Anleitung zu früh auf. network.target bedeutet lediglich, dass die Netzwerkverwaltung gestartet wurde, nicht dass eine IP-Adresse konfiguriert ist. Wer eine erreichbare Adresse braucht, etwa weil das Programm an eine feste IP bindet, will network-online.target.

Dieses Target wird aber nicht von selbst erreicht. Es setzt voraus, dass ein passender Wartedienst aktiviert ist, und der unterscheidet sich je nach Netzwerkverwaltung:

systemctl list-unit-files 'systemd-networkd-wait-online.service' 'NetworkManager-wait-online.service' 'ifupdown-wait-online.service' --no-pager

Auf Ubuntu Server 22.04 und 24.04 läuft das Netzwerk über netplan mit systemd-networkd, dort ist systemd-networkd-wait-online.service aktiv und network-online.target hat eine echte Bedeutung. Auf einer klassischen Debian-Installation mit ifupdown existiert ifupdown-wait-online.service zwar, ist aber nicht aktiviert. Das Target gilt dann sofort als erreicht, und die Wartezeit, auf die man sich verlässt, findet nicht statt.

Auf Debian 12 und 13 mit ifupdown aktiviert man den Wartedienst bei Bedarf einmalig:

systemctl enable ifupdown-wait-online.service

Zwei Wartedienste gleichzeitig zu aktivieren ist keine gute Idee, weil dann beide auf ihre jeweilige Netzwerkverwaltung warten und einer zwangsläufig in den 90-Sekunden-Timeout läuft. Das ist die häufigste Ursache für einen Server, der plötzlich anderthalb Minuten länger bootet. Robuster als jede Reihenfolge ist ohnehin ein Programm, das eine fehlende Verbindung beim Start verkraftet, kombiniert mit Restart=on-failure.

Restart, RestartSec und die Startbremse

Restart=on-failure startet neu bei einem Exit-Code ungleich null, bei einem Signal wie SIGSEGV und bei einem Watchdog-Timeout, aber nicht nach Exit 0 und nicht nach einem manuellen systemctl stop. Restart=always startet zusätzlich nach sauberem Ende neu, und damit baut man sich am schnellsten eine Endlosschleife.

Dagegen hat systemd eine eingebaute Bremse: standardmäßig sind fünf Startversuche innerhalb von zehn Sekunden erlaubt (StartLimitBurst=5, StartLimitIntervalSec=10s). Danach gibt systemd auf und meldet:

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.

Der Dienst bleibt dann im Zustand failed und reagiert auch auf systemctl start nicht mehr, bis der Zähler zurückgesetzt wird:

systemctl reset-failed kh-demo.service

Zwei Details kosten hier regelmäßig Zeit. Erstens gehören StartLimitBurst= und StartLimitIntervalSec= in den Abschnitt [Unit], nicht in [Service]. Im falschen Abschnitt werden sie ignoriert, mit Warnung im Journal, aber ohne Fehler. Zweitens zählen manuelle systemctl restart-Aufrufe mit: wer beim Debuggen fünfmal neu startet, löst die Bremse selbst aus.

Faustregel: StartLimitIntervalSec größer als RestartSec mal StartLimitBurst.

Auf Debian 13 und Ubuntu 24.04 gibt es zusätzlich RestartSteps= und RestartMaxDelaySec= (ab systemd 254) für exponentiell wachsende Wartezeiten. Auf Debian 12 und Ubuntu 22.04 existieren diese Direktiven nicht und werden ignoriert.

Aktivieren, starten und beweisen, dass es läuft

Vor dem ersten Start lohnt die Syntaxprüfung. Sie findet Tippfehler in Direktivennamen, falsch geschriebene Abschnitte und fehlende Programme, ohne etwas zu starten:

systemd-analyze verify /etc/systemd/system/kh-demo.service

Keine Ausgabe bedeutet: alles in Ordnung. Ein Tippfehler wie WorkingDirectiry= erzeugt je nach systemd-Version Unknown key name 'WorkingDirectiry' in section 'Service', ignoring oder Unknown key 'WorkingDirectiry' in section [Service], ignoring. Hier entstehen die stillen Fehler: systemd ignoriert unbekannte Schlüssel, der Dienst startet, verhält sich aber anders als erwartet.

systemctl daemon-reload

daemon-reload liest die Unit-Dateien neu ein, startet aber nichts neu. Wer den Reload vergisst, bekommt beim nächsten systemctl status: Warning: The unit file, source configuration file or drop-ins of kh-demo.service changed on disk. Run 'systemctl daemon-reload' to reload units. Reihenfolge merken: erst daemon-reload, dann restart.

systemctl enable kh-demo.service
systemctl start kh-demo.service

Beides zusammen geht mit systemctl enable --now kh-demo.service.

Jetzt der Teil, den die meisten Anleitungen auslassen: der Nachweis, dass es wirklich funktioniert hat und nicht nur kein Fehler kam. Vier unabhängige Belege.

Erstens, der Symlink existiert. Er ist der komplette Mechanismus hinter enable. Fehlt er, startet der Dienst nach dem Reboot nicht, egal was status gerade sagt.

ls -l /etc/systemd/system/multi-user.target.wants/kh-demo.service
systemctl is-enabled kh-demo.service

Zweitens, der Zustand. Entscheidend ist der Klammerzusatz in der Active:-Zeile. active (running) heißt, ein Prozess läuft. active (exited) heißt, das Programm ist fertig und niemand mehr da. Für einen Dauerdienst ist das ein Fehler, auch wenn grün danebensteht.

systemctl status kh-demo.service --no-pager
systemctl is-active kh-demo.service

Drittens, das Journal. Nicht nur, ob Zeilen ankommen, sondern ob die richtigen ankommen.

journalctl -u kh-demo.service -n 20 --no-pager

In Echtzeit mitlesen: journalctl -u kh-demo.service -f. Nur der letzte Bootvorgang: -b. Zeitraum: --since "-1h". Mehr dazu im Beitrag journalctl: Logs unter systemd auswerten.

Viertens, die wirksame Konfiguration. Nicht die Datei, sondern das, was systemd daraus gemacht hat. Der Unterschied zählt, sobald Drop-ins im Spiel sind.

systemctl show kh-demo.service -p ExecStart -p User -p WorkingDirectory -p Restart -p MainPID
systemctl cat kh-demo.service

Der abschließende Beweis bleibt ein echter Reboot. Danach zeigt dieser Befehl, ob etwas auf der Strecke geblieben ist:

systemctl list-units --type=service --state=failed --no-pager

Die häufigsten Fehler im Wortlaut

systemd meldet Startfehler über eigene Exit-Codes oberhalb von 200. Die Zahl im status sagt bereits, wo man suchen muss.

CodeBedeutungUrsache
200/CHDIREXIT_CHDIRWorkingDirectory= existiert nicht oder ist für den Benutzer nicht betretbar
203/EXECEXIT_EXECExecStart= nicht gefunden, nicht ausführbar oder falscher Interpreter
216/GROUPEXIT_GROUPGroup= existiert nicht
217/USEREXIT_USERUser= existiert nicht
219/CGROUPEXIT_CGROUPcgroup konnte nicht angelegt werden
238/STATE_DIRECTORYEXIT_STATE_DIRECTORYStateDirectory= existiert bereits mit falschem Eigentümer

203/EXEC: der falsche ExecStart-Pfad

Im Journal steht dazu eine Zeile der Form kh-demo.service: Failed at step EXEC spawning /opt/kh-demo/run.sh: No such file or directory, gefolgt von Main process exited, code=exited, status=203/EXEC.

No such file or directory ist irreführend, denn die Meldung hat drei Ursachen. Erstens: die Datei existiert wirklich nicht, meist wegen eines Tippfehlers oder weil das Binary unter /usr/local/bin statt /usr/bin liegt. Zweitens: das Executable-Bit fehlt, dann meldet das Journal Permission denied. Drittens, der fieseste Fall: die Datei ist ausführbar, aber ihre Shebang-Zeile zeigt ins Leere. Ein Skript mit #!/usr/bin/python scheitert auf Debian 12 und neuer genau so, weil dort nur /usr/bin/python3 existiert. Der Kernel meldet das Fehlen des Interpreters, systemd reicht es als Fehlen des Skripts weiter.

Die Prüfung dauert drei Sekunden:

ls -l /opt/kh-demo/run.sh
head -n 1 /opt/kh-demo/run.sh

Relative Pfade und PATH

ExecStart=node server.js funktioniert nicht, auch nicht mit gesetztem WorkingDirectory=. Der Fehler beim Laden lautet Neither a valid executable name nor an absolute path. Nur der erste Eintrag muss absolut sein, die Argumente danach dürfen relativ bleiben. Richtig ist ExecStart=/usr/bin/node server.js, wobei server.js relativ zum WorkingDirectory aufgelöst wird. Den korrekten Pfad liefert command -v:

command -v bash

Vorsicht bei Version-Managern: unter nvm liefert das einen Pfad wie /root/.nvm/versions/node/v22.14.0/bin/node, den es für den Dienstbenutzer nicht gibt. Laufzeiten für Dienste installiert man systemweit, etwa über NodeSource oder Adoptium Temurin.

Fehlende Umgebungsvariablen

Der Klassiker: im interaktiven Login läuft das Programm, als Dienst nicht. Der Grund ist, dass systemd keine Login-Shell startet. Weder /etc/profile noch ~/.bashrc noch ~/.profile werden gelesen, alles dort per export Gesetzte fehlt im Dienst. Der PATH eines Systemdienstes ist ein fester Minimalpfad, üblicherweise /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin.

Variablen kommen deshalb in die Unit oder in eine eigene Datei:

cat > /etc/kh-demo.env <<'EOF'
GREETING=servus
LANG=de_DE.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

Steht in der Ausgabe jetzt GREETING=servus statt GREETING=nicht gesetzt, ist die Datei angekommen. Kontrollieren lässt sich das auch direkt:

systemctl show kh-demo.service -p Environment -p EnvironmentFiles

Drei Fallen in solchen Dateien: Kommandosubstitution mit $(...) findet nicht statt, ein wörtlich gemeintes $ muss als $$ geschrieben werden, und Anführungszeichen landen im Wert, wo sie nicht hingehören.

Unit not found

Failed to start kh-demo.service: Unit kh-demo.service not found. bedeutet fast immer eines von drei Dingen: die Datei liegt im falschen Verzeichnis, sie hat die falsche Endung (.services statt .service) oder daemon-reload fehlt. ls -l /etc/systemd/system/ klärt die ersten beiden Fälle.

Unterschiede zwischen Debian 13, Debian 12, Ubuntu 24.04 und 22.04

Die Grundlagen, also [Unit], [Service], [Install], Type=simple, Restart=, User= und WorkingDirectory=, sind auf allen vier Systemen identisch. Die Unterschiede liegen in den Randbereichen.

Verfügbare Direktiven. Ubuntu 22.04 bringt systemd 249, Debian 12 systemd 252, Ubuntu 24.04 systemd 255 und Debian 13 systemd 257. Alles ab 253 fehlt auf den beiden älteren Systemen: Type=notify-reload (253) sowie RestartSteps=, RestartMaxDelaySec= und RestartMode=direct (254). Sie werden nicht als Fehler quittiert, sondern ignoriert. So entstehen Units, die auf einem System das Gewünschte tun und auf dem anderen scheinbar grundlos etwas anderes.

systemctl --version

Netzwerk. Ubuntu Server nutzt netplan mit systemd-networkd, Debian in der Standardinstallation ifupdown. Damit ist network-online.target auf Ubuntu ohne Zutun aussagekräftig und auf Debian nur nach dem Aktivieren von ifupdown-wait-online.service.

Interpreter- und Laufzeitpfade. Hier entstehen die meisten 203/EXEC-Fehler beim Übertragen einer Unit zwischen Systemen. Node.js liegt bei Debian 13 in 20.19 vor, bei Debian 12 in 18.20, bei Ubuntu 24.04 in 18.19 und bei Ubuntu 22.04 in 12.22. PHP ist 8.4 auf Debian 13, 8.2 auf Debian 12, 8.3 auf Ubuntu 24.04 und 8.1 auf Ubuntu 22.04. Bei Java ist der Sprung am größten: Debian 13 liefert nur openjdk-21-jre-headless, Debian 12 nur openjdk-17-jre-headless, Ubuntu 24.04 und 22.04 dagegen 8, 11, 17 und 21. Wer ExecStart=/usr/lib/jvm/java-17-openjdk-amd64/bin/java von Debian 12 auf Debian 13 kopiert, bekommt zuverlässig 203/EXEC.

Datenbanken. Debian liefert kein mysql-server, dort läuft immer MariaDB. Eine Unit mit After=mysql.service wartet auf Debian folglich auf einen Dienst, den es nicht gibt, und startet ohne jede Verzögerung, still und ohne Warnung. Richtig ist dort After=mariadb.service.

Ändern, zurücknehmen, aufräumen

Eigene Units ändert man direkt in der Datei plus daemon-reload. Für Units aus einem Paket nimmt man dagegen Drop-ins, damit das nächste Update die Anpassung nicht wegräumt:

systemctl edit kh-demo.service

Das legt /etc/systemd/system/kh-demo.service.d/override.conf an, in der nur die geänderten Direktiven stehen. Sonderfall: Listen-Direktiven wie ExecStart= werden nicht ersetzt, sondern ergänzt. Wer sie überschreiben will, muss die Liste zuerst leeren:

ExecStart=
ExecStart=/opt/kh-demo/run.sh --verbose

systemctl revert kh-demo.service entfernt alle Drop-ins wieder. Vollständiges Zurückbauen des Beispiels, in genau dieser Reihenfolge:

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

Achtung beim letzten Schritt: systemctl reset-failed ohne Argument setzt den Fehlerzustand aller Einheiten zurück, nicht nur den des Beispieldienstes. Auf einem System, auf dem noch andere Dienste im Zustand failed stehen, verschwinden damit auch deren Meldungen aus systemctl list-units --state=failed. Wer nur hier aufräumen will, nimmt die gezielte Form systemctl reset-failed kh-demo.service aus dem Abschnitt weiter oben.

Wer die Datei löscht, ohne vorher disable aufzurufen, hinterlässt einen toten Symlink in /etc/systemd/system/multi-user.target.wants/, der bei jedem daemon-reload als Warnung auftaucht. Aufgeräumt wird er mit find /etc/systemd/system -xtype l -delete. Dieser Aufruf entfernt allerdings jeden toten Symlink unterhalb von /etc/systemd/system, also auch Leichen fremder Dienste, die man vielleicht noch braucht. Sicherer ist erst ein Blick ohne -delete, oder gleich die gezielte Variante find /etc/systemd/system -xtype l -name 'kh-demo*' -delete. Optional noch Benutzer und Dateien:

userdel khdemo
rm -rf /opt/kh-demo /etc/kh-demo.env

Absoluter Pfad in ExecStart, passender Type, eigener Benutzer, gesetztes WorkingDirectory, Variablen über EnvironmentFile=: wer das beisammen hat, hat einen Dienst, der einen Reboot übersteht und im Fehlerfall verrät, was los war.

Häufige Fragen

Muss ich nach jeder Änderung an der Unit-Datei systemctl daemon-reload ausführen?
Ja. systemd hält die Unit-Dateien im Speicher, eine Änderung auf der Platte wirkt erst nach dem Reload. Wer ihn vergisst, bekommt beim nächsten systemctl status die Warnung "The unit file, source configuration file or drop-ins of ... changed on disk". Die Reihenfolge ist immer erst systemctl daemon-reload, dann systemctl restart. Der Reload allein startet nichts neu und verändert an laufenden Prozessen nichts.
Was bedeutet status=203/EXEC?
systemd konnte das unter ExecStart angegebene Programm nicht ausführen. Drei Ursachen kommen infrage: die Datei existiert nicht (Tippfehler oder falsches Verzeichnis), das Executable-Bit fehlt, oder die Shebang-Zeile eines Skripts zeigt auf einen Interpreter, den es nicht gibt, etwa #!/usr/bin/python auf Debian 12 oder neuer, wo nur /usr/bin/python3 existiert. Prüfen mit ls -l auf die Datei und head -n 1 auf die erste Zeile.
Warum startet mein Dienst nach dem Reboot nicht, obwohl systemctl start funktioniert?
Fast immer fehlt der Abschnitt [Install] mit WantedBy=multi-user.target, oder systemctl enable wurde nie ausgeführt. Der Nachweis ist der Symlink: ls -l /etc/systemd/system/multi-user.target.wants/name.service muss die Datei zeigen, und systemctl is-enabled muss "enabled" ausgeben. Fehlt der Abschnitt, bricht enable mit "The unit files have no installation config" ab.
Type=simple oder Type=forking?
Type=simple, wenn das Programm im Vordergrund bleibt, was für praktisch alle modernen Anwendungen gilt und der Standard ist. Type=forking nur, wenn sich das Programm zwingend selbst in den Hintergrund legt, dann zusätzlich mit PIDFile=. Die meisten Daemons haben einen Schalter wie --foreground oder --no-daemon, und mit dem ist Type=simple die bessere Wahl. Falsch kombiniert zeigt sich das als "inactive (dead)" direkt nach dem Start oder als Timeout nach 90 Sekunden.
Warum findet mein Dienst seine Umgebungsvariablen nicht?
systemd startet keine Login-Shell. Weder /etc/profile noch ~/.bashrc noch ~/.profile werden gelesen, alles dort per export Gesetzte fehlt. Der PATH ist ein fester Minimalpfad. Variablen gehören deshalb als Environment= in die Unit oder in eine Datei, die über EnvironmentFile= geladen wird. Dort stehen reine KEY=VALUE-Zeilen ohne export, Kommandosubstitution findet nicht statt. Kontrolle mit systemctl show name.service -p Environment.
Der Dienst reagiert nicht mehr auf systemctl start, was tun?
Wahrscheinlich hat die Startbremse zugeschlagen: standardmäßig sind fünf Startversuche in zehn Sekunden erlaubt, danach meldet das Journal "Start request repeated too quickly" und der Dienst bleibt failed. systemctl reset-failed name.service setzt den Zähler zurück. Achtung: auch manuelle systemctl restart-Aufrufe zählen mit, wer beim Debuggen schnell hintereinander neu startet, löst die Bremse selbst aus.

systemd Linux Debian Ubuntu Server-Administration Autostart journalctl Unit-Datei Root-Server