How to Create a systemd Service: Unit Files for Python, Node.js and Go

Create a systemd service for any app, then understand every line: Type=exec, restarts and start limits, a four-line sandbox, journalctl, timers that replace cron, user services and a fix for each common error.

Title card reading “systemd service unit files” for the HourlyVPS guide to creating a systemd service for Python, Node.js and Go apps

To create a systemd service, write a unit file at /etc/systemd/system/myapp.service with [Unit], [Service] and [Install] sections, run sudo systemctl daemon-reload, then sudo systemctl enable --now myapp. systemd then starts your app at every boot, restarts it after a crash when you set Restart=on-failure, and sends everything it prints to the journal, where journalctl -u myapp reads it.

The guide builds one service step by step, then explains every line. It targets Ubuntu 24.04 LTS, which ships systemd 255; Debian 12 ships systemd 252 and Debian 13 ships systemd 257, and the few lines that need a newer version are marked. Every directive was checked against the official manuals on October 3, 2026.

Key takeaways

  • A systemd service is a unit file in /etc/systemd/system/NAME.service; after sudo systemctl daemon-reload, sudo systemctl enable --now NAME starts it now and at every boot.
  • Use Type=exec for long-running apps, as the systemd manual recommends, so systemctl start fails loudly when the binary or the user is missing; use Type=notify only if the app reports readiness.
  • With only Restart=on-failure, a crashing app burns the default 5 starts in 10 seconds in about half a second; StartLimitIntervalSec= and StartLimitBurst= belong in [Unit] and trip only when the burst fits inside the interval.
  • NoNewPrivileges=yes, ProtectSystem=strict, ProtectHome=yes and PrivateTmp=yes cost four lines; give the app a writable StateDirectory= and score the result with systemd-analyze security.
  • A .timer unit replaces cron with per-unit logs, catch-up runs (Persistent=true) and no overlapping runs; check any schedule with systemd-analyze calendar before you enable it.

You need a Linux server you can reach as a sudo user. If this is a fresh VPS, connect over SSH and run through the VPS security checklist first; this guide does not repeat SSH keys, UFW or updates.

How to create a systemd service in 5 steps

The example is a small Python web app with no dependencies, so every step has a check you can run. Swap in your own program at step 2; the templates below cover a Python virtual environment, Node.js and a Go binary.

Step 1: Create a user for the service

A service should not run as root. Create a system account with no login shell and no home directory:

sudo useradd --system --user-group --no-create-home --shell /usr/sbin/nologin myapp

If you would rather not manage an account at all, DynamicUser=yes lets systemd allocate one each time the service starts; see the hardening section for the trade-offs.

Step 2: Put the program where the service can read it

Keep the code in /opt/myapp, owned by root, so the service can read it but not rewrite it. Create the file with sudo nano /opt/myapp/app.py after creating the folder:

sudo install -d -m 755 /opt/myapp
sudo nano /opt/myapp/app.py

Paste this demo app. It answers on 127.0.0.1:8000 and keeps a hit counter in the state folder that systemd will create for it:

#!/usr/bin/env python3
"""Demo app for the systemd guide: a hit counter on 127.0.0.1."""
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path

STATE = Path(os.environ.get("STATE_DIRECTORY", "/tmp")) / "hits.txt"
PORT = int(os.environ.get("PORT", "8000"))


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        hits = int(STATE.read_text()) + 1 if STATE.exists() else 1
        STATE.write_text(str(hits))
        body = f"hello from systemd, hit {hits}\n".encode()
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)


if __name__ == "__main__":
    print(f"listening on 127.0.0.1:{PORT}, state in {STATE}", flush=True)
    HTTPServer(("127.0.0.1", PORT), Handler).serve_forever()

Settings go in a separate environment file. Make it readable by root only: systemd reads it as root before it switches to the myapp user, so the app never needs access to the file itself:

sudo install -d -m 755 /etc/myapp
sudo install -m 600 /dev/null /etc/myapp/myapp.env
echo 'PORT=8000' | sudo tee /etc/myapp/myapp.env > /dev/null

Step 3: Write the unit file

Create /etc/systemd/system/myapp.service with sudo nano. The file name, minus .service, becomes the service name:

[Unit]
Description=My app (demo hit counter)
StartLimitIntervalSec=5min
StartLimitBurst=10

[Service]
Type=exec
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
EnvironmentFile=/etc/myapp/myapp.env
ExecStart=/usr/bin/python3 /opt/myapp/app.py
Restart=on-failure
RestartSec=5s
StateDirectory=myapp
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes

[Install]
WantedBy=multi-user.target

Every line is explained in the anatomy section. One shortcut: sudo systemctl edit --force --full myapp.service creates the same file in your editor and reloads systemd when you save, as the systemctl(1) manual describes.

Step 4: Check the file, then enable and start it

systemd-analyze verify reports unknown sections, misspelled directives and missing executables before anything runs:

sudo systemd-analyze verify /etc/systemd/system/myapp.service

