Skip to content
Go back

systemd DynamicUser: No useradd Needed

By KingPin 15 min read
systemd DynamicUser: No useradd Needed
Contents

Your /etc/passwd Is a Graveyard

Open /etc/passwd on any server older than two years. Somewhere past nobody you will find accounts for software you uninstalled in 2021. A metrics exporter. A chat bridge you tried for one weekend. Each one has a UID, a home directory that may or may not exist, and files scattered around the disk that nobody remembers.

For most small self-hosted daemons, DynamicUser=yes plus StateDirectory= beats useradd --system. systemd invents a user when the service starts, hands it a private data folder, locks the filesystem down around it, and forgets the user when the service stops. Nothing lands in /etc/passwd. Nothing needs cleanup when you remove the service. It has four traps. Each one below comes with output from a real run.

Full example: Clone the working files at github.com/KingPin/sumguy-examples/linux/systemd-dynamicuser

The kit runs on systemd 262.1 (Arch). Debian 13 (systemd 257) and Debian 12 (systemd 252) were one-off checks for the version comparison. The kit’s demo.sh runs inside a throwaway container, so you can try it without touching your host.

The Old Way: useradd and Hope

The classic recipe for a new daemon has four steps:

Terminal window
sudo useradd --system --no-create-home --shell /usr/sbin/nologin webdemo
sudo mkdir -p /var/lib/webdemo /var/cache/webdemo /var/log/webdemo
sudo chown webdemo:webdemo /var/lib/webdemo /var/cache/webdemo /var/log/webdemo
sudo systemctl edit --full webdemo.service # then add User=webdemo

It works. It also leaves three problems behind. The account outlives the service, because nothing removes it when you delete the unit file. The service gets no sandbox: it can write anywhere the account owns, into every world-writable directory, and it can run setuid binaries. And the three mkdir and chown lines live in your shell history instead of in the unit, so the next machine you set up gets them wrong.

A static account is a parking spot you pay rent on forever because you parked there once.

The New Way: Let systemd Hand Out a Name Tag

The full unit below is the one I ran on the test rig.

webdemo.service
[Unit]
Description=Tiny static web server (DynamicUser demo)
[Service]
DynamicUser=yes
StateDirectory=webdemo
CacheDirectory=webdemo
LogsDirectory=webdemo
WorkingDirectory=/srv/www
ExecStart=/usr/bin/python -m http.server 8080 --bind 127.0.0.1
Restart=on-failure
[Install]
WantedBy=multi-user.target

Four directives replace the useradd, the three mkdir calls and the chown. Start it and look at the process:

Terminal window
sudo systemctl enable --now webdemo
ps -o user,uid,cmd -C python
USER UID CMD
webdemo 62871 /usr/bin/python -m http.server 8080 --bind 127.0.0.1

The process runs as webdemo with UID 62871. Nobody ran useradd. The name comes from the unit name, because there is no User= line. If you set User=something, that becomes the name instead. If the unit name is not a valid user name, systemd uses a hashed name.

The service also gets three environment variables with the full paths: $STATE_DIRECTORY, $CACHE_DIRECTORY and $LOGS_DIRECTORY. In this unit they point at /var/lib/webdemo, /var/cache/webdemo and /var/log/webdemo. Use them in your scripts instead of hard-coding paths.

You can also try the idea without writing a unit at all. The systemd-run post covers transient units, and this one-liner needs root:

Terminal window
sudo systemd-run -q --wait -P -p DynamicUser=yes id
uid=61245(run-p268-i18978904) gid=61245(run-p268-i18978904) groups=61245(run-p268-i18978904)

The name run-p268-i18978904 is derived from the transient unit name. The UID sits in the 61184 to 65519 range, which is where the man page says dynamic users come from. It also tells you to keep regular accounts out of that range.

What You Get for Free

