Virtualization / Tutorial

Rootless containers managed by systemd with Podman and Quadlet

Declare a container in a simple file, have it start with the machine without root, and update it automatically.

Virtualization Tutorial Advanced Published October 1, 2026 Translated October 27, 2026 11 min read Tested on: Podman 4.4 and later (Quadlet built in). Examples written for Podman 5 and 6. Version française
Stacked containers and cranes in a port
Photo: taro ohtani on Unsplash

Before you start

  • Podman 4.4 or later: podman --version
  • An unprivileged user account dedicated to services (here spo)
  • Being comfortable with systemd (systemctl, journalctl)

In plain words

A container is an application delivered in its box with everything it needs. Quadlet is the job sheet you hand to the building's caretaker (systemd): "open this box every morning, restart it if it stops, write everything down in the logbook". And the caretaker does it for an ordinary tenant, without needing the owner's keys (root).

Docker Compose starts containers, but it needs a daemon and root rights, and its integration with the system stays external. Quadlet, built into Podman since version 4.4, turns a .container file into a real systemd service: start at boot, restart on failure, logs in journalctl, dependencies, all without root.

The principle

You write a declarative file. On daemon-reload, a systemd generator turns it into a regular .service unit.

Location For
~/.config/containers/systemd/ A user's rootless containers
/etc/containers/systemd/ Containers run as root (avoid if possible)

The file web.container becomes the service web.service.

1. Let the user's services run without a session

By default, user services stop when the user logs out and do not start at boot. Enable lingering:

sudo loginctl enable-linger spo
loginctl show-user spo --property=Linger    # expected: Linger=yes

2. Write the Quadlet file

Example: an nginx server publishing a static folder.

mkdir -p ~/.config/containers/systemd ~/site
echo '<h1>It works</h1>' > ~/site/index.html
nano ~/.config/containers/systemd/web.container
[Unit]
Description=Static web server

[Container]
ContainerName=web
Image=docker.io/library/nginx:stable
PublishPort=8080:80
# %h = home folder; ro = read-only; Z = private SELinux label
Volume=%h/site:/usr/share/nginx/html:ro,Z
# Automatic update from the registry (see step 5)
AutoUpdate=registry
# Security: no privilege escalation possible inside the container
NoNewPrivileges=true

[Service]
Restart=on-failure
TimeoutStartSec=300

[Install]
WantedBy=default.target

Privileged ports

An unprivileged user cannot listen on a port below 1024. Publish on a high port (8080) and put a reverse proxy in front, or lower net.ipv4.ip_unprivileged_port_start knowing what it implies.

3. Generate and start the service

systemctl --user daemon-reload
systemctl --user start web.service
systemctl --user status web.service
curl -s http://localhost:8080

Important

Do not use systemctl --user enable web.service: the unit is generated, so the command fails. Starting at boot is handled by the [Install] section of the .container file.

What about waiting for the network?

No need to add After=network-online.target: a user service cannot wait for a system target, so the line would be ignored. In rootless mode, Quadlet adds a dependency on podman-user-wait-network-online.service by itself.

If something is wrong, the generator simply does not create the service. To find out why:

# Generation errors and checks on the generated service
systemd-analyze --user --generators=true verify web.service

# Show the generated units
/usr/lib/systemd/system-generators/podman-system-generator --user --dryrun

Since Podman 5.6, podman quadlet list shows the installed Quadlet files and their status.

4. Logs and day-to-day operation

journalctl --user -u web.service -f          # live logs
systemctl --user restart web.service          # restart
podman ps                                     # the usual Podman view

After every change to the .container file: systemctl --user daemon-reload, then restart.

5. Automatic updates

With AutoUpdate=registry, Podman compares the local image with the registry's and restarts the service if a new version exists. The image must be referenced by its fully qualified name, registry included (docker.io/library/nginx, not just nginx). A timer shipped with Podman takes care of it every night at midnight:

systemctl --user enable --now podman-auto-update.timer
podman auto-update --dry-run                  # see what would be updated

If the new container fails to start, Podman automatically rolls back to the previous image (rollback, enabled by default).

Warning

Keep auto-update for images whose tag follows compatible versions (stable, 1.2, etc.). On a latest tag that can jump to a new major version, you risk an unwanted migration. For images you build locally, use AutoUpdate=local.

6. Several containers talking to each other

Quadlet also handles networks, volumes and pods with .network, .volume and .pod files. Example of a shared network:

# ~/.config/containers/systemd/app.network
[Network]
NetworkName=app

Then, in each .container:

[Container]
Network=app.network

Containers on the same network reach each other by name (ContainerName), and systemd starts the network before them.

Key takeaways

Action Command
Pick up a modified file systemctl --user daemon-reload
Start, stop, restart systemctl --user start name.service (or stop, restart)
Understand a generation error systemd-analyze --user --generators=true verify name.service
Logs journalctl --user -u name.service
Start at boot without a session sudo loginctl enable-linger <user>

Read next