No output means no problems found. Tell systemd to reread its unit files, then enable the service for boot and start it now:

sudo systemctl daemon-reload
sudo systemctl enable --now myapp.service

Step 5: Prove it works, restarts and survives a reboot

systemctl status myapp.service
curl -s http://127.0.0.1:8000/
sudo journalctl -u myapp.service -n 20 --no-pager

Look for active (running) in the status, a hello from systemd, hit 1 reply, and the listening on line plus one request line in the journal. Now kill the process the hard way. SIGKILL counts as a failure, so Restart=on-failure brings the app back after the 5-second RestartSec=:

sudo systemctl kill --signal=SIGKILL myapp.service
sleep 7
systemctl status myapp.service --no-pager | head -n 3

Finally run sudo reboot, reconnect and curl the app again. The counter keeps counting, because /var/lib/myapp/hits.txt lives in the state directory that survives restarts and reboots.

Start, stop, restart and reload the service

A handful of systemctl verbs cover daily work. You can leave off .service, because systemctl assumes it. Do not confuse reload with daemon-reload: the first asks your app to reread its own config, the second makes systemd reread unit files, a distinction the systemctl(1) manual spells out.

TaskCommandGood to know
Start or stop it nowsudo systemctl start myapp, sudo systemctl stop myappA manual stop is never undone by Restart=; an enabled unit still starts at the next boot
Restart after a code or env-file changesudo systemctl restart myappStop, then start; starts it if it was not running
Reload the app’s own config without a restartsudo systemctl reload myappWorks only if the unit has ExecReload= or Type=notify-reload
Apply edits to the unit filesudo systemctl daemon-reload, then restartA plain restart keeps running the old unit definition
Start at boot, or stop starting at bootsudo systemctl enable myapp, sudo systemctl disable myappAdd --now to also start or stop it
Check from a scriptsystemctl is-active myapp, systemctl is-enabled myappExit code 0 means active or enabled
Everyday systemctl commands, per systemctl(1) and systemd.service(5) for systemd 255 (Ubuntu 24.04), checked October 3, 2026.

Systemd service file anatomy: what each line does

A unit file is an ini-style text file. [Unit] and [Install] are common to every unit type and documented in systemd.unit(5); [Service] options come from systemd.service(5) and, for the process environment and sandboxing, systemd.exec(5).

LineSectionWhat it does
Description=[Unit]The name shown in systemctl status and in log lines.
StartLimitIntervalSec=StartLimitBurst=Rate limit on starts: no more than Burst starts per interval. Both belong in [Unit] (details).
Type=exec[Service]Treats the service as started once the program has actually been executed, so a missing binary or user makes systemctl start fail.
User=, Group=[Service]The account the process runs as. The default for system services is root.
WorkingDirectory=[Service]The current directory for the process. The default is / for system services, which breaks apps that open files by relative path.
EnvironmentFile=[Service]Reads KEY=value lines from a file. A missing file stops the start unless the path is prefixed with -.
ExecStart=[Service]The command that starts the app. Not a shell: no pipes, redirects or &&.
Restart=on-failure[Service]Restarts after a crash, a non-zero exit, a timeout or a watchdog failure, never after systemctl stop.
RestartSec=5s[Service]Wait before each restart. The default is 100 ms.
StateDirectory=myapp[Service]Creates /var/lib/myapp, owned by User=, writable even under ProtectSystem=strict, and passes its path as $STATE_DIRECTORY.
NoNewPrivileges=, ProtectSystem=, ProtectHome=, PrivateTmp=[Service]The four-line sandbox explained in the hardening section.
WantedBy=multi-user.target[Install]Read only by systemctl enable, which links the service into the normal boot target so it starts at boot.
The myapp.service unit, line by line. Behavior per systemd.unit(5), systemd.service(5) and systemd.exec(5) for systemd 255 (Ubuntu 24.04), checked October 3, 2026.

Where do systemd service files go?

