Mastering Systemd: Creating Your First Custom Service Unit File

Learn the fundamentals of Systemd service management by creating a custom Unit File. This tutorial breaks down the essential `[Unit]`, `[Service]`, and `[Install]` sections, providing step-by-step instructions to define, enable, start, and verify a basic background service on Linux using `systemctl`.

Mastering Systemd: Creating Your First Custom Service Unit File

A custom systemd service unit is what you reach for when a script or small application has outgrown a terminal session, a screen window, or a fragile cron workaround. Maybe you have a worker that should restart after a crash. Maybe a tiny internal API needs to start after the network is ready. Maybe a backup script should run as a controlled service so its logs live in the journal and operators can use the same systemctl commands they use for everything else.

The useful part of systemd is not that the unit file is complicated. It is that the unit file makes the process explicit: what runs, who it runs as, when it starts, how it stops, where logs go, and what systemd should do when it fails. Once those decisions are written down, the service becomes much easier to operate.

This walkthrough builds a small service from scratch. The example is intentionally simple, but the patterns are the same ones you would use for a background worker, queue consumer, metrics exporter, or internal daemon.

Start with a real command, not a unit file

A good service unit starts with a command that already works by hand. Before you write systemd configuration, make sure you can run the program directly and understand what it does in the foreground.

For this example, create a small reporter script:

sudo install -d -o root -g root -m 0755 /opt/my-custom-service
sudo nano /opt/my-custom-service/reporter.sh

Add this content:

#!/usr/bin/env bash
set -euo pipefail

while true; do
  echo "$(date --iso-8601=seconds) reporter heartbeat"
  sleep 10
done

Make it executable and test it:

sudo chmod 0755 /opt/my-custom-service/reporter.sh
/opt/my-custom-service/reporter.sh

Stop it with Ctrl-C after you see a few lines. Notice that the script writes to standard output instead of directly appending to /var/log/reporter.log. That is deliberate. For most custom services, letting systemd capture stdout and stderr into the journal is cleaner than making every script manage its own log file permissions, rotation, and failure behavior.

Create a dedicated service user

Avoid running application services as root unless they genuinely need root privileges. A heartbeat script does not. A web app usually does not. A worker that reads from a queue and writes to a database usually does not.

Create a locked-down system user:

sudo useradd --system --no-create-home --shell /usr/sbin/nologin reporter

If your distribution uses a different nologin path, check it with:

command -v nologin || command -v false

The service user should own only the files it needs to write. In this example the script writes to the journal through systemd, so it does not need ownership of /opt/my-custom-service.

Write the service unit

Custom administrator-managed system units normally live in /etc/systemd/system/. Vendor package units commonly live under /usr/lib/systemd/system/ or /lib/systemd/system/, depending on the distribution. Do not edit vendor unit files directly when you can avoid it; use /etc/systemd/system/ for your own units and drop-ins for overrides.

Create the unit:

sudo nano /etc/systemd/system/my-reporter.service

Use this as a practical first version:

[Unit]
Description=My Custom Reporter Service
Documentation=man:systemd.service(5)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=reporter
Group=reporter
ExecStart=/opt/my-custom-service/reporter.sh
Restart=on-failure
RestartSec=5s
WorkingDirectory=/opt/my-custom-service
StandardOutput=journal
StandardError=journal
NoNewPrivileges=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target

The [Unit] section describes relationships. After=network-online.target controls ordering; it does not pull the network-online target in by itself. Wants=network-online.target asks systemd to start that target too. If your service does not need the network, remove both lines and keep the unit simpler.

The [Service] section describes the process. Type=simple is right for a foreground process that does not fork itself into the background. That is the common case for modern services. If a legacy daemon forks, writes a PID file, and returns control to the shell, then you may need Type=forking, but do not use it just because the word sounds more daemon-like.

ExecStart should be an absolute path. Shell features such as pipes, redirects, and && are not interpreted unless you explicitly run a shell, for example ExecStart=/bin/bash -lc 'command one && command two'. Prefer a script when the command needs shell logic; it is easier to test and easier to read.

Restart=on-failure tells systemd to restart the service after abnormal exits. It will not restart after a clean systemctl stop. RestartSec=5s prevents a tight restart loop from hammering the machine.

The hardening options here are modest but useful. NoNewPrivileges=true prevents the process and its children from gaining new privileges through setuid binaries or file capabilities. PrivateTmp=true gives the service a private /tmp view. These are usually safe for simple services, but test them with real applications because some software expects shared temporary paths.

Load and start the unit

After adding or changing a unit file, reload systemd’s manager configuration:

sudo systemctl daemon-reload

Start the service now:

sudo systemctl start my-reporter.service

Check its state:

systemctl status my-reporter.service

You want to see Active: active (running). If it failed, do not guess. Read the logs:

journalctl -u my-reporter.service -n 50 --no-pager

Follow live logs while testing:

journalctl -u my-reporter.service -f

If the script path is wrong, permissions are missing, the user does not exist, or the command exits immediately, systemd will usually say so plainly in the journal.

Enable startup at boot

Starting a service and enabling a service are different actions. start runs it now. enable wires it into the boot target so it starts on future boots.

sudo systemctl enable my-reporter.service

You can do both in one command after the unit has been tested:

sudo systemctl enable --now my-reporter.service

To see whether it is enabled:

systemctl is-enabled my-reporter.service

Make failures easier to diagnose

The most common first-service failures are ordinary Linux problems wearing systemd clothing.

If you see status=203/EXEC, systemd could not execute the command. Check the path, executable bit, shebang line, and line endings. A script copied from Windows with CRLF endings can fail even though it looks fine in an editor.

