Install
Quadlet ships in Podman 4.4+, which means stock Debian 13, Ubuntu 24.04 LTS, Fedora 38+, and RHEL 9.3+:
# Debian / Ubuntu
sudo apt install podman
# Fedora / RHEL
sudo dnf install podman
Verify the version:
podman --version # should be 4.4 or later
podman info --format '{{.Host.Security.Rootless}}' # true if run as a regular user
What "rootless" actually means
Containers run inside a user namespace mapped from your UID to a range of subordinate UIDs allocated to your user in /etc/subuid and /etc/subgid. Root inside the container is your user outside. podman needs no daemon and no setuid binaries for normal operation — just newuidmap / newgidmap (from the uidmap package, pulled in as a dependency).
cat /etc/subuid # should show: alice:100000:65536 or similar
cat /etc/subgid
podman unshare cat /proc/self/uid_map
If /etc/subuid is empty for your user, add a range: sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 alice then log out and back in.
Run something interactively first
To confirm rootless is happy:
podman run --rm -it alpine sh
# inside the container
id # uid=0(root) gid=0(root)
ps # one process
exit
No daemon was started, no root needed, the image is stored under ~/.local/share/containers/storage.
A Quadlet unit
Quadlet is a systemd generator. You drop a .container file in a known location and systemd, on next daemon-reload, materializes a regular .service unit from it.
For user services (run under your login):
mkdir -p ~/.config/containers/systemd
nano ~/.config/containers/systemd/caddy.container
For system services (run as root, recommended only for things that need privileged ports below 1024 without sysctl tweaks):
sudo mkdir -p /etc/containers/systemd
sudo nano /etc/containers/systemd/caddy.container
Example: Caddy
[Unit]
Description=Caddy reverse proxy
After=network-online.target
Wants=network-online.target
[Container]
Image=docker.io/library/caddy:2-alpine
ContainerName=caddy
PublishPort=8080:80
PublishPort=8443:443
Volume=%h/caddy/Caddyfile:/etc/caddy/Caddyfile:Z
Volume=caddy-data:/data
Volume=caddy-config:/config
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=default.target
A few details that bite people new to Quadlet:
- The file is
caddy.container, the resulting service iscaddy.service. You manage it withsystemctl --user start caddy. %his systemd's substitution for the user's home dir.:Zon the bind mount is the SELinux relabel flag — harmless on non-SELinux systems, required on Fedora/RHEL.Volume=caddy-data:/data(no path on the left) creates a named volume managed by Podman; equivalent topodman volume create caddy-data.- For user services binding low ports (< 1024), set
net.ipv4.ip_unprivileged_port_start = 80via sysctl — or publish to high ports and front with a system-level reverse proxy.
Activate
systemctl --user daemon-reload
systemctl --user start caddy
systemctl --user status caddy
journalctl --user -u caddy -f
If the unit doesn't appear after daemon-reload, run /usr/lib/systemd/user-generators/podman-user-generator manually — it prints a friendly parse error.
Keep the user services running after logout
By default, user services stop when the user logs out. Enable lingering so they survive:
sudo loginctl enable-linger $USER
From now on, your user's systemd instance starts at boot and your .container units come up with it — just like running rootful containers under root's systemd, but without root.
Multi-container app: networks and pods
Two related units (an app and its Postgres), connected via a Quadlet-managed network:
# ~/.config/containers/systemd/app-net.network
[Network]
NetworkName=app-net
# ~/.config/containers/systemd/db.container
[Unit]
Description=Postgres for app
Requires=app-net-network.service
After=app-net-network.service
[Container]
Image=docker.io/library/postgres:16-alpine
ContainerName=db
Network=app-net.network
Volume=db-data:/var/lib/postgresql/data
Environment=POSTGRES_PASSWORD=changeme
[Install]
WantedBy=default.target
# ~/.config/containers/systemd/app.container
[Unit]
Description=My web app
Requires=db.service
After=db.service
[Container]
Image=ghcr.io/myorg/app:latest
ContainerName=app
Network=app-net.network
PublishPort=3000:3000
Environment=DATABASE_URL=postgres://postgres:changeme@db:5432/postgres
AutoUpdate=registry
[Install]
WantedBy=default.target
Networks resolve by container name (DNS is provided by Podman's aardvark resolver). The implicit .network generator names the systemd unit app-net-network.service — the suffix is the resource type. Use systemctl --user list-unit-files | grep podman to see exactly what got generated.
If you're coming from compose: .container is your services: entry, .network is networks:, .volume is volumes:. The big differences are that each is a separate file (systemd-style) instead of one YAML, dependency order is via After=/Requires=, and the result is real systemd units with all the supervision, journaling, and restart semantics that brings.
Auto-update images
The AutoUpdate=registry line tells Podman to check the registry for a newer image digest on a schedule. Enable the systemd timer that drives this:
systemctl --user enable --now podman-auto-update.timer
systemctl --user list-timers | grep podman
Default cadence is daily. Updates pull the new image, stop the unit, start it under the new image — with rollback to the previous image if the new container fails to come up.
Troubleshooting
- Quadlet unit doesn't generate. Run
/usr/lib/systemd/user-generators/podman-user-generator --dryrun— it prints what it produced and any parse errors. Common cause: a typo in a section header like[Container ]with a trailing space. - "newuidmap: write to uid_map failed". Your
/etc/subuid/subgiddoesn't have an allocation for your user. Add one withusermod --add-subuidsthen log out and back in. - "port already in use" on PublishPort. Another process (or a previous run that didn't clean up) holds the port.
ss -tlnpfinds it. For host-port collisions in rootless mode, only your user's processes are listed unless you run with sudo. - Bind-mount writes silently disappear after restart. You mounted into a path inside the image that the container creates on startup, blowing your mount away. Check the image's entrypoint, or mount the parent directory instead.
- Container can't reach the host. In rootless mode the host is at
host.containers.internal(Podman's equivalent ofhost.docker.internal). Add--add-host=host.containers.internal:host-gatewayif you need it inside the container's/etc/hosts. - Logs aren't structured. Default log driver is
journald— usejournalctl --user -u app -o jsonand slot it into anything that consumes JSON logs.