Creare un servizio systemd personalizzato e avviarlo automaticamente al boot
Il file unit riga per riga: Type, User, WorkingDirectory, Restart e network-online.target. Con i messaggi di errore alla lettera e la prova che il servizio sopravvive davvero al riavvio.
Un programma che dopo un riavvio non torna su da solo, su un server non è un servizio ma un rischio. nohup, screen e tmux tengono in vita un processo soltanto finché non succede niente. Questo articolo scrive un file unit riga per riga, mostra i messaggi di errore alla lettera e spiega come uscirne quando il servizio resta bloccato in un loop di riavvio.
Tutti i comandi vengono eseguiti come root. Testato su Debian 13 (systemd 257), Debian 12 (252), Ubuntu 24.04 LTS (255) e Ubuntu 22.04 LTS (249). Dove i quattro sistemi si comportano in modo diverso, lo trovi segnalato nel testo.
Che cosa fa un servizio systemd e che cosa non fanno nohup e screen
La differenza non è una questione di comodità, è una questione di responsabilità. systemd avvia il processo al boot in un ordine definito, lo mette in un cgroup dedicato, raccoglie stdout e stderr nel journal, lo riavvia dopo un crash e lo termina in modo pulito allo spegnimento con SIGTERM. Il cgroup è quello che rimpiangi di più: uno script lanciato con nohup lascia dietro di sé processi figli orfani, mentre una unit smonta il proprio gruppo per intero.
Preparazione: programma, utente e directory
Prima che nasca la unit deve funzionare ciò che la unit dovrà avviare. Come esempio usiamo uno script che scrive su stdout. Ed è proprio questo il punto: sotto systemd un servizio non scrive i log in un file per conto suo, scrive su stdout e systemd li porta nel journal.
mkdir -p /opt/kh-demo
cat > /opt/kh-demo/run.sh <<'EOF'
#!/bin/bash
set -euo pipefail
while true; do
echo "kh-demo è attivo, $(date --iso-8601=seconds), PWD=$PWD, GREETING=${GREETING:-non impostato}"
sleep 10
done
EOF
chmod +x /opt/kh-demo/run.sh
Poi un utente di sistema dedicato: --system assegna un UID sotto 1000, /usr/sbin/nologin impedisce gli accessi interattivi.
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
Il test decisivo prima del file unit: il programma funziona sotto questo utente? Se salti questo passaggio, più tardi ti ritrovi a fare debug di systemd anche se il problema sta nel programma.
timeout 3 runuser -u khdemo -- /opt/kh-demo/run.sh; echo "Exitcode $?"
Exitcode 124 qui è il risultato desiderato: timeout ha interrotto un programma ancora in esecuzione. Qualsiasi altro valore significa che lo script è morto da solo, e in quel caso la colpa non è di systemd.
Il file unit riga per riga
Le unit che scrivi tu vanno in /etc/systemd/system/, non in /lib/systemd/system/ o /usr/lib/systemd/system/: queste due directory appartengono al gestore dei pacchetti e vengono sovrascritte al prossimo 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
Sezione [Unit]
Description= è il testo che vedi in systemctl status. Non ha alcun effetto tecnico, ma decide se alle tre di notte capisci al volo che cosa è andato in failed. Scrivi una frase, non una parola.
After= stabilisce solo l'ordine, non la dipendenza. After=network-online.target significa: se questo target viene comunque avviato durante questo boot, aspettalo. Non significa che verrà avviato.
Wants= lo richiede davvero, e lo fa come dipendenza debole. Se il target fallisce, il servizio parte lo stesso. La controparte Requires= trascinerebbe il servizio nell'errore insieme al target. Per la maggior parte delle applicazioni Wants= è la scelta giusta, perché un servizio che non parte affatto a causa di un timeout di rete è peggio di uno che gira brevemente a vuoto e torna su grazie a Restart=.
Sezione [Service]
User= e Group= impostano l'identità del processo. Senza queste righe il servizio gira come root. Su Debian e Ubuntu useradd crea per impostazione predefinita un gruppo con lo stesso nome, per questo Group=khdemo è corretto.
WorkingDirectory= è la directory di lavoro del processo. Senza questa indicazione il servizio parte in /. Qualsiasi programma che cerchi file di configurazione, template o plugin relativi alla directory corrente affonda immediatamente. Se la directory indicata non esiste, l'avvio termina con 200/CHDIR.
EnvironmentFile= carica le variabili da un file. Il segno meno davanti al percorso rende il file opzionale. Senza il meno l'avvio fallisce quando il file manca. Il contenuto sono semplici righe KEY=VALUE, non shell. Un export davanti è sbagliato, e PORT=$BASE_PORT non viene risolto.
ExecStart= richiede un percorso assoluto al file eseguibile. È la regola su cui inciampa la maggior parte delle persone, per questo più avanti c'è una sezione dedicata.
TimeoutStopSec= limita quanto systemd aspetta dopo il SIGTERM prima che arrivi SIGKILL. Il valore predefinito è 90 secondi.
SyslogIdentifier= imposta il nome sotto cui le righe compaiono nel journal. Senza questa riga il mittente si chiama run.sh, il che è inutilizzabile per filtrare.
NoNewPrivileges=true vieta al processo e a tutti i suoi figli di guadagnare privilegi tramite binari setuid. PrivateTmp=true dà al servizio una /tmp tutta sua. ProtectSystem=full monta /usr, /boot e /etc in sola lettura. Il livello strict va oltre e richiede poi StateDirectory= oppure ReadWritePaths= per tutto ciò che deve restare scrivibile.
Sezione [Install]
WantedBy=multi-user.target risponde alla domanda di quando il servizio debba partire automaticamente. multi-user.target è il normale funzionamento multiutente senza interfaccia grafica, cioè esattamente quello che raggiunge un server. Solo systemctl enable valuta questa sezione e crea il symlink. Se [Install] manca, enable si interrompe con The unit files have no installation config (WantedBy=, RequiredBy=, Also=, Alias= settings in the [Install] section, and DefaultInstance= for template units). Il servizio si lascia comunque avviare a mano, ma dopo un riavvio non torna più.
Type=simple, Type=exec e Type=forking
Il Type= risponde esattamente a una domanda: da che cosa capisce systemd che il servizio è partito?
Con Type=simple il servizio conta come avviato non appena il processo è stato creato, quindi dopo la fork() e ancora prima che il programma venga eseguito. È il valore predefinito ed è corretto per quasi tutti i programmi moderni, che restano in primo piano.
Con Type=exec systemd aspetta in più che execve() sia andata a buon fine. La differenza pratica è notevole: con Type=simple il comando systemctl start segnala successo anche se il binario non esiste affatto, e l'errore compare solo dopo, nel journal. Con Type=exec in esattamente questo caso l'avvio fallisce subito. Disponibile da systemd 240 e quindi su tutte e quattro le distribuzioni.
Con Type=forking systemd presuppone che il programma si sposti da solo in background: il processo avviato termina, un figlio continua a girare, e solo quella terminazione vale come segnale di avvio. È il classico comportamento dei demoni Unix. Chi usa questo tipo, di regola ha bisogno anche di PIDFile= con percorso assoluto, altrimenti systemd tira a indovinare quale dei processi rimasti sia il processo principale.
La raccomandazione è netta: usa Type=forking solo se il programma proprio non si lascia convincere altrimenti. Quasi ogni software ha un'opzione apposita, spesso --foreground, -D FOREGROUND, --no-daemon oppure daemon off; nella configurazione. Primo piano più Type=simple rende la unit più corta, il logging finisce nel journal e Restart= funziona in modo affidabile.
La combinazione sbagliata tipica: un programma che si mette da solo in background, sotto Type=simple. systemd vede terminare il processo di avvio, considera concluso il servizio e uccide anche i figli. Il sintomo è Active: inactive (dead) subito dopo un systemctl start che non ha segnalato alcun errore. Il caso opposto, un programma in primo piano sotto Type=forking, lascia systemd in attesa di una fine che non arriva mai: Job for foo.service failed because a timeout was exceeded, dopo 90 secondi.
Collegare correttamente After=network-online.target
Qui quasi ogni guida si ferma troppo presto. network.target significa soltanto che la gestione della rete è stata avviata, non che sia configurato un indirizzo IP. Se ti serve un indirizzo raggiungibile, per esempio perché il programma si lega a un IP fisso, vuoi network-online.target.
Questo target però non viene raggiunto da solo. Presuppone che sia attivo un servizio di attesa adatto, e quale sia dipende dalla gestione di rete in uso:
systemctl list-unit-files 'systemd-networkd-wait-online.service' 'NetworkManager-wait-online.service' 'ifupdown-wait-online.service' --no-pager
Su Ubuntu Server 22.04 e 24.04 la rete passa per netplan con systemd-networkd, lì systemd-networkd-wait-online.service è attivo e network-online.target ha un significato reale. Su un'installazione Debian classica con ifupdown il servizio ifupdown-wait-online.service esiste, ma non è attivato. Il target risulta quindi raggiunto subito, e l'attesa su cui contavi non avviene mai.
Su Debian 12 e 13 con ifupdown il servizio di attesa si attiva una volta sola, se serve:
systemctl enable ifupdown-wait-online.service
Attivare due servizi di attesa contemporaneamente non è una buona idea, perché entrambi aspettano la rispettiva gestione di rete e uno finisce per forza nel timeout di 90 secondi. È la causa più frequente di un server che all'improvviso impiega un minuto e mezzo in più per avviarsi. Più robusto di qualsiasi ordinamento è comunque un programma che sopporta una connessione mancante all'avvio, in combinazione con Restart=on-failure.
Restart, RestartSec e il freno di avvio
Restart=on-failure riavvia in caso di exit code diverso da zero, in caso di un segnale come SIGSEGV e in caso di timeout del watchdog, ma non dopo un exit 0 e non dopo uno systemctl stop manuale. Restart=always riavvia anche dopo una terminazione pulita, ed è il modo più rapido per costruirsi un loop infinito.
Contro questo systemd ha un freno integrato: per impostazione predefinita sono consentiti cinque tentativi di avvio in dieci secondi (StartLimitBurst=5, StartLimitIntervalSec=10s). Dopo di che systemd si arrende e segnala:
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.
Il servizio resta poi nello stato failed e non reagisce più nemmeno a systemctl start, finché il contatore non viene azzerato:
systemctl reset-failed kh-demo.service
Due dettagli qui costano tempo con regolarità. Primo: StartLimitBurst= e StartLimitIntervalSec= vanno nella sezione [Unit], non in [Service]. Nella sezione sbagliata vengono ignorati, con un avviso nel journal ma senza errore. Secondo: contano anche le chiamate manuali a systemctl restart, e chi durante il debug riavvia cinque volte fa scattare il freno da solo.
Regola pratica: StartLimitIntervalSec maggiore di RestartSec moltiplicato per StartLimitBurst.
Su Debian 13 e Ubuntu 24.04 esistono in più RestartSteps= e RestartMaxDelaySec= (da systemd 254) per tempi di attesa a crescita esponenziale. Su Debian 12 e Ubuntu 22.04 queste direttive non esistono e vengono ignorate.
Attivare, avviare e dimostrare che funziona
Prima del primo avvio conviene il controllo della sintassi. Trova errori di battitura nei nomi delle direttive, sezioni scritte male e programmi mancanti, senza avviare nulla:
systemd-analyze verify /etc/systemd/system/kh-demo.service
Nessun output significa che è tutto a posto. Un errore di battitura come WorkingDirectiry= produce, a seconda della versione di systemd, Unknown key name 'WorkingDirectiry' in section 'Service', ignoring oppure Unknown key 'WorkingDirectiry' in section [Service], ignoring. È qui che nascono gli errori silenziosi: systemd ignora le chiavi sconosciute, il servizio parte, ma si comporta diversamente da come ti aspetti.
systemctl daemon-reload
daemon-reload rilegge i file unit, ma non riavvia niente. Chi si dimentica il reload, al successivo systemctl status si trova davanti: Warning: The unit file, source configuration file or drop-ins of kh-demo.service changed on disk. Run 'systemctl daemon-reload' to reload units. Ricorda l'ordine: prima daemon-reload, poi restart.
systemctl enable kh-demo.service
systemctl start kh-demo.service
I due comandi insieme si fanno con systemctl enable --now kh-demo.service.
Adesso la parte che la maggior parte delle guide tralascia: la prova che ha funzionato davvero, non solo che non è comparso un errore. Quattro riscontri indipendenti.
Primo, il symlink esiste. È l'intero meccanismo dietro enable. Se manca, dopo il riavvio il servizio non parte, qualunque cosa dica status in questo momento.
ls -l /etc/systemd/system/multi-user.target.wants/kh-demo.service
systemctl is-enabled kh-demo.service
Secondo, lo stato. Quello che conta è la parentesi nella riga Active:. active (running) significa che un processo è in esecuzione. active (exited) significa che il programma è finito e non c'è più nessuno. Per un servizio permanente questo è un errore, anche se accanto compare il verde.
systemctl status kh-demo.service --no-pager
systemctl is-active kh-demo.service
Terzo, il journal. Non solo se arrivano righe, ma se arrivano quelle giuste.
journalctl -u kh-demo.service -n 20 --no-pager
Per seguire in tempo reale: journalctl -u kh-demo.service -f. Solo l'ultimo boot: -b. Intervallo di tempo: --since "-1h". Di più nell'articolo journalctl: analizzare i log sotto systemd.
Quarto, la configurazione effettiva. Non il file, ma quello che systemd ne ha ricavato. La differenza conta non appena entrano in gioco i drop-in.
systemctl show kh-demo.service -p ExecStart -p User -p WorkingDirectory -p Restart -p MainPID
systemctl cat kh-demo.service
La prova definitiva resta un riavvio vero. Dopo, questo comando mostra se qualcosa è rimasto per strada:
systemctl list-units --type=service --state=failed --no-pager
Gli errori più frequenti alla lettera
systemd segnala gli errori di avvio tramite exit code propri sopra il 200. Il numero nello status dice già dove devi cercare.
| Codice | Significato | Causa |
|---|---|---|
| 200/CHDIR | EXIT_CHDIR | WorkingDirectory= non esiste oppure l'utente non può entrarci |
| 203/EXEC | EXIT_EXEC | ExecStart= non trovato, non eseguibile o interprete sbagliato |
| 216/GROUP | EXIT_GROUP | Group= non esiste |
| 217/USER | EXIT_USER | User= non esiste |
| 219/CGROUP | EXIT_CGROUP | non è stato possibile creare il cgroup |
| 238/STATE_DIRECTORY | EXIT_STATE_DIRECTORY | StateDirectory= esiste già con il proprietario sbagliato |
203/EXEC: il percorso ExecStart sbagliato
Nel journal compare una riga di questa forma: kh-demo.service: Failed at step EXEC spawning /opt/kh-demo/run.sh: No such file or directory, seguita da Main process exited, code=exited, status=203/EXEC.
No such file or directory è fuorviante, perché il messaggio ha tre cause. Primo: il file davvero non esiste, di solito per un errore di battitura o perché il binario si trova in /usr/local/bin invece che in /usr/bin. Secondo: manca il bit di esecuzione, e allora il journal segnala Permission denied. Terzo, il caso più infido: il file è eseguibile, ma la sua riga shebang punta nel vuoto. Uno script con #!/usr/bin/python fallisce su Debian 12 e successivi esattamente per questo, perché lì esiste solo /usr/bin/python3. Il kernel segnala l'assenza dell'interprete, systemd la passa avanti come assenza dello script.
Il controllo dura tre secondi:
ls -l /opt/kh-demo/run.sh
head -n 1 /opt/kh-demo/run.sh
Percorsi relativi e PATH
ExecStart=node server.js non funziona, nemmeno con WorkingDirectory= impostato. L'errore in fase di caricamento recita Neither a valid executable name nor an absolute path. Solo la prima voce deve essere assoluta, gli argomenti che seguono possono restare relativi. La forma corretta è ExecStart=/usr/bin/node server.js, dove server.js viene risolto rispetto alla WorkingDirectory. Il percorso corretto te lo fornisce command -v:
command -v bash
Attenzione ai version manager: sotto nvm il comando restituisce un percorso come /root/.nvm/versions/node/v22.14.0/bin/node, che per l'utente del servizio non esiste. I runtime destinati ai servizi si installano a livello di sistema, per esempio tramite NodeSource o Adoptium Temurin.
Variabili d'ambiente mancanti
Il classico: nel login interattivo il programma funziona, come servizio no. Il motivo è che systemd non avvia una login shell. Non vengono letti né /etc/profile né ~/.bashrc né ~/.profile, quindi tutto ciò che lì è impostato con export manca nel servizio. Il PATH di un servizio di sistema è un percorso minimo fisso, di solito /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin.
Le variabili vanno perciò nella unit oppure in un file dedicato:
cat > /etc/kh-demo.env <<'EOF'
GREETING=ciao
LANG=it_IT.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
Se nell'output ora compare GREETING=ciao invece di GREETING=non impostato, il file è arrivato a destinazione. Puoi controllarlo anche direttamente:
systemctl show kh-demo.service -p Environment -p EnvironmentFiles
Tre trappole in file di questo genere: la sostituzione di comando con $(...) non avviene, un $ inteso alla lettera va scritto come $$, e le virgolette finiscono dentro il valore, dove non dovrebbero stare.
Unit not found
Failed to start kh-demo.service: Unit kh-demo.service not found. significa quasi sempre una di tre cose: il file si trova nella directory sbagliata, ha l'estensione sbagliata (.services invece di .service) oppure manca il daemon-reload. ls -l /etc/systemd/system/ chiarisce i primi due casi.
Differenze tra Debian 13, Debian 12, Ubuntu 24.04 e 22.04
Le basi, cioè [Unit], [Service], [Install], Type=simple, Restart=, User= e WorkingDirectory=, sono identiche su tutti e quattro i sistemi. Le differenze stanno ai margini.
Direttive disponibili. Ubuntu 22.04 porta systemd 249, Debian 12 systemd 252, Ubuntu 24.04 systemd 255 e Debian 13 systemd 257. Tutto ciò che arriva dalla 253 in poi manca sui due sistemi più vecchi: Type=notify-reload (253) così come RestartSteps=, RestartMaxDelaySec= e RestartMode=direct (254). Non vengono segnalate come errore, vengono semplicemente ignorate. Nascono così unit che su un sistema fanno quello che vuoi e sull'altro, apparentemente senza motivo, fanno qualcos'altro.
systemctl --version
Rete. Ubuntu Server usa netplan con systemd-networkd, Debian nell'installazione standard usa ifupdown. Perciò su Ubuntu network-online.target è significativo senza interventi, su Debian solo dopo aver attivato ifupdown-wait-online.service.
Percorsi di interpreti e runtime. È qui che nasce la maggior parte degli errori 203/EXEC quando si sposta una unit da un sistema all'altro. Node.js è disponibile nella versione 20.19 su Debian 13, 18.20 su Debian 12, 18.19 su Ubuntu 24.04 e 12.22 su Ubuntu 22.04. PHP è 8.4 su Debian 13, 8.2 su Debian 12, 8.3 su Ubuntu 24.04 e 8.1 su Ubuntu 22.04. Con Java il salto è il più grande: Debian 13 fornisce solo openjdk-21-jre-headless, Debian 12 solo openjdk-17-jre-headless, mentre Ubuntu 24.04 e 22.04 offrono 8, 11, 17 e 21. Chi copia ExecStart=/usr/lib/jvm/java-17-openjdk-amd64/bin/java da Debian 12 a Debian 13 si becca puntualmente un 203/EXEC.
Database. Debian non fornisce mysql-server, lì gira sempre MariaDB. Una unit con After=mysql.service su Debian aspetta di conseguenza un servizio che non esiste, e parte senza alcun ritardo, in silenzio e senza avvisi. Lì la forma corretta è After=mariadb.service.
Modificare, annullare, ripulire
Le unit che scrivi tu si modificano direttamente nel file, più daemon-reload. Per le unit che arrivano da un pacchetto si usano invece i drop-in, così il prossimo aggiornamento non spazza via la modifica:
systemctl edit kh-demo.service
Il comando crea /etc/systemd/system/kh-demo.service.d/override.conf, dove stanno solo le direttive modificate. Caso particolare: le direttive di tipo lista come ExecStart= non vengono sostituite, vengono aggiunte in coda. Chi vuole sovrascriverle deve prima svuotare la lista:
ExecStart=
ExecStart=/opt/kh-demo/run.sh --verbose
systemctl revert kh-demo.service rimuove di nuovo tutti i drop-in. Smontaggio completo dell'esempio, esattamente in quest'ordine:
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
Attenzione all'ultimo passaggio: systemctl reset-failed senza argomento azzera lo stato di errore di tutte le unit, non solo quello del servizio di esempio. Su un sistema dove altri servizi sono ancora nello stato failed, spariscono così anche i loro messaggi da systemctl list-units --state=failed. Chi vuole fare pulizia solo qui usa la forma mirata systemctl reset-failed kh-demo.service della sezione più sopra.
Chi cancella il file senza aver chiamato prima disable lascia dietro di sé un symlink morto in /etc/systemd/system/multi-user.target.wants/, che a ogni daemon-reload compare come avviso. Si ripulisce con find /etc/systemd/system -xtype l -delete. Questa chiamata rimuove però ogni symlink morto sotto /etc/systemd/system, quindi anche i resti di altri servizi che magari ti servono ancora. È più sicuro dare prima un'occhiata senza -delete, oppure passare subito alla variante mirata find /etc/systemd/system -xtype l -name 'kh-demo*' -delete. Facoltativamente anche utente e file:
userdel khdemo
rm -rf /opt/kh-demo /etc/kh-demo.env
Percorso assoluto in ExecStart, Type adatto, utente dedicato, WorkingDirectory impostato, variabili tramite EnvironmentFile=: chi ha messo insieme questi pezzi ha un servizio che sopravvive a un riavvio e che, quando qualcosa va storto, dice anche che cosa è successo.
Domande frequenti
Devo eseguire systemctl daemon-reload dopo ogni modifica al file unit?
Che cosa significa status=203/EXEC?
Perché il mio servizio non parte dopo il riavvio, anche se systemctl start funziona?
Type=simple oppure Type=forking?
Perché il mio servizio non trova le sue variabili d'ambiente?
Il servizio non reagisce più a systemctl start, che fare?
2026 KernelHost GmbH. Tutti i diritti riservati. Questa guida è protetta dal diritto d'autore. La ripubblicazione su altri siti web, anche parziale o in forma modificata, non è consentita senza il nostro consenso scritto. Le citazioni con indicazione della fonte e un link sono le benvenute.

