ARC2 Proxy
Linux · By Milen Denev

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.

Writing and Managing systemd Services

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.target waits until the network is up before starting (important for a server that binds a socket), and Wants= makes that a soft dependency.
  • [Service] — how to run the process (detailed below).
  • [Install] — what happens when you enable the service. WantedBy=multi-user.target means "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 forks ExecStart. Use notify if your program signals readiness via sd_notify, or forking for 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=always with RestartSec=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 with ReadWritePaths= to allow writes only where needed.
  • ProtectHome / PrivateTmp — hide /home and 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.

Keep reading