Write a systemd Service Unit

Khimananda Oli 10 min read Virtualization
Write a systemd Service Unit

By Khimananda Oli | Last reviewed: August 2026

You need to run a custom application, background worker, or API server on Linux, but relying on ad-hoc shell scripts or legacy cron jobs leads to silent failures and difficult debugging. When you write a systemd service unit, you gain process supervision, automatic restarts, dependency ordering, and integrated logging through the journal. This guide walks you through building a production-grade unit file from scratch, avoiding the common configuration mistakes I see during infrastructure audits.

Unit File/etc/systemd/system/app.service[Unit] [Service] [Install]systemd (PID 1)Service ManagerDependency Graph & Statecgroups v2Resource IsolationCPU / Memory / IO LimitsjournaldStructured Loggingjournalctl -u app.serviceParse & LoadEnforce LimitsCapture stdout/stderr
Systemd service unit architecture: the unit file defines behavior, systemd enforces it via cgroups, and journald captures all output.

How do you structure a systemd service unit file correctly?

A valid unit file requires three distinct sections, each serving a specific purpose in the lifecycle management of your daemon. Missing any section or placing directives in the wrong block causes silent failures or prevents the unit from loading entirely. When you write a systemd service unit, always validate the structure before deployment.

The [Unit] section: metadata and dependencies

This section declares what your service is and when it should start relative to other system components. The most critical directives here control ordering and requirements.

  • Description=: A human-readable string shown in systemctl status output. Keep it concise but descriptive enough for on-call engineers to identify the service at 3 AM.
  • After=: Specifies ordering constraints without creating hard dependencies. Your service starts after listed units have started, but won't fail if they're absent.
  • Requires=: Creates a hard dependency. If the required unit fails or stops, your service also stops. Use this for databases or message queues your app cannot function without.
  • Wants=: A weaker dependency. Systemd attempts to start the wanted unit, but your service continues even if it fails. Ideal for optional caching layers or monitoring agents.
  • ConditionPathExists=: Prevents the unit from starting if a required file or directory is missing. Useful for guarding against incomplete deployments.
[Unit]
Description=Inventory API Backend Service
Documentation=https://internal.docs/inventory-api
After=network-online.target postgresql.service
Requires=postgresql.service
Wants=redis.service
ConditionPathExists=/opt/inventory-api/config.yaml

A common mistake is using After=network.target instead of network-online.target. The former only indicates the network stack is initialized, not that interfaces have received IP addresses. Services binding to specific IPs or performing DNS lookups at startup will fail intermittently with network.target. Always use network-online.target for network-dependent applications, and consider adding Wants=network-online.target to ensure the wait-for-network service itself is activated.

The [Service] section: execution and behavior

This is where you define how the process runs, what user context it uses, and how systemd responds to failures. Every production service needs explicit values for Type=, User=, ExecStart=, and Restart=.

[Service]
Type=simple
User=inventory-api
Group=inventory-api
WorkingDirectory=/opt/inventory-api
EnvironmentFile=/opt/inventory-api/env.production
ExecStart=/opt/inventory-api/bin/server --config /opt/inventory-api/config.yaml
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5s
TimeoutStopSec=30s

The Type= directive fundamentally changes how systemd tracks your process. simple assumes the executed process is the main service — appropriate for most modern applications that don't fork. notify requires the application to send a readiness signal via sd_notify(), enabling precise health tracking for services with lengthy initialization. forking is for legacy daemons that double-fork and exit the parent; avoid this for new code. Using the wrong type causes systemd to mark your service as failed immediately after start or never detect readiness.

The [Install] section: boot enablement

Without this section, systemctl enable refuses to work. It tells systemd which target(s) should pull in your service during boot.

[Install]
WantedBy=multi-user.target

multi-user.target is correct for nearly all server-side services. Only use graphical.target for desktop applications or display managers. For timers, sockets, or path-triggered units, the install target differs — consult the specific unit type documentation.

What security hardening directives should every systemd service include?

