Linux Administration·6 min read·

Writing Custom systemd Unit Files for Your Own Applications

Learn the [Unit], [Service], and [Install] sections, pick the right Type=, and ship a production-ready unit file for your own application on any modern distro.

NB

Netbay Developer Relations

Netbay Engineering

On this page

You built an application, it runs fine in your terminal, and now it needs to survive reboots, restart after crashes, and start in the right order once the network is up. That is exactly what a systemd unit file is for. Writing one is a fifteen-minute task once you understand the three sections every service unit is built from, and a source of endless confusion when you guess. Here is the non-guessing version.

The Three Sections of Every Service Unit

Every service unit is organized into three sections, and each answers a different question. The [Unit] section holds metadata and dependencies: what the service is, when it should start relative to other units, and what should exist alongside it. The [Service] section describes the actual process: how to launch it, which user runs it, and what to do when it dies. The [Install] section defines what enabling means: which target pulls the unit in at boot.

Anatomy of a service unit: three sections, three jobs [Unit] Description=, After=, Wants= [Service] Type=, ExecStart=, User= Restart=, Environment= [Install] WantedBy=multi-user.target What it is and when to run dependencies and ordering How the process is launched and supervised What enable does at boot boot-time wiring

A Complete, Realistic Example

Here is a unit for a background worker, placed at /etc/systemd/system/invoice-worker.service.

ini
[Unit]
Description=Invoice worker for the billing API
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/opt/billing
ExecStart=/usr/bin/node /opt/billing/worker.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
EnvironmentFile=/etc/billing/worker.env

[Install]
WantedBy=multi-user.target

Everything here has a job. User= drops privileges so the worker does not run as root. WorkingDirectory= gives relative paths a stable base. EnvironmentFile= keeps secrets out of the unit file itself. Restart=on-failure makes systemd revive the process if it exits non-zero. Once the file exists, three commands put it to work.

Choosing the Right Type=

Type= tells systemd how to know your service actually started, and picking wrong is the single most common cause of units that hang in activating or report active while broken.

  • simple (the default): systemd considers the service started as soon as ExecStart launches. Right for most modern daemons that stay in the foreground.
  • exec: like simple, but success is reported only after the binary has actually executed. Catches launch failures earlier.
  • forking: for legacy daemons that fork into the background. Add PIDFile= so systemd can track the real master process.
  • oneshot: the process runs and exits. Combine with RemainAfterExit=yes for setup scripts so the unit shows active after finishing.
  • notify: the service calls sd_notify with READY=1 when it is truly ready. Best startup accuracy, but the application must cooperate.
ini
[Service]
Type=oneshot
ExecStart=/usr/local/bin/migrate-database.sh
RemainAfterExit=yes

After= and Wants= Are Not Requirements

Ordering and dependency are separate axes, and mixing them up is the classic unit-file bug. After= only sequences: it says start me after that unit, if both are being started. It never pulls anything in. Wants= and Requires= pull units in: Wants= is a soft dependency that starts the other unit but tolerates its failure, while Requires= is hard — if the required unit fails to start, your unit is not started either. In practice, Wants= plus After= is almost always what you want, and Requires= alone, without After=, does not even imply ordering.

The network-online.target pattern in the example deserves one warning: After=network-online.target only helps if something actually implements the wait. On systems using systemd-networkd, enable systemd-networkd-wait-online.service; on other init networking stacks, verify the equivalent exists before relying on it.

Shipping the Unit

bash
sudo systemctl daemon-reload
sudo systemctl enable --now invoice-worker
systemctl status invoice-worker
journalctl -u invoice-worker -f

daemon-reload makes systemd re-scan unit files after any edit; forget it and your changes do not exist yet. Then enable --now starts the service and arranges the boot-time symlink in one command, and the journalctl follow shows you the first minutes of stdout and stderr, which is where new units reveal their problems.

Takeaway: three sections, an honest Type=, Wants= plus After= for ordering, and absolute paths everywhere. That is a production-grade unit in about thirty lines.

Need a Linux VPS to deploy your own units on? Spin up an Ubuntu 24.04 instance on Netbay in under 60 seconds at netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance