How to Fix a systemd Service That Fails to Start (2026 Guide)

Nov 10, 2026 · 11 min · Ajay Kumar

This guide is for anyone staring at Active: failed on a Linux server and wanting the shortest path from that line to a running service. By the end you will be able to read systemctl status and journalctl output properly, map systemd's own exit codes (203, 217, 226 and friends) to their causes, get out of restart loops and start-limit-hit, fix environment files and permission problems, and write a unit file that fails loudly and recovers sensibly. Everything here matches the systemd v262 man pages and works on the versions shipped by Ubuntu 24.04 (255), Debian 13 and RHEL 10 (257) and Ubuntu 26.04 (259). You need root or sudo on the box; no extra packages are required.

Step 1: Read systemctl status properly

Start with the status, with full lines and no pager. Replace myapp with your unit.

# -l: do not truncate lines; --no-pager: print and exit
systemctl status myapp.service -l --no-pager

A failure looks like this (example output, trimmed):

× myapp.service - My App API
     Loaded: loaded (/etc/systemd/system/myapp.service; enabled; preset: enabled)
     Active: failed (Result: exit-code) since Tue 2026-11-10 09:14:02 UTC; 8s ago
    Process: 4121 ExecStart=/opt/myapp/bin/myapp --port 8080 (code=exited, status=203/EXEC)
   Main PID: 4121 (code=exited, status=203/EXEC)

Three fields tell you most of the story. Loaded: shows the file systemd actually parsed; if it is not the path you edited, you are editing the wrong file or forgot a reload. Result: is the category: exit-code (process exited non-zero), signal (killed, often SIGKILL from the OOM killer), timeout (never became ready), core-dump, or start-limit-hit. And status= is the exit code. Values from 200 upward with a name such as 203/EXEC are generated by systemd itself before your program ever ran. Values from 1 to 199 came from your program.

If systemctl status prints a warning that the unit file changed on disk, run the reload before anything else:

sudo systemctl daemon-reload
sudo systemctl restart myapp.service

Step 2: Filter journalctl to the failure

The status shows only the last ten log lines. The journal has the rest, and the useful flags are few.

# Everything from this unit in the current boot, jump to the end, with explanations
journalctl -u myapp.service -b -e -x

# Only the last 50 lines, no pager (good for pasting into a ticket)
journalctl -u myapp.service -n 50 --no-pager

# Errors and worse across the whole system in the last 15 minutes
journalctl -p err --since "15 min ago"

# The previous boot, if the box rebooted after the failure
journalctl -u myapp.service -b -1

# Kernel messages: OOM kills, segfaults, SELinux and AppArmor denials
journalctl -k -b | grep -Ei 'oom|killed process|segfault|denied'

Look for the line systemd writes when it cannot spawn the process. It always has the same shape, and the step name matches the exit code name (illustrative output in the v262 message format):

myapp.service: Unable to locate executable '/opt/myapp/bin/myapp': No such file or directory
myapp.service: Failed at step EXEC spawning /opt/myapp/bin/myapp: No such file or directory
myapp.service: Main process exited, code=exited, status=203/EXEC
myapp.service: Failed with result 'exit-code'.

If the journal for your unit is empty after a reboot, journald is probably in volatile mode. Create /var/log/journal/ and restart systemd-journald so the next failure survives; the systemd explainer covers why that is the default.

Triage path for Active: failed systemctl status -l then journalctl -u UNIT -b -e start-limit-hit Too many restarts in StartLimitIntervalSec reset-failed, then fix the first error status 200 to 245 203 EXEC: path, +x, shebang 217 USER: no such user 226 NAMESPACE: missing path fix the unit file Result: timeout Never signalled ready within TimeoutStartSec fix Type= (forking, notify) or the timeout status 1 to 199 The program ran and exited with an error run it by hand as the service user Codes 200 and above are generated by systemd before execve(); the full table is in systemd.exec(5). Result: signal with SIGKILL usually means the OOM killer: check journalctl -k and MemoryMax=.
Triage flow derived from the Result values in systemd.service(5) and the exit code table in systemd.exec(5), systemd v262.

Step 3: Fix 203/EXEC (the binary could not be executed)

The systemd.exec exit code table defines 203 as a failure of the execve() call itself, most often a missing or inaccessible executable. Check the causes in this order:

# 1. Does the path in ExecStart= exist? It must be absolute.
systemctl cat myapp.service | grep ^ExecStart
ls -l /opt/myapp/bin/myapp

# 2. Is it executable, and can the service user traverse every directory?
namei -l /opt/myapp/bin/myapp
sudo chmod 755 /opt/myapp/bin/myapp

# 3. Scripts: first line must be a shebang, and no Windows line endings
head -1 /opt/myapp/bin/start.sh | od -c | head -2   # look for \r before \n
sed -i 's/\r$//' /opt/myapp/bin/start.sh

# 4. Is the filesystem mounted noexec? (common for /tmp and some /home mounts)
findmnt -T /opt/myapp/bin/myapp -o TARGET,OPTIONS

