Linux

Manage Services with systemd: Create a Custom Service and Timer

The First Thing You Hit on a Fresh VPS

You just got a new VPS from a provider like thueVPS with a Vietnam IPv4 and an Ubuntu 24.04 image. You run the standard upgrades, set up SSH keys, and then you need a script to check disk usage every hour and log it. The old habit is to drop a cron job into /etc/crontab. But on any modern Linux VPS shipping with systemd as init, which is every major distribution today including Ubuntu 24.04, Debian 12, and AlmaLinux 9, the proper way is a systemd timer. Timers give you persistent logging, dependency ordering, and real-time feedback that cron cannot match. This guide walks you through creating a custom systemd service unit and a timer unit that runs it, and then verifying the whole pipeline end-to-end.

Prerequisites

  • A VPS running Ubuntu 24.04 LTS (these commands work on Debian 12 and AlmaLinux 9 with minor path changes).
  • Root access or a user with sudo privileges.
  • A shell script you want to run periodically (we will create a minimal one as example).
  • Basic familiarity with the command line.

Why Use a systemd Timer Instead of cron

Every sysadmin knows cron. It has worked for decades. But systemd timers bring features that cron lacks natively. First, every timer invocation is logged by journald, you run journalctl -u myscript.service and see exact stdout, stderr, timestamps, and error codes. Second, timers support real-time and monotonic (relative to boot) schedules, so you can run a service 5 minutes after boot even if you boot at 3 AM. Third, you can set dependencies: a backup timer can depend on a network-online target, so it never runs before the network is up. Fourth, systemd handles missed executions gracefully, a timer can fire immediately after a missed run if you configure OnCalendar= with Persistent=true. For scheduled maintenance, database backups, and log rotation on production servers, these advantages make timers the recommended approach.

The Anatomy of a systemd Service Unit

SectionPurposeExample key-value
[Unit]Metadata and dependenciesDescription=Disk check script
[Service]
Execution type, command, userType=oneshot; ExecStart=/usr/local/bin/disk-check.sh
[Install]WantedBy target (usually multi-user.target)WantedBy=multi-user.target

A service unit file lives in /etc/systemd/system/ with a .service extension. The oneshot type is ideal for scripts that run, do work, and exit. ExecStart is the full path to the executable, never rely on $PATH inside a unit file. Our example service will run a simple disk check.

Step 1, Create the Script That the Service Will Run

Create a test script at /usr/local/bin/disk-check.sh. This script writes the current disk usage summary to a log file. Open a root shell (or use sudo -i) and run:

cat << 'EOF' > /usr/local/bin/disk-check.sh
#!/bin/bash
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
echo "[$TIMESTAMP] Disk usage:" >> /var/log/disk-check.log
df -h / >> /var/log/disk-check.log
EOF
chmod +x /usr/local/bin/disk-check.sh

The script logs a timestamp and the result of df -h / to /var/log/disk-check.log. Verify it runs manually:

/usr/local/bin/disk-check.sh
cat /var/log/disk-check.log

You should see one line with the current date and the disk usage output. If not, check writable permissions on /var/log, any user in the adm group can write there, but root can write directly.

Step 2, Create the systemd Service Unit

Create the service file /etc/systemd/system/disk-check.service:

cat << 'EOF' > /etc/systemd/system/disk-check.service
[Unit]
Description=Disk usage check service
After=network.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/disk-check.sh
User=root
Group=root

[Install]
WantedBy=multi-user.target
EOF

The After=network.target is a safeguard, your script does not need network, but including it is a good habit for any service that might later require network access. Type=oneshot tells systemd that the service starts, runs once, and exits; this is required for timer-eligible services. Reload the systemd daemon so it picks up the new unit:

systemctl daemon-reload

Test the service manually:

systemctl start disk-check.service
systemctl status disk-check.service

Expected output includes Active: inactive (dead), that is correct for a oneshot service that exited successfully. If you see any error, run journalctl -u disk-check.service to inspect the full log.

Step 3, Create the systemd Timer Unit

