systemd y cron: dejar un programa corriendo como servicio y programar tareas en Linux

Tienes un programa que funciona en tu terminal y quieres que quede corriendo en el servidor: que arranque solo al encender, que vuelva a levantarse si se cae y que puedas ver qué hizo anoche. O quieres que un respaldo se haga solo cada madrugada. En Linux, las dos cosas se resuelven con systemd y cron. Esta guía te lleva desde cero hasta un servicio propio y tareas programadas, con los errores que te vas a encontrar. Todas las órdenes se ejecutaron de verdad en un Debian 13 limpio, y las salidas están copiadas tal cual.

Qué es systemd y por qué es el proceso 1

Cuando enciendes un equipo con Linux, el kernel arranca un único programa y le da el número de proceso 1. Ese programa se encarga de arrancar todo lo demás: la red, el servidor SSH, la base de datos y, si se lo pides, tus propios programas. En casi todas las distribuciones actuales (Debian, Ubuntu, Fedora, Red Hat, Arch, openSUSE) ese proceso es systemd.

Terminal$ ps -p 1 -o pid,comm
    PID COMMAND
      1 systemd
$ systemctl --version | head -1
systemd 257 (257.13-1~deb13u1)
$ systemctl is-system-running
running

systemd no solo arranca programas: los vigila mientras corren, los reinicia si se caen, guarda lo que escriben en su diario (el journal) y los detiene en orden al apagar. Por eso, cuando quieres que un programa tuyo quede funcionando para siempre en un servidor, lo correcto no es dejarlo abierto en una terminal ni lanzarlo con nohup: es convertirlo en un servicio de systemd.

Quién arranca a quién. Al encender, el kernel de Linux arranca systemd, que es el proceso número 1. systemd arranca a su vez las unidades: un servicio como cron.service, tu programa como latido.service, una alarma como respaldo.timer y un grupo como multi-user.target. Cada pieza que systemd maneja es una unidad, y el final del nombre dice de qué tipo es.Quién arranca a quiénDel encendido a tu programakernel de Linuxsystemdproceso número 1 (PID 1)cron.serviceun serviciolatido.servicetu programarespaldo.timeruna alarmamulti-user.targetun grupoCada pieza que systemd maneja es una unidad.El final del nombre dice de qué tipo es.

Del encendido a tus programas: el kernel arranca systemd y systemd arranca las unidades. Esquema: decodigo.com.
En contenedores de Docker normalmente no hay systemd. Ahí el proceso 1 es tu propio programa y quien lo reinicia es Docker (--restart). Esta guía es para servidores y máquinas virtuales; para contenedores, mira la guía de Docker.

Las unidades y systemctl

Todo lo que systemd maneja se llama unidad y se describe en un archivo de texto. El final del nombre dice el tipo:

  • .service: un programa que corre (un servidor web, una base de datos, tu script).
  • .timer: una alarma que arranca un servicio en cierto momento; es la alternativa de systemd a cron.
  • .target: un grupo de unidades que marcan una etapa del arranque, como multi-user.target («el sistema ya está listo para usuarios»).
  • .socket, .mount, .path y otras, menos frecuentes al empezar.

Para hablar con systemd se usa systemctl. Estas son las órdenes que vas a usar casi siempre:

  • systemctl status nombre: estado, PID, memoria y las últimas líneas del diario.
  • sudo systemctl start nombre / stop / restart: arrancar, detener o reiniciar ahora.
  • sudo systemctl enable nombre / disable: que arranque (o no) solo al encender el equipo. enable --now hace las dos cosas a la vez.
  • systemctl is-active nombre y is-enabled nombre: respuestas de una palabra, útiles en scripts.
  • sudo systemctl daemon-reload: releer los archivos de unidad después de editarlos.
  • systemctl list-units --type=service y systemctl --failed: qué servicios hay y cuáles fallaron.

En el equipo de prueba, recién arrancado, corren solo cuatro servicios. Uno de ellos es cron:

Terminal$ systemctl list-units --type=service --state=running
  UNIT                     LOAD   ACTIVE SUB     DESCRIPTION
  cron.service             loaded active running Regular background program processing daemon
  dbus.service             loaded active running D-Bus System Message Bus
  systemd-journald.service loaded active running Journal Service
  systemd-logind.service   loaded active running User Login Management

Legend: LOAD   → Reflects whether the unit definition was properly loaded.
        ACTIVE → The high-level unit activation state, i.e. generalization of SUB.
        SUB    → The low-level unit activation state, values depend on unit type.

