Sooner or later every developer ends up with a small program that needs to keep running: an API written in Node.js, a Python worker that processes a queue, or a shell script that has to fire every night. The naive way to keep it alive is to open a terminal, run the command, and walk away. The moment the server reboots, your SSH session drops, or the process crashes, everything stops and nobody notices until something breaks.
This is exactly the problem systemd solves. It is the init system that ships with Ubuntu, and it is responsible for starting, stopping, supervising, and restarting almost everything on your machine. Once you learn to write a service unit, you can hand any program to systemd and let it worry about keeping it running, restarting it on failure, starting it at boot, and capturing its logs.
In this tutorial you will turn a simple script into a managed service, make it survive reboots and crashes, read its logs, and then replace a cron job with a systemd timer. This guide is for developers, sysadmins, and DevOps engineers who are comfortable on the Ubuntu command line but have never written their own unit file. By the end you will be able to run your own software the same way the operating system runs its own daemons.
Understanding the Key Concepts
Before writing any files, it helps to know the vocabulary.
A unit is the basic building block in systemd. Everything systemd manages is a unit of some type. The two types you care about here are service units (files ending in .service) that describe a long-running process, and timer units (files ending in .timer) that trigger another unit on a schedule.
A daemon is just a program that runs in the background instead of attached to your terminal. When you “make something a service,” you are asking systemd to run it as a daemon and watch over it.
systemctl is the command you use to talk to systemd: start, stop, enable, check status. journalctl is the companion command that reads the logs systemd collects from every service it runs. You do not need to set up log files yourself; systemd captures whatever your program prints to standard output and error.
One more distinction matters: enabling a service is not the same as starting it. Starting runs it right now. Enabling tells systemd to start it automatically at boot. Most of the time you want both, but they are separate actions on purpose.
Prerequisites
To follow along you need:
- An Ubuntu server or desktop (this guide was written against Ubuntu 22.04 and 24.04, but it works on any release using systemd)
- A user account with
sudoprivileges - Basic command-line skills: editing files, moving around directories, running commands
- A text editor you are comfortable with, such as
nanoorvim
No extra packages are required. systemd is already installed and running on every modern Ubuntu system. You can confirm that with:
systemctl --version
You should see output similar to:
systemd 255 (255.4-1ubuntu8)
+PAM +AUDIT +SELINUX -APPARMOR +IMA ...
Step 1: Create a Program to Run
So that this tutorial works on any machine without installing anything, we will supervise a tiny script. In the real world this would be your application, but the mechanics are identical.
Create a small script that writes a heartbeat line every five seconds:
sudo nano /usr/local/bin/heartbeat.sh
Paste in the following:
#!/usr/bin/env bash
while true; do
echo "heartbeat at $(date '+%Y-%m-%d %H:%M:%S')"
sleep 5
done
Save the file, then make it executable. A script that systemd cannot execute will fail with a confusing permission error, so this step is not optional:
sudo chmod +x /usr/local/bin/heartbeat.sh
Putting custom scripts in /usr/local/bin is a common convention on Ubuntu. It keeps your own tools separate from packages managed by apt.
Step 2: Create a Dedicated System User
You could run the service as root, but you should not. If your program is ever compromised, an attacker inherits whatever permissions it runs with. Running as a limited user is one of the easiest security wins available.
Create a system user that has no login shell and no home directory:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin heartbeat
The --system flag creates a user meant for running services rather than for a human to log in as. The nologin shell means nobody can use this account to open a session.
Step 3: Write the Service Unit File
Now for the heart of the tutorial. Service unit files for software you install yourself belong in /etc/systemd/system/. Create one for the heartbeat:
sudo nano /etc/systemd/system/heartbeat.service
Add the following:
[Unit]
Description=Heartbeat demo service
After=network.target
[Service]
Type=simple
User=heartbeat
ExecStart=/usr/local/bin/heartbeat.sh
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Every line earns its place, so let us walk through them.
The [Unit] section holds metadata and ordering. Description is the human-readable name you see in status output. After=network.target tells systemd to start this service only after networking is up, which matters for any program that opens a socket or makes outbound connections.
The [Service] section describes how to run the process. Type=simple is the default and means systemd considers the service started as soon as it launches ExecStart; use it for programs that run in the foreground, which is most of them. User=heartbeat runs the process as the unprivileged account you just created. ExecStart is the exact command to run, and it must be an absolute path because systemd does not use your shell’s PATH. Restart=on-failure tells systemd to relaunch the program automatically if it exits with an error, and RestartSec=5 makes it wait five seconds between attempts so a crashing process does not spin in a tight loop.
The [Install] section controls what happens when you enable the service. WantedBy=multi-user.target is the standard target for background services on a server; it means “start this when the system reaches normal multi-user operation,” which is effectively at boot.
Step 4: Start and Enable the Service
Whenever you create or change a unit file, systemd needs to reload its configuration so it notices the change:
sudo systemctl daemon-reload
Now start the service and check on it:
sudo systemctl start heartbeat.service
sudo systemctl status heartbeat.service
You should see something like this:
● heartbeat.service - Heartbeat demo service
Loaded: loaded (/etc/systemd/system/heartbeat.service; disabled; preset: enabled)
Active: active (running) since Fri 2026-06-12 10:14:02 UTC; 6s ago
Main PID: 4821 (heartbeat.sh)
Tasks: 2 (limit: 4613)
Memory: 512.0K
CPU: 8ms
CGroup: /system.slice/heartbeat.service
├─4821 /usr/bin/bash /usr/local/bin/heartbeat.sh
└─4839 sleep 5
The Active: active (running) line is what you want. Notice it also says disabled, which means it will not yet start at boot. Fix that:
sudo systemctl enable heartbeat.service
To prove it survives a reboot, you can run sudo reboot and check the status again once the machine comes back. The service will be running without you touching anything.
You can stop, restart, and disable it with the matching commands:
sudo systemctl stop heartbeat.service
sudo systemctl restart heartbeat.service
sudo systemctl disable heartbeat.service
Step 5: Read the Logs with journalctl
Because the script prints to standard output, systemd captures every line. To see them:
sudo journalctl -u heartbeat.service
The most useful flag during development is -f, which follows the log live, just like tail -f:
sudo journalctl -u heartbeat.service -f
You will see new heartbeat lines appear every five seconds. Press Ctrl+C to stop following.
A few more flags are worth remembering. To see only today’s entries:
sudo journalctl -u heartbeat.service --since today
To see the last 50 lines and then exit:
sudo journalctl -u heartbeat.service -n 50 --no-pager
This is the same logging system used by services you install with apt and by your own services such as a self-hosted GitHub Actions runner, so once you are comfortable with journalctl you can debug almost anything on the machine.
Step 6: Replace a Cron Job with a systemd Timer
Long-running services are one half of the story. The other half is scheduled tasks, the kind people traditionally write as cron jobs. systemd timers do the same thing with a few real advantages: their runs are logged in the journal, a missed run can be caught up after the machine was off, and the task itself is a normal service unit you can trigger manually for testing.
A timer always works in a pair: a .service that does the work, and a .timer that decides when. The service does not need to be enabled itself; the timer is what you enable.
First, the work. Let us pretend we run a daily cleanup. Create the service:
sudo nano /etc/systemd/system/cleanup.service
[Unit]
Description=Daily temp cleanup
[Service]
Type=oneshot
ExecStart=/usr/bin/find /tmp -type f -atime +7 -delete
Type=oneshot is the key difference from before. It tells systemd this process runs, does its job, and exits, rather than staying alive. The command here deletes files in /tmp that have not been accessed in more than seven days.
Now the timer that drives it:
sudo nano /etc/systemd/system/cleanup.timer
[Unit]
Description=Run temp cleanup daily
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.target
OnCalendar defines the schedule. The format here means “every day at 02:30.” The syntax is flexible: OnCalendar=hourly, OnCalendar=weekly, or OnCalendar=Mon *-*-* 09:00:00 for every Monday at 9 AM all work. Persistent=true is the feature cron cannot match: if the machine was powered off at 02:30, systemd runs the job once after the next boot instead of skipping it entirely.
systemd automatically pairs cleanup.timer with cleanup.service because they share a name. If you ever need to point a timer at a differently named service, add a Unit= line under [Timer].
Reload, then enable and start the timer (not the service):
sudo systemctl daemon-reload
sudo systemctl enable --now cleanup.timer
The --now flag is a handy shortcut that enables and starts in one command. Confirm it is scheduled:
systemctl list-timers cleanup.timer
NEXT LEFT LAST PASSED UNIT ACTIVATES
Sat 2026-06-13 02:30:00 UTC 16h left - - cleanup.timer cleanup.service
Before trusting any timer, test the work itself by running its service by hand. This runs the cleanup immediately, no waiting required:
sudo systemctl start cleanup.service
sudo journalctl -u cleanup.service -n 20 --no-pager
If the manual run does what you expect, the timer will too. Scheduled tasks like database dumps or automated encrypted backups with restic are a perfect fit for this pattern.
Common Mistakes and Troubleshooting
The service fails with status 203/EXEC. This almost always means systemd could not run the path in ExecStart. Check that the file exists, the path is absolute, and the file is executable with chmod +x. A relative path like heartbeat.sh will never work here.
The service starts and immediately stops. Run systemctl status and journalctl -u <name> to see the error. A frequent cause is Type=simple on a program that forks into the background and exits the parent; for that style of program use Type=forking. Another is a script that exits cleanly when you expected it to loop.
Changes to the unit file seem to do nothing. You forgot sudo systemctl daemon-reload. systemd reads unit files into memory, so it does not see edits until you reload. This catches almost everyone at least once.
The service runs fine manually but fails as a service. Remember that systemd does not load your shell profile, so environment variables and PATH are minimal. Use absolute paths everywhere, and set any variables your program needs with Environment= lines in the [Service] section, like Environment=NODE_ENV=production.
The timer is enabled but never fires. Confirm you enabled the .timer and not the .service, and double-check the OnCalendar syntax. You can validate a calendar expression without guessing:
systemd-analyze calendar "*-*-* 02:30:00"
It prints the next few times the expression will match, which makes typos obvious.
Best Practices
Always run services as an unprivileged user. Only use root when the program genuinely needs it. The dedicated system user from Step 2 should be your default habit.
Add lightweight sandboxing. systemd can lock down a service with a few extra lines in the [Service] section. NoNewPrivileges=true stops the process from gaining new privileges, ProtectSystem=strict makes most of the filesystem read-only, and PrivateTmp=true gives the service its own isolated /tmp. These cost nothing and meaningfully reduce the damage a compromised service can do.
Set a restart policy that matches reality. Restart=on-failure is a sensible default for most services. Use Restart=always for something that must never stay down, but keep RestartSec at a few seconds so a hard-failing process does not hammer your CPU restarting over and over.
Prefer timers over cron for anything important. The journal integration and the Persistent=true catch-up behavior make timers far easier to trust and debug than a silent line in a crontab.
Keep unit files in version control. Treat the contents of /etc/systemd/system/ as configuration that belongs in a Git repository or an Ansible playbook, so rebuilding a server is repeatable rather than a memory exercise.
Conclusion
You started with a plain script that would have died on the first reboot, and you turned it into a proper managed service: running as its own user, restarting on failure, starting at boot, and streaming its logs into the journal. Then you swapped a cron job for a systemd timer that logs every run and catches up on missed schedules.
These are the same tools the operating system uses to run itself, which means the skill transfers directly to managing real applications, whether that is a Node.js API, a Python worker, or a third-party agent. From here, good next steps are exploring the sandboxing directives in man systemd.exec, learning systemd-analyze blame to see what slows your boot, and converting one of your existing background processes over to a unit file. Once you start thinking in units, keeping software running reliably on Ubuntu stops being a chore and becomes a solved problem.