Podman Quadlet: Running Containers as systemd Services

A practical guide to Quadlet - where unit files go, what every section does, how networks and volumes are wired, building images declaratively, auto-updates, and the specific errors that waste the most time. Includes the rootless requirements nobody mentions.

Every container setup eventually runs into the same question: what starts this after a reboot?

The traditional answers are all a second mechanism bolted onto the first — a restart policy inside a daemon, a @reboot cron entry, or a hand-maintained unit file that duplicates what your Compose file already says.

Quadlet answers it differently. You describe a container in a file that looks like a systemd unit, and a generator turns it into a real service at boot. The container is a systemd service, with proper dependency ordering, restart handling, and logs in the journal alongside everything else on the machine.

This is the reference I wanted when I started. It assumes Podman 4.4 or later; the examples were written against Podman 5 on Debian 13.

What Quadlet Actually Is

Not a daemon, not a tool you run. It is a systemd generator — a binary systemd executes early in boot, which reads your files and writes real unit files into a runtime directory.

# rootful
/usr/lib/systemd/system-generators/podman-system-generator

# rootless
/usr/lib/systemd/user-generators/podman-user-generator

If those exist, Quadlet is installed. There is nothing to enable.

The consequence of the generator model is the rule that trips up everybody: generators only run on daemon-reload or boot. Editing a unit file changes nothing until you reload. More on that below, because it is worth repeating.

The older podman generate systemd command did something superficially similar but produced a static unit file you then had to maintain by hand. It is deprecated. Quadlet replaces it.

Where Files Go

ModeDirectory
Rootless (per user)~/.config/containers/systemd/
Rootful/etc/containers/systemd/
Rootful, package-provided/usr/share/containers/systemd/
Admin-provided, for a user/etc/containers/systemd/users/$UID/

For a personal server, rootless is the one you want:

mkdir -p ~/.config/containers/systemd

The File Types

ExtensionCreatesGenerated service name
.containerA containername.service
.volumeA named volumename-volume.service
.networkA networkname-network.service
.podA podname-pod.service
.buildAn image built from a Containerfilename-build.service
.imageA pulled imagename-image.service
.kubeResources from a Kubernetes YAMLname.service

The one to note is .container: a file named web.container becomes web.service, and that is what you type into systemctl.

Anatomy of a .container File

[Unit]
Description=My web application
Wants=network-online.target
After=network-online.target

[Container]
ContainerName=web
Image=docker.io/library/caddy:2-alpine
Network=apps.network
PublishPort=8080:80
Volume=/home/admin/site:/srv:ro
Environment=TZ=Asia/Jakarta

[Service]
Restart=always
TimeoutStartSec=300

[Install]
WantedBy=default.target

Four sections, and understanding which is which prevents most early mistakes.

[Unit] and [Install] are ordinary systemd sections. They mean exactly what they mean in any other unit — description, ordering, dependencies, and what pulls the service in at boot.

[Container] is Quadlet’s own section. Every key here becomes a podman run argument. This is the only section with a Podman-specific vocabulary.

[Service] is where systemd service directives go. Restart=always belongs here, not in [Container]. Putting restart handling in the Podman section is the most common formatting error, and it fails silently — the service just does not restart.

Keys Worth Knowing

Inside [Container]:

KeyPurpose
Image=Fully qualified image, or a .build / .image unit reference
ContainerName=The container’s name — also its DNS name on a network
Exec=Override the image’s command
Environment=One variable; repeat the key for more
EnvironmentFile=Load variables from a file
Secret=Mount a Podman secret
Volume=HOST:CONTAINER[:opts], or name.volume:/path
PublishPort=HOST:CONTAINER[/protocol]
Network=name.network, or host, or none
User= / Group=UID/GID inside the container
ReadOnly=trueRead-only root filesystem
NoNewPrivileges=trueBlock privilege escalation
DropCapability=Remove Linux capabilities
AutoUpdate=registry or local
HealthCmd=Health check command
Pull=always, missing, never, newer
PodmanArgs=Escape hatch for anything without a dedicated key

PodmanArgs= is the pressure valve. If a podman run flag has no Quadlet key, pass it there rather than abandoning the approach.

The Lifecycle

This is the loop, and it is short:

# 1. write or edit a unit file
nano ~/.config/containers/systemd/web.container

# 2. regenerate — ALWAYS
systemctl --user daemon-reload

# 3. start it
systemctl --user start web

# 4. check
systemctl --user status web
journalctl --user -u web -f

Two things about this loop deserve emphasis.

