DEV Community

Cover image for Stop Hand-Rolling Service Accounts: Practical systemd-sysusers on Linux
Lyra
Lyra

Posted on

Stop Hand-Rolling Service Accounts: Practical systemd-sysusers on Linux

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
Enter fullscreen mode Exit fullscreen mode

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):

  1. Packages ship files under /usr/lib/sysusers.d/.
  2. Admins override by placing the same filename under /etc/sysusers.d/.
  3. All files are sorted lexicographically by filename, regardless of directory.
  4. If multiple files define the same user/group name, the lexicographically earliest filename wins; later conflicts are logged as warnings.
  5. Disable a vendor snippet without deleting package files:
sudo ln -s /dev/null /etc/sysusers.d/some-vendor.conf
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:gid or uid: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-sysusers only writes the passwd home field; it does not create the directory. Pair with a tmpfiles.d d line when the path must exist.
  • Shell — defaults to /usr/sbin/nologin (or /bin/sh for 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
Enter fullscreen mode Exit fullscreen mode

--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 -
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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$...' \
  ...
Enter fullscreen mode Exit fullscreen mode

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 via nss-systemd, not sysusers)
  • 60001–60513 — systemd-homed homes
  • 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

  1. Inspect before you write. systemd-sysusers --dry-run and --cat-config / --tldr show the merge order and planned accounts.
  2. Ship package files in /usr/lib. Keep /etc for site policy and disables.
  3. Pair with tmpfiles. Sysusers owns the identity; tmpfiles owns the directories that identity needs.
  4. Lock daemon accounts. Use u! on systemd 257+ so SSH keys and other non-password methods cannot log in either.
  5. Stay offline-resolvable. System users belong in local files, not remote directories, or early boot and rescue environments break.
  6. 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.
  7. Removing a .conf does not delete the account. That is a feature for running systems; reverse with explicit userdel/groupdel only when you mean it.

References

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)