4 loaded units listed.
Terminal$ sudo systemctl status cron
● cron.service - Regular background program processing daemon
     Loaded: loaded (/usr/lib/systemd/system/cron.service; enabled; preset: enabled)
     Active: active (running) since Sat 2026-11-07 10:56:34 CST; 8s ago
 Invocation: 0c1a666d0fb34a34a6f0ffba28f19455
       Docs: man:cron(8)
   Main PID: 73 (cron)
      Tasks: 1 (limit: 150)
     Memory: 300K (peak: 2M)
        CPU: 10ms
     CGroup: /system.slice/cron.service
             └─73 /usr/sbin/cron -f

Nov 07 10:56:34 servidor systemd[1]: Started cron.service - Regular background program processing daemon.
Nov 07 10:56:34 servidor cron[73]: (CRON) INFO (pidfile fd = 3)
Nov 07 10:56:34 servidor cron[73]: (CRON) INFO (Running @reboot jobs)

Cómo se lee status: Loaded dice dónde está el archivo de la unidad y si está habilitada (enabled: arranca sola al encender). Active dice si corre ahora y desde cuándo. Main PID es el proceso principal, y CGroup muestra todos sus procesos: systemd los agrupa para poder detenerlos todos juntos, incluidos los hijos que el programa haya lanzado. Abajo van las últimas líneas de su diario. Si no usas sudo, algunas de esas líneas no aparecen por permisos.

Tu programa como servicio

Vamos a convertir en servicio un programa pequeño en Python que escribe un renglón cada 5 segundos. Sirve igual para un servidor web, un bot o cualquier binario: lo único que cambia es la línea ExecStart.

#!/usr/bin/env python3
# Programa de ejemplo de la guía de systemd y cron de decodigo.com: escribe un renglón cada 5 segundos.
import os, pwd, time

mensaje = os.environ.get("MENSAJE", "sin mensaje")
usuario = pwd.getpwuid(os.getuid()).pw_name
print(f"latido arranca: usuario={usuario} pid={os.getpid()}", flush=True)
n = 0
while True:
    n += 1
    print(f"{mensaje} #{n}", flush=True)
    time.sleep(5)

La unidad de servicio es un archivo con tres secciones. Va en /etc/systemd/system/, que es la carpeta para las unidades que creas tú; las que traen los paquetes viven en /usr/lib/systemd/system/ y no conviene editarlas.

[Unit]
Description=Latido, programa de ejemplo de decodigo.com
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/bin/python3 /opt/latido/latido.py
User=latido
EnvironmentFile=/etc/latido/latido.env
Restart=on-failure
RestartSec=3

[Install]
WantedBy=multi-user.target
  • [Unit]: la descripción y el orden respecto a otras unidades (lo vemos en dependencias).
  • ExecStart: la orden que arranca el programa, con rutas absolutas. No pasa por una shell: nada de cd, tuberías ni &&. Si los necesitas, ponlos en un script y llama al script.
  • User: el usuario con el que corre. Sin esta línea, corre como root.
  • EnvironmentFile: un archivo con variables de entorno, una por línea.
  • Restart y RestartSec: qué hacer si se cae y cuánto esperar.
  • [Install] WantedBy=multi-user.target: a qué etapa del arranque se engancha cuando lo habilitas.

El archivo de variables es así de simple:

MENSAJE=Hola desde systemd

Se crea un usuario de sistema para el servicio (sin carpeta personal y sin poder iniciar sesión), se copian los archivos a su lugar y se avisa a systemd:

Terminal$ sudo useradd --system --no-create-home --shell /usr/sbin/nologin latido
$ sudo install -D -m 755 latido.py /opt/latido/latido.py
$ sudo install -D -m 644 latido.env /etc/latido/latido.env
$ sudo install -m 644 latido.service /etc/systemd/system/latido.service
Terminal$ sudo systemctl daemon-reload
$ sudo systemctl enable --now latido
Created symlink '/etc/systemd/system/multi-user.target.wants/latido.service' → '/etc/systemd/system/latido.service'.

El mensaje de enable enseña lo que hace en realidad: crea un enlace en la carpeta multi-user.target.wants. Al arrancar el equipo, systemd ve ese enlace y arranca el servicio. Doce segundos después:

Terminal$ sudo systemctl status latido
● latido.service - Latido, programa de ejemplo de decodigo.com
     Loaded: loaded (/etc/systemd/system/latido.service; enabled; preset: enabled)
     Active: active (running) since Sat 2026-11-07 10:56:43 CST; 12s ago
 Invocation: d3ee236529c148fba9bd57ec3d9a46f3
   Main PID: 236 (python3)
      Tasks: 1 (limit: 150)
     Memory: 2.8M (peak: 3.3M)
        CPU: 14ms
     CGroup: /system.slice/latido.service
             └─236 /usr/bin/python3 /opt/latido/latido.py