Running services as root with full filesystem and network access is an audit finding waiting to happen. Systemd provides extensive sandboxing primitives that restrict capabilities without modifying application code. In my compliance work across SOC 2 and ISO 27001 engagements, these directives are baseline expectations for any internet-facing or data-handling service.

Privilege SeparationUser= / Group=DynamicUser=yesNoNewPrivileges=yesCapabilityBoundingSet=AmbientCapabilities=Filesystem RestrictionsProtectSystem=strictProtectHome=yesReadOnlyPaths=/etcReadWritePaths=/var/lib/appPrivateTmp=yesNetwork & DeviceRestrictAddressFamilies=AF_INET AF_INET6IPAddressDeny=anyIPAddressAllow=localhostDevicePolicy=closedRestrictSUIDSGID=yes++Defense-in-Depth: Each Layer Independently Reduces Attack Surface
Security hardening layers in a systemd service unit: privilege separation, filesystem restrictions, and network/device controls combine for defense-in-depth.

User and privilege isolation

Never run application services as root. Create a dedicated system user with no login shell and no home directory:

# Create a locked system account for the service
sudo useradd --system --no-create-home --shell /usr/sbin/nologin inventory-api

Then specify it in the unit file alongside NoNewPrivileges=yes, which prevents the process and its children from gaining additional privileges via setuid binaries or kernel exploits. For stateless services, DynamicUser=yes allocates a transient UID/GID pair at runtime, eliminating the need for persistent system accounts entirely.

Filesystem and namespace restrictions

ProtectSystem=strict mounts the entire filesystem hierarchy as read-only except for paths explicitly whitelisted via ReadWritePaths=. Combined with ProtectHome=yes and PrivateTmp=yes, this prevents compromised services from modifying system binaries, reading user data, or planting backdoors in shared temporary directories.

ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/var/lib/inventory-api /var/log/inventory-api
ReadOnlyPaths=/opt/inventory-api/config.yaml

Capability and syscall filtering

Linux capabilities split root privileges into discrete permissions. Drop everything except what your application genuinely needs. Most web APIs require only CAP_NET_BIND_SERVICE (for ports below 1024) and nothing else.

CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
RestrictSUIDSGID=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources

The SystemCallFilter= directive uses predefined groups maintained by systemd. @system-service covers typical userspace operations while ~@privileged blocks dangerous syscalls like mount, kexec_load, and ptrace. Test thoroughly in staging first — overly restrictive filters cause cryptic SIGSYS crashes.

How does systemd handle service restarts and failure recovery?

Automatic restart behavior separates resilient services from fragile ones. The Restart= directive accepts several values, each with distinct semantics:

Restart ValueBehaviorUse Case
noNever restart automaticallyOne-shot batch jobs, maintenance scripts
on-successRestart only on clean exit (code 0)Rarely useful; mostly for chained workflows
on-failureRestart on non-zero exit, signal, timeout, or watchdog missDefault for most long-running services
on-abnormalRestart on signal, crash, timeout, or watchdog; not on clean exitServices where intentional shutdown must stay stopped
alwaysRestart regardless of exit reason (except manual stop)Critical infrastructure: load balancers, DNS resolvers

Always pair Restart= with RestartSec= to prevent tight restart loops that consume CPU and flood logs. A 5–10 second delay gives transient issues time to resolve. For services with external dependencies, add StartLimitIntervalSec=60 and StartLimitBurst=3 to cap restart attempts within a window — systemd enters a failed state rather than spinning indefinitely.

Restart=on-failure
RestartSec=5s
StartLimitIntervalSec=60
StartLimitBurst=3

For graceful shutdowns, configure TimeoutStopSec= appropriately. Systemd sends SIGTERM, waits the specified duration, then escalates to SIGKILL. Setting this too low kills processes mid-transaction; setting it too high delays deployments. Match it to your application's longest expected request completion time plus a safety margin. Refer to the Ubuntu server setup guide for baseline timeout recommendations aligned with common application frameworks.

