There’s a quiet satisfaction in watching a script you cobbled together at 11:47 PM become a proper system service that starts on boot, restarts on failure, and logs neatly to the journal. That’s the magic of systemd — once you understand how .service files work, you can turn literally anything into a managed daemon.
In this tutorial, we’ll write systemd services from scratch for three real-world scenarios: a Python script, a Node.js app, and a periodic task triggered by a timer. By the end, you’ll have a template you can reuse for any future projects.
Prerequisites
- Any Linux distribution with systemd (Ubuntu 16.04+, Debian 8+, Fedora 15+, Arch, etc.)
- Root or sudo access
- A script or application you want to run as a service
- Basic comfort with a terminal
Why systemd?
Before systemd, we had init scripts, then Upstart, then shell scripts with nohup and prayer. systemd gives us:
- Automatic restart on crash (no more dead daemons)
- Dependency ordering — start the database before the app that needs it
- Resource control — limit CPU, memory, I/O per service
- Unified logging —
journalctlcaptures stdout and stderr automatically - Timer units — a more reliable, auditable replacement for cron
Part 1: A Python Script as a Service
Let’s say you have a Python script at /opt/myapp/monitor.py that watches a directory and sends notifications. You want it to run continuously in the background.
Step 1: Create the service file
Systemd service files live in /etc/systemd/system/. Create a new one:
sudo nano /etc/systemd/system/monitor.service
Paste in the following:
[Unit]
Description=Directory Monitor Service
After=network.target
[Service]
Type=simple
User=noodly
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/python3 /opt/myapp/monitor.py
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
Understanding each field
- Description — human-readable name shown in
systemctl status - After=network.target — wait for network before starting (adjust as needed)
- Type=simple — systemd considers the service “started” as soon as ExecStart runs. For forking daemons, use
Type=forking - User= — run as this user instead of root (always a good idea)
- WorkingDirectory= — set the cwd for the process
- ExecStart= — the command to run. Always use absolute paths.
- Restart=on-failure — restart only on non-zero exit codes or crashes, not on clean stops
- RestartSec=5 — wait 5 seconds before restarting (prevents restart loops)
- StandardOutput/StandardError=journal — capture all output to journald
- WantedBy=multi-user.target — start during normal multi-user boot (the equivalent of “runlevel 3”)
Step 2: Enable and start the service
sudo systemctl daemon-reload
sudo systemctl enable monitor.service
sudo systemctl start monitor.service
daemon-reload tells systemd to re-read all unit files — you must run this after every edit to a .service file.
Step 3: Check it’s running
sudo systemctl status monitor.service
You should see active (running) in green. To view logs:
journalctl -u monitor.service -f
The -f flag follows the logs in real time (like tail -f).
Part 2: A Node.js Web App
Same pattern, different runtime. Say you have an Express app at /opt/webapp/:
[Unit]
Description=Web Application Server
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/webapp
ExecStart=/usr/bin/node /opt/webapp/server.js
Restart=always
RestartSec=3
Environment=NODE_ENV=production
Environment=PORT=3000
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
Notice the Environment= lines — these are how you pass environment variables to your service without cluttering your code with config files. You can also use EnvironmentFile=/etc/myapp/env to load variables from a file (one KEY=value per line).
Part 3: systemd Timers (Better Than Cron)
Instead of a cron job, you can create a timer unit that triggers a service unit. This gives you logging, dependency management, and the ability to check when a task last ran.
Let’s say you want to run a backup script every day at 2:00 AM.
Step 1: Create the service (the thing that runs)
sudo nano /etc/systemd/system/backup.service
[Unit]
Description=Daily Backup
[Service]
Type=oneshot
ExecStart=/opt/scripts/backup.sh
User=root
Type=oneshot tells systemd: “this command runs and exits; it’s not a daemon.” systemd will wait for it to finish before marking the service as done.
Step 2: Create the timer (the schedule)
sudo nano /etc/systemd/system/backup.timer
[Unit]
Description=Run daily backup at 2:00 AM
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
[Install]
WantedBy=timers.target
- OnCalendar= uses a flexible date/time format.
*-*-* 02:00:00means “every day at 2:00 AM.” You can also useMon,Fri *-*-* 02:00:00for specific days, or*:00/30for every 30 minutes. - Persistent=true — if the system was off at 2:00 AM, run the task on boot instead. No missed backups.
Step 3: Enable the timer (not the service!)
sudo systemctl daemon-reload
sudo systemctl enable backup.timer
sudo systemctl start backup.timer
Check timer status:
systemctl list-timers --all
This shows all timers, their last trigger time, and when they’ll fire next. Incredibly useful for debugging “why didn’t my cron job run?” mysteries.
Common Pitfalls and Troubleshooting
1. “Failed to start: Unit not found”
Did you run sudo systemctl daemon-reload after creating or editing the file? Systemd caches unit files — it won’t see your changes until you reload.
2. “Permission denied” even with User= set
The User= directive changes the OS user, but that user still needs filesystem permissions. Check with:
sudo -u noodly ls -la /opt/myapp/
3. Service starts but immediately exits
Check the logs first:
journalctl -u monitor.service --no-pager -n 50
Common causes: wrong path in ExecStart=, missing Python/Node dependencies, or the script exiting cleanly (systemd sees a clean exit as “success” and won’t restart unless you configured Restart=always).
4. Environment variables are missing
Systemd services don’t inherit your shell’s environment. Always set variables explicitly with Environment= or EnvironmentFile=.
5. Timer didn’t fire
Check if the timer is active:
systemctl status backup.timer
If it shows inactive (dead), you probably enabled the service instead of the timer. Remember: enable the .timer unit, not the .service unit.
Quick Reference: Useful systemctl Commands
# Start/stop/restart
sudo systemctl start myapp.service
sudo systemctl stop myapp.service
sudo systemctl restart myapp.service
# Enable/disable at boot
sudo systemctl enable myapp.service
sudo systemctl disable myapp.service
# View status and logs
systemctl status myapp.service
journalctl -u myapp.service -f
journalctl -u myapp.service --since "1 hour ago"
# Edit a service in place (creates an override)
sudo systemctl edit myapp.service
# See all failed units
systemctl --failed
Wrapping Up
Once you internalize the pattern — write a .service file, reload, enable, start — you’ll find yourself reaching for systemd instead of nohup, screen sessions, or fragile init scripts. It’s one of those skills that pays compound interest: every service you create this way is one more thing that “just works” after a reboot, a power outage, or a 3 AM crash.
Start with one script. Write the service file. Watch it hum along. Then never think about it again — which is exactly how infrastructure should feel.
Leave a Reply