If you still create service accounts with ad hoc useradd commands in install scripts or README files, you are making something important harder to audit, harder to override, and easier to forget.
systemd-sysusers gives you a declarative way to create system users and groups from simple config files. It is built for service identities, package installs, image builds, and first-boot provisioning, not for interactive human accounts.
In this guide, I will show how to:
- define service users and groups with
sysusers.d - validate changes safely with
--dry-run - understand override precedence between
/usr/liband/etc - use
--replacefor packaging workflows - pair account creation with the right directory-management approach
All commands and behaviors below were checked against the current systemd-sysusers(8) and sysusers.d(5) documentation, plus test runs on a Linux host with systemd 257.
What systemd-sysusers is for, and what it is not
systemd-sysusers creates system users and groups and adds users to groups based on declarative config.
It is a good fit for:
- daemon accounts like
_demoapporpostgres - package install or image-build workflows
- reproducible service identities across machines
- first-boot or offline-root provisioning with
--rootor--image
It is not the right tool for normal login users. The man page is explicit that it is for system users and groups, and it works directly with local account files like /etc/passwd and /etc/group rather than remote identity sources.
Why this is better than useradd in random scripts
A sysusers.d file is easier to reason about than an imperative shell snippet because it is:
- declarative, the desired account state lives in a file
- auditable, you can see exactly which identities a package or service expects
-
override-friendly, admins can replace vendor defaults in
/etc/sysusers.d -
testable,
systemd-sysusers --dry-runshows what would happen before anything is written
That makes it especially useful for service packaging and reproducible infrastructure.
File locations and precedence
sysusers.d files are read from these locations:
/etc/sysusers.d/*.conf/run/sysusers.d/*.conf/usr/local/lib/sysusers.d/*.conf/usr/lib/sysusers.d/*.conf
The important rule is:
-
/etc/sysusers.doverrides/run/sysusers.dand/usr/lib/sysusers.d -
/run/sysusers.doverrides/usr/lib/sysusers.d - vendor packages should install files in
/usr/lib/sysusers.d - local admin overrides belong in
/etc/sysusers.d
If you want to disable a vendor file entirely, the documented approach is to place a symlink to /dev/null in /etc/sysusers.d with the same filename.
The format, one line at a time
A sysusers.d file is line-oriented. The most common record types are:
-
uto create a system user, and implicitly a same-named group -
gto create a group -
mto add a user to a group -
rto define a UID/GID allocation range
Here is a practical example for a daemon called demoapp:
# /usr/lib/sysusers.d/demoapp.conf
u! _demoapp - "Demo app service user"
g demoapp-data -
m _demoapp demoapp-data
What this does:
- creates a locked system user named
_demoapp - lets systemd allocate a UID automatically
- creates a supplemental group called
demoapp-data - adds
_demoappto that group
A few details matter here:
-
u!is preferable for most daemon accounts because it creates a fully locked account -
-in the ID field means automatic UID/GID allocation - prefixing service accounts with
_is strongly recommended by the docs to avoid clashes with human users
Validate before touching the real system
My favorite part of this workflow is that you can test it safely.
Example:
tmpdir=$(mktemp -d)
mkdir -p "$tmpdir"/{etc,usr/lib}/sysusers.d
cat >"$tmpdir/usr/lib/sysusers.d/demoapp.conf" <<'EOF'
u! _demoapp - "Demo app service user"
g demoapp-data -
m _demoapp demoapp-data
EOF
systemd-sysusers \
--root="$tmpdir" \
--dry-run \
"$tmpdir/usr/lib/sysusers.d/demoapp.conf"
On my test run, this reported output like:
Creating group 'demoapp-data' with GID 999.
Creating group '_demoapp' with GID 998.
Creating user '_demoapp' (Demo app service user) with UID 998 and GID 998.
Would write /etc/group…
Would write /etc/gshadow…
Would write /etc/passwd…
Would write /etc/shadow…
That gives you a no-surprises review step for CI, image builds, or packaging checks.
When you are done testing, clean up the temp root:
rm -rf "$tmpdir"
Admin override example
Suppose a vendor ships this file:
# /usr/lib/sysusers.d/demoapp.conf
u! _demoapp - "Demo app service user"
g demoapp-data -
m _demoapp demoapp-data
But you want a fixed UID and GID locally for a migration or NFS-mapped storage policy.
Create an override in /etc/sysusers.d/demoapp.conf:
u! _demoapp 450 "Demo app service user"
g demoapp-data 451
m _demoapp demoapp-data
You can test the effective result without writing anything:
tmpdir=$(mktemp -d)
mkdir -p "$tmpdir"/{etc,usr/lib}/sysusers.d
cat >"$tmpdir/usr/lib/sysusers.d/demoapp.conf" <<'EOF'
u! _demoapp - "Demo app service user"
g demoapp-data -
m _demoapp demoapp-data
EOF
cat >"$tmpdir/etc/sysusers.d/demoapp.conf" <<'EOF'
u! _demoapp 450 "Demo app service user"
g demoapp-data 451
m _demoapp demoapp-data
EOF
systemd-sysusers --root="$tmpdir" --dry-run demoapp.conf
In my test run, the override won and systemd-sysusers planned to create _demoapp with UID 450 and demoapp-data with GID 451.
That is exactly the kind of behavior you want in a packaging-friendly declarative system.
Inline testing is handy for quick experiments
If you just want to test a few rules without writing a file first, --inline is useful:
tmpdir=$(mktemp -d)
mkdir -p "$tmpdir/etc/sysusers.d"
systemd-sysusers \
--root="$tmpdir" \
--dry-run \
--inline \
'u! _cachebot - "Cache Bot" /var/lib/cachebot /usr/sbin/nologin' \
'g cachebot-data -' \
'm _cachebot cachebot-data'
This is great for:
- experimenting during packaging work
- verifying syntax in CI
- teaching teammates what each field does
--replace is built for packaging workflows
One easy-to-miss feature is --replace=PATH.
This matters when a package installation script needs to create accounts before its final sysusers.d file is present on disk. The man page includes this exact pattern:
printf '%s\n' 'u! _radvd - "radvd daemon"' \
| systemd-sysusers --dry-run --replace=/usr/lib/sysusers.d/radvd.conf -
That tells systemd-sysusers to treat the supplied content as if it were replacing /usr/lib/sysusers.d/radvd.conf, while still respecting any higher-priority admin override that may already exist in /etc/sysusers.d.
That is a subtle feature, but a very useful one for package maintainers.
Important limitation: this does not create your data directories
systemd-sysusers creates account entries. It does not create the service's state directories for you.
The sysusers.d(5) docs explicitly recommend pairing it with the right directory mechanism.
For modern systemd services, prefer unit-level directory management when possible:
# /etc/systemd/system/demoapp.service
[Service]
User=_demoapp
Group=_demoapp
StateDirectory=demoapp
CacheDirectory=demoapp
LogsDirectory=demoapp
That is usually cleaner than a separate tmpfiles rule because the directory lifecycle stays attached to the service definition.
If you really need a separate tmpfiles policy, use tmpfiles.d:
# /usr/lib/tmpfiles.d/demoapp.conf
d /var/lib/demoapp 0750 _demoapp _demoapp - -
d /var/log/demoapp 0750 _demoapp _demoapp - -
Then apply or test it with:
systemd-tmpfiles --create --prefix=/var/lib/demoapp --prefix=/var/log/demoapp
A few practical rules I would follow
-
Use automatic UID/GID allocation unless you truly need fixed numbers. The docs strongly recommend
-for most cases. -
Prefix daemon accounts with
_. This reduces collision risk with human users. -
Prefer
u!for service identities. Locked accounts are safer for daemons. -
Keep vendor config in
/usr/lib/sysusers.d, local policy in/etc/sysusers.d. That is the intended split. - Pair accounts with service-level directories or tmpfiles. Do not assume the home or state path will magically appear.
-
Use
--dry-runin CI and image builds. It is cheap and catches bad assumptions early.
When to use DynamicUser= instead
If your service does not need a persistent, named account in /etc/passwd, consider DynamicUser= in the service unit instead.
Use systemd-sysusers when you want a stable, declarative real system identity. Use DynamicUser= when you want systemd to allocate a temporary runtime identity for a service.
That distinction matters:
-
systemd-sysusersis better for packages, shared file ownership, and predictable account names -
DynamicUser=is better for tighter isolation when persistence is unnecessary
Final thought
If you care about reproducible Linux systems, service identities should live in configuration, not in half-remembered setup commands.
systemd-sysusers is one of those small systemd tools that quietly removes a lot of operational mess. You get clearer intent, safer testing, and a much better override story than hand-written account creation scripts.
For daemon users, that is a solid trade.
Sources and references
-
systemd-sysusers(8)man page: https://man7.org/linux/man-pages/man8/systemd-sysusers.8.html -
sysusers.d(5)man page: https://man7.org/linux/man-pages/man5/sysusers.d.5.html - systemd upstream notes on UID/GID ranges: https://systemd.io/UIDS-GIDS/
- systemd upstream notes on user/group naming: https://systemd.io/USER_NAMES/
-
tmpfiles.d(5)man page: https://man7.org/linux/man-pages/man5/tmpfiles.d.5.html -
systemd.exec(5)forStateDirectory=and related directives: https://man7.org/linux/man-pages/man5/systemd.exec.5.html
Top comments (0)