What is the difference between Type=simple, Type=forking, and Type=notify?

Misunderstanding service types is the single most frequent cause of "service starts but immediately shows as failed" errors. Each type defines a different contract between your application and systemd's process tracker.

Type=simpleExecStart IS the main processAPPsystemd tracks PID directlyReady = process started✓ Modern apps✓ No forking needed✗ No readiness signalType=forkingParent exits, child becomes daemonPARENTexits (PID gone)CHILDRequires PIDFile= or guessing✓ Legacy daemons only✗ Fragile PID tracking✗ Avoid for new codeType=notifyApp signals readiness explicitlyAPPsd_notify(READY=1)Precise readiness detection✓ Complex init sequences✓ Health-aware orchestration~ Requires sd_notify support
Systemd service type comparison: simple tracks the exec'd process directly, forking relies on PID files for legacy daemons, and notify uses explicit readiness signals for precise lifecycle control.

Type=simple is correct when your application runs in the foreground and doesn't fork. Systemd considers the service active the moment ExecStart= executes successfully. This suits Node.js, Go, Python, Rust, and Java applications that log to stdout/stderr and bind ports synchronously.

Type=forking exists solely for traditional Unix daemons that fork twice, detach from the controlling terminal, and write a PID file. You must specify PIDFile= so systemd knows which child process to monitor. Without it, systemd guesses based on cgroup membership, which breaks under containerization or complex process trees. If you're writing new software, don't use this type.

Type=notify is the gold standard for services with multi-stage initialization. Your application calls sd_notify("READY=1") after completing database migrations, cache warming, and listener binding. Systemd marks the unit as active only upon receiving this signal, making dependent services wait for genuine readiness rather than mere process existence. Libraries exist for every major language: libsystemd for C/C++, systemd-python, go-systemd, and systemd-journal for Rust. For services behind load balancers or orchestrators, pairing Type=notify with WatchdogSec= enables automatic restart on application-level hangs, not just process death. See the systemd services and timers guide for advanced watchdog patterns.

How do you debug and validate a systemd service unit before production?

Deploying untested unit files causes outages. Follow this validation sequence every time you write a systemd service unit:

  1. Syntax check: Run systemd-analyze verify /etc/systemd/system/yourservice.service to catch typos, invalid directives, and missing referenced files without starting anything.
  2. Daemon reload: Execute sudo systemctl daemon-reload after every edit. Systemd caches unit definitions; edits take effect only after reload.
  3. Dry-run start: Use systemctl start --dry-run yourservice.service (systemd v256+) to simulate activation and inspect the execution plan without spawning processes.
  4. Journal inspection: After starting, immediately run journalctl -u yourservice.service -n 50 --no-pager to verify clean startup. Look for permission denied errors, missing environment variables, or capability violations.
  5. Status verification: Confirm systemctl status yourservice.service shows active (running) with the correct main PID, memory usage, and uptime. Check systemctl show yourservice.service to dump all effective properties including inherited defaults.
  6. Security audit: Run systemd-analyze security yourservice.service to receive a scored assessment of sandboxing coverage. Aim for exposure level ≤4 for internet-facing services.

When troubleshooting restart loops, combine journalctl -u yourservice.service -e with systemctl reset-failed yourservice.service to clear the rate-limit counter. Persistent failures often stem from environment differences between interactive shells and systemd's minimal execution context. Always specify absolute paths for executables and configuration files, and use EnvironmentFile= rather than inline Environment= for secrets to avoid leaking them into process listings. For structured logging integration, consult the structured logging best practices article to align your service's output format with centralized log aggregation pipelines.

Write a systemd service unit that survives production

A well-crafted unit file is the foundation of reliable Linux service management. Define clear dependencies, enforce least-privilege sandboxing, choose the correct service type, and validate rigorously before enabling in production. These practices reduce mean time to recovery, satisfy compliance auditors, and prevent the 3 AM pages caused by missing restart policies or runaway processes. When you write a systemd service unit following this framework, you build infrastructure that operators trust and applications depend on. Need help designing hardened service configurations for your stack? Reach out to discuss your infrastructure requirements.

