Writing systemd Unit Files With LLMs in October 2026: Verify Before You Enable, Harden the Sandbox, and Use Timers Instead of Cron
Ask an LLM to "write a systemd service for my app" and you will get something that looks right in seconds. It will usually start, too. The trouble shows up later: the service runs as root, it restarts forever in a tight loop, it starts before the network is actually up, or a secret ends up readable by anyone who runs systemctl show. This guide walks through a practical workflow for using a model to draft unit files while keeping the checks that catch those problems before they reach a server.

Why unit files are easy to get almost right
A systemd unit is a small INI-style file, and models have seen thousands of them. That is the problem. Many of the examples online are old, copied from distro packages with different needs, or written for a quick demo. A model blends them, so you get a plausible mix of directives, some of which are in the wrong section, some of which are silently ignored, and some of which do something different from what the comment says.
systemd is also forgiving in a way that hides mistakes. An unknown directive or one in the wrong section usually produces a warning in the journal, not a failure, so the service starts and you never see the warning unless you look. That makes verification a deliberate step, not something you can count on happening by itself.
Step 1: Give the model the facts it cannot guess
The quality of the draft depends on what you tell the model up front. Before you prompt, write down:
- The exact binary path and arguments, for example
/opt/myapp/bin/myapp --config /etc/myapp/config.toml. - Whether the process stays in the foreground or forks into the background. Most modern apps stay in the foreground.
- Which user should run it, or whether a throwaway dynamic user is fine.
- Which directories it must write to (data, cache, logs) and which ports it listens on.
- Whether it needs the network to be reachable at startup or only needs local sockets.
- Your systemd version, from
systemctl --version. Several useful directives only exist in newer releases, and the model cannot know what your distro ships.
A prompt that includes those details gets a far better first draft than "write a service file for my Node app." Also tell the model to explain every directive it uses in one line. That explanation is what you will check against the documentation.
Step 2: Review the draft against a known-good shape
For a typical long-running app, a sound starting point looks like this:
[Unit]
Description=My App API server
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=exec
User=myapp
Group=myapp
ExecStart=/opt/myapp/bin/myapp --config /etc/myapp/config.toml
Restart=on-failure
RestartSec=5
EnvironmentFile=/etc/myapp/myapp.env
[Install]
WantedBy=multi-user.target
Compare the model's version to this shape and look for these common mistakes:
- Start limits in the wrong section.
StartLimitIntervalSecandStartLimitBurstbelong in[Unit]. Many older examples put them in[Service], where systemd only accepts them for backward compatibility; keep new units in the documented place. After=network-online.targetwithoutWants=.After=only sets ordering. WithoutWants=network-online.target, nothing pulls that target in, so the ordering may do nothing. If your service only listens on a socket and does not need outbound connections at boot, you may not need either line.- The wrong
Type=.Type=forkingis only for programs that daemonize themselves. If your app stays in the foreground and the unit saysforking, systemd will wait for a fork that never comes and eventually time out.Type=execorType=simplefits most foreground apps;Type=notifyis only correct if the app actually sends readiness notifications. Restart=alwaysfor a one-shot job. Batch jobs should useType=oneshotand usually no restart policy at all.- Shell syntax in
ExecStart=. systemd does not run a shell, so pipes,&&, and redirects like>> /var/log/app.logare not interpreted the way they would be in bash. Either wrap the command in/bin/sh -c '...'deliberately, or better, let the journal capture output and drop the redirect entirely. - Editing vendor files. If the model tells you to edit a file under
/usr/lib/systemd/system/or/lib/systemd/system/, stop. Package upgrades overwrite those. Usesystemctl edit myapp.service, which creates a drop-in override under/etc/systemd/system/myapp.service.d/.