DynamicUser=yes also switches on a bundle of hardening you would otherwise type by hand. From man systemd.exec, these are implied and cannot be turned off: RemoveIPC=, NoNewPrivileges= and RestrictSUIDSGID=. It also implies ProtectSystem=strict and ProtectHome=read-only, and PrivateTmp=disconnected unless you set PrivateTmp= to true yourself.

On the test box, try to write into /etc from inside a dynamic user’s sandbox:

Terminal window
sudo systemd-run -q --wait -P -p DynamicUser=yes touch /etc/nope
touch: cannot touch '/etc/nope': Read-only file system

The whole filesystem is read-only except the directories you declared. Writing to /tmp works, but it is a private /tmp that other services cannot see.

NoNewPrivileges has a funny side effect. In a one-off check outside the kit, I ran su -c id root from inside a dynamic service, and it died with su: Authentication token manipulation error. Setuid binaries no longer raise privileges, so even a compromised service cannot climb back up through su or sudo.

This is the 2 AM argument for the feature. A bug in your little web server used to mean “attacker can write wherever that user owns files, plus every world-writable directory on the disk”. Now it means “attacker can write to three folders and a private /tmp, and nowhere else”. Reads are a separate matter: a dynamic user still reads anything world-readable, which is why the hardening section below matters.

The User That Isn’t There

The user exists only while the service runs. I started a service called sleeper with DynamicUser=yes and asked the name service about it:

Terminal window
getent passwd sleeper
sleeper:x:63741:63741:Dynamic User:/:/usr/bin/nologin

Then I stopped it:

Terminal window
sudo systemctl stop sleeper
getent passwd sleeper

Nothing came back. The man page says the user and group are allocated when the unit starts and released when it stops. They never get written to /etc/passwd or /etc/group. The nss-systemd module makes them resolvable while they exist, which is why getent found sleeper at all. UIDs are recycled after the unit stops, so another service can get the same number later.

That recycling is the root of every trap below. Keep it in mind.

Why Your Data Lives in a Folder Called private

Run ls -l /var/lib and the first listing confuses people:

lrwxrwxrwx 1 root root 15 Nov 7 14:04 webdemo -> private/webdemo

With DynamicUser=, systemd creates the state, cache and log directories under /var/lib/private, /var/cache/private and /var/log/private. Those parents are mode 0700 and owned by root, so unprivileged users cannot walk in. A symlink at the normal path hides the detail from the service.

The reason, per the man page: a later service that recycles the same UID must not be able to read the old service’s data. Root-only parent directories close that door. The systemd-tmpfiles post touched on this path detail too.

Two behaviors surprise people. First, these directories are not removed when the unit stops. Your data survives restarts and even uninstalls. Only RuntimeDirectory= gets cleaned up on stop. Second, the owner of the directory depends on your systemd version.

Version difference: who owns the directory

On systemd 257 and newer, with id-mapped mount support, the state directory is owned by nobody (65534) on the host and mapped to the service UID inside the service’s view. On 262.1 and on Debian 13 (257), the listing looked like this:

Terminal window
ls -ln /var/lib/private/
drwxr-xr-x 2 65534 65534 4096 Nov 7 14:04 webdemo

On Debian 12 (systemd 252), in a one-off check outside the kit, the same command showed the dynamic UID directly:

drwxr-xr-x 2 61657 61657 ... demo

The systemd 257 NEWS says the data is “preferably made available by establishing ID mapped from the ‘nobody’ user to the dynamic user, rather than via recursive chown()ing.” Directories that already exist without id-mapping are not converted to id-mapped mounts, so systemd still chowns them to the dynamic UID. That detail matters in the migration section.

Does the UID Stay Stable?

Mostly, and you should still not rely on it. I restarted webdemo three times and got UID 62871 every time. A second fresh container (a one-off check, not in the kit’s saved output) gave webdemo 62871 and sleeper 63741 again.