PathWhat lives thereEdit it?
/etc/systemd/system/Units you write as the administrator. They override same-named units below.Yes: your own services go here
/etc/systemd/system/NAME.service.d/*.confDrop-ins: partial overrides for any unit, created by systemctl edit NAMEYes: the safe way to change a packaged unit
/usr/lib/systemd/system/Units installed by packages (PostgreSQL, nginx, Docker)No: package upgrades overwrite it
~/.config/systemd/user/A user’s own user servicesYes, as that user
/etc/systemd/user/User units the administrator provides for all usersRarely
Unit load paths from systemd.unit(5). /etc wins over /usr/lib for the same file name.

Which Type= should you use?

Type= tells systemd when the service counts as started, which decides when systemctl start returns and when units ordered after it may start. The systemd.service manual is direct about the usual choice:

It is recommended to use Type=exec for long-running services, as it ensures that process setup errors (e.g. errors such as a missing service executable, or missing user) are properly tracked.

systemd.service(5), systemd 255
TypeStarted when…Use it for
simplethe process has been forked, before the binary even runs (the default when ExecStart= is set)Legacy units. systemctl start reports success even if the binary is missing.
execthe binary has been executed (systemd 240+)Any long-running Python, Node.js or Go app. The default choice in this guide.
notifythe app sends READY=1 to systemdApps that other units depend on, so they start only once it accepts connections (example).
notify-reloadlike notify; systemctl reload sends SIGHUP and waits for the app to confirm (systemd 253+)Apps that reload config on SIGHUP and implement the reload notification. Not on Debian 12.
oneshotthe process has exitedScripts and jobs, including timer jobs. Several ExecStart= lines are allowed.
forkingthe parent process exits after forking a daemonOld daemons that background themselves. The manual discourages it in favor of notify or dbus.
Service types per systemd.service(5). Version numbers from the systemd NEWS file.

ExecStart= is not a shell command

systemd splits ExecStart= into words itself and runs the program directly. The manual lists what that means in practice:

  • The program must be an absolute path, or a bare name found in systemd’s fixed search path, which includes /usr/local/bin and /usr/bin. A relative path such as ./app is rejected.
  • Pipes, redirection (>, >>), && and background & are not supported. Wrap them explicitly: ExecStart=/bin/sh -c 'mycmd | tee -a /var/log/myapp/out.log'.
  • ${VAR} expands to one argument and $VAR splits at whitespace; the variable comes from Environment= or EnvironmentFile=, not from your login shell. Write $$ for a literal dollar sign.
  • A - prefix (ExecStartPre=-/usr/bin/false) turns a failure of that command into success. Do not use ExecStartPre= for long-running processes; systemd kills them before the next command runs.

Environment variables and secrets

A service does not read ~/.bashrc or your login environment, so a program that works in your shell can fail under systemd for lack of one variable. Set what it needs explicitly:

[Service]
Environment=NODE_ENV=production "GREETING=hello world"
EnvironmentFile=/etc/myapp/myapp.env
EnvironmentFile=-/etc/myapp/optional.env

In the file, one KEY=value per line; lines starting with # or ; are comments, and single- or double-quoted values follow POSIX shell quoting rules. Values from EnvironmentFile= override Environment=. Unit files are normally world-readable, so never put a password on an Environment= line. systemd.exec(5) goes further and says environment variables are not suitable for secrets at all, because they are exposed over D-Bus and inherited by child processes; it recommends LoadCredential=. Our Discord bot guide shows how to pass a token with LoadCredential= and read it from $CREDENTIALS_DIRECTORY.

Wait for the network only if you need it

An app that only listens on 0.0.0.0, 127.0.0.1 or [::] needs no network ordering: systemd’s network-online guide notes those addresses are always available. An app that must reach the internet the moment it starts, such as a bot that logs in to an API, should wait until the network is up:

[Unit]
Wants=network-online.target
After=network-online.target

Both lines are needed: Wants= pulls the target in and After= orders the start after it. network.target alone says almost nothing about connectivity at boot.

Systemd service file examples for Python, Node.js and Go

Each template assumes a myapp user (step 1), code in /opt/myapp and settings in /etc/myapp/myapp.env. Real-world versions of these units run the Discord bot, the Telegram bot, the Minecraft server and the Valheim dedicated server in our other guides.

Python in a virtual environment

Point ExecStart= at the virtual environment’s own interpreter; you never activate a venv in a unit file. On Ubuntu 24.04, python3 -m venv needs the python3-venv package first.

sudo apt install python3-venv
sudo python3 -m venv /opt/myapp/venv
sudo /opt/myapp/venv/bin/pip install -r /opt/myapp/requirements.txt
[Unit]
Description=My Python app

[Service]
Type=exec
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
Environment=PYTHONUNBUFFERED=1
EnvironmentFile=/etc/myapp/myapp.env
ExecStart=/opt/myapp/venv/bin/python /opt/myapp/app.py
Restart=on-failure
RestartSec=5s
StateDirectory=myapp
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes

[Install]
WantedBy=multi-user.target

PYTHONUNBUFFERED=1 has the same effect as python -u: output reaches the journal line by line instead of in delayed blocks. For a WSGI app served by Gunicorn, use ExecStart=/opt/myapp/venv/bin/gunicorn --bind 127.0.0.1:8000 app:app with Type=notify, which Gunicorn’s deployment docs use because Gunicorn reports readiness itself.

Node.js

Find the real path of node with command -v node. Ubuntu’s and NodeSource’s packages install /usr/bin/node; a version manager such as nvm installs under your home directory, which ProtectHome=yes hides from the service, so the start fails with status=203/EXEC. Install Node.js system-wide for services, or copy the binary to /usr/local/bin.

[Unit]
Description=My Node.js app

[Service]
Type=exec
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
Environment=NODE_ENV=production
EnvironmentFile=/etc/myapp/myapp.env
ExecStart=/usr/bin/node /opt/myapp/server.js
Restart=on-failure
RestartSec=5s
StateDirectory=myapp
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes

[Install]
WantedBy=multi-user.target

Install dependencies as root inside /opt/myapp (sudo npm ci --omit=dev), so the running app cannot modify its own node_modules. Do not add MemoryDenyWriteExecute=yes to a Node.js unit: systemd.exec(5) says it is incompatible with JIT engines, and V8 is one.

A Go (or any compiled) binary

A compiled program is the simplest case. Copy the binary to /usr/local/bin with root ownership; the unit then needs no interpreter path:

sudo install -m 755 ./myapp /usr/local/bin/myapp

Start from the Python template, delete the PYTHONUNBUFFERED line, and change these lines:

[Unit]
Description=My Go app

[Service]
WorkingDirectory=/var/lib/myapp
ExecStart=/usr/local/bin/myapp

The working directory is now the state directory itself, so files the program writes by relative path land somewhere writable. systemd creates /var/lib/myapp before it changes into it.

Type=notify without a library (Python)

With Type=notify, systemd waits for the app to say READY=1 before it marks the service started. The protocol is one datagram to the Unix socket named in $NOTIFY_SOCKET. This function is trimmed from the standalone Python implementation in the sd_notify(3) manual (published there under MIT-0) and uses only the standard library:

import os
import socket


def sd_notify(message: bytes) -> None:
    """Send a state change to systemd; do nothing outside systemd."""
    path = os.environ.get("NOTIFY_SOCKET")
    if not path:
        return
    if path[0] == "@":  # abstract socket namespace
        path = "\0" + path[1:]
    with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_CLOEXEC) as sock:
        sock.connect(path)
        sock.sendall(message)


# ...open the database, bind the port, load the config...
sd_notify(b"READY=1")

Then set Type=notify in the unit; systemd sets NotifyAccess=main by itself. Add WatchdogSec=30s and call sd_notify(b"WATCHDOG=1") more often than that, and systemd kills and restarts an app that hangs without crashing. Node.js has no built-in way to send this datagram, so Node.js services usually stay on Type=exec.

Restart=on-failure, RestartSec and start limits explained

Restart= decides which kinds of exit trigger a restart. This table is the one in systemd.service(5), condensed:

Exit causenoalwayson-successon-failureon-abnormalon-aborton-watchdog
Clean exit: code 0, or SIGHUP, SIGINT, SIGTERM, SIGPIPENoYesYesNoNoNoNo
Unclean exit code (1 to 255)NoYesNoYesNoNoNo
Unclean signal (SIGKILL, segfault)NoYesNoYesYesYesNo
Timeout (start, stop, reload)NoYesNoYesYesNoNo
Watchdog timeoutNoYesNoYesYesNoYes
Which exits restart the service, per systemd.service(5) for systemd 255. None of them restart after systemctl stop. Newer manuals add an OOM-kill row that restarts under always, on-failure and on-abnormal.

The manual calls on-failure “the recommended choice for long-running services”. Use always only for programs that should never exit on their own, such as a bot. Two details trip people up: a clean SIGTERM from outside is not a failure, and Type=oneshot services reject always and on-success.

Why your service stops restarting: the start-limit arithmetic

Every start counts against a rate limit, including restarts and your own systemctl start. The defaults in systemd-system.conf(5) are 5 starts per 10 seconds, and RestartSec= defaults to 100 ms. So a unit with only Restart=on-failure and an app that crashes at once burns its 5 starts in about half a second, and systemd gives up:

myapp.service: Start request repeated too quickly.
myapp.service: Failed with result 'start-limit-hit'.

The limit can only trip if the burst fits inside the interval. Our arithmetic: with a crash after R seconds of running, consecutive starts are RestartSec + R apart, so the start that exceeds the burst comes StartLimitBurst × (RestartSec + R) seconds after the first one. The limit trips only if that is shorter than StartLimitIntervalSec. That gives four useful profiles:

GoalSettingsCrash loop (app dies at once)Occasional crashes
systemd defaultsRestart=on-failure only5 starts in about 0.4 s; the 6th, at about 0.5 s, is refused and the unit stays failedRestarted
Retry foreverRestartSec=5s, default limitsNever trips (5 × 5 s = 25 s, longer than 10 s): about 17,280 attempts a dayRestarted
Give up on a tight loopRestartSec=5s plus StartLimitIntervalSec=5min and StartLimitBurst=10 in [Unit]10 starts in 45 s; the 11th, at about 50 s, is refusedRestarted while each run lasts more than about 25 s (10 × 30 s = 300 s)
Back off, keep trying (systemd 254+)RestartSec=2s, RestartSteps=5, RestartMaxDelaySec=1minWaits of 2, 3.9, 7.8, 15.4 and 30.4 s, then 60 s per attempt: at most 1,440 a dayRestarted, but each crash climbs one step: from the sixth automatic restart on, every wait is 60 s
Restart profiles. Defaults from systemd-system.conf(5); backoff steps from the RestartSteps= formula in systemd.service(5); attempt counts are our arithmetic for an app that crashes immediately.

The quick-start unit uses the third profile: a broken config fails fast and visibly, while a crash every few minutes still gets restarted. The fourth needs RestartSteps= and RestartMaxDelaySec=, added in systemd 254: fine on Ubuntu 24.04 (255) and Debian 13 (257), ignored with a warning on Debian 12 (252). Backoff counts every automatic restart since the last manual start, not only the latest crash loop: systemd’s service code (v255) resets the counter, which systemctl show myapp -p NRestarts prints, only on a manual start or systemctl reset-failed.

The [Service] trap: StartLimitIntervalSec= and StartLimitBurst= are [Unit] options. systemd’s own parser (load-fragment-gperf.gperf.in, v255) still accepts the legacy spellings StartLimitInterval= and StartLimitBurst= under [Service], but not StartLimitIntervalSec=. Put both under [Service] and you get your burst with the default 10-second interval, plus one line in the journal: Unknown key name 'StartLimitIntervalSec' in section 'Service', ignoring. systemd-analyze verify prints the same warning, so run it.

After you fix whatever caused a crash loop, clear the counter and start again. StartLimitIntervalSec=0 turns the limit off entirely:

sudo systemctl reset-failed myapp.service
sudo systemctl start myapp.service

How to harden a systemd service

The four sandbox lines in every template cost nothing at runtime and shrink what a bug or a compromised dependency can touch. Each comes from systemd.exec(5), which recommends ProtectSystem= and ProtectHome= for all long-running services:

OptionWhat it doesWhat it can breakFix
NoNewPrivileges=yesThe process and its children can never gain privileges through setuid, setgid or file capabilitiesAn app that shells out to sudo or another setuid helperDo that step outside the service
ProtectSystem=strictMounts the whole file system read-only for the service, except /dev, /proc and /sysRead-only file system (Python OSError: [Errno 30], Node.js EROFS) when it writesStateDirectory=, CacheDirectory=, LogsDirectory= or ReadWritePaths=
ProtectHome=yesMakes /home, /root and /run/user empty and inaccessibleCode, a venv or a Node.js install under /home: 203/EXEC or 200/CHDIRMove it to /opt, or use ProtectHome=read-only
PrivateTmp=yesGives the service its own /tmp and /var/tmp, deleted when it stopsSharing a socket or file in /tmp with another programRuntimeDirectory=myapp for /run/myapp
ReadWritePaths=/srv/uploadsAllow-lists one writable path under ProtectSystem=strictstatus=226/NAMESPACE if the path does not existCreate it first, or write -/srv/uploads to skip it when missing
StateDirectory=myappCreates /var/lib/myapp owned by User= and sets $STATE_DIRECTORYIf the folder exists with another owner, systemd changes ownership recursivelyPoint it at a folder used only by this service
Sandbox options per systemd.exec(5) for systemd 255. Exit statuses from its Process Exit Codes table.

Then measure. systemd-analyze security scores a service from 0.0 (tight) to 10.0 (wide open) and lists every setting it checked. The manual warns the number is an estimate of systemd’s own sandboxing, not proof that a service is or is not vulnerable:

systemd-analyze security myapp.service

To tighten further, add the options below one or two at a time with sudo systemctl edit myapp.service, restart, exercise the app, and read the journal. Test each step: an empty CapabilityBoundingSet= removes every capability:

[Service]
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
LockPersonality=yes
CapabilityBoundingSet=
SystemCallArchitectures=native

Two related options belong in the same review. DynamicUser=yes allocates a throwaway user each start and implies ProtectSystem=strict, ProtectHome=read-only, a private /tmp and NoNewPrivileges=yes, so any state must live in StateDirectory=. MemoryMax= and CPUQuota= cap resources so a runaway process cannot starve SSH; the AI agent sandbox shows both with sizing for a small VPS.

How to read systemd service logs with journalctl

Everything a service writes to stdout and stderr goes to the journal by default, with timestamps, across restarts. Use sudo, or add your user to the adm or systemd-journal group, to read system services’ logs:

GoalCommand
Follow live, like tail -fsudo journalctl -u myapp -f
Last 50 lines, no pagersudo journalctl -u myapp -n 50 --no-pager
Jump to the end with explanations (the usual first look at a failure)sudo journalctl -xeu myapp
A time windowsudo journalctl -u myapp --since "1 hour ago" or --since today --until "10:30"
This boot, or the previous onesudo journalctl -u myapp -b, -b -1
Only the message textsudo journalctl -u myapp -o cat
Only warnings and errorssudo journalctl -u myapp -p warning
A user servicejournalctl --user -u myapp
Disk used by the journal, and trimming itjournalctl --disk-usage, sudo journalctl --vacuum-time=2weeks
journalctl options per journalctl(1), checked October 3, 2026.

A -p filter only sees priorities the app set. Plain print() or console.log() output is stored at the info level, so a Python traceback does not count as an error; search the text with grep instead. Logs that never appear usually mean output buffering (see PYTHONUNBUFFERED above). The Discord bot guide walks through reading a real bot’s tracebacks.

Systemd timers instead of cron: OnCalendar examples

A timer is a second unit that starts a service on a schedule. Use it for anything you would put in a crontab: cleanups, reports, a nightly restic backup or a daily game-server restart. The job is a Type=oneshot service with no [Install] section. This one deletes the demo app’s temporary files older than 7 days:

# /etc/systemd/system/myapp-cleanup.service
[Unit]
Description=Delete myapp temp files older than 7 days

[Service]
Type=oneshot
User=myapp
Group=myapp
ExecStart=/usr/bin/find /var/lib/myapp -name '*.tmp' -mtime +7 -delete
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/var/lib/myapp

systemd removes the single quotes around '*.tmp' and does no globbing, so find receives the pattern itself, as it should. The timer has the same name with .timer, which is how it knows which service to start:

# /etc/systemd/system/myapp-cleanup.timer
[Unit]
Description=Run myapp-cleanup every day at 03:30

[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
RandomizedDelaySec=15min

[Install]
WantedBy=timers.target

Check the schedule, enable the timer (not the service), and run the job once by hand to test it:

systemd-analyze calendar --iterations=3 '*-*-* 03:30:00'
sudo systemctl daemon-reload
sudo systemctl enable --now myapp-cleanup.timer
systemctl list-timers myapp-cleanup.timer
sudo systemctl start myapp-cleanup.service
sudo journalctl -u myapp-cleanup.service -n 20 --no-pager

Persistent=true runs a missed job at the next boot if the server was off at 03:30; it only works with OnCalendar=. RandomizedDelaySec= spreads starts over 15 minutes so several jobs do not hit the disk at once. Without it, AccuracySec= still lets systemd shift the start by up to 1 minute (the default) to batch wake-ups.

Cron to OnCalendar translations

crontab lineOnCalendar= valueMeaning
*/5 * * * **:0/5Every 5 minutes, on the clock (:00, :05, …)
0 * * * *hourlyEvery hour on the hour (*-*-* *:00:00)
30 3 * * **-*-* 03:30:00Every day at 03:30
0 0 * * *dailyEvery day at midnight
0 4 * * 1Mon *-*-* 04:00:00Mondays at 04:00
0 9 * * 1-5Mon..Fri *-*-* 09:00:00Weekdays at 09:00
0 2 1 * **-*-01 02:00:00The 1st of each month at 02:00
@rebootOnBootSec=2min (not OnCalendar=)Once, 2 minutes after boot
Calendar syntax and the hourly/daily shorthands from systemd.time(7). Check any expression with systemd-analyze calendar before you enable the timer.

Times are in the server’s time zone, which timedatectl shows; many VPS images default to UTC. Append a zone to pin a job to a fixed local time, for example OnCalendar=*-*-* 03:30:00 Europe/Istanbul for a server in our Istanbul location; timedatectl list-timezones prints every valid name.

Is a systemd timer better than cron?

cronsystemd timer
SetupOne line in a crontabTwo small files
Server was off at run timeThe run is skippedPersistent=true runs it at the next boot
Previous run still goingStarts another copyLeaves the running service alone; no overlap
OutputMailed to the crontab owner, or lost without a mail setupIn the journal, per unit: journalctl -u NAME
Sandbox, user, memory capNot built inEvery [Service] option: User=, ProtectSystem=, MemoryMax=
Run it now to testCopy the command into a shell, with a different environmentsystemctl start NAME.service, same environment as the schedule
Spread start timesNot built inRandomizedDelaySec=
Timer behavior per systemd.timer(5); a timer does not restart a service that is still active.

Cron is the better fit for a quick one-liner, for crontabs you already maintain, and for scripts that must also run on systems without systemd. For anything you need to debug later, the journal and the no-overlap rule usually win. Lab 7 of our Linux labs schedules a health-check script with a timer instead of cron, on a throwaway server.

User services vs system services

Everything above is a system service, managed by PID 1 with sudo systemctl. Each logged-in user also gets a personal service manager that runs units from ~/.config/systemd/user/ without root. That suits a developer’s own tools, but it changes several rules:

System serviceUser service
Unit location/etc/systemd/system/~/.config/systemd/user/
Managed withsudo systemctl …systemctl --user …, no sudo
Runs asAny account via User= (root by default)Always that user; User= cannot switch
WantedBy=multi-user.targetdefault.target
Starts at bootYes, once enabledOnly with lingering enabled; otherwise it starts at login and stops after logout
Logssudo journalctl -u NAMEjournalctl --user -u NAME
SandboxingAll optionsProtectHome=, PrivateTmp= and similar need unprivileged user namespaces; some options are system-only
System and user service managers compared, per systemd.exec(5), [email protected](5) and loginctl(1).

To keep a user service running without an open SSH session, enable lingering, which starts the user’s manager at boot and keeps it after logout (loginctl(1)):

mkdir -p ~/.config/systemd/user
nano ~/.config/systemd/user/myapp.service
systemctl --user daemon-reload
systemctl --user enable --now myapp.service
sudo loginctl enable-linger "$USER"

From root, sudo -iu alice systemctl --user status fails with Failed to connect to bus, because that shell has no user session. Since systemd 248, connect to her manager directly: sudo systemctl --user -M alice@ status myapp. For anything that must survive reboots and be sandboxed, a system service with User=alice is simpler.

How to debug a failed systemd service

