Podman Quadlet on Debian 13: Rootless Units

10 min read

If you run a few containers on one server, you need something to start them at boot, restart them when they crash and collect their logs. You don't need a Docker daemon running as root for that. Podman Quadlet handles it with systemd: you write a small .container file, and a systemd generator turns it into a normal service unit. This post sets up Podman Quadlet on Debian 13 (trixie) using the packaged podman 5.4.2. We'll build one hardened rootless service, give it a dedicated network, a secret and registry-based auto-updates, and check the result after each step. Most Quadlet guides are written for Fedora or RHEL. Here the versions, paths and behaviour are the ones Debian ships.

What Quadlet is and when to use it

Quadlet is a systemd generator that comes with podman. When systemd starts or reloads, the generator reads unit-like files (.container, .network, .volume, .pod, .image, .build, .kube) and writes matching .service units. Those units run podman run with the flags you declared. So you get systemd features (dependencies, restart policy, journald, timers) without writing ExecStart= lines yourself.

Use it when:

  • a host runs a handful of long-lived services, and you want them managed the same way as everything else under systemd;
  • you want rootless containers that run under a dedicated Unix user;
  • you'd rather not keep a root-owned daemon socket around.

Skip it if you need multi-host scheduling. Also skip it if your team's workflow is built around compose files and you don't want to translate them.

Installing Podman Quadlet on Debian 13

Trixie ships podman 5.4.2+ds1-2 (binaries rebuilt as 5.4.2+ds1-2+b2), systemd 257.13-1~deb13u1 and passt 0.0~git20250503.587980c-2+deb13u1, according to packages.debian.org. The podman package depends on conmon, crun or runc, and netavark. It recommends passt, uidmap, dbus-user-session and catatonit. Rootless setups need those recommends, so install them explicitly:

sudo apt update
sudo apt install podman passt uidmap dbus-user-session catatonit

Check the version, cgroup mode and generator paths. Quadlet needs cgroup v2, which Debian 13 uses by default:

podman --version
podman info --format '{{.Host.CgroupsVersion}}'
ls -l /usr/lib/systemd/system-generators/podman-system-generator
ls -l /usr/lib/systemd/user-generators/podman-user-generator
command -v pasta
podman version 5.4.2
v2

The package also installs podman-auto-update.service and podman-auto-update.timer twice: in /usr/lib/systemd/system/ for root and in /usr/lib/systemd/user/ for rootless users.

A dedicated user with linger

Create a service user. By default, a user's systemd instance stops when their last session ends, and their containers stop with it. Enabling linger keeps the user manager running from boot:

sudo adduser --disabled-password --gecos '' web
sudo loginctl enable-linger web
loginctl show-user web --property=Linger
grep '^web:' /etc/subuid /etc/subgid
Linger=yes

The grep must return a subordinate UID range and a subordinate GID range. Rootless podman maps container IDs into these ranges, and UserNS=auto carves per-container slices out of them. If the lines are missing, add a range before you continue.

From here on, work as web in a real login session, for example ssh web@host with a key, or sudo machinectl shell web@. If you use plain su, systemctl --user usually can't reach the user bus.

Writing a hardened .container unit

Rootless Quadlet files go in ~/.config/containers/systemd/. The generator also reads $XDG_RUNTIME_DIR/containers/systemd/, /etc/containers/systemd/users/ and /etc/containers/systemd/users/$UID. Rootful files go in /etc/containers/systemd/.

Network unit

[Network]
Subnet=10.89.10.0/24
Gateway=10.89.10.1

For a file named webnet.network, Quadlet creates a podman network called systemd-webnet and a service called webnet-network.service. Any container that references Network=webnet.network automatically depends on that service. To give containers on the network no outside access, set Internal=true. The DNS plugin is on unless you set DisableDNS=true, so containers on the network can reach each other by name.

Secret

Create the secret with podman before you start the unit. Reading it from stdin keeps it out of shell history:

read -rs APP_TOKEN
printf '%s' "$APP_TOKEN" | podman secret create app_token -
unset APP_TOKEN
podman secret ls

The default file driver keeps the secret in a read-protected file, but the man page doesn't say it's encrypted. If you need encryption at rest, use the pass driver, which stores secrets GPG-encrypted.

The container

[Unit]
Description=nginx (rootless, Quadlet)

[Container]
Image=docker.io/nginxinc/nginx-unprivileged:latest
PublishPort=8080:8080
Network=webnet.network
UserNS=auto
ReadOnly=true
DropCapability=all
NoNewPrivileges=true
Secret=app_token,type=env,target=APP_TOKEN
AutoUpdate=registry

[Service]
Restart=on-failure
TimeoutStartSec=300

[Install]
WantedBy=default.target

What each part does:

  • ReadOnly=true mounts the image read-only. ReadOnlyTmpfs= defaults to true, so podman still mounts writable tmpfs on /dev, /dev/shm, /run, /tmp and /var/tmp. The unprivileged nginx image writes its PID file and temp files to /tmp, so that's sufficient.
  • DropCapability=all removes the whole default capability set. This image runs as a non-root user and listens on 8080, so it needs no capabilities. An image that starts as root and then switches user would break here (more on that below).
  • NoNewPrivileges=true stops processes in the container from gaining privileges through setuid binaries or file capabilities.
  • UserNS=auto gives each container its own slice of the subordinate ID range. Two containers can't share UIDs on the host, so escaping one doesn't give you the file ownership of another.
  • TimeoutStartSec=300 is there because the first start pulls the image, and that can take longer than systemd's 90-second default.
  • [Install] works differently from normal units. Generated units are transient, so systemctl enable does nothing. The generator applies WantedBy=, RequiredBy= and Alias= itself.

Generating, starting and debugging

Run the generator in dry-run mode first. It prints the units it would generate, or the reason it can't:

/usr/lib/systemd/system-generators/podman-system-generator --user --dryrun

Abridged, illustrative output. The exact flag order varies between versions. The Requires= and After= lines for the network service match what the podman 5.4.2 Quadlet source adds for .network references:

---web.service---
[Unit]
Description=nginx (rootless, Quadlet)
Requires=webnet-network.service
After=webnet-network.service
...
[Service]
Type=notify
ExecStart=/usr/bin/podman run --name systemd-web ... --userns auto --read-only --cap-drop all --security-opt no-new-privileges --network systemd-webnet --secret app_token,type=env,target=APP_TOKEN --label io.containers.autoupdate=registry --publish 8080:8080 docker.io/nginxinc/nginx-unprivileged:latest

If a key you used is missing from the ExecStart line, or the unit doesn't show up at all, Quadlet didn't accept it. Fix the file, then reload and start:

systemctl --user daemon-reload
systemctl --user start web.service
systemctl --user status web.service
systemd-analyze --user --generators=true verify web.service

Check the container and its logs:

podman ps --format '{{.Names}} {{.Status}} {{.Ports}}'
podman network ls
curl -sI http://127.0.0.1:8080/
journalctl --user -u web.service -n 50

If journalctl --user shows nothing (common without a persistent journal), read the system journal as root and filter on the user-unit field:

sudo journalctl _SYSTEMD_USER_UNIT=web.service -n 50

To confirm the hardening reached the running container:

podman inspect systemd-web --format '{{.HostConfig.ReadonlyRootfs}} {{.HostConfig.CapDrop}}'
podman top systemd-web user huser

podman top with huser shows the host UID behind the container user. That UID should come from your subordinate range, not the UID of web itself.

Auto-updates

AutoUpdate=registry sets the io.containers.autoupdate label. Registry mode needs a fully qualified image reference, which is why the unit uses docker.io/... and not a bare nginx. Enable the user timer, which runs daily at midnight by default:

systemctl --user enable --now podman-auto-update.timer
systemctl --user list-timers podman-auto-update.timer
podman auto-update --dry-run

Warning: an update restarts the service, so expect a short outage. Rollback is on by default: if the unit fails to start on the new image, podman restarts it on the old one. Podman detects failure most reliably when the container sends READY over sd_notify (Notify=true). Without that, an image that crashes a few seconds after starting can get past the check. Pin a version tag if you want updates to happen on your schedule, not upstream's.

Rootful variant

For a rootful service, put the same files in /etc/containers/systemd/ and use plain systemctl. For a rootful UserNS=auto, /etc/subuid and /etc/subgid need an entry for a user named containers with an unused range. Without it, the container fails to start.

sudo /usr/lib/systemd/system-generators/podman-system-generator --dryrun
sudo systemctl daemon-reload
sudo systemctl start web.service

Pitfalls, limitations and alternatives

Trixie's 5.4 vs upstream docs

Debian 13 stays on podman 5.4.2 for its whole lifetime, apart from security fixes. Upstream has moved on: at the time of writing, GitHub marks v6.1.3 as the latest release, and the 5.8 branch is at v5.8.8. The Quadlet docs on docs.podman.io describe current upstream and include keys that 5.4 doesn't know. The man page warns that an option from a newer Quadlet version can produce Unit web.service not found. On trixie, read man podman-systemd.unit or the trixie page on manpages.debian.org, and use the dry-run to check every key you add.

Ports below 1024

Rootless containers can't publish ports below 1024 by default, because unprivileged users can't bind them. Podman's troubleshooting guide suggests lowering net.ipv4.ip_unprivileged_port_start:

net.ipv4.ip_unprivileged_port_start=80
sudo sysctl --system
cat /proc/sys/net/ipv4/ip_unprivileged_port_start

This applies to the whole host: every local user can then bind 80 and 443. On a shared box, keep the container on 8080 and put a rootful reverse proxy in front of it instead.

Other traps

  • Root-then-drop images. Many official images start as root and switch user, which needs CAP_SETUID, CAP_SETGID and often CAP_CHOWN. With DropCapability=all they fail right away. Either use an unprivileged image variant, or add back only the capabilities you need with AddCapability=.
  • UserNS=auto and keep-id. New UserNS=auto containers fail to start while another container exists that uses keep-id or nomap without a size limit.
  • Network naming. webnet.network becomes systemd-webnet. If another tool expects the bare name, set NetworkName=.
  • Rootless networking is pasta. In podman 5 the default rootless network mode is pasta, not slirp4netns. If passt isn't installed, rootless networking breaks.

Hardening checklist

Quadlet flags only cover the container side. The generated unit is still a systemd service, so you can add [Service] directives too. Test them carefully, because podman needs access to its storage and to user namespaces. The approach is covered in sandboxing systemd services with systemd-analyze security. For the threats these settings defend against, see how container escapes work through runc.

  • Run rootless under a dedicated user with linger; no login shell needed for daily use.
  • Set UserNS=auto so containers don't share host UIDs.
  • Set ReadOnly=true and add Tmpfs= only where the app writes.
  • Use DropCapability=all and add back single capabilities with AddCapability=.
  • Set NoNewPrivileges=true.
  • Pass credentials with Secret=, not Environment= in the unit file.
  • Use fully qualified images and either a pinned tag or AutoUpdate=registry on purpose.
  • Keep crun, runc and podman current with apt. Rootless doesn't make runtime CVEs harmless.

Alternatives

ToolStrengthsWeaknesses
QuadletNative systemd units, rootless, declarative, built-in auto-updateOne file per object, key set tied to the podman version
podman generate systemdWorks from an existing containerDeprecated upstream: only urgent bug fixes, no new features
docker composeOne YAML for a whole stack, well knownNeeds a Docker daemon (usually rootful; rootless mode exists but is extra setup); boot ordering and logs live outside systemd

Takeaways and cheat sheet

TaskCommand or key
Rootless file location~/.config/containers/systemd/
Rootful file location/etc/containers/systemd/
Preview generated unitspodman-system-generator --user --dryrun
Apply changessystemctl --user daemon-reload
Keep running after logoutloginctl enable-linger
Logsjournalctl --user -u web.service
Verify unitsystemd-analyze --user --generators=true verify
UpdatesAutoUpdate=registry and podman-auto-update.timer
Test updatespodman auto-update --dry-run
Hardening keysUserNS=auto, ReadOnly=true, DropCapability=all, NoNewPrivileges=true
  • On Debian 13, follow the trixie man page, not upstream docs: you're on podman 5.4.2.
  • Run the generator dry-run every time you edit a file. A rejected key means a missing unit, not a clear error.
  • Enable linger, or rootless services stop when the user logs out.
  • Don't run systemctl enable on Quadlet units. Put WantedBy= in [Install] instead.
  • Prefer a reverse proxy over lowering the unprivileged port limit for the whole host.

Sources

Comments