If you see permission errors, remember that the service runs as reporter, not as your shell user. Test with:

sudo -u reporter /opt/my-custom-service/reporter.sh

If the service starts and immediately stops, the process probably exits. Type=simple expects the command to keep running. A one-shot setup command should use Type=oneshot, not simple.

If logs are missing, check whether the application writes to files instead of stdout/stderr, or whether it changes users internally. For most small services, writing to stdout is the least surprising option.

Useful management commands

Once the unit is in place, day-to-day operation is straightforward:

sudo systemctl start my-reporter.service
sudo systemctl stop my-reporter.service
sudo systemctl restart my-reporter.service
sudo systemctl reload my-reporter.service
systemctl status my-reporter.service
journalctl -u my-reporter.service --since "1 hour ago"
systemctl cat my-reporter.service
systemctl show my-reporter.service -p User -p Restart -p ExecStart

systemctl cat is especially useful on machines with drop-in overrides because it shows the effective unit fragments systemd is reading.

A custom unit file does not need to be clever. It needs to be boring, explicit, and testable. Get the command working by hand, run it as a dedicated user, write the smallest unit that describes the service accurately, reload systemd, and use the journal when something fails. That workflow scales from a toy reporter script to real production daemons.

Add environment and configuration cleanly

Sooner or later the service needs configuration: a port, a database URL, a feature flag, or a path. Avoid burying those values inside the unit file when they vary by environment. A common pattern is an environment file:

sudo nano /etc/my-reporter.env

Example:

REPORT_INTERVAL=10
REPORT_LABEL=production

Lock down the file if it contains anything sensitive:

sudo chown root:reporter /etc/my-reporter.env
sudo chmod 0640 /etc/my-reporter.env

Then reference it from the unit:

[Service]
EnvironmentFile=/etc/my-reporter.env
ExecStart=/opt/my-custom-service/reporter.sh

In the script, read the variable with a default:

interval="${REPORT_INTERVAL:-10}"
label="${REPORT_LABEL:-default}"

For secrets, be careful. An environment variable can be exposed through process inspection or service metadata depending on system configuration and permissions. For highly sensitive values, prefer a proper secret manager, a credential file with tight permissions, or systemd’s newer credential features if your distribution supports them. The important habit is to decide deliberately instead of sprinkling passwords into unit files because it is convenient.

Use drop-in overrides for local changes

If a package installs a unit and you need to change one setting, do not edit the vendor file. Use a drop-in:

sudo systemctl edit my-reporter.service

That opens an override file under /etc/systemd/system/my-reporter.service.d/. For example:

[Service]
RestartSec=15s

Reload and restart after saving:

sudo systemctl daemon-reload
sudo systemctl restart my-reporter.service

Check the merged result:

systemctl cat my-reporter.service

Drop-ins matter because package updates can replace vendor units. Your overrides in /etc remain visible and intentional.

Think about shutdown behavior

Starting is only half the lifecycle. A service should also stop cleanly. By default, systemd sends SIGTERM, waits, and then may send SIGKILL if the process does not exit. For many simple services that is fine. For queue workers, upload processors, and database writers, you may need to handle termination so the process finishes or abandons current work safely.

You can tune the timeout:

[Service]
TimeoutStopSec=30s
KillSignal=SIGTERM

Do not set extremely long stop timeouts unless you have a reason. Long shutdowns slow deployments, reboots, and incident recovery. A worker should usually stop accepting new work, finish the item it is processing, and exit within a bounded time.

Prevent noisy restart loops

Restart=on-failure is useful, but a broken service can still restart repeatedly. Add limits when the failure mode could be noisy:

[Unit]
StartLimitIntervalSec=300
StartLimitBurst=5

This tells systemd to stop trying after too many failures within the interval. When you fix the issue, reset the failed state:

sudo systemctl reset-failed my-reporter.service
sudo systemctl start my-reporter.service

That command is useful during testing too. A service can remain in a failed state even after you have corrected the script or permissions.

Validate units before relying on them

Systemd has a useful verifier:

systemd-analyze verify /etc/systemd/system/my-reporter.service

It will not catch every application problem, but it can catch syntax mistakes, unknown settings on your systemd version, and some ordering issues. Run it after larger edits or when copying a unit between distributions. Systemd features vary by version, so a hardening option that works on a new Fedora server may not exist on an older enterprise distribution.

Also check the unit’s dependency view when startup ordering gets confusing:

systemctl list-dependencies my-reporter.service
systemctl list-dependencies --reverse my-reporter.service

The first command shows what the service pulls in or depends on. The reverse view shows what depends on it. This is useful when a service starts unexpectedly or when disabling one unit affects another.

Decide whether the service is system-level or user-level

This article uses a system service under /etc/systemd/system/. That is the right choice for machine services that should start at boot and run independently of a login session. Systemd also supports user services, usually managed with systemctl --user, for per-user background processes.

Do not use a user service for infrastructure daemons just to avoid sudo. User services have different lifecycle rules, environment handling, and login behavior. For application workers, exporters, and host-level agents, a system service with a dedicated least-privileged user is usually easier to reason about.

Keep the first unit boring

It is tempting to add every hardening directive you find online: private devices, read-only paths, syscall filters, capability bounding, namespace restrictions, and more. Those are valuable tools, but add them one at a time after the service works. When a heavily restricted service fails to start, beginners often cannot tell whether the unit is wrong, the application is wrong, or a sandbox setting blocked a file it needs.

A good production path is incremental: working service, dedicated user, reliable logging, restart policy, basic hardening, then stronger sandboxing after you have a test that proves the application still behaves correctly.