Work from the outside in. These five commands find the cause of almost every failed unit:

  1. systemctl status myapp: the Active: line, the Main PID line with code=exited, status=…, and the last 10 log lines.
  2. sudo journalctl -xeu myapp: the full error, with systemd’s explanation of the result.
  3. sudo systemd-analyze verify /etc/systemd/system/myapp.service: typos, unknown keys and missing executables.
  4. systemctl cat myapp: the unit and drop-ins systemd actually loaded. It warns if the file changed on disk since the last daemon-reload.
  5. systemctl show myapp -p ExecStart -p User -p Restart -p NRestarts: the effective values, including how often it has restarted.

The status= number points at the step that failed. Codes 200 to 245 come from systemd itself, before or while it starts your program; the full list is the Process Exit Codes table in systemd.exec(5).

What you seeLikely causeFix
status=203/EXECThe ExecStart= program is missing, not executable, lacks a #! line, or sits under /home with ProtectHome=yesCheck the path with ls -l; chmod +x; move the program to /opt or /usr/local/bin
status=200/CHDIRWorkingDirectory= is missing or unreadable for User=Create it, fix ownership, or prefix the path with -
status=217/USERThe User= account does not existCreate it (step 1) or use DynamicUser=yes
status=226/NAMESPACEA path in ReadWritePaths= or ReadOnlyPaths= does not existCreate the path, or prefix it with -
status=1/FAILURE (or another small number)Your app started and exited with an errorRead the journal lines just above the exit
Failed to load environment filesThe EnvironmentFile= path is wrongFix the path, or prefix it with - if the file is optional
Read-only file system, EROFSProtectSystem=strict is doing its jobWrite to $STATE_DIRECTORY or add a ReadWritePaths= entry
ModuleNotFoundError (Python)ExecStart= uses the system Python, not the venvUse /opt/myapp/venv/bin/python
Start request repeated too quicklyCrash loop hit the start limitFix the cause, then systemctl reset-failed (arithmetic)
Loaded: bad-settingA line systemd cannot accept, such as a relative ExecStart= pathRun systemd-analyze verify and fix the line it names
changed on disk. Run 'systemctl daemon-reload'You edited the file but systemd still uses the old versionsudo systemctl daemon-reload, then restart
Works in your shell, fails as a serviceA variable from your shell profile, or a file only your user can readSet it with Environment=; run the command as sudo -u myapp to compare
Not running after a rebootStarted but never enabled, or no [Install] sectionsystemctl is-enabled myapp; sudo systemctl enable myapp
Timer never firesYou enabled the .service instead of the .timersudo systemctl enable --now NAME.timer; check systemctl list-timers
Troubleshooting matrix. Exit codes from systemd.exec(5); messages as printed by systemd 255.

