Deploying a Podman Quadlet stack on RHEL9 with linux-system-roles
Earlier I hand-wrote Quadlet files for Caddy + PHP-FPM directly on the host. The linux-system-roles.podman role turns that into something declarative: instead of writing .container/.network unit files as text, you describe them as nested YAML matching each unit's own [Section] layout, and the role generates and manages the actual files. Paired with linux-system-roles.network for the host's own networking, one playbook takes a bare RHEL9 box to a running container stack.
Host networking first
- name: Configure the host's network
hosts: rhel9_hosts
roles:
- linux-system-roles.network
vars:
network_connections:
- name: eth0
type: ethernet
ip:
dhcp4: false
address:
- 192.168.20.51/24
gateway4: 192.168.20.1
dns:
- 192.168.20.1
A stable, known IP before anything gets deployed on top of it — ordinary NetworkManager connection profile management, nothing container-specific yet.
The Quadlet stack: Caddy + Adminer + PHP
- name: Deploy the container stack
hosts: rhel9_hosts
roles:
- linux-system-roles.podman
vars:
podman_quadlet_specs:
- name: appnet
type: network
Network: {}
- name: php
type: container
Install:
WantedBy: default.target
Container:
Image: docker.io/library/php:8.3-fpm-alpine
ContainerName: php
Network: appnet.network
Volume: /srv/app:/srv:ro
- name: adminer
type: container
Install:
WantedBy: default.target
Container:
Image: docker.io/library/adminer:latest
ContainerName: adminer
Network: appnet.network
- name: caddy
type: container
Install:
WantedBy: default.target
Container:
Image: docker.io/library/caddy:2-alpine
ContainerName: caddy
Network: appnet.network
PublishPort: "80:80"
Volume:
- /srv/Caddyfile:/etc/caddy/Caddyfile:ro
- /srv/app:/srv:ro
Every piece here maps directly onto the raw Quadlet syntax from the manual version — Network: appnet.network is exactly the Network=appnet.network line from a hand-written .container file, just expressed as a YAML key. type: network with an empty Network: {} produces the equivalent of the plain podman network create step from before.
One thing that isn't obvious from a single-value example: a hand-written Quadlet unit lets a key like Volume= repeat as many separate lines as you need. A YAML mapping can't have two keys with the same name, so the role represents that repetition as a YAML list under one Volume: key instead — that's what caddy's two mounts above are. linux-system-roles.podman's own CI test file does the same thing for Environment:, which confirms the pattern rather than it being a guess.
Ordering isn't cosmetic
The spec list order is the dependency order the role applies things in — appnet has to come before anything that references Network: appnet.network, exactly like a hand-written .network file has to exist before a .container file that points at it. Get the order backwards and the role will try to wire a container to a network unit that doesn't exist yet on that same run.
Why bother with this over raw Quadlet files
Three things the manual version doesn't give you for free: restarts/restarts_on on a quadlet spec to automatically restart a service when a dependency file changes (a mounted config, a secret) without touching the unit file itself; podman_secrets for managing Podman secrets from the same variable structure; and ordinary Ansible idempotency — rerunning the playbook against a host that already has this stack just confirms nothing drifted, the same guarantee any other Ansible-managed config gets.
Created 2026-09-16T23:01:07+02:00 · Edit