Writing and Managing systemd Services
Turn a bare executable into a proper managed service. This guide covers the anatomy of a systemd unit file, the [Service] options that matter most, binding low ports as a non-root user, sandboxing directives that harden a service, and the everyday systemctl and journalctl commands for running and debugging it.
Introduction
Running a program from your terminal is fine while you're testing, but a real server process needs to start on boot, restart if it crashes, log somewhere sensible, and run under the right user with the right environment. On modern Linux, the tool for all of that is systemd. This guide covers how to write a service unit, the options that matter most, and the day-to-day commands for managing services — using a long-running network daemon as the running example.
What a service unit is
systemd manages "units", and the kind that runs a program is a service unit — a plain text file ending in
.service. System-wide units live in /etc/systemd/system/ (for ones you write) or
/usr/lib/systemd/system/ (for ones installed by packages). Create a file there and systemd can start, stop,
supervise, and boot-launch your program.
A minimal unit file
Here is a complete, production-shaped unit for a network daemon installed at /opt/myapp/myapp:
[Unit]
Description=My application server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/myapp
EnvironmentFile=-/opt/myapp/myapp.env
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
Every section earns its place:
[Unit]— metadata and ordering.After=network-online.targetwaits until the network is up before starting (important for a server that binds a socket), andWants=makes that a soft dependency.[Service]— how to run the process (detailed below).[Install]— what happens when youenablethe service.WantedBy=multi-user.targetmeans "start this at boot, in the normal multi-user system state".
The [Service] options that matter
Type=simple— the default: systemd considers the service started as soon as it forksExecStart. Usenotifyif your program signals readiness viasd_notify, orforkingfor old-style daemons that background themselves.ExecStart=— the command to run, as an absolute path. This is the one required field for most services.User=/Group=— run as an unprivileged account, not root. Create a dedicated system user for the service so a compromise is contained.WorkingDirectory=— the process's current directory. Set this if your program loads files by relative path (templates, static assets, config).EnvironmentFile=— load environment variables (secrets, config) from a file. The leading-means "don't fail if the file is missing".Restart=alwayswithRestartSec=3— restart the process if it exits for any reason, waiting three seconds between attempts. This is what turns a program into a resilient service.
Binding low ports as a non-root user
A web server usually needs port 80 and 443, which are privileged (below 1024). You don't want to run the whole process as root just for that. systemd lets you grant only the specific capability to bind low ports:
[Service]
User=myapp
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
Now the service runs as the unprivileged myapp user but can still bind 80/443 — the best of both worlds.
Hardening a service
systemd can sandbox your process with a few extra directives, each of which shrinks what a compromised service can do:
[Service]
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/opt/myapp/data
NoNewPrivileges— the process can never gain more privileges (e.g. via setuid binaries).ProtectSystem=strict— mounts most of the filesystem read-only; combine withReadWritePaths=to allow writes only where needed.ProtectHome/PrivateTmp— hide/homeand give the service its own private/tmp.
Managing the service
After writing the unit file, reload systemd and control the service with systemctl:
# Re-read unit files after creating or editing one
sudo systemctl daemon-reload
# Start now, and start automatically on every boot
sudo systemctl enable --now myapp
# Check status (running? recent log lines?)
systemctl status myapp
# Restart after a deploy
sudo systemctl restart myapp
# Stop, and disable boot-start
sudo systemctl stop myapp
sudo systemctl disable myapp
Reading the logs
When a service uses Type=simple, everything it writes to stdout and stderr is captured by the systemd journal.
You read it with journalctl:
# All logs for this unit
journalctl -u myapp
# Follow live, like tail -f
journalctl -u myapp -f
# Just today, and just errors
journalctl -u myapp --since today -p err
This is a major convenience: you get structured, filterable, rotated logs for free without your application having to manage log files at all.
Conclusion
A systemd unit turns a bare executable into a proper managed service: it starts on boot, restarts on failure, runs as a
contained unprivileged user, loads its secrets from an environment file, and logs to the journal. Get comfortable with the
three sections, the handful of [Service] options that matter, and the systemctl and
journalctl commands, and you can deploy and operate any long-running program on Linux with confidence. In the
next article we put this to use, deploying a compiled Rust binary end to end.