Edit, override or remove a service

To change a unit, prefer a drop-in over editing the file. sudo systemctl edit myapp opens a drop-in file, override.conf under /etc/systemd/system/myapp.service.d/, in your editor and reloads systemd when you save. This is the safe way to change units that packages or installers own, such as the one the Ollama install script writes, because a package upgrade or a rerun of the installer replaces the main file. List-type settings add up across files, so clear ExecStart= before you set a new one:

[Service]
ExecStart=
ExecStart=/opt/myapp/venv/bin/python /opt/myapp/app.py --workers 2
Environment=LOG_LEVEL=debug

sudo systemctl revert myapp deletes all drop-ins and returns to the main file. To remove a service completely:

sudo systemctl disable --now myapp.service
sudo systemctl clean --what=state myapp.service
sudo systemctl reset-failed myapp.service
sudo rm -f /etc/systemd/system/myapp.service
sudo rm -rf /etc/systemd/system/myapp.service.d
sudo systemctl daemon-reload

systemctl clean --what=state deletes the StateDirectory= data, so skip that line if you want to keep /var/lib/myapp. It only works on a stopped unit, which is why disable --now comes first. reset-failed runs while the unit is still loaded, so a failed state does not linger in systemctl --failed.

Practice on a throwaway server, then run it 24/7

Unit files are easiest to learn where breaking things costs nothing: kill the process, misspell a path, reboot, and watch what systemd does. An hourly VPS is built for that. Deploy one, run the five steps and the troubleshooting rows on purpose, then delete it.

Cost: a Quartz Q1 (1 vCPU, 1 GB RAM) is billed at $0.01/hour, so a two-hour systemd lab costs $0.02. Every server is billed by the hour: the plan’s hourly rate is deducted from the server’s prepaid balance for every hour it exists, powered on or off, until you delete it. A stopped server is still billed, because its vCPU, memory, disk and IP addresses stay reserved for you; only deleting the server stops billing. Ordering a server also takes an initial credit, prepaid and used for that server’s hours; the hourly billing guide shows how the balance and top-ups work. Before you delete, copy anything you want to keep with the delete VPS checklist.

