Most Linux packages still ship shell that looks like this:
getent group myapp >/dev/null || groupadd --system myapp
getent passwd myapp >/dev/null || useradd --system --gid myapp --home-dir /var/lib/myapp --shell /usr/sbin/nologin myapp
It works until someone races the script, a container image rebuild forgets the accounts, an admin overrides /etc/login.defs, or you need the same identity on a chroot, a DDI, and a live host without copy-pasting three different useradd dialects.
systemd-sysusers is the declarative fix. Drop one line per account under sysusers.d/, run it at package install or early boot, and the system users appear the same way every time — without inventing human logins, and without touching NIS/LDAP databases.
What it is (and is not)
systemd-sysusers reads sysusers.d fragments and creates system users and groups by writing /etc/passwd and /etc/group directly. That is intentional:
- It is for daemon identities and privilege separation, not for people.
- It bypasses complex remote user databases (NIS, LDAP, and friends).
- System users must resolve without the network, including early boot and initrd paths.
If you need portable encrypted human homes, that is systemd-homed. If you want a throwaway UID for one unit with no persistent name, that is DynamicUser=. sysusers sits in the middle: named, persistent, local system accounts that packages and images can declare.
The boot unit is systemd-sysusers.service. You can also invoke the tool by hand.
Config locations and precedence
Search paths (highest override wins on the same basename):
| Path | Role |
|---|---|
/etc/sysusers.d/*.conf |
Local administrator |
/run/sysusers.d/*.conf |
Runtime |
/usr/local/lib/sysusers.d/*.conf |
Local software installs |
/usr/lib/sysusers.d/*.conf |
Vendor / package |
Rules from sysusers.d(5):
- Packages ship files under
/usr/lib/sysusers.d/. - Admins override by placing the same filename under
/etc/sysusers.d/. - All files are sorted lexicographically by filename, regardless of directory.
- If multiple files define the same user/group name, the lexicographically earliest filename wins; later conflicts are logged as warnings.
- Disable a vendor snippet without deleting package files:
sudo ln -s /dev/null /etc/sysusers.d/some-vendor.conf
Name files like package.conf or package-part.conf so one piece can be overridden cleanly.
Line format
#Type Name ID GECOS Home directory Shell
u! httpd 404 "HTTP User"
u! _authd /usr/bin/authd "Authorization user"
u! postgres - "Postgresql Database" /var/lib/pgsql /usr/libexec/postgresdb
g input - -
m _authd input
u root 0 "Superuser" /root /bin/zsh
r - 500-900
Empty lines and # comments are ignored. Fields after Name depend on the type.
Types you will actually use
| Type | Meaning |
|---|---|
u |
Create a system user and a same-named group if missing. Account is created disabled (invalid password). Default primary group is the matching group unless ID says otherwise. |
u! |
Same as u, but the account is fully locked. Recommended for daemon users. The lock matters for non-password auth (SSH and similar), not only password logins. Added in systemd 257. |
g |
Create a system group only. u already creates a matching group, so use g for shared groups. |
m |
Add a user to a group. Missing user or group is created implicitly. |
r |
Add a numeric UID/GID range to the allocation pool (or a single ID). UIDs and GIDs share the pool so same-named user/group pairs tend to get matching numbers. |
Name rules
Strict syntax from the man page and systemd.io/USER_NAMES:
- Allowed:
a-z,A-Z,0-9,_,- - First character: letter or
_(not a digit, not-) - Length: 1–31 characters
Strong recommendation: prefix system names with _ (for example _myapp) so they do not collide with admin-created people accounts.
ID field
For u / g:
-
-— automatic allocation from the system UID/GID pool (preferred) - numeric UID/GID — pin a specific ID when you truly must
- absolute path — take owner/group from an existing file (handy for SUID/SGID binaries already on disk)
-
uid:gidoruid:groupname— set a specific primary group; the group must already exist or be created explicitly -
-:groupname— automatic UID with an explicit primary group
Do not use 65535 or 4294967295. Both are reserved placeholder values on Linux ((uid_t)-1 in 16-bit and 32-bit eras).
For m, the ID field is the group name.
For r, the ID field is FROM-TO or a single number.
GECOS, home, shell
-
GECOS — short quoted description; no colons. Only for
u. -
Home — defaults to
/if omitted.systemd-sysusersonly writes the passwd home field; it does not create the directory. Pair with atmpfiles.ddline when the path must exist. -
Shell — defaults to
/usr/sbin/nologin(or/bin/shfor UID 0). Leave it alone unless software truly needs another shell.
Useful CLI flags
# Apply all discovered sysusers.d snippets
sudo systemd-sysusers
# Dry-run: show what would be created (no writes)
sudo systemd-sysusers --dry-run
# Inspect merged config
systemd-sysusers --cat-config | less
systemd-sysusers --tldr
# One admin file
sudo systemd-sysusers /etc/sysusers.d/myapp.conf
# Inline lines without a file
sudo systemd-sysusers --inline 'u! _demo - "Demo service user"'
# Operate on an alternate root or disk image (image builders / offline)
sudo systemd-sysusers --root=/mnt/root
sudo systemd-sysusers --image=/var/lib/machines/lab.raw
--dry-run landed in systemd 250 (-n short option in 262+). --inline and --replace= arrived in 238.
Package install pattern: --replace=
When a package script runs before its own /usr/lib/sysusers.d/foo.conf is on disk, feed the lines on stdin but keep admin override priority:
echo 'u! radvd - "radvd daemon"' | \
sudo systemd-sysusers --replace=/usr/lib/sysusers.d/radvd.conf -
That behaves as if radvd.conf were already installed, while still honoring an earlier admin file such as /etc/sysusers.d/radvd.conf or /etc/sysusers.d/00-overrides.conf.
Hands-on lab: a locked service user + shared group + state dir
Assume a small metrics agent that should run as _metrics, share a metrics-readers group with a log shipper, and own /var/lib/metrics.
1. Declare accounts
sudo tee /etc/sysusers.d/metrics.conf >/dev/null <<'EOF'
# Locked daemon user; automatic UID/GID from the system pool
u! _metrics - "Metrics agent"
# Shared group for readers (no login user implied)
g metrics-readers -
# Membership
m _metrics metrics-readers
EOF
On systemd older than 257, use plain u instead of u! and lock separately if your policy requires it. Prefer u! on current releases.
2. Declare the state directory (tmpfiles, not sysusers)
sudo tee /etc/tmpfiles.d/metrics.conf >/dev/null <<'EOF'
d /var/lib/metrics 0750 _metrics _metrics - -
EOF
3. Apply and verify
# Preview first
sudo systemd-sysusers --dry-run /etc/sysusers.d/metrics.conf
# Create accounts
sudo systemd-sysusers /etc/sysusers.d/metrics.conf
# Create the directory tree
sudo systemd-tmpfiles --create /etc/tmpfiles.d/metrics.conf
# Verify
getent passwd _metrics
getent group _metrics metrics-readers
id _metrics
namei -l /var/lib/metrics
You should see a system UID (typically in the distribution system range, commonly 1–999), shell /usr/sbin/nologin, and membership in both _metrics and metrics-readers.
4. Wire a service
# /etc/systemd/system/metrics-agent.service
[Unit]
Description=Example metrics agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=_metrics
Group=_metrics
SupplementaryGroups=metrics-readers
StateDirectory=metrics
ExecStart=/usr/local/bin/metrics-agent --data=%S/metrics
# Harden lightly; expand with your real profile
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/var/lib/metrics
[Install]
WantedBy=multi-user.target
StateDirectory=metrics manages /var/lib/metrics for the unit lifetime under the service UID. Keeping the matching tmpfiles.d line is still useful for boot-time ownership repair and non-unit tools that expect the path to exist.
5. Idempotence check
sudo systemd-sysusers /etc/sysusers.d/metrics.conf
# Second run: no changes if the user/group/membership already exist
sysusers.d(5) is explicit: existing users, groups, and memberships are left alone. That is why re-running from package scripts is safe.
6. Cleanup (lab only)
sudo systemctl disable --now metrics-agent.service 2>/dev/null || true
sudo rm -f /etc/systemd/system/metrics-agent.service
sudo systemctl daemon-reload
sudo rm -f /etc/sysusers.d/metrics.conf /etc/tmpfiles.d/metrics.conf
# Accounts are not auto-deleted by removing snippets — remove deliberately if this was only a lab:
# sudo userdel _metrics
# sudo groupdel metrics-readers
# sudo rm -rf /var/lib/metrics
First-boot credentials (empty /etc)
systemd-sysusers understands service credentials (same family as LoadCredential= / SetCredential=):
| Credential | Purpose |
|---|---|
passwd.hashed-password.USER |
Set initial UNIX hash when creating USER (often root) |
passwd.plaintext-password.USER |
Plaintext alternative; hashed form wins if both set |
passwd.shell.USER |
Shell to use at creation time |
sysusers.extra |
Extra sysusers.d lines, processed after on-disk drop-ins |
These only apply when the account is created. They do not rotate passwords on existing users. systemd-sysusers.service inherits the root password/shell credentials and sysusers.extra by default, which is why first-boot containers can seed root like this (hash abbreviated for readability):
systemd-nspawn --image=... \
--set-credential=passwd.hashed-password.root:'$y$j9T$...' \
...
Generate hashes with mkpasswd(1). Prefer hashed credentials over plaintext outside throwaway labs.
Where UIDs come from
From Users, Groups, UIDs and GIDs on systemd Systems:
- 0 — root
- 1–999 — system users (default boundary; do not casually move it)
- 1000+ — regular/human users (and higher special ranges)
-
65534 —
nobody/ overflow - 65535 and 4294967295 — unusable placeholders
-
61184–65519 —
DynamicUser=allocations (synthesized vianss-systemd, notsysusers) -
60001–60513 —
systemd-homedhomes - Other ranges exist for greeters, nspawn user namespaces, and foreign OS images
sysusers allocates named system accounts from the system pool (or your r ranges). It does not claim the dynamic or homed ranges. Keep automatic - IDs unless you have a documented reason to pin numbers (NFS export tables, hard-coded third-party packages, and similar).
Boundaries: pick the right tool
| Need | Tool |
|---|---|
| Named persistent system daemon user/group | systemd-sysusers / sysusers.d |
Create /run and /var/lib trees + ownership |
systemd-tmpfiles / tmpfiles.d |
| Per-unit ephemeral UID, no long-lived name |
DynamicUser=yes in the unit |
| Human users with portable encrypted homes | systemd-homed / homectl |
| Secrets for services | systemd-creds / LoadCredentialEncrypted= |
| Interactive people accounts on a workstation |
useradd / your IdM — not sysusers |
Common anti-pattern: creating a system user with a real login shell and home under /home. Prefer u!, /usr/sbin/nologin, and a data directory under /var/lib managed by tmpfiles or StateDirectory=.
Operational tips
-
Inspect before you write.
systemd-sysusers --dry-runand--cat-config/--tldrshow the merge order and planned accounts. -
Ship package files in
/usr/lib. Keep/etcfor site policy and disables. - Pair with tmpfiles. Sysusers owns the identity; tmpfiles owns the directories that identity needs.
-
Lock daemon accounts. Use
u!on systemd 257+ so SSH keys and other non-password methods cannot log in either. - Stay offline-resolvable. System users belong in local files, not remote directories, or early boot and rescue environments break.
-
Image builds. Prefer
--root=/--image=in builders so the guest passwd database is populated offline the same way the host would do at first boot. -
Removing a
.confdoes not delete the account. That is a feature for running systems; reverse with explicituserdel/groupdelonly when you mean it.
References
- sysusers.d(5) — file format, types, precedence, idempotence
-
systemd-sysusers(8) — CLI,
--replace=, credentials, image/root modes - Users, Groups, UIDs and GIDs on systemd Systems — UID ranges and special IDs
- User/Group Name Syntax — strict vs relaxed name rules
- tmpfiles.d(5) — create the directories sysusers only records
-
systemd.exec(5) —
User=,DynamicUser=,StateDirectory=, credentials - Debian man pages mirror: sysusers.d(5), systemd-sysusers(8)
Stop scattering useradd one-liners through packaging scripts. Declare the account once, apply it everywhere, and let tmpfiles own the directories that account needs.
Top comments (0)