The picking logic lives in src/core/dynamic-user.c, in pick_uid. First it tries the owner UID of the state directories. Then it tries a hash of the user name. Then it falls back to random UIDs. On 257 and newer the state directory is owned by nobody, which sits outside the dynamic range, so the name hash does the work there. The same unit name usually lands on the same number, but never hard-code that number in an ACL, an NFS export or a script.

Trap 1: Root-Only Config Files

Your daemon needs a secret in /etc/demo.conf, mode 600, owned by root. The old static account would not read it either, but people usually fix that with chown. Here is what a dynamic user sees:

Terminal window
sudo systemd-run -q --wait -P -p DynamicUser=yes cat /etc/demo.conf
cat: /etc/demo.conf: Permission denied

Do not chmod 644 the file. Use a credential instead. The service manager reads the file as root and hands the service a private copy:

Terminal window
sudo systemd-run -q --wait -P -p DynamicUser=yes \
-p LoadCredential=democonf:/etc/demo.conf \
sh -c 'cat "$CREDENTIALS_DIRECTORY/democonf"'

The secret prints. In a real unit that is one line, LoadCredential=democonf:/etc/demo.conf, and your daemon reads $CREDENTIALS_DIRECTORY/democonf. The systemd-credentials post goes through the full set of credential directives, including encrypted ones.

Trap 2: ReadWritePaths Leaves Orphaned Files

This is the one that bites silently. You have a shared directory, say /srv/shared, mode 777. You give your dynamic service write access with ReadWritePaths=/srv/shared. It writes a file. Looks fine.

Afterward the listing shows:

Terminal window
ls -ln /srv/shared
-rw-r--r-- 1 62317 62317 0 Nov 7 14:04 x

That file belongs to UID 62317, an account that no longer exists. Any later unit that happens to receive UID 62317 now owns it. The man page warns about exactly this. The fix is boring: write inside StateDirectory= and let systemd manage ownership. If another service needs to read that data, expose it through the service, not through a shared folder.

If your real need is “write into a directory other humans and services also use”, you want a static user. Skip to the section on when not to use this.

Trap 3: Migrating From a Static User Does Nothing

You have an existing service with User=olddemo, where olddemo came from useradd --system (UID 969 on my rig), and StateDirectory=olddemo holds a file called keep.txt. You swap User=olddemo for DynamicUser=yes and restart. The journal says:

olddemo.service: Apparently, service previously had DynamicUser= turned off, and has now turned it on.

systemd moves /var/lib/olddemo to /var/lib/private/olddemo, as promised. But the process still ran as UID 969. The man page rule is the gotcha: “If a statically allocated user or group of the configured name already exists, it is used and no dynamic user/group is allocated.” The old account named olddemo still existed, so systemd used it and the switch did nothing.

Delete the account first, and check for files it owns elsewhere:

Terminal window
sudo find / -xdev -user olddemo
sudo userdel olddemo
sudo systemctl restart olddemo
ps -o user,uid,cmd -C python

After the userdel and a restart, the process ran as UID 61337. This directory was created without id-mapping, so systemd did what the man page describes for a directory with the wrong owner: it changed ownership recursively.

-rw-r--r-- 1 61337 61337 5 ... keep.txt

Sequence for a safe migration:

  1. Stop the service.
  2. List stray files with find / -xdev -user <name> and move anything important into the state directory.
  3. Run userdel <name>.
  4. Swap User= for DynamicUser=yes and keep StateDirectory=.
  5. Start the service and check ps -o user,uid,cmd.

Step 3 is the one people skip, and the service keeps running as the old user without a word of complaint.

Trap 4: Low Ports and a Real Hardening Score

The last trap is a mismatch of expectations. DynamicUser=yes gets you part of the way. I ran systemd-analyze security on three versions of the same unit. Lower is better:

UnitScore
No User= (runs as root)9.6 UNSAFE
DynamicUser=yes only8.2 EXPOSED
Hardened version below1.4 OK

So the free bundle moves you from 9.6 to 8.2. That is real, and it is also still “EXPOSED”. The extra directives are where the score collapses. Here is the hardened unit, on port 80:

webdemo.service (hardened)
[Service]
DynamicUser=yes
StateDirectory=webdemo
CacheDirectory=webdemo
LogsDirectory=webdemo
WorkingDirectory=/srv/www
ExecStart=/usr/bin/python -m http.server 80 --bind 127.0.0.1
Restart=on-failure
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
ProtectHome=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
ProcSubset=pid
RestrictAddressFamilies=AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
UMask=0077

Ports below 1024 need CAP_NET_BIND_SERVICE on a normal host, because net.ipv4.ip_unprivileged_port_start defaults to 1024. A dynamic user has no special rights, so the two capability lines grant exactly that one. A caveat on my own test: inside Docker that sysctl is 0, so my container run could not prove the capability is required. It only proved the unit works with it.

A second caveat: MemoryDenyWriteExecute=yes can break JIT runtimes such as Node, Java and LuaJIT. It worked with CPython here, and I am not claiming more than that. If your daemon crashes right at startup after you add it, remove that line first.

For a broader view of scoring your whole host, the Lynis hardening audit post covers a different tool that looks at the system as a whole.

When Not to Use DynamicUser

I like this feature, and I still would not use it everywhere. Reach for a static user when:

Using this feature for a Jellyfin that writes metadata next to your media is like hiring a forklift to move a couch. Technically it works, but your neighbors will have questions. A boring static user with a group on the media folder is the better tool there.

Containers are a different topic, and I will leave them out here. For everything else, the rule is simple: if the service has one data folder and talks over a socket, make it dynamic.

If you also want to let a non-root user restart one of these services, the polkit post shows how, and the systemd timers vs cron post covers scheduling. A timer’s service unit takes the same DynamicUser= line.

Who Already Does This

You are not the first to try it. On Arch’s systemd 262.1, systemd-journal-gatewayd.service and systemd-journal-upload.service ship with DynamicUser=yes. So do [email protected], [email protected], [email protected] and a few more. If the people who write the init system trust it for their own daemons, a small web server on your homelab is safe ground.

The SumGuy Take

Use DynamicUser=yes with StateDirectory=, CacheDirectory= and LogsDirectory= for every new small daemon that fits the model above. You skip the useradd, you skip the chown, and you get a read-only filesystem and no-new-privileges for free. The traps are real, but each one has a one-line fix: use LoadCredential= for root-only files, stay inside StateDirectory=, delete the old account when migrating, and treat the hardening score as a to-do list.

Your 2 AM self will thank you when a service is gone and /etc/passwd has nothing left to clean up.

Common Questions

What UID range does systemd DynamicUser use?

The systemd DynamicUser range is 61184 to 65519, according to man systemd.exec. That range sits just below the 65534 nobody user. The man page advises against using it for regular accounts. Systemd tries to give a unit the same UID each time, but UIDs are recycled after a unit stops.

Can a DynamicUser service read files in my home directory?

Yes, if the file permissions allow it. DynamicUser=yes implies ProtectHome=read-only, which blocks writes to /home but not reads, and a dynamic user counts as “other” for permission bits. A world-readable file in a 755 home directory is readable. Add ProtectHome=yes to make /home, /root and /run/user empty for the service.

How do I migrate a service from a system user to DynamicUser?

Stop the service, then delete the old account with userdel, then replace User= with DynamicUser=yes and start the service again. If the old account still exists, systemd uses it and ignores the dynamic setting. Run find / -xdev -user <name> first to find files the account owns.

Which systemd version do I need for DynamicUser?

DynamicUser= needs systemd 232 or newer, which is the version where the man page says it was added. Systemd 257 and newer improve it by using id-mapped mounts for the state directory, so the data is owned by nobody on the host instead of being recursively chowned. Check your version with systemctl --version.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
Postgres in Docker: The shm_size Gotcha
Next Post
Rate Limits That Lock Out the Wrong User

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts