How I Run a Rootless Podman Container with Quadlet and Automatic Updates

AI-generated

I recently replaced a hand-written docker-compose service in my homelab with a rootless Podman Quadlet unit. The result is less YAML, no separate compose process and normal systemd management for starting, logs and updates.

I use Podman on Fedora and Arch systems, mostly for small self-hosted services which do not need a full Kubernetes setup. Quadlet is especially nice here because the container definition is just a systemd-related file in my home directory.

Why I moved away from docker-compose for small services

My old compose files usually had one container, a bind mount, a few environment variables and a published port. Docker Compose works fine for that, but I always ended up with another command to remember:

$ docker compose up -d
$ docker compose logs -f
$ docker compose pull

With Quadlet, systemd creates the service unit from the container file. I can use the commands I already use for other services:

$ systemctl --user status nginx.service
$ journalctl --user -u nginx.service -f
$ systemctl --user restart nginx.service

Rootless containers are also a good default for these services. The container process does not get root permissions on the host and all images, volumes and container state belong to my normal user.

Rootless does not make an insecure container image safe. It just removes a big class of host-side mistakes.

Preparing the rootless systemd user

Quadlet files for rootless containers live in ~/.config/containers/systemd/. I create the directory once:

$ mkdir -p ~/.config/containers/systemd
$ mkdir -p ~/containers/nginx-html

For a service which should keep running after logout and start after a reboot, I enable lingering for my user:

$ loginctl enable-linger "$USER"

This is an important part which is easy to miss. Without lingering, a user systemd manager might stop when you log out. On a server I normally verify it like this:

$ loginctl show-user "$USER" -p Linger
Linger=yes

I also make sure that my old compose service is stopped before binding the same port with Podman:

$ docker compose down

My first Quadlet container file

This is a small nginx example, but the same structure works for applications like Gitea, a dashboard or an internal web service. I save it as ~/.config/containers/systemd/nginx.container.

[Unit]
Description=Rootless nginx container
After=network-online.target
Wants=network-online.target

[Container]
Image=docker.io/library/nginx:stable-alpine
ContainerName=nginx
PublishPort=8080:80
Volume=%h/containers/nginx-html:/usr/share/nginx/html:ro,Z
HealthCmd=wget -q -O /dev/null http://127.0.0.1/
HealthInterval=30s
HealthRetries=3
HealthStartPeriod=10s
HealthOnFailure=kill
AutoUpdate=registry

[Service]
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=default.target

The generated systemd service is called nginx.service, based on the file name. AutoUpdate=registry adds the Podman auto-update label to the container. Podman will compare the local image with the registry tag and only pull when the tag points to a newer image.

I use a named and explicit registry path here. This avoids surprises from short image name resolution. For production services I also prefer a stable vendor tag over latest.

On Fedora with SELinux enabled, the :Z on the bind mount matters. It relabels the mounted directory for this specific container. On Arch or Gentoo systems without SELinux, Podman accepts the option but there is nothing to relabel.

Do not use podman system prune -a blindly on a host with auto-updated services. Keeping the former image locally makes a manual rollback much easier.

After creating or changing the file, I reload my user manager and enable the generated service:

$ systemctl --user daemon-reload
$ systemctl --user enable --now nginx.service
Created symlink '/home/yves/.config/systemd/user/default.target.wants/nginx.service'  '/run/user/1000/systemd/generator/nginx.service'.

$ systemctl --user status nginx.service

The generated file itself is in systemd's runtime generator directory. I never edit it directly. The .container file is the source of truth.

Adding automatic image updates

Podman provides podman auto-update, but I prefer my own user timer. This makes the setup work consistently on current Fedora, Arch and other systemd-based distributions, even when they only ship a system-wide auto-update timer.

First I create ~/.config/systemd/user/podman-auto-update.service:

[Unit]
Description=Update rootless Podman containers

[Service]
Type=oneshot
ExecStart=/usr/bin/podman auto-update --rollback

Then I add ~/.config/systemd/user/podman-auto-update.timer:

[Unit]
Description=Daily rootless Podman auto-update check

[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=30m

[Install]
WantedBy=timers.target

Persistent=true means the timer catches up after a machine was powered off during its scheduled run. The random delay is useful when several machines pull from the same registry.

I enable it with:

$ systemctl --user daemon-reload
$ systemctl --user enable --now podman-auto-update.timer
$ systemctl --user list-timers podman-auto-update.timer

Before trusting the timer, I always run a dry check manually:

$ podman auto-update --dry-run

The --rollback option tells Podman to restore the previous image when restarting an updated systemd-managed container fails. This covers broken images which exit immediately or cannot start with the existing configuration.

Health checks and rollback checks

A successful container start is not always a working application. This is why I add a health command where the image has the required tool available. The nginx Alpine image includes wget, so it is useful for this example.

I check the health state after a deployment or update:

$ podman inspect --format '{{.State.Health.Status}}' nginx
healthy

I also test the actual service from outside the container:

$ curl -fsS http://127.0.0.1:8080/

HealthOnFailure=kill stops an unhealthy container. Combined with Restart=on-failure, systemd starts it again. It is not a replacement for proper application monitoring, but it catches many basic failures.

For important services I keep the data directory in my normal backup jobs. A bind mount below ~/containers/ is simple to find and restore, similar to how I handle changed files in my Linux backup setup.

If I host images in a private registry, I also make sure its TLS certificate is trusted by Podman. My own Linux certificate authority setup is useful for internal services like that.

This setup is now my default for single-container services. If you know a better way to validate an updated container before switching traffic to it, let me know.