Nov 07 10:56:43 servidor systemd[1]: Started latido.service - Latido, programa de ejemplo de decodigo.com.
Nov 07 10:56:43 servidor python3[236]: latido arranca: usuario=latido pid=236
Nov 07 10:56:43 servidor python3[236]: Hola desde systemd #1
Nov 07 10:56:48 servidor python3[236]: Hola desde systemd #2
Nov 07 10:56:53 servidor python3[236]: Hola desde systemd #3

El programa corre con el usuario latido, no con root, y systemd guarda en su diario cada renglón que imprime. El mensaje con espacios, «Hola desde systemd», llegó entero desde el EnvironmentFile.

Que se reinicie solo: Restart=

La razón principal para usar systemd es que vigile el programa. Para probarlo, matamos el proceso a la fuerza con kill -9, como si se hubiera quedado sin memoria o tuviera un error grave:

Terminal$ sudo kill -9 236
$ sudo journalctl -u latido -n 6
Nov 07 10:56:55 servidor systemd[1]: latido.service: Main process exited, code=killed, status=9/KILL
Nov 07 10:56:55 servidor systemd[1]: latido.service: Failed with result 'signal'.
Nov 07 10:56:58 servidor systemd[1]: latido.service: Scheduled restart job, restart counter is at 1.
Nov 07 10:56:58 servidor systemd[1]: Started latido.service - Latido, programa de ejemplo de decodigo.com.
Nov 07 10:56:58 servidor python3[291]: latido arranca: usuario=latido pid=291
Nov 07 10:56:58 servidor python3[291]: Hola desde systemd #1
$ systemctl show latido -p MainPID -p NRestarts
MainPID=291
NRestarts=1

systemd vio morir el proceso, esperó los 3 segundos de RestartSec y lo volvió a arrancar con otro PID. El contador NRestarts lleva la cuenta. Los valores de Restart= más útiles son:

  • no (el de fábrica): nunca lo reinicia.
  • on-failure: lo reinicia si termina con un código de error, por una señal como la de kill -9 o por tardar demasiado. Es el más usado.
  • always: lo reinicia siempre que termine, aunque haya terminado bien. Sirve para programas que nunca deberían salir.

La vida de un servicio con Restart=on-failure y RestartSec=3. Detenido, inactive; con systemctl start pasa a active (running), tu programa trabajando. Si el programa se cae, pasa a auto-restart, espera 3 segundos y lo vuelve a arrancar. Si se cae 5 veces en 10 segundos, se rinde y queda en failed. Si lo detienes tú con systemctl stop, vuelve a inactive y no se reinicia.La vida de un servicioCon Restart=on-failure y RestartSec=3inactivedetenidosystemctl startactive (running)tu programa trabajandoel programa se caeauto-restartespera 3 s y lo arrancareintenta5 caídas en 10 sfailedse rinde y te avisasystemctl stopSi lo detienes tú, no se reinicia.

Los estados de un servicio con Restart=on-failure: si se cae, espera y lo arranca; si se cae demasiadas veces seguidas, se rinde. Esquema: decodigo.com.

Hay un freno: si el servicio se cae 5 veces en 10 segundos (los valores de fábrica de StartLimitBurst y StartLimitIntervalSec), systemd deja de intentarlo y lo marca como failed, para no quedarse en un bucle infinito. Lo verás en los errores típicos.

Y cuando eres tú quien lo detiene, no lo reinicia:

Terminal$ sudo systemctl stop latido
$ systemctl is-active latido
inactive
$ sudo journalctl -u latido -n 2
Nov 07 10:57:00 servidor systemd[1]: latido.service: Deactivated successfully.
Nov 07 10:57:00 servidor systemd[1]: Stopped latido.service - Latido, programa de ejemplo de decodigo.com.

Sin privilegios y con variables de entorno

Un programa que corre como root puede leer y borrar cualquier cosa del sistema: si tiene un fallo de seguridad, el atacante se queda con todo. Por eso cada servicio debería tener su propio usuario con los permisos mínimos, como hicimos con latido:

sudo useradd --system --no-create-home --shell /usr/sbin/nologin latido

El diario y ps lo confirman:

Terminal$ sudo journalctl -u latido -n 4
Nov 07 10:56:43 servidor python3[236]: latido arranca: usuario=latido pid=236
Nov 07 10:56:43 servidor python3[236]: Hola desde systemd #1
Nov 07 10:56:48 servidor python3[236]: Hola desde systemd #2
Nov 07 10:56:53 servidor python3[236]: Hola desde systemd #3
$ ps -o user,pid,cmd -C python3
USER         PID CMD
latido       236 /usr/bin/python3 /opt/latido/latido.py