# 5. Wrong architecture (an amd64 binary on an arm64 host gives Exec format error)
file /opt/myapp/bin/myapp; uname -m

On SELinux systems (RHEL, Fedora), a binary copied from a home directory keeps the wrong label and fails with Permission denied even with mode 755. ls -Z shows the label; restorecon -v /opt/myapp/bin/myapp resets it, and ausearch -m avc -ts recent shows the denial.

One more trap: ExecStart= is not run through a shell. Pipes, &&, redirects and $(...) do not work. Either move the logic into a script or wrap it explicitly with ExecStart=/bin/sh -c '...'.

Step 4: Fix 217/USER and permission errors

Code 217 means systemd could not determine or switch to the credentials in User= (or set up user namespacing for PrivateUsers=). The usual cause is a user that does not exist yet, typically because the package that creates it has not been installed on this host, or the name has a typo.

# Does the user exist?
getent passwd myapp || echo "no such user"

# Create a system user with no login shell and no home directory
sudo useradd --system --no-create-home --shell /usr/sbin/nologin myapp

# Or skip the user entirely: let systemd allocate one per start
sudo systemctl edit myapp.service
#   [Service]
#   DynamicUser=yes
#   StateDirectory=myapp

If the service starts but logs Permission denied on a file or port, test as the service user rather than as root, because root bypasses the very checks that are failing:

# Run the same command as the same user, in a transient unit
sudo systemd-run --pty --wait -p User=myapp /opt/myapp/bin/myapp --port 8080

# Ports below 1024 need a capability when not running as root
#   [Service]
#   AmbientCapabilities=CAP_NET_BIND_SERVICE

Directories the service writes should be owned by its user. Prefer StateDirectory=, LogsDirectory= and RuntimeDirectory=, which create /var/lib/<name>, /var/log/<name> and /run/<name> with the right owner at start, over mkdir in an ExecStartPre=.

Step 5: Fix 226/NAMESPACE (a sandbox path is missing)

226 means systemd failed to set up the mount, UTS or IPC namespace for the sandboxing directives: ProtectSystem=, ReadWritePaths=, ReadOnlyPaths=, InaccessiblePaths=, PrivateTmp= and similar. By far the most common cause is a path in ReadWritePaths= that does not exist on this host. The journal names it (illustrative):

myapp.service: Failed to set up mount namespacing: /var/lib/myapp/cache: No such file or directory
myapp.service: Failed at step NAMESPACE spawning /opt/myapp/bin/myapp: No such file or directory

The man page allows a - prefix on these paths, which makes systemd ignore them when they do not exist. Better still, let systemd create them:

[Service]
# Ignored if missing instead of failing with 226
ReadWritePaths=-/var/lib/myapp/cache
# Or create /var/lib/myapp owned by User= on every start
StateDirectory=myapp

226 also appears inside containers and some minimal VMs where the kernel or runtime does not allow new mount namespaces. If the same unit works on a normal host, that is the likely cause, and the fix is to relax sandboxing for that environment in a drop-in rather than in the main unit.

Step 6: Stop restart loops and start-limit-hit

With Restart=on-failure and the defaults, a service that crashes on startup restarts every 100ms (systemd.service), and after five starts within ten seconds (systemd-system.conf) systemd stops trying (illustrative journal lines):

myapp.service: Scheduled restart job, restart counter is at 5.
myapp.service: Start request repeated too quickly.
myapp.service: Failed with result 'start-limit-hit'.

start-limit-hit is a symptom. The real error is earlier in the journal, in the first failure. Scroll up to it, fix it, then clear the counter:

# How many times has it restarted, and what was the last result?
systemctl show myapp.service -p NRestarts -p Result -p ExecMainStatus

# Clear the failed state and the rate-limit counter
sudo systemctl reset-failed myapp.service
sudo systemctl start myapp.service

Then make the restart policy sane, so that a dependency outage does not burn the budget in half a second. Exponential backoff has existed since v254:

[Unit]
# Rate limits are [Unit] settings
StartLimitIntervalSec=5min
StartLimitBurst=10

[Service]
Restart=on-failure
RestartSec=2s
# Back off 2s, 4s, 8s ... up to 60s
RestartSteps=5
RestartMaxDelaySec=60s

StartLimitIntervalSec= placed under [Service] is not a recognised key, so systemd logs a warning and uses the defaults. Only the legacy spellings StartLimitInterval= and StartLimitBurst= are still accepted there, according to the v262 parser table. Run systemd-analyze verify to catch it.

Step 7: Fix environment files and variables

Three things go wrong with environment configuration, and none of them produce a clear error.

First, EnvironmentFile= is not a shell script. Lines are KEY=value; export KEY=value is not valid syntax, and $(command) is not executed. If the file is missing, the service fails to start; prefix the path with - to make it optional.

# /etc/myapp/env  (no 'export', no command substitution)
DATABASE_URL=postgres://myapp@db.internal:5432/myapp
LOG_LEVEL=info

