Systemd Unit Generator
Fill in what your service does and get a .service unit with each directive explained and cited — built entirely on this device.
Unit file
Fill the form and press Build unit file — the .service text appears here.
Scrollable region — focus it and use the arrow keys.
Nothing runs from this page — these lines are for your Linux machine's terminal. The file text above was generated on this device; the golden test vectors were minted on macOS, which cannot execute systemd.
The form covers a declared band of 19 directives plus six hardening rows — not the full ~300-directive space; systemd.unit(5) documents the rest. Out of scope here: .socket, .mount, .path and .target units, --user units, resource control (CPUQuota=, MemoryMax=), full sandboxing (SystemCallFilter=), drop-in .d/ overrides, reading journald logs, and converting crontabs (that is the cron-to-systemd-timer tool). Type=forking units usually also want a PIDFile=, which is outside this band.
Press ? for shortcuts
Do it offline:$ systemd-analyze verify ./my-app.service
How to use it
- Name the unit and enter the ExecStart command as an absolute path — the form refuses relative paths and multi-line values instead of guessing.
- Pick Type= and Restart=, set User= and Group= for an unprivileged account, and add Environment rows or an EnvironmentFile as needed.
- Tick hardening rows you want from the systemd.exec(5) band, and optionally paste an OnCalendar= value to get a .timer alongside.
- Press Build unit file, read the directive table with its citations, then copy or download the .service (and .timer) text.
Describe what your service does — the command, the user, the restart policy, the environment — and get a complete .service unit back, every directive explained and cited to its man page. A curated hardening band from systemd.exec(5) is one checkbox per row, an optional OnCalendar= value attaches the matching .timer, and the install commands for a Linux machine sit beside the file text. Everything is generated on this device.
How it works
A unit file is INI-shaped text in three sections. [Unit] carries identity and relationships: Description=, ordering (After=) and dependencies (Requires=, Wants=). [Service] describes the process itself — what to run, as whom, with what environment. [Install] is read only by systemctl enable and disable: it records which target adopts the unit. systemd accepts the sections in any order, but the header a directive sits under is load-bearing — the parser scopes every assignment to its section, and a [Service] line pasted under [Unit] is an error, not a hint. This generator always emits the conventional [Unit], [Service], [Install] sequence so diffs against other units stay readable.
Type= decides when systemd considers the service started, which gates everything ordered After= it. With simple, the unit counts as started the moment the process is forked — even if the binary path is wrong. exec is stricter: startup completes only once the binary has actually been executed, so a missing executable fails the start job instead of succeeding silently. oneshot holds the start job open until the command exits, which is why it suits migrations and cleanups. forking expects the classic daemon dance — the parent exits, the child carries on. notify trusts the program itself: startup finishes when it sends READY=1 through sd_notify, and a program that never sends it leaves the unit stuck activating until the timeout.
Restart= names the exit conditions that trigger a restart: no and always are the poles, on-failure covers unclean exit codes, signals and timeouts, and the narrower values (on-abnormal, on-abort, on-watchdog) slice that set finer. RestartSec= is the pause before each attempt and defaults to 100ms. The interaction people trip over is the start limit: by default a unit may attempt to start five times within ten seconds, and once the burst is spent the unit lands in a failed state that a further Restart= cannot leave. A crash-looping service with a tiny RestartSec= burns through the budget almost instantly, so a deliberate RestartSec=5 or more is often the difference between recovering overnight and paging someone.
The hardening band toggles six systemd.exec(5) directives, each narrowing what the process may touch: NoNewPrivileges= blocks privilege gains through setuid and friends, ProtectSystem=strict turns the file-system hierarchy read-only for the service, ProtectHome= hides user homes, PrivateTmp= and PrivateDevices= give it a private /tmp and a device tree without physical hardware, and RestrictSUIDSGID= stops it minting set-id files. What the band does not do is equally important: it proves nothing about the code you run, it does not filter system calls (SystemCallFilter= is outside this form), and ticking all six is a starting posture, not an assessment. The page therefore never awards a score — it shows the exact lines added and cites where each is defined.
Examples
/usr/bin/node /srv/my-app/server.js[Unit]
Description=My Node app
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/node /srv/my-app/server.js
Restart=on-failure
RestartSec=5
User=appuser
Group=appuser
WorkingDirectory=/srv/my-app
Environment=PORT=8080
Environment=NODE_ENV=production
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
A long-running Node app: ordered After= the network-online target and also Wants= it, restarted on failure after a five-second pause, and scoped to an unprivileged appuser account with two plain Environment rows. Both stream directives point at the journal, so journalctl -u my-app shows everything the process prints.
/usr/local/bin/prune-exports --keep 30[Unit]
Description=Prune old exports
[Service]
Type=oneshot
ExecStart=/usr/local/bin/prune-exports --keep 30
Restart=no
[Install]
WantedBy=multi-user.target
A oneshot maintenance job: the start job stays open until the command exits, and Restart=no is written out explicitly rather than left implicit. With no User= line the job runs as root — the generator says so in a warning note instead of hiding it.
/usr/local/bin/backup --to /var/backups[Unit]
Description=Nightly backup
[Service]
Type=exec
ExecStart=/usr/local/bin/backup --to /var/backups
Restart=on-failure
User=backup
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
RestrictSUIDSGID=true
[Install]
WantedBy=multi-user.target
The full six-row band on a User=-scoped backup, with Type=exec so a missing binary fails the start instead of pretending. The same build carries an OnCalendar= value, so the matching .timer arrives alongside — its text comes byte-for-byte from the shipped timer templater this site already uses.
./run.shExecStart= needs an absolute path starting with / — "./run.sh" is relative, and systemd does not resolve it against a shell's working directory.A refusal, printed verbatim from the engine: relative command paths are the most common reason a hand-written unit fails verification, so the form catches them before the file exists.
Every example on this page is executed against the same engine that powers the tool, on every build.
Gotchas worth knowing
Only Type=oneshot may list several commands
Paste two command lines into ExecStart and a oneshot unit emits two ExecStart= directives that run in order; every other type refuses the second line, because systemd.service(5) allows exactly one command unless the type is oneshot. The refusal names the fix: keep one command or switch the type.
% is a specifier, %% is a percent sign
systemd expands %i to the instance name, %h to the user's home and %n to the full unit name before running anything (systemd.unit(5) Specifiers). The generator passes % through untouched so templates keep working — write %% when you mean a literal percent character.
Restart=always never fits a oneshot
systemd refuses to load a Type=oneshot unit whose Restart= is always or on-success — the unit exits by design, so it would loop forever. Rather than emit a file that fails verification, the generator drops the line and tells you so in a note; on-failure remains legal and is kept.
ProtectSystem=strict can lock out your own WorkingDirectory
strict makes the entire hierarchy read-only for the service except /dev, /proc and /sys, so a job that writes into its WorkingDirectory= will start and then fail on the first write. The build warns when both are set together; the remedy is ReadWritePaths= naming the directories that must stay writable.
A trailing @ names a template
worker@.service is a template: systemctl start worker@payments creates an instance whose %i is payments. The form accepts the trailing @ and emits the file unchanged — instantiation happens on the Linux machine, not here, and the install lines still need an instance name after the @ to act on.
Common errors
ExecStart= needs an absolute path starting with / — "./run.sh" is relative, and systemd does not resolve it against a shell's working directory.- Unit files have no notion of a current directory at parse time. Spell the executable out from the root — /srv/app/run.sh — and use WorkingDirectory= if the program itself expects to start somewhere particular.
The Environment= value contains a line break or NUL — a unit directive is one line, so the value is refused rather than truncated (a broken line could smuggle in another directive).- This is the injection defence: a value that could carry its own newline would be able to write arbitrary directives into the file. The refusal is deliberate — silently cutting the value at the break would generate a unit you did not ask for.
ExecStart= is required for Type=simple — only a Type=oneshot unit with an ExecStop= may leave it out (systemd.service(5)).- Every long-running type needs the command that becomes the main process. The single exception systemd carves out is a oneshot whose only job is running something at stop time.
"soon" is not a systemd time span for RestartSec= — write a number with units, like 5, 5s, 100ms or 1min 30s (systemd.time(7)).- Time spans are numbers with unit words, and a bare number means seconds. Unit words are case-sensitive where it matters: min is minutes, M is months.
A field is longer than the 512-character limit (8,192 for the whole file, 20 rows per list). Verify long units on a Linux machine with: systemd-analyze verify ./my-app.service- A refusal, never a truncation — half a command line could quietly mean a different command. Real service definitions are short; an overflow here usually means a script was pasted where its path belongs.
FAQ
Why does systemctl say the unit is not found after I saved the file?
systemd keeps an in-memory copy of every unit it has loaded and does not watch the directory for new files. Run sudo systemctl daemon-reload once after writing or editing the file and the manager re-reads its search paths; the install card on this page puts that command first for exactly this reason.
Does this page validate my unit against a real systemd?
No, and it does not pretend to. Generation is pure text templating in your browser, checked against hand-derived test vectors — and the machine that minted those vectors is a Mac, which cannot execute systemd at all. The honest check is the page's CLI line: run systemd-analyze verify against the saved file on the Linux machine that will own the service.
What does WantedBy=multi-user.target actually change?
Nothing at runtime — it is read by systemctl enable, which creates a symlink in multi-user.target.wants/ so the unit is pulled in when that target is reached at boot. Disable removes the symlink. A unit without an [Install] section still starts manually; it just has nothing for enable to act on.
Why did the generator leave my Restart= line out of the file?
You chose Type=oneshot together with Restart=always or on-success. systemd rejects that pairing when loading the unit, so emitting it would hand you a file that fails verification — the build note says the line was dropped and why. Pick on-failure if a failed run should retry.
Can I write pipes or && in the ExecStart field?
You can, but they reach the program as literal argument text — systemd executes the command directly, with no shell in between. The build attaches a note whenever it spots shell metacharacters and shows the explicit form that works: /bin/sh -c 'one | two'. Writing the shell out yourself keeps the cost visible.
Where do the six hardening rows come from, and why only six?
Each row is a directive from systemd.exec(5), cited in the directive table next to its emitted line. Six is deliberate: they are broadly safe defaults for ordinary services, while the sharper tools — SystemCallFilter=, CapabilityBoundingSet=, resource control — need per-service judgement this form cannot make for you. The band is a starting point to build on, and the page never grades the result.
Keyboard shortcuts
- ⌘/Ctrl + Enter
- Build or rebuild the unit file.
- ⌘/Ctrl + S
- Download the .service file.
- ⌘/Ctrl + C
- Copy the active pane's file text when nothing is selected.
- ⌘/Ctrl + Shift + C
- Copy the active pane's file text even over a selection.
- t
- Cycle the color theme.
- ? or ⌘/Ctrl + /
- Show the binding list.
- Esc
- Close the topmost dialog; the form stays put.