Para las variables de entorno hay dos formas:

  • Environment=, directo en la unidad: Environment="PUERTO=8080". Cómodo para valores públicos.
  • EnvironmentFile=, en un archivo aparte: mejor para contraseñas y claves de API, porque ese archivo puede ser de root con permisos 600. systemd lo lee como root antes de cambiar de usuario, así que el servicio recibe las variables sin poder leer el archivo.
Ojo con los espacios en Environment=. systemd separa por espacios: Environment=MENSAJE=Hola desde el usuario deja la variable en «Hola» y descarta el resto. Hay que encerrar toda la asignación entre comillas: Environment="MENSAJE=Hola desde el usuario". Lo verás pasar de verdad en servicios de usuario. En un EnvironmentFile, en cambio, la línea sin comillas llegó completa.

Ver lo que dice tu programa: journalctl

Todo lo que tu programa escribe en la salida estándar o en la de errores termina en el diario de systemd. No hace falta que el programa escriba su propio archivo de log: basta con imprimir. Para leerlo:

  • journalctl -u latido: todo el diario de esa unidad.
  • journalctl -u latido -n 50: las últimas 50 líneas.
  • journalctl -u latido -f: seguirlo en vivo, como tail -f. Se sale con Ctrl+C.
  • journalctl -u latido --since "1 hour ago" o --since 10:00 --until 11:00: por horario.
  • journalctl -u latido -b: solo desde el último arranque del equipo.
  • journalctl -p err: solo errores, de todo el sistema.

Por permisos, un usuario normal solo ve su propio diario: usa sudo o agrega tu usuario al grupo systemd-journal. Si quieres entender qué debe llevar una buena línea de log y qué no, lo explicamos en logs y archivos de configuración.

Dependencias y orden: After= y Wants=

Muchos programas necesitan que algo esté listo antes: la red, una base de datos, un disco montado. En la sección [Unit] se dice de dos formas distintas, y conviene no confundirlas:

  • Wants= (o Requires=): «cuando me arranques, arranca también esto». Wants lo intenta y sigue aunque falle; Requires detiene tu servicio si la otra unidad falla.
  • After=: «arráncame después de esto». Solo ordena: no arranca nada por sí mismo.

Por eso casi siempre van juntos. After=network-online.target con Wants=network-online.target significa «arranca la espera de la red y no me arranques hasta que haya red». Con solo After, si nadie más pidió esa unidad, tu servicio no espera nada. Así quedó nuestro servicio:

Terminal$ systemctl show latido -p After -p Wants -p WantedBy
Wants=network-online.target
WantedBy=multi-user.target
After=network-online.target systemd-journald.socket basic.target system.slice sysinit.target

systemd agregó por su cuenta varias dependencias básicas (sysinit.target, basic.target, el socket del diario). Para una base de datos local sería, por ejemplo, After=postgresql.service y Wants=postgresql.service.

Errores típicos con servicios

Editaste la unidad y no pasó nada

systemd lee los archivos de unidad una sola vez. Si los editas, tienes que pedirle que los relea con daemon-reload; si no, sigue usando la versión vieja y te avisa:

Terminal$ sudo sed -i 's/RestartSec=3/RestartSec=5/' /etc/systemd/system/latido.service
$ sudo systemctl start latido
Warning: The unit file, source configuration file or drop-ins of latido.service changed on disk. Run 'systemctl daemon-reload' to reload units.
$ sudo systemctl daemon-reload
$ sudo systemctl restart latido
$ systemctl show latido -p RestartUSec
RestartUSec=5s

«Start request repeated too quickly»

Este servicio intenta correr el mismo programa, pero guardado en la carpeta personal de ana, con el usuario latido:

Terminal$ sudo systemctl daemon-reload
$ sudo systemctl start roto
$ sudo systemctl status roto -n 4
× roto.service - Latido desde la carpeta de ana
     Loaded: loaded (/etc/systemd/system/roto.service; static)
     Active: failed (Result: exit-code) since Sat 2026-11-07 10:57:03 CST; 883ms ago
   Duration: 33ms
 Invocation: 696755edc2604a6ba656a52c28681699
    Process: 471 ExecStart=/usr/bin/python3 /home/ana/latido.py (code=exited, status=2)
   Main PID: 471 (code=exited, status=2)

Nov 07 10:57:03 servidor systemd[1]: roto.service: Scheduled restart job, restart counter is at 5.
Nov 07 10:57:03 servidor systemd[1]: roto.service: Start request repeated too quickly.
Nov 07 10:57:03 servidor systemd[1]: roto.service: Failed with result 'exit-code'.
Nov 07 10:57:03 servidor systemd[1]: Failed to start roto.service - Latido desde la carpeta de ana.