Step 3: Verify before you enable
systemd ships tools that catch most mistakes in seconds. Run them on every generated unit:
# Check syntax, unknown directives, and missing executables
sudo systemd-analyze verify /etc/systemd/system/myapp.service
# Reload, start, and watch what actually happens
sudo systemctl daemon-reload
sudo systemctl start myapp.service
systemctl status myapp.service
journalctl -u myapp.service -b --no-pager
systemd-analyze verify flags directives it does not recognize and executables it cannot find. Treat every warning as something to fix, not noise. Then read the journal from the first start: warnings like "Unknown key name" or "Ignoring" are exactly the silent failures described above.
Only after a clean start and a clean journal should you run sudo systemctl enable myapp.service. Enabling a broken unit means it fails again on every boot, often when nobody is watching.
It is also worth testing failure on purpose. Kill the process with sudo systemctl kill myapp.service or point it at a bad config, and confirm that it restarts a few times and then stops once it hits the start limit, instead of looping forever and filling the journal.
Step 4: Harden the sandbox on purpose
Models tend to either skip hardening completely or paste a long block of every protection directive they have seen, some of which will break your app. A better approach is to add protections in a deliberate order and test after each group.
Start by measuring where you are:
systemd-analyze security myapp.service
This prints an exposure score and a list of protections that are and are not enabled. A plain service with no hardening scores as highly exposed. Then add directives in layers:
[Service]
# Layer 1: identity and privilege
User=myapp
NoNewPrivileges=yes
CapabilityBoundingSet=
# Layer 2: filesystem
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/var/lib/myapp
StateDirectory=myapp
# Layer 3: kernel and devices
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
# Layer 4: network and syscalls
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
SystemCallFilter=@system-service
LockPersonality=yes
A few things to watch for when you review what the model suggests:
ProtectSystem=strictmakes the whole filesystem read-only for the service. Anything the app writes to must be listed inReadWritePaths=or created throughStateDirectory=,CacheDirectory=, orLogsDirectory=. If the app fails with "Read-only file system" errors after you add it, that is the cause.MemoryDenyWriteExecute=yesbreaks JIT runtimes. Node.js, the JVM, and some Python extensions generate executable code at runtime. Models often include this directive by default. Leave it out for those runtimes unless you have tested it.- Binding to ports below 1024. Instead of running as root, a service can get just that ability with
AmbientCapabilities=CAP_NET_BIND_SERVICEand a matchingCapabilityBoundingSet=CAP_NET_BIND_SERVICE. Do not combine that with an emptyCapabilityBoundingSet=, which removes all capabilities. DynamicUser=yesis a good choice for stateless services, since systemd creates a temporary user for each run. Pair it withStateDirectory=if the service needs to keep data between restarts.
After each layer, restart the service, run your app's own health check, and rerun systemd-analyze security. The goal is not a perfect score; it is the lowest exposure your app actually tolerates.
If you want to try a directive without editing the real unit, systemd-run can launch a temporary unit with properties set on the command line, for example sudo systemd-run --pty -p ProtectSystem=strict -p PrivateTmp=yes /opt/myapp/bin/myapp --check-config.
Step 5: Keep secrets out of the unit file
A common model suggestion is Environment=API_KEY=... directly in the unit. Avoid it. Unit files are usually world-readable, and environment values set this way show up in systemctl show output.
Safer options, in rough order of preference:
LoadCredential=on systemd 247 or newer. It copies a secret file into a private directory only the service can read, exposed through the$CREDENTIALS_DIRECTORYenvironment variable. Your app reads the secret from a file instead of the environment.EnvironmentFile=pointing at a file owned by root with mode0600, such as/etc/myapp/myapp.env. This keeps secrets out of the unit itself, though the process environment can still be read by the same user or root.
Whatever you choose, never paste real secrets into the prompt when asking a model to write the unit. Use a placeholder like REPLACE_ME and fill it in on the server.
Step 6: Use timers instead of cron for scheduled jobs
When you ask for a scheduled job, models often reach for a crontab line. On a systemd machine, a timer paired with a oneshot service is usually better: output goes to the journal, failures show up in systemctl --failed, and you get the same sandboxing as any other service.
# /etc/systemd/system/myapp-backup.service
[Unit]
Description=Nightly backup for My App
[Service]
Type=oneshot
User=myapp
ExecStart=/opt/myapp/bin/backup --target /var/backups/myapp
# /etc/systemd/system/myapp-backup.timer
[Unit]
Description=Run My App backup nightly
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=15m
[Install]
WantedBy=timers.target
Enable the timer, not the service: sudo systemctl enable --now myapp-backup.timer. Persistent=true means a run missed while the machine was off happens soon after it boots, and RandomizedDelaySec= spreads load if many machines share the same schedule.
Calendar expressions are where models make the most subtle errors, such as confusing day-of-week syntax. Check every one before you trust it:
systemd-analyze calendar "Mon..Fri *-*-* 03:00:00" --iterations=3
systemctl list-timers myapp-backup.timer
The first command prints the next few times the expression will fire, in your local time zone. If those times are not what you expected, fix the expression before you deploy it.
A short checklist for every generated unit
- The model was given the binary path, user, writable paths, ports, and systemd version.
Type=matches how the process actually behaves.- Start limits are in
[Unit], and a forced failure stops after a few restarts. systemd-analyze verifyand the first-boot journal show no warnings.- Hardening was added in layers and the app still passes its health check.
- No secrets live in the unit file or the prompt.
- Scheduled work uses a timer, and its calendar expression was checked with
systemd-analyze calendar. - Changes to packaged units went into a drop-in via
systemctl edit.
Bottom line
LLMs are genuinely useful for systemd work: they remember directive names you have forgotten and produce a reasonable skeleton fast. But systemd's habit of warning instead of failing means a unit can look healthy while ignoring half of what you wrote. Give the model real facts, verify with the tools systemd already ships, add sandboxing in tested layers, and treat timers and secrets as things to check by hand. That turns a quick draft into a service you can trust after the next reboot.
Comments
Post a Comment