Podman Quadlet — Containers as systemd services¶
The problem¶
Your container runs perfectly… until you reboot the server. Then you meet the usual pattern: a docker-compose.yml, a restart: always, and a hand-written systemd unit calling docker compose up — two supervisors arguing over the same process. When the container dies, systemd thinks the service is still alive because the start command returned 0 half an hour ago.
Quadlet removes the middle layer: you write a declarative file, systemd generates the unit, and the container is the service. One supervisor, real dependencies, journalctl working exactly as it does for any other daemon.
What this covers and what it doesn't
This page is about the lifecycle: declaring, starting, ordering dependencies, checking health and updating. The rootless privilege model (user namespaces, subuid/subgid) lives in Rootless Podman, which introduces Quadlet briefly; this page is the full version.
📋 Table of Contents¶
- How it works
- Your first .container
- Where the files go
- Volumes, networks and pods
- Healthchecks and lifecycle
- Dependencies and startup order
- Automatic updates
- Migrating from Docker Compose
- Troubleshooting
- Best practices
- References
How it works¶
Quadlet is a systemd generator. It is not a daemon or a process: it runs during daemon-reload, reads your .container files and writes ephemeral .service units in memory.
flowchart LR
A["myapp.container"] -->|daemon-reload| B[Quadlet generator]
B --> C["myapp.service<br/><i>generated unit</i>"]
C -->|systemctl start| D["podman run ..."]
Three practical consequences of it being a generator:
- Every change requires
daemon-reload. Editing the file is not enough. - You cannot
systemctl enablea generated unit: it does not exist as a linkable file on disk. Autostart is declared with[Install] WantedBy=inside the.containeritself. - Syntax errors surface at
daemon-reload, not at start time. A malformed file simply produces no unit, andsystemctl startanswers "Unit not found".
Your first .container¶
# ~/.config/containers/systemd/whoami.container
[Unit]
Description=whoami service
After=network-online.target
[Container]
Image=docker.io/traefik/whoami:latest
PublishPort=8080:80
Environment=WHOAMI_NAME=demo
[Service]
Restart=always
[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user start whoami.service
systemctl --user status whoami.service
The unit name derives from the filename: whoami.container → whoami.service. The [Unit], [Service] and [Install] sections are plain systemd and are copied verbatim into the generated unit; only [Container] is Quadlet-specific.
Surviving logout
User services die when you disconnect, unless you enable lingering:
loginctl enable-linger $USER
WantedBy=default.target starts the container at login, not at boot. This is the number one failure when deploying rootless on a server.
Where the files go¶
| Mode | Path | Control |
|---|---|---|
| Rootless (recommended) | ~/.config/containers/systemd/ |
systemctl --user |
| Rootful | /etc/containers/systemd/ |
sudo systemctl |
| Distribution packages | /usr/share/containers/systemd/ |
sudo systemctl |
Rootless uses WantedBy=default.target; rootful uses WantedBy=multi-user.target. Copying an example from one to the other without changing this yields a service that never starts on its own.
Volumes, networks and pods¶
Each resource type has its own extension, and .container files reference them by filename, not by the actual resource name. Quadlet resolves the dependencies for you.
# ~/.config/containers/systemd/appdata.volume
[Volume]
VolumeName=appdata
Label=app=myapp
# ~/.config/containers/systemd/internal.network
[Network]
Subnet=10.89.0.0/24
Gateway=10.89.0.1
# ~/.config/containers/systemd/myapp.container
[Container]
Image=docker.io/library/nginx:alpine
Volume=appdata.volume:/usr/share/nginx/html:Z
Network=internal.network
PublishPort=8080:80
Referencing appdata.volume does two things at once: it mounts the volume and adds a Requires=/After= on appdata-volume.service. The volume is created before the container starts, without you ordering anything.
SELinux: :Z and :z
On Fedora, RHEL or any distro with SELinux in enforcing mode, an unlabeled volume produces "Permission denied" inside the container even when POSIX permissions are correct. :Z labels the content exclusively for that container; :z shares it across several. Use :Z unless two containers genuinely need the same directory.
To group containers sharing a network — the app-plus-sidecar localhost pattern:
# ~/.config/containers/systemd/web.pod
[Pod]
PublishPort=8080:80
# In each .container of the group
[Container]
Pod=web.pod
Ports are published on the pod, not on the containers. Inside the pod, members reach each other over localhost.
Healthchecks and lifecycle¶
Restart=always restarts the container when the process dies. It does nothing when the process is alive but the service is wedged: exhausted connection pool, deadlock, infinite loop. That is precisely the failure that takes longest to notice.
[Container]
Image=docker.io/library/nginx:alpine
HealthCmd=curl -fsS http://localhost/ || exit 1
HealthInterval=30s
HealthTimeout=5s
HealthRetries=3
HealthStartPeriod=10s
HealthOnFailure=restart
| Key | What for |
|---|---|
HealthCmd |
Command run inside the container; exit 0 = healthy |
HealthInterval |
How often to check |
HealthRetries |
Consecutive failures before marking it unhealthy |
HealthStartPeriod |
Startup grace period: failures here don't count |
HealthOnFailure |
What to do on failure: none, kill, restart, stop |
HealthOnFailure=restart is the key that turns the healthcheck into more than decoration in podman ps. Without it, the container stays flagged unhealthy indefinitely and nobody finds out.
HealthStartPeriod matters more than it looks: a database taking 40 seconds to accept connections with a 10 s start period enters a restart loop and never comes up. Measure the real startup and add margin.
The healthcheck must exist inside the image
HealthCmd=curl ... against an alpine image without curl always fails, and the symptom is a container restarting with no visible cause in the logs. Check what you actually have:
podman exec myapp sh -c 'command -v curl wget nc'
pg_isready, redis-cli ping, nginx -t) or expose an endpoint that answers wget -qO-.
Current state and why it failed:
podman healthcheck run myapp # run the check right now
podman inspect myapp --format '{{.State.Health.Status}}'
podman inspect myapp --format '{{range .State.Health.Log}}{{.Output}}{{end}}'
The same reasoning applied to orchestration is in Kubernetes probes: HealthStartPeriod is the conceptual equivalent of startupProbe.
Dependencies and startup order¶
With Compose, depends_on only waits for the container to start, not for the service to be ready. Here you have real systemd:
# app.container
[Unit]
Requires=db.service
After=db.service
[Container]
Image=registry.example.com/myapp:1.4.2
Network=internal.network
Requires= propagates failure (if db doesn't start, neither does app) and After= fixes the ordering. To wait until the database is ready and not merely started, combine it with a healthcheck on db.container and Restart=on-failure on app: if it starts too early it fails, and systemd retries until the dependency answers.
Controlled restarts, without hammering the service:
[Service]
Restart=on-failure
RestartSec=10
StartLimitBurst=5
StartLimitIntervalSec=300
After 5 failures in 5 minutes systemd stops retrying and leaves the unit failed, where your monitoring can see it. Far better than a container restart-looping for days without triggering a single alert.
Automatic updates¶
[Container]
Image=docker.io/library/nginx:alpine
Label=io.containers.autoupdate=registry
systemctl --user enable --now podman-auto-update.timer
podman auto-update --dry-run # what would be updated, touching nothing
With registry, Podman compares the local digest against the registry's and, if they differ, pulls the new image and restarts the unit. If the container fails to start, it automatically rolls back to the previous image: that is why this is usable in production and a bare watchtower is not.
Alternative Label=io.containers.autoupdate=local: only updates if you build the image locally yourself; it queries no registry.
Auto-update wants moving tags, your deployments want pinned ones
autoupdate=registry on :1.4.2 does nothing: that tag never changes. On :latest it works, but it means accepting whatever the maintainer publishes, including a breaking change, at 3 a.m.
A reasonable middle ground: minor tags (:1.4) for home services, pinned tags and deliberate updates for anything that must not go down. Automatic rollback covers a startup failure, not the behavior change that corrupts your data.
Migrating from Docker Compose¶
podman-compose exists and works, but it keeps both supervisors. If the goal is for systemd to own the service, the translation is direct:
| Compose | Quadlet |
|---|---|
image: |
Image= |
ports: |
PublishPort= |
volumes: |
Volume= (plus :Z under SELinux) |
environment: |
Environment= |
env_file: |
EnvironmentFile= |
networks: |
Network= |
restart: always |
[Service] Restart=always |
depends_on: |
[Unit] Requires= + After= |
healthcheck: |
HealthCmd= and friends |
user: |
User= |
cap_drop: |
DropCapability= |
| (no equivalent) | PodmanArgs= for flags without a dedicated key |
Shortcut for services already running: generate the skeleton from the container and edit it.
podman container create --name tmp -p 8080:80 docker.io/library/nginx:alpine
podman generate systemd --new --files --name tmp # starting point to port into .container
generate systemd is deprecated in favor of Quadlet — treat it as a starting point, not a destination.
Decision rule: Compose for local development (bring the whole stack up and tear it down), Quadlet for servers (boot, dependencies, healthchecks and logs integrated with the rest of the system).
Troubleshooting¶
| Symptom | Cause | Check |
|---|---|---|
Unit myapp.service not found |
Syntax error, or file in the wrong path | Read the daemon-reload output |
| Doesn't start after a host reboot | Lingering not enabled | loginctl show-user $USER \| grep Linger |
Permission denied on a volume |
Missing SELinux label | Add :Z |
| Restart loop with no clear error | HealthCmd not runnable in the image |
podman inspect --format '{{range .State.Health.Log}}{{.Output}}{{end}}' |
| Changes not taking effect | Missing daemon-reload |
Reload again and restart |
| Fails rootless, works as root | Port <1024, or subuid not configured | sysctl net.ipv4.ip_unprivileged_port_start |
See the exact podman run Quadlet generated — the tool that solves most cases:
# Podman 5.x
podman quadlet print myapp.container
# Any version: run the generator by hand
/usr/lib/systemd/system-generators/podman-system-generator --user --dryrun
If the resulting podman run isn't what you expected, the problem is in the file, not in Podman. And the logs, as for any system service:
journalctl --user -u myapp.service -f
Best practices¶
- One file per resource, versioned in Git and deployed with Ansible.
.containerfiles are plain text: infrastructure as code with no extra tooling. loginctl enable-lingeron every rootless server. Without it, nothing starts by itself.- Healthcheck on anything serving traffic, with
HealthOnFailure=restartand aHealthStartPeriodthat was measured, not guessed. - Always set
StartLimitBurst. A silent restart loop is worse than a service that is down and visible. - Reference
.volumeand.networkby file, not by resource name: startup dependencies come for free. - Secrets via
Secret=, notEnvironment=: environment variables are readable inpodman inspectand in the journal. See secrets management. - Auto-update with judgment. Enable it where automatic rollback is enough; update critical services yourself.