El estado solo dice que el programa terminó con el código 2 y que systemd se rindió tras cinco intentos. La causa de verdad está en el diario:

Terminal$ sudo journalctl -u roto | grep -m1 python3
Nov 07 10:57:01 servidor python3[454]: /usr/bin/python3: can't open file '/home/ana/latido.py': [Errno 13] Permission denied
$ ls -ld /home/ana
drwx------ 1 ana ana 4096 Nov  7 10:56 /home/ana

La carpeta personal es drwx------: solo ana puede entrar, y el servicio corre como latido. La solución es poner el programa en un lugar del sistema, como /opt, que es lo que hicimos con el bueno. Después de arreglar algo, sudo systemctl reset-failed nombre borra el estado de fallo y el contador de intentos.

Otros que vas a ver

  • status=203/EXEC: systemd no pudo ejecutar lo de ExecStart. La ruta está mal, el archivo no tiene permiso de ejecución o un script no empieza con #!.
  • status=217/USER: el usuario de User= no existe.
  • El servicio arranca a mano pero no al encender: falta enable, o falta la sección [Install].
  • Funciona en tu terminal y no como servicio: el servicio no carga tu .bashrc, ni tu PATH, ni tu carpeta actual. Usa rutas absolutas y pon las variables en Environment. Si necesitas una carpeta de trabajo, usa WorkingDirectory=.

La regla para depurar: systemctl status para ver qué pasó y journalctl -u nombre para ver por qué.

Servicios de usuario, sin sudo

Si no tienes acceso de administrador, o el programa es solo tuyo, puedes tener tus propios servicios. Van en ~/.config/systemd/user/ y se manejan con systemctl --user, sin sudo. Esta es la primera versión del archivo, con un error a propósito:

[Unit]
Description=Latido de ana

[Service]
ExecStart=/usr/bin/python3 %h/latido.py
Environment=MENSAJE=Hola desde el usuario
Restart=on-failure

[Install]
WantedBy=default.target

%h es un atajo de systemd para tu carpeta personal. Por defecto, tus servicios se detienen al cerrar tu última sesión. Para que sigan corriendo sin sesión abierta, y arranquen al encender el equipo, el administrador tiene que activar el linger de tu usuario una vez:

Terminal$ sudo loginctl enable-linger ana
$ sleep 3; loginctl show-user ana -p Linger -p State
State=lingering
Linger=yes
Terminal$ systemctl --user daemon-reload
$ systemctl --user enable --now latido
Created symlink '/home/ana/.config/systemd/user/default.target.wants/latido.service' → '/home/ana/.config/systemd/user/latido.service'.
$ systemctl --user status latido
● latido.service - Latido de ana
     Loaded: loaded (/home/ana/.config/systemd/user/latido.service; enabled; preset: enabled)
     Active: active (running) since Sat 2026-11-07 10:57:07 CST; 7s ago
 Invocation: 8b993acbdf474231a598c99d027e83f8
   Main PID: 554 (python3)
      Tasks: 1 (limit: 150)
     Memory: 3M (peak: 3.6M)
        CPU: 13ms
     CGroup: /user.slice/user-1000.slice/user@1000.service/app.slice/latido.service
             └─554 /usr/bin/python3 /home/ana/latido.py

Nov 07 10:57:07 servidor systemd[514]: Started latido.service - Latido de ana.
Nov 07 10:57:07 servidor python3[554]: latido arranca: usuario=ana pid=554
Nov 07 10:57:07 servidor python3[554]: Hola #1
Nov 07 10:57:12 servidor python3[554]: Hola #2
$ journalctl --user -u latido -n 2
Nov 07 10:57:07 servidor python3[554]: Hola #1
Nov 07 10:57:12 servidor python3[554]: Hola #2

Mira la salida: dice «Hola #1», no «Hola desde el usuario #1». Es el error de los espacios en Environment= que mencionamos antes. Con la línea entre comillas, Environment="MENSAJE=Hola desde el usuario":

Terminal$ systemctl --user daemon-reload
$ systemctl --user restart latido
$ journalctl --user -u latido -n 2
Nov 07 10:57:14 servidor python3[599]: latido arranca: usuario=ana pid=599
Nov 07 10:57:14 servidor python3[599]: Hola desde el usuario #1
Al conectarte por SSH, tu sesión ya trae lo necesario para systemctl --user. Si usas su o sudo -u para cambiar de usuario, en cambio, verás «Failed to connect to user scope bus»: entra directamente con ese usuario, por ejemplo por SSH.

Temporizadores de systemd: la alternativa a cron

Un temporizador son dos archivos: el servicio que hace el trabajo y el .timer que dice cuándo. Este ejemplo empaqueta una carpeta cada día a las 3 de la mañana. Primero el script:

#!/bin/sh
# Respaldo de ejemplo: empaqueta /srv/datos con la fecha en el nombre.
destino=/var/respaldos/datos-$(date +%F-%H%M%S).tar.gz
tar -czf "$destino" -C /srv datos
echo "respaldo listo: $destino"

El servicio es de tipo oneshot: corre, termina y ya. No lleva sección [Install] porque no se habilita él, sino su temporizador:

[Unit]
Description=Respaldo de /srv/datos

[Service]
Type=oneshot
ExecStart=/usr/local/bin/respaldo.sh
[Unit]
Description=Respaldo diario de /srv/datos

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true

[Install]
WantedBy=timers.target

El temporizador se llama igual que el servicio, y así systemd sabe cuál arrancar. Persistent=true es la gran ventaja sobre cron: si el equipo estaba apagado a las 3:00, el respaldo se hace en cuanto vuelva a encender, en lugar de saltárselo.

Terminal$ sudo install -m 755 respaldo.sh /usr/local/bin/respaldo.sh
$ sudo install -m 644 respaldo.service respaldo.timer /etc/systemd/system/
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now respaldo.timer
Created symlink '/etc/systemd/system/timers.target.wants/respaldo.timer' → '/etc/systemd/system/respaldo.timer'.

La sintaxis de OnCalendar es día-de-la-semana año-mes-día hora:minuto:segundo, y el asterisco significa «cualquiera». Lo mejor es que se puede probar antes de usarla: systemd-analyze calendar te dice cuándo se disparará.

Terminal$ systemd-analyze calendar '*-*-* 03:00:00'
Normalized form: *-*-* 03:00:00
    Next elapse: Sun 2026-11-08 03:00:00 CST
       (in UTC): Sun 2026-11-08 09:00:00 UTC
       From now: 16h left
$ systemd-analyze calendar 'Mon..Fri 08:30'
  Original form: Mon..Fri 08:30
Normalized form: Mon..Fri *-*-* 08:30:00
    Next elapse: Mon 2026-11-09 08:30:00 CST
       (in UTC): Mon 2026-11-09 14:30:00 UTC
       From now: 1 day 21h left
$ systemd-analyze calendar --iterations=3 '*:0/15'
  Original form: *:0/15
Normalized form: *-*-* *:00/15:00
    Next elapse: Sat 2026-11-07 11:00:00 CST
       (in UTC): Sat 2026-11-07 17:00:00 UTC
       From now: 2min 42s left
   Iteration #2: Sat 2026-11-07 11:15:00 CST
       (in UTC): Sat 2026-11-07 17:15:00 UTC
       From now: 17min left
   Iteration #3: Sat 2026-11-07 11:30:00 CST
       (in UTC): Sat 2026-11-07 17:30:00 UTC
       From now: 32min left

También acepta palabras: hourly, daily, weekly, monthly. Para ver todos los temporizadores y cuándo toca el siguiente:

Terminal$ systemctl list-timers respaldo.timer
NEXT                        LEFT LAST PASSED UNIT           ACTIVATES
Sun 2026-11-08 03:00:00 CST  16h -         - respaldo.timer respaldo.service

1 timers listed.
Pass --all to see loaded but inactive timers, too.

Y para probar el trabajo sin esperar a las 3 de la mañana, se arranca el servicio a mano. Su salida queda en el diario, como la de cualquier servicio:

Terminal$ sudo systemctl start respaldo.service
$ sudo journalctl -u respaldo -n 3
Nov 07 10:57:17 servidor respaldo.sh[738]: respaldo listo: /var/respaldos/datos-2026-11-07-105717.tar.gz
Nov 07 10:57:17 servidor systemd[1]: respaldo.service: Deactivated successfully.
Nov 07 10:57:17 servidor systemd[1]: Finished respaldo.service - Respaldo de /srv/datos.
$ ls /var/respaldos
datos-2026-11-07-105717.tar.gz

Otras formas de decir cuándo: OnBootSec=5min (cinco minutos después de encender), OnUnitActiveSec=1h (una hora después de la última vez) y RandomizedDelaySec=15min, que reparte la hora al azar para que cien servidores no hagan su respaldo en el mismo segundo.

cron: la forma clásica

cron existe desde los años setenta y sigue en casi todos los sistemas Unix. Cada usuario tiene su crontab, una tabla con una tarea por línea: cinco campos que dicen cuándo y, después, la orden.

Los cinco campos de cron. Cada línea dice cuándo y luego qué. Ejemplo: 30 3 * * 1-5 seguido de la orden. El primer campo es el minuto, de 0 a 59; el segundo la hora, de 0 a 23; el tercero el día del mes, de 1 a 31; el cuarto el mes, de 1 a 12; el quinto el día de la semana, de 0 a 7, donde 0 y 7 son domingo. El ejemplo se ejecuta a las 3:30, de lunes a viernes. Otros ejemplos: */15 * * * *, cada 15 minutos; 0 * * * *, al empezar cada hora; 0 9 1 * *, el día 1 de cada mes a las 9:00; @reboot, al arrancar el equipo.Los cinco campos de cronCada línea: cuándo, y luego qué303**1-5orden1.30minuto0 a 592.3hora0 a 233.*día del mes1 a 314.*mes1 a 125.1-5día semana0 a 7A las 3:30, de lunes a viernes.En día de la semana, 0 y 7 son domingo.*/15 * * * *cada 15 minutos0 * * * *al empezar cada hora0 9 1 * *el día 1 de cada mes, 9:00@rebootal arrancar el equipo

Los cinco campos de una línea de crontab, con ejemplos. Esquema: decodigo.com.

En cada campo se puede poner un número, un asterisco (cualquiera), una lista (1,15), un rango (1-5) o un paso (*/10, «cada 10»). Ojo con un detalle: si pones día del mes y día de la semana a la vez, cron ejecuta la tarea cuando se cumpla cualquiera de los dos, no ambos.

Las órdenes para manejar tu crontab:

  • crontab -e: editarla (abre tu editor).
  • crontab -l: ver lo que tiene.
  • crontab -r: borrarla entera, sin preguntar. La r está junto a la e en el teclado; antes de editar, guarda una copia con crontab -l > respaldo-crontab.txt.

Además de la crontab de cada usuario están /etc/crontab y los archivos de /etc/cron.d/, que llevan un campo más, el usuario, entre la hora y la orden. Las carpetas /etc/cron.daily, cron.weekly y cron.monthly ejecutan los scripts que pongas ahí.

Para ver las trampas de cron, preparamos cinco tareas que se ejecutan cada minuto. Una guarda su entorno, dos escriben la fecha (una mal y otra bien) y dos llaman a un script de ana que está en ~/bin:

Terminal$ crontab -l
no crontab for ana
$ crontab -e
$ crontab -l
# minuto hora día-del-mes mes día-de-la-semana  orden
* * * * * env > /home/ana/env-cron.txt
* * * * * date +%F >> /home/ana/fecha.txt
* * * * * date +\%F >> /home/ana/fecha-bien.txt 2>&1
* * * * * respaldo-mio.sh
* * * * * respaldo-mio.sh >> /home/ana/cron-errores.log 2>&1

En la terminal de ana, el script se encuentra sin problema:

Terminal$ echo $PATH
/home/ana/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games
$ command -v respaldo-mio.sh
/home/ana/bin/respaldo-mio.sh

Un minuto después, esto es lo que pasó:

Terminal$ ls *.txt *.log
cron-errores.log
env-cron.txt
fecha-bien.txt
$ grep PATH env-cron.txt
PATH=/usr/bin:/bin
$ cat fecha.txt
cat: fecha.txt: No such file or directory
$ cat fecha-bien.txt
2026-11-07
$ cat cron-errores.log
/bin/sh: 1: respaldo-mio.sh: not found
Terminal$ sudo journalctl -t CRON --since 10:58 | grep -E 'CMD|MTA'
Nov 07 10:58:01 servidor CRON[820]: (ana) CMD (env > /home/ana/env-cron.txt)
Nov 07 10:58:01 servidor CRON[821]: (ana) CMD (respaldo-mio.sh)
Nov 07 10:58:01 servidor CRON[822]: (ana) CMD (date +)
Nov 07 10:58:01 servidor CRON[824]: (ana) CMD (respaldo-mio.sh >> /home/ana/cron-errores.log 2>&1)
Nov 07 10:58:01 servidor CRON[825]: (ana) CMD (date +%F >> /home/ana/fecha-bien.txt 2>&1)
Nov 07 10:58:01 servidor CRON[811]: (CRON) info (No MTA installed, discarding output)
Nov 07 10:58:01 servidor CRON[813]: (CRON) info (No MTA installed, discarding output)

Las trampas de cron

Tu terminal no es cron: la misma orden se ejecuta en otro entorno. PATH: en tu terminal incluye ~/bin, /usr/local/bin y más; en cron, solo /usr/bin:/bin. El símbolo %: en tu terminal es un %, en cron corta la orden. La salida: en tu terminal la ves en pantalla, en cron va por correo o se pierde. Tu .bashrc: en tu terminal se carga, en cron no. Por eso una orden que funciona a mano puede fallar en cron sin decir nada.Tu terminal no es cronLa misma orden, otro entornoEn tu terminalEn cronPATH~/bin,/usr/local/bin ymássolo/usr/bin:/binel símbolo %es un %corta la ordenla salidala ves enpantallava por correo ose pierdetu .bashrcse cargano se cargaPor eso una orden que funciona a manopuede fallar en cron sin decir nada.