Second, variables in ExecStart= use ${VAR} (passed as one argument) or $VAR (split on whitespace), and only variables systemd knows about, from Environment= or EnvironmentFile=, are expanded. Your interactive shell's environment, including PATH additions from .bashrc, does not exist for services.

Third, check what the service actually received:

# Environment as configured in the unit
systemctl show myapp.service -p Environment -p EnvironmentFiles

# Environment of the running process
sudo cat /proc/$(systemctl show -p MainPID --value myapp.service)/environ | tr '\0' '\n'

Put secrets in a file with mode 0600 owned by root, or better, use LoadCredential= so they never appear in the environment at all.

Step 8: Write a correct unit file and verify it

Most of the failures above are prevented by a unit file written with them in mind. This one is a reasonable default for a long-running network service:

# /etc/systemd/system/myapp.service  (systemd 254 or newer for RestartSteps=)
[Unit]
Description=My App API
Documentation=https://example.com/docs/myapp
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=5min
StartLimitBurst=10

[Service]
# exec: start counts as failed if execve() fails, unlike simple
Type=exec
User=myapp
Group=myapp
EnvironmentFile=-/etc/myapp/env
ExecStart=/opt/myapp/bin/myapp --port 8080
Restart=on-failure
RestartSec=2s
RestartSteps=5
RestartMaxDelaySec=60s
TimeoutStartSec=30s
StateDirectory=myapp
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes

[Install]
WantedBy=multi-user.target

Lint it, load it, start it and watch it in one go:

sudo systemd-analyze verify /etc/systemd/system/myapp.service
sudo systemctl daemon-reload
sudo systemctl enable --now myapp.service
journalctl -u myapp.service -f

Type= matters for timeouts. If your daemon forks into the background, Type=exec or simple will see the parent exit and treat the service as dead; use Type=forking with PIDFile=, or better, run it in the foreground. If you set Type=notify and the program never sends READY=1, systemd waits for TimeoutStartSec= (90 seconds by default) and then reports Result: timeout.

Troubleshooting

Symptom Likely cause Fix
status=203/EXEC, "No such file or directory" Wrong or relative path in ExecStart= Use an absolute path; check with ls -l and namei -l
status=203/EXEC, "Exec format error" Script without shebang, CRLF endings, wrong CPU architecture Add #!/bin/sh, run sed -i 's/\r$//', check file vs uname -m
status=203/EXEC, "Permission denied" No execute bit, noexec mount, or SELinux label chmod 755, findmnt -T, restorecon -v
status=217/USER User= does not exist on this host useradd --system, or DynamicUser=yes
status=226/NAMESPACE Path in ReadWritePaths= or similar is missing Create it, prefix with -, or use StateDirectory=
Result: start-limit-hit Crashed 5 times in 10s with default limits Fix the first error in the journal, then systemctl reset-failed
Result: timeout Wrong Type= (forking or notify) or slow start Match Type= to the program; raise TimeoutStartSec=
Result: signal, SIGKILL OOM killer, often MemoryMax= journalctl -k, raise MemoryMax= or fix the leak
Edits have no effect Forgot daemon-reload, or editing a file under /usr/lib systemctl daemon-reload; use systemctl edit for drop-ins
Works by hand, fails as service Missing env vars or PATH, or root-only file access Set Environment=; test with systemd-run -p User=

FAQ

How do I see why a systemd service failed?

Run systemctl status <unit> -l --no-pager for the result and exit code, then journalctl -u <unit> -b -e for the full log. The line beginning "Failed at step" names the exact setup stage that failed when the exit code is 200 or higher.

What does status=203/EXEC mean in systemd?

systemd could not execute the program in ExecStart=. The file is missing, not executable, a script without a valid shebang or with Windows line endings, on a noexec mount, or built for another CPU architecture.

How do I fix "Start request repeated too quickly"?

The service hit its start rate limit (5 starts in 10 seconds by default). Find and fix the original error earlier in the journal, run systemctl reset-failed <unit>, and add RestartSec= with RestartSteps= so future failures back off instead of burning the limit.

Do I need daemon-reload after editing a unit file?

Yes. systemd keeps parsed unit files in memory, so edits do nothing until systemctl daemon-reload. systemctl edit runs the reload for you when you save.

What I check first

When a unit fails on a host I run, I read the status= value before anything else, because it splits the problem in half: 200 and above means the unit file or the host is wrong and I do not need to look at the application at all; below 200 means the program ran and I go straight to running it by hand with systemd-run -p User=. Then I check that the unit sets RestartSec=, backoff and limits in [Unit], and that systemd-analyze verify is clean in CI before the file ever reaches a server. For the full background on units, timers and cgroups, read the systemd explainer for DevOps. Official references: systemd.service, systemd.exec, systemd.unit and journalctl.

Related: How to Troubleshoot Linux Performance in 60 Seconds (2026 Checklist), Fix Kubernetes OOMKilled and Exit Code 137: Step-by-Step Guide (2026) and How to Fix SSH Permission Denied (publickey): Causes and Fixes (2026).

Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related