The timer unit has the same base filename but a .timer extension. It ties to the service through its Unit= directive. Create /etc/systemd/system/disk-check.timer:

cat << 'EOF' > /etc/systemd/system/disk-check.timer
[Unit]
Description=Run disk check every hour
Requires=disk-check.service

[Timer]
OnCalendar=*-*-* *:00:00
Persistent=true
Unit=disk-check.service

[Install]
WantedBy=timers.target
EOF

The OnCalendar=*-*-* *:00:00 fires at the start of every hour, minute 0, second 0. Persistent=true means the timer catches up if the system was off during the scheduled time (e.g., after a reboot during a maintenance window). Reload and enable the timer (not the service, enabling the timer auto-starts the service when the timer fires):

systemctl daemon-reload
systemctl enable --now disk-check.timer

The --now flag starts the timer immediately after enabling. Verify the timer is active:

systemctl status disk-check.timer

You should see Active: active (waiting) and the next trigger time in the Triggers: line. List all active timers with systemctl list-timers --all, the output shows when the service last ran and when it runs next.

How to Verify the Timer Actually Runs

Wait for the next minute boundary or force a run manually to test:

systemctl start disk-check.service
cat /var/log/disk-check.log

If you prefer to wait for the timer to fire naturally, run watch -n 5 systemctl list-timers --all, the NEXT column updates after each trigger. When the timer fires, check the log again. Every hour you see a new entry.

To see the full execution history including stderr, use journald:

journalctl -u disk-check.service --since "1 hour ago"

This shows stdout and systemd-injected metadata. If the script fails, say the partition is full, you see the error message here immediately, something cron cannot do without extra shell wrappers.

Troubleshooting Common systemd Timer Issues

Service never runs

Check the timer status: systemctl status disk-check.timer. If it shows dead or failed, enable it again: systemctl enable --now disk-check.timer. Then verify the syntax with systemd-analyze verify /etc/systemd/system/disk-check.*, this tool catches missing directives and path typos.

Script runs but timer says it missed

If the system was offline past the scheduled time and Persistent=false (the default), the timer skips that execution. Run systemctl edit disk-check.timer and add Persistent=true under [Timer], then reload.

Timer fires multiple times

A common mistake is having both the timer and a cron job calling the same script. Remove the cron entry with crontab -e. Another cause: the service unit has Type=simple instead of Type=oneshot. A simple service is considered active until the process exits, systemd may restart it on timer firing even if still running. Change the service type back to oneshot.

FAQ

Can a systemd timer run a script as a non-root user?

Yes. Set User= and Group= in the [Service] section of the service unit, for example User=www-data. The script runs under that user's permissions. Ensure the script and its logfile are writable by that user.

How do I schedule something every 15 minutes with a timer?

Use OnCalendar=*:0/15:00, this fires at minute 0, 15, 30, and 45 of every hour. For more complex schedules (every Monday at 3 AM) use Mon *-*-* 03:00:00. Refer to man systemd.time for the full grammar.

What happens if the script takes longer than the timer interval?

systemd waits for the current execution to finish before starting a new one, it does not stack multiple instances. This is safer than cron, which can spawn overlapping processes. If you need concurrent execution, set Type=simple and handle locking yourself inside the script.

Can I use environment variables inside a systemd service?

Yes. Add Environment="MY_VAR=value" in the [Service] section, or point to a file with EnvironmentFile=/etc/myservice.env. The variable is available to the ExecStart script.

Is there a way to disable the timer without removing the unit file?

Run systemctl stop disk-check.timer to stop it temporarily, and systemctl disable disk-check.timer to prevent it from starting on boot. Re-enable with systemctl enable --now disk-check.timer.

How do I delete a systemd timer and service completely?

Stop and disable both the timer and service: systemctl stop disk-check.timer disk-check.service, then systemctl disable disk-check.timer disk-check.service. Delete the files: rm /etc/systemd/system/disk-check.*. Finally reload: systemctl daemon-reload.

Related articles

Note: This guide is for general reference. Every system and infrastructure has its own specifics, so test each step in a safe environment and consult a qualified engineer before applying it in production.