La misma orden en tu terminal y en cron: otro PATH, el símbolo % y la salida que se pierde. Esquema: decodigo.com.

Cada una de las tareas anteriores muestra una trampa:

  • El PATH es mínimo. En cron vale /usr/bin:/bin y nada más, así que respaldo-mio.sh, que vive en ~/bin, no se encuentra. Usa rutas absolutas (/home/ana/bin/respaldo-mio.sh) o define el PATH en la primera línea de la crontab: PATH=/home/ana/bin:/usr/local/bin:/usr/bin:/bin.
  • El % corta la orden. Para cron, un % significa «salto de línea»: lo que va detrás se manda a la entrada de la orden. Por eso date +%F >> fecha.txt se quedó en date +, como dice el diario, y fecha.txt nunca se creó. Se escribe \%, como en la línea que sí funcionó.
  • La salida se pierde. cron envía por correo lo que imprime una tarea. Si el equipo no tiene servidor de correo, la tira: «No MTA installed, discarding output». Así se perdió el error de la tarea sin redirigir. La que terminaba en >> /home/ana/cron-errores.log 2>&1 sí lo guardó: «respaldo-mio.sh: not found».
  • No carga tu .bashrc. Ni tus alias ni las variables que definas ahí. Y la shell es /bin/sh, no bash: si tu orden usa cosas propias de bash, ponla en un script que empiece con #!/bin/bash.
  • La hora es la del sistema. Si el servidor está en UTC, 0 3 * * * son las 3:00 UTC, que en el centro de México son las 21:00 del día anterior.

Cómo depurar una tarea de cron

  1. Confirma que cron la ejecutó: sudo journalctl -t CRON (o grep CRON /var/log/syslog en sistemas sin journal). Busca la línea CMD con tu orden.
  2. Redirige siempre la salida a un archivo: >> /ruta/tarea.log 2>&1. Es la única forma de ver el error.
  3. Para probarla con el mismo entorno que cron, ponla una vez como * * * * * con env > /tmp/env-cron.txt y compara ese archivo con tu env.
  4. Usa rutas absolutas para todo: el programa, los archivos y las carpetas.

Un ejemplo completo de Bash con cron, paso a paso, está en programar tareas con cron desde Bash.

¿systemd o cron?

Para un programa que debe estar siempre corriendo (un servidor, un bot, un trabajador de colas), la respuesta es un servicio de systemd: cron no vigila ni reinicia nada.

Para una tarea que se ejecuta de vez en cuando, los dos sirven:

  • Elige un temporizador de systemd si quieres que no se pierda la ejecución cuando el equipo estaba apagado (Persistent=true), tener la salida en el diario sin hacer nada, evitar que se encime con la anterior (systemd no arranca el servicio si sigue corriendo), limitar su memoria o CPU, o correrla con un usuario sin privilegios definido en la unidad.
  • Elige cron si quieres algo rápido de una línea, si el sistema no tiene systemd (contenedores, algunos BSD, routers) o si mantienes scripts que ya funcionan con él.

Una regla práctica: tareas nuevas en un servidor con systemd, con temporizadores; cron para lo sencillo y lo heredado.

Hoja de referencia

  • Crear un servicio: archivo en /etc/systemd/system/nombre.service con ExecStart (ruta absoluta), User, Restart=on-failure y WantedBy=multi-user.target; después sudo systemctl daemon-reload y sudo systemctl enable --now nombre.
  • Ver qué pasa: systemctl status nombre y journalctl -u nombre -f.
  • Tras editar: daemon-reload y restart.
  • Variables: Environment="CLAVE=valor con espacios" o EnvironmentFile= con permisos 600 para secretos.
  • Temporizador: nombre.service de tipo oneshot más nombre.timer con OnCalendar y Persistent=true; prueba la fecha con systemd-analyze calendar y revísalo con systemctl list-timers.
  • cron: minuto hora día mes día-semana orden, con crontab -e; rutas absolutas, \% en vez de % y la salida a un archivo con >> archivo.log 2>&1.

Si estás empezando con la terminal, antes te servirá la guía de los comandos de Linux que de verdad se usan.

Todo se probó en un contenedor desechable con Debian 13 «trixie», systemd 257 como proceso 1, cron 3.0 y Python 3.13, sin conexión a la red y con un usuario normal con sudo. Las salidas están copiadas de esa ejecución; las horas son las del equipo de prueba. Imagen de portada: logotipo de systemd, de Tobias Bernard, con licencia CC BY-SA 4.0.