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=truemounts the image read-only.ReadOnlyTmpfs=defaults to true, so podman still mounts writable tmpfs on/dev,/dev/shm,/run,/tmpand/var/tmp. The unprivileged nginx image writes its PID file and temp files to/tmp, so that's sufficient.DropCapability=allremoves 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=truestops processes in the container from gaining privileges through setuid binaries or file capabilities.UserNS=autogives 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=300is 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, sosystemctl enabledoes nothing. The generator appliesWantedBy=,RequiredBy=andAlias=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_SETGIDand oftenCAP_CHOWN. WithDropCapability=allthey fail right away. Either use an unprivileged image variant, or add back only the capabilities you need withAddCapability=. - UserNS=auto and keep-id. New
UserNS=autocontainers fail to start while another container exists that useskeep-idornomapwithout a size limit. - Network naming.
webnet.networkbecomessystemd-webnet. If another tool expects the bare name, setNetworkName=. - Rootless networking is pasta. In podman 5 the default rootless network mode is
pasta, not slirp4netns. Ifpasstisn'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=autoso containers don't share host UIDs. - Set
ReadOnly=trueand addTmpfs=only where the app writes. - Use
DropCapability=alland add back single capabilities withAddCapability=. - Set
NoNewPrivileges=true. - Pass credentials with
Secret=, notEnvironment=in the unit file. - Use fully qualified images and either a pinned tag or
AutoUpdate=registryon purpose. - Keep crun, runc and podman current with
apt. Rootless doesn't make runtime CVEs harmless.
Alternatives
| Tool | Strengths | Weaknesses |
|---|---|---|
| Quadlet | Native systemd units, rootless, declarative, built-in auto-update | One file per object, key set tied to the podman version |
podman generate systemd | Works from an existing container | Deprecated upstream: only urgent bug fixes, no new features |
| docker compose | One YAML for a whole stack, well known | Needs a Docker daemon (usually rootful; rootless mode exists but is extra setup); boot ordering and logs live outside systemd |
Takeaways and cheat sheet
| Task | Command or key |
|---|---|
| Rootless file location | ~/.config/containers/systemd/ |
| Rootful file location | /etc/containers/systemd/ |
| Preview generated units | podman-system-generator --user --dryrun |
| Apply changes | systemctl --user daemon-reload |
| Keep running after logout | loginctl enable-linger |
| Logs | journalctl --user -u web.service |
| Verify unit | systemd-analyze --user --generators=true verify |
| Updates | AutoUpdate=registry and podman-auto-update.timer |
| Test updates | podman auto-update --dry-run |
| Hardening keys | UserNS=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 enableon Quadlet units. PutWantedBy=in[Install]instead. - Prefer a reverse proxy over lowering the unprivileged port limit for the whole host.
Sources
- Debian trixie package: podman
- Debian trixie podman file list (amd64)
- Debian trixie package: systemd
- Debian trixie package: passt
- podman-systemd.unit(5) – Debian trixie manpage
- podman-auto-update(1) – Debian trixie manpage
- podman-run(1) – Debian trixie manpage
- podman-secret-create(1) – Debian trixie manpage
- podman-generate-systemd(1) – Debian trixie manpage
- Quadlet source (quadlet.go) at podman v5.4.2
- Docker docs: Rootless mode
- Podman troubleshooting guide
- Podman releases on GitHub
- nginxinc/nginx-unprivileged on Docker Hub
Comments