At 2:00 AM on a Tuesday, my phone buzzed incessantly with alert notifications. The order synchronization service had abruptly stopped running. Worse still, the cron job backing up our 80GB PostgreSQL database hadn’t written a single log entry in the preceding 6 hours.
I opened my laptop and SSH’d into the server. Running crontab -l showed the schedule intact. However, due to a missing PATH environment variable, the script had been crashing silently since midnight without anyone noticing. After that on-call shift, our team decided to ditch outdated crontabs entirely and migrate everything to systemd services and systemd timers across our Ubuntu 22.04 clusters.
Quick start: Create a Service and Timer in 5 Minutes
If you need a scheduled task solution with clear logging and no silent failures, follow these 4 steps.
Step 1: Write the Target Script
Create a bash script at /usr/local/bin/backup-task.sh:
sudo nano /usr/local/bin/backup-task.sh
Add the following script to clean up temporary files and stale cache:
#!/usr/bin/env bash
set -euo pipefail
NOW=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$NOW] Starting cache cleanup and log compression..."
# Delete application cache older than 7 days
find /tmp/app-cache/ -type f -mtime +7 -delete 2>/dev/null || true
echo "[$NOW] Task completed successfully!"
Grant execute permissions to the file:
sudo chmod +x /usr/local/bin/backup-task.sh
Step 2: Create the Systemd Service
The service defines what command to execute and under which user. Create a unit file at /etc/systemd/system/backup-task.service:
sudo nano /etc/systemd/system/backup-task.service
Add the following configuration:
[Unit]
Description=Periodic cache cleanup and backup task
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-task.sh
User=root
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
Step 3: Create a Systemd Timer for Scheduling
The timer is responsible for triggering the service. Create a file with the matching name at /etc/systemd/system/backup-task.timer:
sudo nano /etc/systemd/system/backup-task.timer
Define the execution schedule (e.g., daily at 02:00 AM):
[Unit]
Description=Trigger backup-task daily at 02:00 AM
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
[Install]
WantedBy=timers.target
Step 4: Reload Configuration and Enable
Run the following commands in your terminal:
# 1. Reload systemd to recognize new unit files
sudo systemctl daemon-reload
# 2. Test-run the service immediately to check for syntax errors
sudo systemctl start backup-task.service
# 3. Inspect logs to verify script execution output
journalctl -u backup-task.service -n 20 --no-pager
# 4. Enable and start the timer at boot
sudo systemctl enable --now backup-task.timer
Setup takes just 5 minutes. From now on, your script is properly supervised and all output logs are reliably captured.
Why Systemd Timers Are More Reliable Than Crontab
Crontab was introduced back in 1975, when servers were far less complex than they are today. When a cron command fails, it defaults to piping output to sendmail. Because most modern servers do not run a local mail transfer agent, failures vanish without a trace.
In contrast, systemd’s decoupled architecture offers 3 distinct advantages:
- Explicit Dependency Management: You can easily declare
After=network-online.targetorWants=docker.service. The script will only run once network connectivity and databases are fully ready—something crontab simply cannot do. - Catch-up Scheduling (Persistent=true): If a job is scheduled for 02:00 AM but the server was down for maintenance between 01:30 and 03:00, systemd timer detects the missed run and triggers it immediately after boot. With crontab, that execution is simply lost until the next day.
- Resource Constraints (cgroups): You can set
MemoryMax=500MorCPUQuota=30%directly in the service unit. Heavy tasks won’t starve neighboring web processes of CPU or memory.
Production Configuration: Self-Healing Daemons on Crash
Beyond one-off (oneshot) scripts, systemd is an outstanding solution for managing long-running background daemons such as Node.js, Python FastAPI, or Go applications.
Here is a sample service unit at /etc/systemd/system/api-gateway.service that our team uses in production:
[Unit]
Description=Production API Gateway
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/opt/api-gateway
ExecStart=/usr/bin/node /opt/api-gateway/server.js
# Automatically restart if the application crashes
Restart=always
RestartSec=5s
# Environment and file descriptor limits
LimitNOFILE=65535
Environment=NODE_ENV=production
EnvironmentFile=/opt/api-gateway/.env
# Tighten filesystem security
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
Pay special attention to two critical settings:
RestartSec=5s: Introduces a 5-second backoff before restarting. If a critical logic error causes the application to crash continuously, this prevents a runaway crash loop from pinning CPU usage at 100%.EnvironmentFile: Loads all environment variables directly from a.envfile. This permanently resolves the common pitfall where a script runs fine manually under a personal user account, but fails as a background daemon due to missing environment variables.
Validating OnCalendar Schedules Without Syntax Errors
Writing crontab’s 5-asterisk syntax makes it easy to mix up day-of-month and day-of-week. With systemd, you can validate your schedule directly using systemd-analyze calendar:
systemd-analyze calendar "Mon..Fri *-*-* 09:00:00"
The system immediately parses the expression and outputs the next execution timestamp alongside an exact countdown:
Normalized form: Mon..Fri *-*-* 09:00:00
Next elapse: Fri 2026-10-23 09:00:00 UTC
(in 22h left)
Quick Debugging Tips During Incidents
When a service alert fires, use these 3 commands to isolate the root cause in under a minute:
1. List All Active Timers
systemctl list-timers --all
This command outputs a comprehensive overview table. The NEXT column shows the upcoming trigger time, LEFT counts down the remaining time, and LAST RESULT indicates the exit status of the most recent run.
2. Inspect Logs in Real Time with Journalctl
Instead of digging through fragmented text files in /var/log/, you can filter logs directly by unit name:
# Stream logs continuously in real time (similar to tail -f)
journalctl -u backup-task.service -f
# Filter logs generated within the last 30 minutes
journalctl -u backup-task.service --since "30 minutes ago"
# Show only log entries with Error priority or higher
journalctl -u backup-task.service -p err
3. Resolve “Command not found” Caused by PATH Mismatches
This is the single most common issue when migrating from interactive shell scripts to systemd services. Systemd does not load user profile files like ~/.bashrc or ~/.profile, leaving the default PATH restricted to standard system directories: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin.
If your script relies on binaries installed via NVM, Pyenv, or Poetry, explicitly define PATH inside the [Service] block:
[Service]
Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/home/deploy/.nvm/versions/node/v20.10.0/bin"
Alternatively, the safest approach is to always use absolute paths for binaries in your scripts (for example, invoking /usr/bin/python3 instead of plain python3).
Investing 15 minutes to migrate from Crontab to Systemd Timers makes your infrastructure far more transparent and resilient. You gain precise scheduling visibility, strict resource controls, and centralized logging ready whenever issues arise.

