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 is caddy.service. You manage it with systemctl --user start caddy.
  • %h is systemd's substitution for the user's home dir.
  • :Z on 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 to podman volume create caddy-data.
  • For user services binding low ports (< 1024), set net.ipv4.ip_unprivileged_port_start = 80 via 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.

Quadlet vs docker-compose

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/subgid doesn't have an allocation for your user. Add one with usermod --add-subuids then 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 -tlnp finds 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 of host.docker.internal). Add --add-host=host.containers.internal:host-gateway if you need it inside the container's /etc/hosts.
  • Logs aren't structured. Default log driver is journald — use journalctl --user -u app -o json and slot it into anything that consumes JSON logs.