Frequently Asked Questions

A valid unit requires three sections: Unit, Service, and Install. The Unit section adds metadata, Service defines execution commands like ExecStart, and Install specifies targets such as multi-user.target.wants for enabling the service at boot using systemctl enable.

Custom units belong in /etc/systemd/system/ to override vendor defaults safely. Avoid editing files in /lib/systemd/system/ directly, as package updates will overwrite them. Use drop-in directories with .conf extensions for partial modifications without replacing the entire original unit definition.

Run sudo systemctl daemon-reload to apply configuration changes. This command parses updated unit files without restarting active services. Always reload before starting or enabling modified units to prevent systemd from using stale cached definitions during activation.

Type defines how systemd tracks process startup completion. Simple assumes immediate readiness, exec waits for binary execution, forking expects double-fork daemons, notify requires sd_notify signals, and oneshot runs single tasks. Choosing correctly prevents premature dependency activation and timeout failures during boot sequences.

Set Restart=on-failure in the Service section to trigger automatic recovery after non-zero exit codes or signals. Combine with RestartSec=5 to add delay between attempts. Use StartLimitIntervalSec and StartLimitBurst to prevent infinite restart loops that consume resources during persistent failure conditions.

Unit files must be owned by root with 644 permissions. Never make them executable or world-writable. Systemd rejects units with incorrect ownership or excessive permissions as a security precaution. Validate settings using systemd-analyze verify before enabling production services to catch permission errors early.

Specify User= and Group= directives in the Service section to drop privileges before executing ExecStart. Ensure the target user owns required directories and socket paths. Combine with DynamicUser=yes for ephemeral sandboxed accounts that auto-generate UIDs and restrict filesystem access automatically.

Yes, use Environment= for inline key-value pairs or EnvironmentFile= to load external files. Values support specifiers like %i for instance names. Avoid embedding secrets directly; instead reference credential stores or encrypted files. Variables expand before execution but do not inherit the calling shell context.

Run systemctl status followed by journalctl -u service-name --no-pager -n 50 to inspect logs. Check exit codes against systemd.exec documentation. Verify path existence, permissions, and SELinux contexts. Use systemd-analyze verify to validate unit syntax before deployment and catch missing dependencies or invalid directives.

Requires creates hard dependencies where failure stops your service entirely. Wants establishes soft dependencies that continue even if the target fails. Use Requires for critical components like databases and Wants for optional services like logging agents. Both activate targets but differ fundamentally in error propagation behavior.

Write a companion .timer unit with OnCalendar or OnBootSec triggers matching your .service name. Enable only the timer, not the service directly. Timers offer monotonic scheduling, randomized delays, and persistent tracking across reboots. List active timers with systemctl list-timers to verify next elapse times.

Yes, always specify full binary paths like /usr/bin/python3 instead of relying on PATH resolution. Systemd uses a minimal default PATH that excludes many standard locations. Hardcoded paths prevent ambiguity, improve security by avoiding hijacking, and ensure consistent behavior across different system configurations and environments.

Apply cgroup controllers via directives like MemoryMax=, CPUQuota=, and IOWeight= in the Service section. These enforce hard limits preventing runaway processes from destabilizing hosts. Monitor enforcement with systemctl show and adjust values based on observed peak consumption during load testing cycles.

Yes, wrap podman or docker run commands in ExecStart with appropriate restart policies. Use Delegate=yes to grant cgroup management to the container runtime. Prefer quadlet for Podman-native unit generation in 2026. This integrates containers into systemd dependency trees and journal logging seamlessly.

Use LoadCredential= or SetCredentialEncrypted= to inject secrets at runtime without storing plaintext in unit files. Credentials reside in protected memory or encrypted storage accessible only to the service process. Never embed passwords in Environment= lines since they appear in process listings and journal logs visibly.