For the real deployment, leave the server running; there is no plan to switch to. It is still billed by the hour, and charges stop once they reach the plan’s monthly price, so a Q1 never costs more than $5.00 in a billing period (one month from your order date). That is what a monthly VPS is here: an always-on server with an automatic monthly cap, no contract and no monthly prepayment. The hourly vs monthly guide explains why a capped hourly bill never costs more than a monthly one, and the cost calculator prices your own schedule.

What the service runsStarting planMonthly capReasoning
One small API, bot or worker (Python, Node.js or Go)Quartz Q1$5.00One app process plus Ubuntu fits in 1 GB for small apps; check the Memory: line in systemctl status
App plus Caddy for HTTPS and a databaseQuartz Q2$10.00Three services share RAM; a PostgreSQL server wants its own headroom
Several services, or a game serverQuartz Q4$15.002 vCPUs keep one busy service from slowing the rest
Starting points, not measurements: size up after the Memory: line in systemctl status shows real use. The monthly cap is the most one server is charged in a billing period; below it you pay by the hour.

If your app already ships as a container, Docker’s restart policies replace most of this guide; see how to install Docker on a VPS. For one or two programs, a unit file is one layer fewer.

Deploy this setup

Run your Python, Node.js or Go app 24/7 under systemd

Quartz Q1 · 1 shared vCPU · 1 GB RAM · 25 GB NVMe · Istanbul

  • Per hour$0.01/hour
  • Per day (24 h)$0.24/day
  • Monthly cap$5.00/monthFor this job
Deploy Quartz Q1

Starts with a $5 initial credit, which goes into the server’s balance and pays for its hours.

Billed by the hour, never more than $5.00 per billing period. Delete the server and billing stops.

FAQ

Where do I put a systemd service file?

Put your own system services in /etc/systemd/system/, named after the service, for example myapp.service. Packages install theirs in /usr/lib/systemd/system/, which you should change only through drop-ins (systemctl edit), and user services go in ~/.config/systemd/user/.

How do I make a systemd service start on boot?

Give the unit an [Install] section with WantedBy=multi-user.target (default.target for a user service) and run sudo systemctl enable myapp. Adding --now also starts it immediately, and systemctl is-enabled myapp confirms the result.

What is the difference between Type=simple and Type=exec?

Type=simple marks the service started as soon as systemd has forked the process, so systemctl start succeeds even if the program cannot run. Type=exec, available since systemd 240, waits until the program has actually been executed, so a wrong path or a missing user makes the start fail visibly.

What does "Start request repeated too quickly" mean?

The unit was started more often than its start limit allows, by default 5 times in 10 seconds, usually because the app crashes right after each restart. Fix the error shown in journalctl, run sudo systemctl reset-failed on the unit, and set RestartSec= to a few seconds so one bad start cannot exhaust the limit.

Should I use Restart=always or Restart=on-failure?

The systemd manual recommends on-failure for long-running services: it restarts after crashes, non-zero exits, timeouts and watchdog failures, but lets a program exit cleanly on purpose. Restart=always also restarts after a clean exit; neither restarts a service you stopped with systemctl stop.

Can a systemd service run without root?

Yes. A system service runs as any existing account set with User= and Group=, or as a temporary account with DynamicUser=yes. Without sudo at all, a user can run user services from ~/.config/systemd/user/, which keep running after logout only when lingering is enabled with loginctl enable-linger.

How do I see why a systemd service failed?

Run systemctl status on the unit and read the status= value on the Main PID line, then sudo journalctl -xeu with the unit name for the full error. Codes from 200 upward, such as 203/EXEC or 217/USER, mean systemd failed before or while starting the program; small codes such as 1 come from the app itself.

Sources

  1. systemd.service(5): service unit configurationsystemd project (freedesktop.org) · freedesktop.org · checked
  2. systemd.unit(5): unit configuration, load paths and start limitssystemd project (freedesktop.org) · freedesktop.org · checked
  3. systemd.exec(5): execution environment, sandboxing and exit codessystemd project (freedesktop.org) · freedesktop.org · checked
  4. systemd.timer(5): timer unit configurationsystemd project (freedesktop.org) · freedesktop.org · checked
  5. systemd.time(7): time and calendar event syntaxsystemd project (freedesktop.org) · freedesktop.org · checked
  6. systemctl(1): control the systemd system and service managersystemd project (freedesktop.org) · freedesktop.org · checked
  7. journalctl(1): print log entries from the systemd journalsystemd project (freedesktop.org) · freedesktop.org · checked
  8. systemd-analyze(1): verify, security and calendar commandssystemd project (freedesktop.org) · freedesktop.org · checked
  9. sd_notify(3): readiness notification protocol and standalone implementationssystemd project (freedesktop.org) · freedesktop.org · checked
  10. systemd.service(5) for Ubuntu 24.04 LTS (systemd 255)Ubuntu Manpages (Canonical) · manpages.ubuntu.com · checked
All posts