Step 2 is not optional and is not intuitive. Nothing about editing a text file suggests you must reload a system service afterwards. Skipping it — then wondering why the change had no effect — is the single most common Quadlet mistake, and everyone makes it at least twice.

You cannot systemctl --user enable a Quadlet service. The generated unit lives in /run, not on disk, so there is nothing for enable to symlink. Boot startup comes from [Install] WantedBy=default.target plus a daemon-reload. If you try to enable one, the error message about a missing unit file is confusing rather than wrong.

To stop and remove something, delete the file and reload:

systemctl --user stop web
rm ~/.config/containers/systemd/web.container
systemctl --user daemon-reload

Networks and Container DNS

A .network unit is usually two lines:

[Unit]
Description=Shared application network

[Network]
NetworkName=apps

Reference it from a container by filename, not network name:

[Container]
Network=apps.network

Quadlet resolves the reference and adds the dependency, so the network exists before the container starts. You never write an After= for it.

Set NetworkName= explicitly. Without it, Quadlet names the network after the unit with a prefix — systemd-apps — and that prefixed name is what you then type in every podman network inspect for the rest of the project.

The payoff is DNS. Containers on a shared user-defined network resolve each other by container name:

# in caddy.container
Network=apps.network
ContainerName=caddy

# in web.container
Network=apps.network
ContainerName=web

Caddy can now reach http://web:3000. No IP addresses, no host ports, no link flags.

This requires aardvark-dns to be installed. Without it, containers start fine, appear healthy, and cannot resolve each other — an error that looks like a networking bug and is actually a missing package.

sudo apt install aardvark-dns netavark   # Debian/Ubuntu
sudo dnf install aardvark-dns netavark   # Fedora/RHEL

Volumes

Named volumes get their own unit:

[Volume]
VolumeName=app-data

Referenced the same way — by filename:

[Container]
Volume=app-data.volume:/var/lib/data

Bind mounts use absolute paths:

Volume=/home/admin/apps/caddy/Caddyfile:/etc/caddy/Caddyfile:ro

Two notes. First, Quadlet does not reliably expand %h in every field, so write /home/admin/... in full rather than relying on specifiers. Second, on SELinux systems (Fedora, RHEL) add :Z to bind mounts so the label is set correctly; on Debian and Ubuntu it is unnecessary.

The Permission Problem

The confusion everyone hits with rootless: a bind-mounted directory owned by your user shows up as owned by nobody inside the container.

That is user namespace mapping working correctly. Your UID 1000 maps to a different UID inside. Two fixes:

# let Podman chown the volume to match the container user
Volume=/home/admin/data:/data:U

Or shift ownership on the host, from inside the namespace:

podman unshare chown -R 1000:1000 /home/admin/data

Environment and Secrets

[Container]
Environment=NODE_ENV=production
Environment=PORT=3000
EnvironmentFile=/home/admin/apps/web/.env

For anything sensitive, use Podman secrets rather than an environment file sitting in your home directory:

printf 'super-secret-value' | podman secret create db_password -
[Container]
Secret=db_password,type=env,target=DB_PASSWORD

type=mount places it as a file instead, which is better still — environment variables leak through /proc and process listings.

Health Checks

[Container]
HealthCmd=curl -f http://localhost:3000/health || exit 1
HealthInterval=30s
HealthRetries=3
HealthStartPeriod=10s
Notify=healthy

Notify=healthy is the interesting one. It tells systemd not to consider the service started until the health check passes. Anything ordered After= this unit then genuinely waits for the application to be ready, not just for the container process to exist.

Building Images Declaratively

A .build unit builds an image as part of the dependency chain:

[Build]
ImageTag=localhost/myapp:latest
File=/home/admin/apps/myapp/Containerfile
SetWorkingDirectory=/home/admin/apps/myapp/src

Reference it from the container by filename:

[Container]
Image=myapp.build

Quadlet builds the image before starting the container. It is elegant, and it means a fresh machine can go from unit files to running services with one daemon-reload.

I still build manually in my own deploys, because I want to see build output and control exactly when a rebuild happens. Worth knowing it exists, though, especially for reproducible provisioning.

Pods

For containers that should share a network namespace:

# web.pod
[Pod]
PodName=web
PublishPort=8080:80
# app.container
[Container]
Pod=web.pod
Image=localhost/myapp:latest

Containers in a pod reach each other over localhost and ports are published at the pod level. This is the Kubernetes pod concept, and it maps cleanly onto sidecar patterns — an app and its metrics exporter, say.

For unrelated services, a shared network is the better fit. Pods are for things that genuinely belong together.

Automatic Updates

[Container]
Image=docker.io/library/caddy:2-alpine
AutoUpdate=registry

