Crear un servicio systemd propio y ejecutarlo automáticamente al iniciar el sistema
El archivo de unidad línea por línea: Type, User, WorkingDirectory, Restart y network-online.target. Con los mensajes de error literales y la prueba de que el servicio sobrevive de verdad a un reinicio.
Un programa que no vuelve por sí solo después de un reinicio no es un servicio en un servidor, es un riesgo. nohup, screen y tmux mantienen vivo un proceso mientras no ocurra nada. Este artículo escribe un archivo de unidad propio línea por línea, muestra los mensajes de error literales y explica cómo salir del apuro cuando el servicio se queda atrapado en un bucle de arranque.
Todos los comandos se ejecutan como root. Probado en Debian 13 (systemd 257), Debian 12 (252), Ubuntu 24.04 LTS (255) y Ubuntu 22.04 LTS (249). Allí donde los cuatro se diferencian, se indica en el punto correspondiente.
Qué aporta un servicio systemd que nohup y screen no aportan
La diferencia no es comodidad, sino responsabilidad. systemd arranca el proceso durante el boot en un orden definido, lo coloca en una cgroup propia, recoge stdout y stderr en el journal, lo reinicia tras una caída y lo termina limpiamente con SIGTERM al apagar el sistema. La cgroup es lo que más se echa de menos: un script lanzado con nohup deja procesos hijos huérfanos, mientras que una unidad limpia su grupo por completo.
Preparación: programa, usuario y directorio
Antes de crear la unidad tiene que funcionar aquello que va a arrancar. Como ejemplo usamos un script que escribe en stdout. Ese es justamente el punto: un servicio bajo systemd no registra sus logs en un archivo por su cuenta, escribe en stdout y systemd lo lleva al journal.
mkdir -p /opt/kh-demo
cat > /opt/kh-demo/run.sh <<'EOF'
#!/bin/bash
set -euo pipefail
while true; do
echo "kh-demo funciona, $(date --iso-8601=seconds), PWD=$PWD, GREETING=${GREETING:-sin definir}"
sleep 10
done
EOF
chmod +x /opt/kh-demo/run.sh
Después, un usuario de sistema propio: --system asigna un UID por debajo de 1000 y /usr/sbin/nologin impide los inicios de sesión interactivos.
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
La prueba decisiva antes de escribir el archivo de unidad: ¿funciona el programa con ese usuario? Quien se salta este paso acaba depurando systemd más tarde, aunque el problema esté en el programa.
timeout 3 runuser -u khdemo -- /opt/kh-demo/run.sh; echo "Exitcode $?"
Exitcode 124 es aquí el resultado deseado: timeout ha interrumpido un programa que estaba en ejecución. Cualquier otro valor significa que el script murió por sí solo, y entonces el fallo no está en systemd.
El archivo de unidad línea por línea
Las unidades propias van en /etc/systemd/system/, no en /lib/systemd/system/ ni en /usr/lib/systemd/system/: esos dos directorios pertenecen al gestor de paquetes y se sobrescriben en la siguiente 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
Sección [Unit]
Description= es el texto que aparece en systemctl status. No tiene ningún efecto técnico, pero decide si a las tres de la madrugada entiendes qué es lo que ha fallado. Una frase, no una palabra.
After= define únicamente el orden, no la dependencia. After=network-online.target significa: si este target va a arrancar de todos modos en este proceso de boot, espera a que lo haga. No significa que vaya a arrancar.
Wants= sí lo solicita de verdad, y lo hace como dependencia débil. Si el target falla, el servicio arranca igualmente. Su contrapartida Requires= arrastraría al servicio al mismo fallo. Para la mayoría de las aplicaciones, Wants= es lo correcto, porque un servicio que ni siquiera llega a arrancar por culpa de un timeout de red es peor que uno que trabaja un momento en vacío y vuelve gracias a Restart=.
Sección [Service]
User= y Group= definen la identidad del proceso. Sin estas líneas, el servicio se ejecuta como root. En Debian y Ubuntu, useradd crea por defecto un grupo con el mismo nombre, por eso Group=khdemo encaja.
WorkingDirectory= es el directorio de trabajo del proceso. Sin este dato, el servicio arranca en /. Cualquier programa que busque archivos de configuración, plantillas o plugins de forma relativa al directorio actual se hunde de inmediato. Si el directorio indicado no existe, el arranque termina con 200/CHDIR.
EnvironmentFile= carga variables desde un archivo. El signo menos delante de la ruta hace que el archivo sea opcional. Sin ese menos, el arranque falla cuando el archivo no está. El contenido son simples líneas KEY=VALUE, no es un shell. Poner export delante es un error, y PORT=$BASE_PORT no se resuelve.
ExecStart= necesita una ruta absoluta al archivo ejecutable. Esta es la regla con la que más gente tropieza, por eso tiene su propia sección más abajo.
TimeoutStopSec= limita cuánto espera systemd tras el SIGTERM antes de enviar SIGKILL. El valor por defecto son 90 segundos.
SyslogIdentifier= establece el nombre con el que aparecen las líneas en el journal. Sin esta línea, el remitente se llama run.sh, lo que resulta inservible a la hora de filtrar.
NoNewPrivileges=true prohíbe al proceso y a todos sus hijos ganar privilegios a través de binarios setuid. PrivateTmp=true le da al servicio un /tmp propio. ProtectSystem=full monta /usr, /boot y /etc en modo solo lectura. El nivel strict va más lejos y entonces exige StateDirectory= o ReadWritePaths= para todo lo que deba ser escribible.
Sección [Install]
WantedBy=multi-user.target responde a la pregunta de cuándo debe arrancar el servicio de forma automática. multi-user.target es el funcionamiento multiusuario normal sin interfaz gráfica, es decir, el estado al que llega un servidor. Solo systemctl enable evalúa esta sección y crea el symlink. Si falta [Install], enable aborta con The unit files have no installation config (WantedBy=, RequiredBy=, Also=, Alias= settings in the [Install] section, and DefaultInstance= for template units). El servicio se puede arrancar entonces a mano, pero después de un reinicio no vuelve nunca.
Type=simple, Type=exec y Type=forking
El Type= responde exactamente a una pregunta: ¿en qué reconoce systemd que el servicio ya ha arrancado?
Con Type=simple, el servicio se considera arrancado en cuanto se ha creado el proceso, es decir, después del fork() y todavía antes de que el programa se ejecute. Es el valor por defecto y el correcto para casi todos los programas modernos que se mantienen en primer plano.
Con Type=exec, systemd espera además a que execve() haya tenido éxito. La diferencia práctica es considerable: con Type=simple, systemctl start informa de éxito aunque el binario ni siquiera exista, y el error solo aparece después en el journal. Con Type=exec, el arranque falla de inmediato justo en ese caso. Disponible desde systemd 240 y, por tanto, en las cuatro distribuciones.
Con Type=forking, systemd da por hecho que el propio programa se retira al segundo plano: el proceso lanzado termina, un hijo sigue en ejecución y solo ese final cuenta como señal de arranque. Es el comportamiento clásico de un daemon de Unix. Quien use este tipo necesita casi siempre además PIDFile= con ruta absoluta; si no, systemd adivina cuál de los procesos restantes es el principal.
La recomendación es clara: Type=forking solo cuando no haya manera de convencer al programa de lo contrario. Casi todo el software tiene una opción para ello, a menudo --foreground, -D FOREGROUND, --no-daemon o daemon off; en la configuración. En primer plano y con Type=simple, la unidad queda más corta, el logging acaba en el journal y Restart= funciona de forma fiable.
La combinación errónea típica: un programa que se pasa a sí mismo al segundo plano, bajo Type=simple. systemd ve terminar el proceso de arranque, da el servicio por finalizado y mata a los hijos con él. El síntoma es Active: inactive (dead) justo después de un systemctl start que no ha informado de ningún error. El caso contrario, un programa en primer plano bajo Type=forking, deja a systemd esperando un final que nunca llega: Job for foo.service failed because a timeout was exceeded, después de 90 segundos.
Conectar bien After=network-online.target
Aquí es donde casi todos los tutoriales se quedan cortos. network.target significa únicamente que la gestión de red ha arrancado, no que haya una dirección IP configurada. Si necesitas una dirección accesible, por ejemplo porque el programa se enlaza a una IP fija, lo que quieres es network-online.target.
Pero a este target no se llega solo. Presupone que hay activado un servicio de espera adecuado, y ese servicio varía según la gestión de red que uses:
systemctl list-unit-files 'systemd-networkd-wait-online.service' 'NetworkManager-wait-online.service' 'ifupdown-wait-online.service' --no-pager
En Ubuntu Server 22.04 y 24.04, la red funciona mediante netplan con systemd-networkd; allí systemd-networkd-wait-online.service está activo y network-online.target tiene un significado real. En una instalación clásica de Debian con ifupdown, el servicio ifupdown-wait-online.service existe, pero no está activado. El target se da entonces por alcanzado de inmediato, y la espera con la que contabas no llega a producirse.
En Debian 12 y 13 con ifupdown, el servicio de espera se activa una sola vez si hace falta:
systemctl enable ifupdown-wait-online.service
Activar dos servicios de espera a la vez no es buena idea, porque entonces cada uno espera a su propia gestión de red y uno de ellos acaba forzosamente en el timeout de 90 segundos. Esa es la causa más habitual de que un servidor tarde de repente un minuto y medio más en arrancar. En cualquier caso, más robusto que cualquier orden de arranque es un programa que tolere la falta de conexión al iniciarse, combinado con Restart=on-failure.
Restart, RestartSec y el freno de arranque
Restart=on-failure reinicia cuando el código de salida es distinto de cero, ante una señal como SIGSEGV y ante un timeout del watchdog, pero no tras un exit 0 ni tras un systemctl stop manual. Restart=always reinicia además tras una finalización limpia, y esa es la forma más rápida de construirse un bucle infinito.
Frente a eso, systemd tiene un freno incorporado: por defecto se permiten cinco intentos de arranque dentro de diez segundos (StartLimitBurst=5, StartLimitIntervalSec=10s). Después systemd se rinde y avisa:
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.
El servicio se queda entonces en estado failed y deja de responder incluso a systemctl start hasta que se restablece el contador:
systemctl reset-failed kh-demo.service
Dos detalles cuestan tiempo aquí de forma habitual. Primero, StartLimitBurst= y StartLimitIntervalSec= van en la sección [Unit], no en [Service]. En la sección equivocada se ignoran, con un aviso en el journal pero sin error. Segundo, las llamadas manuales a systemctl restart también cuentan: quien reinicia cinco veces mientras depura dispara el freno él mismo.
Regla práctica: StartLimitIntervalSec mayor que RestartSec multiplicado por StartLimitBurst.
En Debian 13 y Ubuntu 24.04 existen además RestartSteps= y RestartMaxDelaySec= (a partir de systemd 254) para tiempos de espera que crecen de forma exponencial. En Debian 12 y Ubuntu 22.04 esas directivas no existen y se ignoran.
Activar, arrancar y demostrar que funciona
Antes del primer arranque merece la pena comprobar la sintaxis. Encuentra erratas en los nombres de las directivas, secciones mal escritas y programas que faltan, sin arrancar nada:
systemd-analyze verify /etc/systemd/system/kh-demo.service
Que no haya salida significa que todo está en orden. Una errata como WorkingDirectiry= genera, según la versión de systemd, Unknown key name 'WorkingDirectiry' in section 'Service', ignoring o Unknown key 'WorkingDirectiry' in section [Service], ignoring. Aquí es donde nacen los fallos silenciosos: systemd ignora las claves desconocidas, el servicio arranca, pero se comporta de forma distinta a la esperada.
systemctl daemon-reload
daemon-reload vuelve a leer los archivos de unidad, pero no reinicia nada. Si te olvidas del reload, en el siguiente systemctl status te encuentras con: Warning: The unit file, source configuration file or drop-ins of kh-demo.service changed on disk. Run 'systemctl daemon-reload' to reload units. Recuerda el orden: primero daemon-reload, después restart.
systemctl enable kh-demo.service
systemctl start kh-demo.service
Ambas cosas juntas se consiguen con systemctl enable --now kh-demo.service.
Ahora la parte que la mayoría de los tutoriales se salta: la prueba de que realmente ha funcionado y no solo de que no apareció ningún error. Cuatro comprobaciones independientes.
Primero, el symlink existe. Es todo el mecanismo que hay detrás de enable. Si falta, el servicio no arranca tras el reinicio, diga lo que diga status en ese momento.
ls -l /etc/systemd/system/multi-user.target.wants/kh-demo.service
systemctl is-enabled kh-demo.service
Segundo, el estado. Lo decisivo es lo que aparece entre paréntesis en la línea Active:. active (running) significa que hay un proceso en ejecución. active (exited) significa que el programa ha terminado y ya no queda nadie. Para un servicio permanente eso es un fallo, aunque al lado se muestre en verde.
systemctl status kh-demo.service --no-pager
systemctl is-active kh-demo.service
Tercero, el journal. No solo si llegan líneas, sino si llegan las correctas.
journalctl -u kh-demo.service -n 20 --no-pager
Leer en tiempo real: journalctl -u kh-demo.service -f. Solo el último proceso de boot: -b. Intervalo de tiempo: --since "-1h". Más sobre esto en el artículo journalctl: analizar los logs bajo systemd.
Cuarto, la configuración efectiva. No el archivo, sino lo que systemd ha hecho con él. La diferencia cuenta en cuanto entran drop-ins en juego.
systemctl show kh-demo.service -p ExecStart -p User -p WorkingDirectory -p Restart -p MainPID
systemctl cat kh-demo.service
La prueba definitiva sigue siendo un reinicio real. Después, este comando muestra si algo se ha quedado por el camino:
systemctl list-units --type=service --state=failed --no-pager
Los errores más frecuentes, con su texto literal
systemd notifica los fallos de arranque mediante códigos de salida propios por encima de 200. El número que aparece en status ya indica dónde hay que buscar.
| Código | Significado | Causa |
|---|---|---|
| 200/CHDIR | EXIT_CHDIR | WorkingDirectory= no existe o el usuario no puede entrar en él |
| 203/EXEC | EXIT_EXEC | ExecStart= no encontrado, no ejecutable o intérprete equivocado |
| 216/GROUP | EXIT_GROUP | Group= no existe |
| 217/USER | EXIT_USER | User= no existe |
| 219/CGROUP | EXIT_CGROUP | no se pudo crear la cgroup |
| 238/STATE_DIRECTORY | EXIT_STATE_DIRECTORY | StateDirectory= ya existe con un propietario incorrecto |
203/EXEC: la ruta ExecStart equivocada
En el journal aparece para esto una línea del tipo kh-demo.service: Failed at step EXEC spawning /opt/kh-demo/run.sh: No such file or directory, seguida de Main process exited, code=exited, status=203/EXEC.
No such file or directory es engañoso, porque el mensaje tiene tres causas. Primera: el archivo realmente no existe, casi siempre por una errata o porque el binario está en /usr/local/bin y no en /usr/bin. Segunda: falta el bit de ejecución, y entonces el journal informa de Permission denied. Tercera, la más traicionera: el archivo es ejecutable, pero su línea shebang apunta al vacío. Un script con #!/usr/bin/python falla exactamente así en Debian 12 y posteriores, porque allí solo existe /usr/bin/python3. El kernel informa de que falta el intérprete y systemd lo traslada como si faltara el script.
La comprobación dura tres segundos:
ls -l /opt/kh-demo/run.sh
head -n 1 /opt/kh-demo/run.sh
Rutas relativas y PATH
ExecStart=node server.js no funciona, ni siquiera con WorkingDirectory= definido. El error al cargar la unidad es Neither a valid executable name nor an absolute path. Solo la primera entrada tiene que ser absoluta, los argumentos posteriores pueden seguir siendo relativos. Lo correcto es ExecStart=/usr/bin/node server.js, donde server.js se resuelve de forma relativa al WorkingDirectory. La ruta correcta la da command -v:
command -v bash
Cuidado con los gestores de versiones: bajo nvm, eso devuelve una ruta como /root/.nvm/versions/node/v22.14.0/bin/node, que para el usuario del servicio no existe. Los entornos de ejecución de los servicios se instalan a nivel de todo el sistema, por ejemplo mediante NodeSource o Adoptium Temurin.
Variables de entorno que faltan
El clásico: en una sesión interactiva el programa funciona, como servicio no. El motivo es que systemd no arranca ninguna shell de inicio de sesión. No se leen ni /etc/profile, ni ~/.bashrc, ni ~/.profile, así que todo lo que allí se define con export falta en el servicio. El PATH de un servicio del sistema es una ruta mínima fija, normalmente /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin.
Por eso las variables van en la unidad o en un archivo propio:
cat > /etc/kh-demo.env <<'EOF'
GREETING=hola
LANG=es_ES.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 en la salida aparece ahora GREETING=hola en lugar de GREETING=sin definir, el archivo ha llegado. También se puede comprobar directamente:
systemctl show kh-demo.service -p Environment -p EnvironmentFiles
Tres trampas en este tipo de archivos: la sustitución de comandos con $(...) no se produce, un $ con significado literal tiene que escribirse como $$, y las comillas acaban dentro del valor, donde no pintan nada.
Unit not found
Failed to start kh-demo.service: Unit kh-demo.service not found. significa casi siempre una de estas tres cosas: el archivo está en el directorio equivocado, tiene la extensión equivocada (.services en lugar de .service) o falta el daemon-reload. ls -l /etc/systemd/system/ aclara los dos primeros casos.
Diferencias entre Debian 13, Debian 12, Ubuntu 24.04 y 22.04
Lo esencial, es decir [Unit], [Service], [Install], Type=simple, Restart=, User= y WorkingDirectory=, es idéntico en los cuatro sistemas. Las diferencias están en los márgenes.
Directivas disponibles. Ubuntu 22.04 trae systemd 249, Debian 12 systemd 252, Ubuntu 24.04 systemd 255 y Debian 13 systemd 257. Todo lo que llegó a partir de la versión 253 falta en los dos sistemas más antiguos: Type=notify-reload (253), así como RestartSteps=, RestartMaxDelaySec= y RestartMode=direct (254). No se devuelven como error, simplemente se ignoran. Así nacen unidades que en un sistema hacen lo que quieres y en el otro hacen otra cosa sin motivo aparente.
systemctl --version
Red. Ubuntu Server usa netplan con systemd-networkd; Debian, en la instalación estándar, ifupdown. Por eso network-online.target es significativo en Ubuntu sin hacer nada, y en Debian solo después de activar ifupdown-wait-online.service.
Rutas de intérpretes y entornos de ejecución. Aquí es donde nacen la mayoría de los errores 203/EXEC al trasladar una unidad de un sistema a otro. Node.js viene en la versión 20.19 en Debian 13, en la 18.20 en Debian 12, en la 18.19 en Ubuntu 24.04 y en la 12.22 en Ubuntu 22.04. PHP es 8.4 en Debian 13, 8.2 en Debian 12, 8.3 en Ubuntu 24.04 y 8.1 en Ubuntu 22.04. En Java el salto es el mayor de todos: Debian 13 solo ofrece openjdk-21-jre-headless, Debian 12 solo openjdk-17-jre-headless, mientras que Ubuntu 24.04 y 22.04 ofrecen 8, 11, 17 y 21. Quien copie ExecStart=/usr/lib/jvm/java-17-openjdk-amd64/bin/java de Debian 12 a Debian 13 recibe 203/EXEC sin falta.
Bases de datos. Debian no incluye mysql-server, allí siempre corre MariaDB. Una unidad con After=mysql.service espera en Debian, por tanto, a un servicio que no existe, y arranca sin ningún retardo, en silencio y sin aviso. Allí lo correcto es After=mariadb.service.
Modificar, revertir y limpiar
Las unidades propias se modifican directamente en el archivo, más un daemon-reload. Para las unidades que vienen de un paquete se usan en cambio drop-ins, para que la siguiente actualización no se lleve por delante tu ajuste:
systemctl edit kh-demo.service
Eso crea /etc/systemd/system/kh-demo.service.d/override.conf, donde solo figuran las directivas modificadas. Caso especial: las directivas de lista, como ExecStart=, no se sustituyen, se añaden. Quien quiera sobrescribirlas tiene que vaciar primero la lista:
ExecStart=
ExecStart=/opt/kh-demo/run.sh --verbose
systemctl revert kh-demo.service vuelve a eliminar todos los drop-ins. Desmontaje completo del ejemplo, exactamente en este orden:
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
Atención con el último paso: systemctl reset-failed sin argumento restablece el estado de error de todas las unidades, no solo el del servicio de ejemplo. En un sistema en el que haya otros servicios en estado failed, también desaparecen sus mensajes de systemctl list-units --state=failed. Si solo quieres limpiar aquí, usa la forma dirigida systemctl reset-failed kh-demo.service de la sección anterior.
Quien borre el archivo sin ejecutar antes disable deja un symlink muerto en /etc/systemd/system/multi-user.target.wants/, que aparece como aviso en cada daemon-reload. Se limpia con find /etc/systemd/system -xtype l -delete. Ahora bien, esa llamada elimina todos los symlinks muertos por debajo de /etc/systemd/system, es decir, también los restos de otros servicios que quizá todavía necesites. Es más seguro echar primero un vistazo sin -delete, o usar directamente la variante dirigida find /etc/systemd/system -xtype l -name 'kh-demo*' -delete. Opcionalmente, también el usuario y los archivos:
userdel khdemo
rm -rf /opt/kh-demo /etc/kh-demo.env
Ruta absoluta en ExecStart, el Type adecuado, un usuario propio, WorkingDirectory definido y variables mediante EnvironmentFile=: quien tenga todo eso en su sitio tiene un servicio que sobrevive a un reinicio y que, si algo falla, cuenta qué ha pasado.
Preguntas frecuentes
¿Tengo que ejecutar systemctl daemon-reload después de cada cambio en el archivo de unidad?
¿Qué significa status=203/EXEC?
¿Por qué mi servicio no arranca tras el reinicio aunque systemctl start funciona?
¿Type=simple o Type=forking?
¿Por qué mi servicio no encuentra sus variables de entorno?
El servicio ya no responde a systemctl start, ¿qué hago?
2026 KernelHost GmbH. Todos los derechos reservados. Esta guía está protegida por derechos de autor. Su publicación en otros sitios web, aunque sea de forma parcial o modificada, no está permitida sin nuestro consentimiento por escrito. Las citas con indicación de la fuente y un enlace son muy bienvenidas.

