Systemd Unit Validator · Scheduling
Check a systemd unit before systemd ignores it.
.service, .timer and .socket files, checked against systemd's own reading of them: the directive in the wrong section, the typo it discards without telling you, the OnCalendar= that means your timer never fires. Every finding names a line, says what systemd itself does about it, and carries the fix — and nothing you paste leaves the tab.
Runs in your browser — nothing you paste leaves this page. How we prove that
Systemd Unit Validator playground
WantedBy= lives in [Install] and is what systemctl enable reads to create its symlinks. Type= tells systemd how to decide the service is up. Persistent=true makes a calendar timer run immediately at boot if it missed its window.
Detected: —
Results update as you type — press Enter to run now.
Press Esc to release keyboard focus from the editor; ⌘/Ctrl + Enter checks and leaves the editor. Tap a Line badge to jump to that line. Nothing you paste is uploaded.
Paste a .service, .timer or .socket file above — or tap an example — to see line-numbered findings here, each with the reason it matters and the fix.
The Gap
systemd does not tell you when it ignores you.
A unit file that loads is not a unit file that works. Write User=deploy in [Unit] instead of [Service] and the service runs as root; write WantedBy= there and systemctl enable quietly does nothing, so the unit never comes back after a reboot. Both log one line — “Unknown key name … ignoring” — into a journal nobody reads on a good day, and both leave a unit that looks healthy in systemctl status.
The verification tool systemd ships, systemd-analyze verify, is more thorough than this page will ever be — and it needs a machine running systemd, usually root, and it resolves your unit against THAT host's users, paths and drop-ins. None of which you have while reviewing a .service in a pull request on a laptop.
And then there is generated output. Ask an assistant for a timer and you get something fluent and plausible: RestartSecs=5 (there is no such directive), ExecReload= in [Unit], a WantedBy= in the wrong block, or the single most common one of all — OnCalendar=*/15, which is cron syntax that systemd rejects outright, leaving a timer that never fires and never appears in systemctl list-timers. Checking the claim takes a second; knowing what to check is the hard part. That is what the 35 checks here are for.
Migrating from cron? The Cron to systemd Converter writes the .timer and .service pair for you — then paste the result here to check your hand edits. Both tools share one OnCalendar= grammar, so they cannot disagree about a schedule.
The Pipeline
How it works.
Four steps, all inside your browser tab, re-run as you type.
-
Parse it the way systemd does.
A `\` continuation folds onto the next line — and a comment line inside that continuation is dropped, not folded in. `#` and `;` start a comment only at the start of a line: there are NO end-of-line comments, so `RestartSec=30 # later` really does set the value to `30 # later`.
-
Resolve what actually took effect.
Repeating a list directive appends; repeating a scalar means the last one silently wins; an empty assignment resets the list. So the rules read the value systemd would end up with, not the first one in the file — and say which line lost.
-
Check names, values and semantics.
Each name is looked up in the section it was written in, against 439 known directives. Values are checked only where systemd is strict: enums case-sensitively, booleans case-insensitively, and OnCalendar= against the full systemd.time(7) grammar.
-
Say what systemd itself would do.
Every finding names the physical line and quotes the consequence — “refuses to load the unit”, “logs Unknown key name and ignores the line”. Where this page cannot know something, it says so instead of guessing.
Reference
Which directive goes where.
systemd reads a directive only from the section that owns it, and it matches both section and directive names case-sensitively. These are the ones that appear in real units — the validator knows 439 names in total, across five sections it checks and six it names but leaves alone.
[Unit]
| Directive | What it does |
|---|---|
| Description= | One line, shown by systemctl status and in logs. |
| Documentation= | Space-separated URIs — man:, https:, file:. |
| After= | Ordering only: start after these, if they are starting too. |
| Wants= | Pull these in, but do not fail if they fail. |
| Requires= | Pull these in and fail with them. Ordering is NOT implied. |
| ConditionPathExists= | Skip the unit (not fail it) when the path is absent. |
| StartLimitIntervalSec= | Window for the start-rate limit. Lives here, not in [Service]. |
| StartLimitBurst= | Starts allowed inside that window before systemd gives up. |
[Install]
| Directive | What it does |
|---|---|
| WantedBy= | What `systemctl enable` links this into. Timers: timers.target. |
| RequiredBy= | As WantedBy=, but the target fails if this unit fails. |
| Alias= | Extra name to answer to once enabled. |
| Also= | Other units to enable and disable alongside this one. |
[Service]
| Directive | What it does |
|---|---|
| Type= | simple, exec, forking, oneshot, dbus, notify, notify-reload, idle. |
| ExecStart= | Absolute path. One line unless Type=oneshot. Not a shell. |
| ExecStartPre= | Runs before ExecStart=; a failure aborts the start. |
| ExecReload= | What `systemctl reload` runs. Belongs here, never in [Unit]. |
| ExecStop= | Optional graceful stop; systemd still sends the kill signals after. |
| PIDFile= | Required in practice for Type=forking: names the real main process. |
| Restart= | no, on-success, on-failure, on-abnormal, on-watchdog, on-abort, always. |
| RestartSec= | Delay before a restart. NOT “RestartSecs”. |
| RemainAfterExit= | Report a finished oneshot as active rather than dead. |
| User= / Group= | Who the process runs as. [Service], never [Unit]. |
| DynamicUser= | Allocate a throwaway UID per start. Pair with StateDirectory=. |
| StateDirectory= | systemd creates /var/lib/<name> and re-chowns it every start. |
| WorkingDirectory= | cwd for the processes. Does not affect Exec* lookup. |
| Environment= | KEY=value pairs; repeatable, and $KEY expands in Exec* lines. |
| EnvironmentFile= | Read pairs from a file. Prefix with `-` to tolerate absence. |
| TimeoutStartSec= | How long systemd waits for the start to finish. |
[Timer]
| Directive | What it does |
|---|---|
| OnCalendar= | Wall-clock schedule, systemd.time(7) syntax. Repeatable. |
| OnBootSec= | Monotonic: this long after boot. |
| OnUnitActiveSec= | Monotonic: this long after the unit last activated. |
| AccuracySec= | How much slack systemd may take to batch wakeups. Default 1min. |
| RandomizedDelaySec= | Spread the fire time to avoid a thundering herd. |
| Persistent= | Calendar timers only: catch up a run missed while powered off. |
| Unit= | What to trigger. Defaults to the .service of the same basename. |
| WakeSystem= | Wake the machine from suspend to fire. Needs the right hardware. |
[Socket]
| Directive | What it does |
|---|---|
| ListenStream= | Port, address:port, or an absolute path for a UNIX socket. |
| ListenDatagram= | The UDP / SOCK_DGRAM equivalent. |
| Accept= | no: one service gets the listening socket. yes: one per connection. |
| SocketMode= | Permissions for a UNIX socket or FIFO, e.g. 0660. |
| SocketUser= / SocketGroup= | Owner of a UNIX socket in the filesystem. |
| Service= | What to activate. Defaults to the .service of the same basename. |
| FileDescriptorName= | Name the FD so a service with several sockets can tell them apart. |
OnCalendar= syntax
The one field where a mistake is invisible: a schedule systemd cannot parse means the unit does not load, so the timer never fires AND never shows up as broken. The validator checks the full systemd.time(7) grammar — the same module the Cron to systemd Converter writes against.
| Form | Meaning |
|---|---|
| DOW YYYY-MM-DD HH:MM:SS | The full shape. Weekday, date and seconds are all optional; a date alone means 00:00:00. |
| * | Any value for that component. |
| a,b,c | A list of values. |
| a..b | A range, low value first. Weekdays run Mon..Sun, so Sun cannot start a range. |
| a/n | Every n, starting at a. The start value is NOT optional — see below. |
| a..b/n | Every n across a range. |
| *-*~03 | Third-from-last day of the month (the `~` counts back from the end). |
| … UTC | … Europe/Berlin | A trailing timezone. Needs systemd 242 or newer; the name is resolved against the host’s tzdata. |
| Shorthand | Expands to |
|---|---|
| minutely | *-*-* *:*:00 |
| hourly | *-*-* *:00:00 |
| daily | *-*-* 00:00:00 |
| monthly | *-*-01 00:00:00 |
| weekly | Mon *-*-* 00:00:00 |
| yearly | *-01-01 00:00:00 |
| annually | *-01-01 00:00:00 |
| quarterly | *-01,04,07,10-01 00:00:00 |
| semiannually | *-01,07-01 00:00:00 |
Rejected — the repeat has no start value
[Timer]
OnCalendar=*/15
OnCalendar=0 3 * * * Accepted — every 15 minutes, and 03:00 daily
[Timer]
OnCalendar=*-*-* *:00/15:00
OnCalendar=*-*-* 03:00:00 The Fence
What it deliberately does not check.
Every one of these was considered and rejected, and the same list sits in a comment at the top of the engine. A validator that guessed would be worse than one that says “I read one file, as text”.
Whether the units you depend on exist
After=, Wants= and Unit= name other files. They are not in front of us, so a “unit not found” claim would be invented.
Whether a path, user or capability exists
ExecStart=/usr/local/bin/x is checked for being absolute, not for being there. Only your machine knows the rest.
Time-span syntax
systemd’s grammar for `5min`, `1h30s` and `2 weeks` is generous. Re-implementing it would produce more false positives than catches.
Drop-ins and filename agreement
A .d/ override, and whether the file’s own name matches what Unit= expects, are both outside a single pasted file.
Ordering without a dependency
After= with no Wants= is frequently deliberate. Flagging it would be an opinion dressed as a finding.
Values in [Mount], [Path], [Swap], [Automount], [Slice] and [Scope]
Their directive NAMES are known — so `What=` in [Service] is attributed correctly — but their values are not checked, and the results say so out loud.
ProtectSystem=, ProtectHome=, RestrictNamespaces= and friends
They take named values OR a boolean, so a strict check would reject legal input. They are carried as opaque strings on purpose.
PrivateTmp= as a plain boolean
systemd 257 added `PrivateTmp=disconnected`. A validator that called a legal value an error would be the cry-wolf failure this tool exists to avoid.
The next elapse of a timer
That needs a clock and a timezone database. A “next run” printed as fact and quietly wrong is worse than no answer at all.
Anything about the running system
No systemctl, no journal, no drop-in tree, no tzdata. One file, read as text — which is also why it needs no root and uploads nothing.
Limits, stated rather than hidden: the validator scans up to 200,000 characters, keeps at most 20 findings per check and 200 in total, renders at most 50 rows per severity, and tells you the real count whenever a cap applies. An unrecognised directive is always a note, never an error.
Next Step
Paste the report into the review.
"Copy report" gives you the whole run as plain text — one line per finding, with the line number, the check id and the fix. Then keep going: turn a crontab line into a real timer, or read a cron schedule back in plain English first.
Systemd Unit Validator — 2 errors, 1 warning across 8 lines
Scope: system
ERRORS (2)
L3 wrong-section: WantedBy= belongs in [Install], not [Unit]. — fix: Move the line into the `[Install]` section.
L8 typo-directive: ExecStrat= is not a systemd directive — did you mean ExecStart=? — fix: Write `ExecStart=`.
WARNINGS (1)
missing-install: No [Install] section, so “systemctl enable” has nothing to do. — fix: Add `[Install]` with `WantedBy=multi-user.target` (or `default.target` for a user unit).
Checked client-side at opscanopy.com/systemd-unit-validator/ — 0 bytes uploaded. FAQ
Questions, answered.
Tap a question to expand the answer.
What does the Systemd Unit Validator check?
35 checks over a real parse of the file, in four groups. Structure: an invalid section header, a lower-cased [unit], an unknown or repeated section, a line with no "=", an assignment written before the first section. Names: a directive in the wrong section (User= in [Unit]), a typo systemd throws away (ExecStrat=), a deprecated name, a name it simply does not know. Values: an enum that is wrong because systemd compares those case-sensitively, a non-boolean boolean, an Exec* path that is not absolute, shell syntax in an Exec* line that systemd will not interpret, an unknown % specifier, and an end-of-line "comment" that is really part of the value. Semantics: a service with no ExecStart=, two ExecStart= lines when Type is not oneshot, Type=forking with no PIDFile=, Restart=always with Type=oneshot, a timer with no trigger, an OnCalendar= systemd cannot parse, Persistent= on a monotonic timer, a socket with nothing to listen on, a missing [Install], and a system target in a user unit.
Is this systemd-analyze verify?
No, and it cannot be. systemd-analyze verify runs on a machine that has systemd, needs root for anything in the system search path, and — crucially — resolves the unit against THAT machine: the users, the paths, the drop-ins, the other units. That makes it the more thorough tool and the wrong tool for the moment you actually want the check: reviewing a .service in a pull request on a macOS laptop, or sanity-checking a unit an assistant just wrote for you. This reads one file as text, in your browser, and is explicit about the difference — everything it cannot know from the text alone is listed on this page rather than guessed at.
Does my unit file ever leave my browser?
No. The parser and all 35 checks are JavaScript running in your tab — there is no server, no API call and no logging, so 0 bytes are uploaded. That matters here: unit files carry internal hostnames, private paths, service account names, EnvironmentFile= locations and occasionally a credential someone should not have put in a value.
Why does my systemd timer never fire?
The commonest cause by far is an OnCalendar= that systemd cannot parse, because cron syntax was pasted into it. Two forms account for nearly all of it. First, a bare "*/15": a systemd calendar repeat needs an explicit start value, so "*-*-* *:00/15:00" (or the shorter "*:00/15:00") is accepted and "*/15" is rejected outright. Second, a whole five-field crontab schedule ("0 3 * * *"): systemd calendar events are "DOW YYYY-MM-DD HH:MM:SS", not five fields, and this tool recognises the shape and sends you to the converter. After that, look for a timer with no trigger at all, and for a .timer with no Unit= whose matching .service has a different basename. A unit systemd refuses to load never appears in systemctl list-timers, which is why nothing looks wrong.
Why is WantedBy= in [Unit] an error rather than a warning?
Because the setting does not take effect at all. systemd reads WantedBy= only from [Install]; anywhere else it logs "Unknown key name 'WantedBy' in section 'Unit', ignoring." and moves on. The unit loads, starts by hand, looks healthy — and systemctl enable silently does nothing, so it never comes back after a reboot. On this page an error means systemd refuses the unit OR discards the setting entirely; a warning means the setting applies and is a trap. A dropped setting belongs in the first group, because the file no longer describes what the machine will do.
Why is an unrecognised directive only a note?
Because this page ships a directive table — 439 names — and your machine ships a systemd. New directives arrive with every release, so a name this table has not heard of may be perfectly valid where you are going to run it. Calling that an error would train you to ignore the whole report, which is the one thing a linter cannot survive. A name that is a near-miss of a real directive is different: there the evidence is strong enough to say systemd is throwing the line away, and that IS reported as an error with the correction.
What does the System / User toggle change?
Three things, all about which manager will load the file. In user scope, a WantedBy= naming a system target (multi-user.target, graphical.target) is flagged: the user manager has no such target, so systemctl --user enable has nothing to link and the unit never starts — default.target is what you want, and timers.target does exist there. The scope also travels in the share link, so a link you send reproduces the findings you saw rather than a different set. Everything else — the parse, the names, the values — is identical, because systemd reads the file the same way either way.
Can it check a template unit, a drop-in, or the units I depend on?
It reads exactly one file, so: a template is parsed normally and %i is flagged as a note (it only has a value when the file is name@.service started as name@instance.service); a drop-in fragment is parsed fine on its own, but nothing here knows what it is overriding, so a partial file will look like it is missing an ExecStart=; and a unit named in After=, Wants= or Unit= is never resolved, because that file is not in front of us. Guessing at any of the three would be a confidently wrong answer, which is worse than no answer.
How large a unit file can it handle, and are findings ever truncated?
Up to 200,000 characters, which is roughly four thousand lines — two orders of magnitude past any real unit file. Past that it refuses with a message instead of freezing your tab, because an input that big is a journal dump, not a unit. Findings are capped too, at 20 per check and 200 in total, and the panel states the cap and the real count whenever one applies. The results list is capped separately at 50 rows per severity, again with the real number shown.
More free, private DevOps tools.
The Systemd Unit Validator is one tool in OpsCanopy — a growing canopy of browser-based validators, converters and testers that never touch a server.
More in Scheduling
39 free tools, every one offline-capable — opscanopy.com works with no signup and nothing uploaded.
Related: Cron to systemd Converter to write the timer in the first place, Cron Expression Tester to read the cron side in plain English, the Dockerfile Linter and GitLab CI Validator for the same "catch it before it ships" check on other config, and the Env Example Checker for the variables an EnvironmentFile= is supposed to provide — or browse the full tools directory.
Not affiliated with or endorsed by the systemd project. Directive and format names are used only to describe what this tool checks; always confirm a unit against the systemd version that will load it.