Then enable the timer:

systemctl --user enable --now podman-auto-update.timer

AutoUpdate=registry checks the registry for a newer image with the same tag and restarts the container if one exists. AutoUpdate=local only reacts to a newer locally built image, which is what you want for your own applications.

Preview before trusting it:

podman auto-update --dry-run

If the new image fails its health check, Podman rolls back to the previous one — but only if the container has a health check. Without one, “started successfully” is the only signal available, and a container that starts and then misbehaves will not trigger a rollback.

My rule: registry for third-party infrastructure I want patched without waiting for me, local for my own code, which should only change when I deploy it.

Rootless Requirements

Everything above assumes rootless. Four things must be true, and each fails differently.

1. Subordinate UID ranges must exist.

grep "$USER" /etc/subuid /etc/subgid

Expect something like admin:100000:65536. If missing:

sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 "$USER"
podman system migrate

2. Lingering must be enabled, or your services stop when you log out and never start at boot:

sudo loginctl enable-linger "$USER"
loginctl show-user "$USER" --property=Linger

This is the one that makes an otherwise perfect setup look broken. Everything works while you are logged in, and dies the moment you disconnect.

3. Ports below 1024 need a decision. A rootless container cannot bind 80 or 443 by default:

echo 'net.ipv4.ip_unprivileged_port_start=80' | sudo tee /etc/sysctl.d/99-ports.conf
sudo sysctl --system

Understand the trade-off: this lets any unprivileged process bind those ports. Fine on a single-admin box, wrong on a shared system.

4. Run systemctl --user from a real session as that user. Reaching for sudo -u admin systemctl --user ... fails, because that shell has no XDG_RUNTIME_DIR pointing at the user’s session bus.

Debugging

See the unit that was actually generated:

/usr/libexec/podman/quadlet -dryrun -user

This prints what Quadlet would produce, without touching anything. It is the fastest way to find a key that was silently ignored — usually because it is in the wrong section or misspelled.

Find the generated files on disk:

ls /run/user/$UID/systemd/generator/

Validate a unit:

systemd-analyze --user verify web.service

Watch a start attempt in full:

journalctl --user -u web -f

Errors Worth Recognising

SymptomCause
Unit web.service not foundNo daemon-reload after creating the file
Edits have no effectSame — reload after every change
Failed to connect to busRan systemctl --user via sudo -u
Containers die on logoutLingering not enabled
no such host between containersDifferent networks, or aardvark-dns missing
short-name resolution enforcedUse the fully qualified image name
Files owned by nobody in containerUser namespace mapping — use :U or podman unshare chown
bind: permission denied on port 80ip_unprivileged_port_start not lowered
Service restarts in a loopContainer exits immediately; check journalctl for the real error

Coming From Compose

The translation is mostly mechanical:

ComposeQuadlet
image:Image=
container_name:ContainerName=
ports:PublishPort= (one per line)
volumes:Volume= (one per line)
environment:Environment= (one per line)
env_file:EnvironmentFile=
networks:Network=name.network
restart: alwaysRestart=always in [Service]
depends_on:After= / Requires= in [Unit]
build:A .build unit, or build manually
healthcheck:HealthCmd= and friends

The structural difference is that Compose puts every service in one file, while Quadlet uses one file per resource. That feels like more files at first. In practice it means a service can be added, changed, or removed without touching anything else, and each file is short enough to read at a glance.

When Not to Use Quadlet

Local development. The edit–reload–restart loop is slower than podman run or compose up while you are iterating. I use Compose locally and Quadlet on the server, and see no contradiction in that.

Non-systemd hosts. Alpine with OpenRC, most containers-in-containers setups, macOS. Quadlet is a systemd generator and needs systemd.

Multi-host orchestration. Quadlet manages containers on one machine. If you need scheduling across nodes, you need Kubernetes or Nomad, and this is not a substitute.

Anything Docker-specific. Obviously, but worth stating: this is a Podman feature with no Docker equivalent.

Why It Was Worth Learning

The unit file format took an afternoon. What I got back was one supervision mechanism instead of two.

Boot ordering, restart policy, dependencies, resource limits, and logs are all handled by systemd — which was designed for exactly this and has been doing it reliably for years. There is no daemon to keep alive, no orchestration layer to update, and no second place to look when something has not started.

On a single server that I maintain alone, being able to read the entire deployment as a handful of systemd units is worth more than any individual feature.


Related: Why I Chose Podman Over Docker for the runtime comparison, and Setting Up a VPS, Part 3 for this applied to a real server from scratch.

© 2026 r3p.dev. All rights reserved.