Quadlet — the 80/20 course for systemd-managed Podman on RHEL¶
Scope: declare Podman containers, volumes, networks, pods, and Kubernetes-YAML deployments as systemd units on a single RHEL 9.4+ / Fedora 40+ host. Custom systemd generators, advanced Kubernetes-on-Podman patterns, the deprecated
podman generate systemdflow, and Podman internals beyond what Quadlet exposes are intentionally out of scope — see tasks 81 (RHEL) and 82 (Podman) for the surrounding layers.Read this top-to-bottom once, then come back to specific sections as reference. This module's glossary is the dictionary for any term you don't recognise here — it also seeds the Retain flashcards.
TL;DR — in 30 seconds:
- Quadlet replaces
podman generate systemdwith declarative source files —.container,.volume,.network,.pod,.kube— that systemd's generator turns into unit files at boot and on everydaemon-reload; you author sources, never edit the generated units. daemon-reloadis the regenerate step — after any edit to a Quadlet file you must runsystemctl daemon-reloadfor systemd to re-run the generator; forgetting this is the #1 "why didn't my change take effect" cause.- Rootful (
/etc/containers/systemd/) and rootless (~/.config/containers/systemd/) Quadlets are filesystem-segregated but share the same file format — managed withsystemctlvssystemctl --user(rootless needsloginctl enable-lingerto persist after logout).
1. Why Quadlet — and when not to use it¶
Before Quadlet, the canonical way to run Podman containers as boot-time services was podman generate systemd: a subcommand that emitted a verbose, opinionated .service file you'd hand-edit, drop under /etc/systemd/system/, and systemctl daemon-reload. The unit files were ugly, hand-merge-prone, and went stale when the container's options changed.
Quadlet replaces that with declarative source files. You drop a 10-line .container file describing what you want; systemd's generator infrastructure turns it into a .service unit at boot (and on every daemon-reload).
What you get:
- Tiny config. A complete service spec in 10 lines vs ~80 lines of generated
.service. - No re-generation step. Edit the
.container,systemctl daemon-reload, done. The unit file is regenerated automatically; you never edit it. - Systemd-native lifecycle.
systemctl start/stop/restart/enable,journalctl -u. No Podman-specific commands for service management. - One file per concern. Containers in
.container, volumes in.volume, networks in.network, pods in.pod, Kubernetes-YAML in.kube. Clear separation; clear diff in PRs. - Auto-update built in. One label (
AutoUpdate=registry) + a system timer = containers that update themselves nightly.
When not to reach for Quadlet:
- You're orchestrating across multiple hosts → Kubernetes, not Quadlet.
- You need rolling updates, replicas, autoscaling → Kubernetes.
- You're on a non-systemd init (Alpine, void) → Quadlet's substrate doesn't exist.
- You're on Podman < 4.4 → upgrade. Quadlet ships in-tree since 4.4 and is stable in 5.x.
The 80/20 sweet spot Quadlet nails: multi-container apps running on a single RHEL host, supervised by systemd, declared in code that fits in a PR.
2. Mental model¶
Author time
~/.config/containers/systemd/
├── webapp.network +
├── pgdata.volume | You write these.
├── postgres.container | No generated files in git.
├── api.container |
└── caddy.container +
Boot / daemon-reload
v
+--------------------------+
| systemd starts |
| /usr/libexec/podman/ | Reads sources, emits unit files into
| quadlet (generator) | /run/systemd/generator.<late|early>/
+-----------+--------------+
v
/run/systemd/generator/
├── webapp-network.service +
├── pgdata-volume.service | Generated. Never edit. Don't commit.
├── postgres.service |
├── api.service |
└── caddy.service +
Runtime
v
+----------+--------+
| systemd | start, stop, restart, journal, dep graph
| + journald | You only use systemctl + journalctl.
+-------------------+
v
+--------+----------+
| podman | Actual containers, networks, volumes.
+-------------------+
Three things to internalise:
- You author sources, not units.
.container,.volume,.network, etc. are the source format. The generated.servicefiles live in/run/...and are throwaway — never check them into git, never edit them by hand. systemctl daemon-reloadis the regenerate step. After any edit to a Quadlet file, you mustdaemon-reloadfor systemd to re-run the generator. Forgetting this is the #1 "why didn't my change take effect" cause.- Rootful and rootless are filesystem-segregated. Rootful Quadlets live in
/etc/containers/systemd/; rootless live in~/.config/containers/systemd/. Same file format. Rootful units are managed withsystemctl; rootless withsystemctl --user(and needloginctl enable-linger <user>to persist after logout).
3. Setup on a RHEL host¶
Quadlet ships with Podman 4.4+. On RHEL 9.4+, you already have it.
# Verify
podman --version # 5.x
ls /usr/libexec/podman/quadlet # the generator binary
systemctl --version # 252+
# Rootless user setup (one-time per user)
loginctl enable-linger $USER # keep user processes after logout
# Confirm the directories exist (create on first use)
mkdir -p ~/.config/containers/systemd # rootless
sudo mkdir -p /etc/containers/systemd # rootful
A first sanity check — a one-file rootless container:
cat > ~/.config/containers/systemd/hello.container <<'EOF'
[Unit]
Description=Hello-world container
After=network-online.target
[Container]
Image=quay.io/podman/hello:latest
Exec=podman-hello
[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user start hello.service
journalctl --user -u hello.service --no-pager
journalctl should show the hello-world output. Then:
systemctl --user status hello.service # state
systemctl --user stop hello.service
systemctl --user disable hello.service
rm ~/.config/containers/systemd/hello.container
systemctl --user daemon-reload
If daemon-reload doesn't regenerate anything, dry-run the generator yourself:
/usr/libexec/podman/quadlet -dryrun -user # rootless
sudo /usr/libexec/podman/quadlet -dryrun # rootful
This prints the exact unit files Quadlet would produce, without writing them. Invaluable for debugging.
4. The .container file — the everyday unit¶
Pretty much every Quadlet you'll write is a .container file. The skeleton:
[Unit]
Description=A short, human-readable description
After=network-online.target
Wants=network-online.target
[Container]
Image=registry.example.com/myapp:1.2.3
ContainerName=myapp # optional; default systemd-<basename>
Volume=mydata.volume:/data:Z # mount the .volume target
Volume=/etc/myapp/config:/etc/config:ro,Z # bind mount, read-only, SELinux relabel
Network=mynet.network # attach to the .network target
PublishPort=8080:80 # host:container
Environment=NODE_ENV=production
EnvironmentFile=/etc/myapp/.env # secrets out of band
HealthCmd=wget -qO- http://localhost/healthz || exit 1
HealthInterval=30s
AutoUpdate=registry # opt in to nightly auto-update
[Service]
Restart=always
RestartSec=5
TimeoutStartSec=180
[Install]
WantedBy=multi-user.target # rootful; use default.target for --user
Filename matters: myapp.container produces myapp.service. Files starting with _ are templates (not yet a stable Quadlet feature; treat as off-limits).
The [Container] directives you'll use 95% of the time:
| Directive | What it maps to in podman run |
|---|---|
Image= |
The first positional argument (the image to run). |
Exec= |
The command after the image. Overrides the image's CMD. |
ContainerName= |
--name. Default is systemd-<unit-basename>. |
Volume= |
-v (repeatable). Use :Z for SELinux private label, :z for shared. |
Network= |
--network (repeatable). Reference .network files by basename + .network. |
Pod= |
--pod. Reference a .pod file by basename + .pod. |
PublishPort= |
-p host:container[/proto] (repeatable). |
Environment= |
-e KEY=value (repeatable). |
EnvironmentFile= |
--env-file <path>. |
User= |
--user uid[:gid]. |
UserNS= |
--userns (auto, keep-id, nomap, host). |
SecurityLabelType= |
--security-opt label=type:<x>. |
ReadOnly=true |
--read-only. |
NoNewPrivileges=true |
--security-opt=no-new-privileges. |
DropCapability=ALL |
--cap-drop=ALL. Repeat to drop multiple. |
AddCapability=NET_BIND_SERVICE |
--cap-add=.... Repeat to add multiple. |
HealthCmd= |
--health-cmd "...". |
HealthInterval=30s |
--health-interval. |
Notify=container |
Forwards NOTIFY_SOCKET so the container can do sd_notify(READY=1). |
AutoUpdate=registry |
--label io.containers.autoupdate=registry. |
Pull=always |
--pull=always on start. Default missing. |
Tmpfs=/tmp:rw,size=64M |
--tmpfs .... |
LogDriver=journald |
--log-driver=journald (default on RHEL). |
PodmanArgs=... |
Escape hatch — appended verbatim. Use only when no directive fits. |
[Unit] and [Install] are pure systemd; they apply the same semantics to the generated service as they would to any handwritten unit.
[Service] knobs worth knowing:
Restart=always— restart on any exit. Quadlet injects no restart policy by default, so an unspecified[Service]inherits systemd'sRestart=no(a crashed container stays down) — addRestart=alwaysorRestart=on-failureyourself.TimeoutStartSec=180— how long to wait for the container to come up. Bump for slow Postgres / databases.RestartSec=5— pause between restarts.
Put together, one .container file becomes one generated systemd service becomes one supervised container:
flowchart LR
A["myapp.container<br/>(source — you write and commit this)"] -->|"edit + daemon-reload"| B["myapp.service<br/>(generated — never edit, never commit)"]
B -->|"systemctl start"| C["podman container<br/>(systemd-myapp)"]
C -->|"journalctl / systemctl status"| D["observe, then iterate"]
5. The .volume file¶
[Volume]
VolumeName=pgdata # optional; default systemd-<basename>
Driver=local # optional; this is the default
Label=app=postgres
Label=env=prod
Filename pgdata.volume produces a one-shot pgdata-volume.service that ensures the volume exists. Reference it from a .container:
[Container]
Volume=pgdata.volume:/var/lib/postgresql/data:Z
Quadlet automatically injects a Requires= and After= between the container service and the volume service. You don't write the dependency by hand.
When not to use a .volume: ephemeral data Podman should manage entirely (use podman volume create once, ad-hoc) or bind-mounted host paths. .volume is for declarative, named, persistent storage that should exist as soon as the host is up.
6. The .network file¶
[Network]
NetworkName=webapp # optional; default systemd-<basename>
Subnet=10.89.0.0/24 # optional; auto-allocated otherwise
Gateway=10.89.0.1 # optional; auto-allocated otherwise
Label=app=webapp
DisableDNS=false # default; keep DNS on for service discovery
Internal=false # set true to deny external traffic
Filename webapp.network produces a one-shot webapp-network.service. Reference it from containers:
[Container]
Network=webapp.network
Containers on the same named network resolve each other by name via Podman's embedded DNS — postgres resolves to the postgres container's IP, no --link needed.
For host networking (no isolation), skip .network and use Network=host directly in [Container].
7. The .pod file¶
A pod groups containers that should share network and IPC namespaces. The same primitive Kubernetes uses; in Podman it's local to one host.
# webapp.pod
[Unit]
Description=Webapp pod
[Pod]
PodName=webapp
PublishPort=8080:80
Network=webapp.network
[Install]
WantedBy=multi-user.target
Containers join via Pod=:
# nginx.container
[Container]
Pod=webapp.pod
Image=docker.io/library/nginx:1.25
# No Network=, no PublishPort= here — those belong to the pod.
What's true once they're in a pod:
- They share
localhost.nginxreachesappvialocalhost:3000. - Ports are published on the pod, not the containers.
systemctl stop webapp-pod.servicestops every member container.systemctl startbrings them all up in dependency order.
Pods are the canonical shape for "two or three tightly coupled containers" on a single host. Bigger groupings: use .kube.
8. The .kube file — Kubernetes YAML¶
For multi-pod / multi-container deployments that already exist as Kubernetes YAML:
# webapp.yaml
apiVersion: v1
kind: Pod
metadata:
name: webapp
spec:
containers:
- name: nginx
image: docker.io/library/nginx:1.25
ports:
- containerPort: 80
hostPort: 8080
- name: api
image: registry.example.com/myapi:1.0
env:
- name: DATABASE_URL
value: postgres://postgres:secret@localhost:5432/db
# webapp.kube
[Unit]
Description=Webapp Kubernetes deployment
[Kube]
Yaml=webapp.yaml # relative to the .kube file
Network=webapp.network
ConfigMap=webapp-config.yaml # optional, ConfigMap YAML
[Install]
WantedBy=multi-user.target
systemctl start webapp-kube.service runs podman play kube webapp.yaml; stop runs podman play kube --down.
When to reach for .kube over .container+.pod:
- The YAML already exists (you author once, run it on real K8s elsewhere).
- You want Kubernetes-specific objects (
ConfigMap,Secret,PersistentVolumeClaim) that don't have Quadlet equivalents. - The deployment has > 3 containers or non-trivial init containers.
When .container+.pod is better:
- Fewer than ~3 containers; no Kubernetes-only objects.
- You want one-Quadlet-file-per-container in PRs.
- You want fine-grained systemd dependency ordering per container.
9. .build and .image files (briefly)¶
Less common, but worth knowing:
.image — declare that an image should be pulled and present:
# baseos.image
[Image]
Image=registry.access.redhat.com/ubi9/ubi:9.4
AuthFile=/etc/containers/auth.json
Generates a one-shot service that podman pulls the image on boot (and on daemon-reload). Useful for guaranteeing an image is up-to-date before any container that references it starts.
.build — declare that an image should be built from a Containerfile on the host:
# myapp.build
[Build]
ImageTag=localhost/myapp:1.0
File=/srv/myapp/Containerfile
WorkingDirectory=/srv/myapp
BuildArg=VERSION=1.0
Generates a one-shot service that podman builds the image at boot. Used for "build at install time" patterns or edge devices that compile their own images.
For most setups: pull from a registry (.image), don't build at boot.
10. Rootless vs rootful Quadlets¶
| Rootless | Rootful | |
|---|---|---|
| Source directory | ~/.config/containers/systemd/ |
/etc/containers/systemd/ |
| Manage with | systemctl --user ... |
systemctl ... |
| Logs | journalctl --user -u <unit> |
journalctl -u <unit> |
[Install] |
WantedBy=default.target |
WantedBy=multi-user.target |
| Privileged ports | < 1024 forbidden by default | All ports allowed |
| Persistence at logoff | Requires loginctl enable-linger <user> |
Not applicable |
| SELinux | :Z/:z still required for bind mounts |
Same |
| Network ranges | Allocated from rootless pool | System-wide |
| Use for | App workloads, no host-level features needed | Things that genuinely need root (kernel modules, privileged ports, host networking, certain CNI plugins) |
Default to rootless for app workloads. Reach for rootful only when the workload requires it.
For a rootless container that needs <1024 ports:
# Option 1 (preferred): bump the unprivileged-port boundary system-wide.
echo 'net.ipv4.ip_unprivileged_port_start=80' | sudo tee /etc/sysctl.d/80-rootless.conf
sudo sysctl --system
# Option 2: grant the capability to rootlesskit (advanced).
sudo setcap cap_net_bind_service=ep $(which rootlesskit)
11. Auto-update¶
For any container labeled io.containers.autoupdate=registry, Podman's podman-auto-update subcommand:
- Resolves the image tag against the registry to get the current digest.
- Compares with the local digest.
- If they differ, pulls, stops the unit, starts the unit (now using the new image).
Quadlet sets the label for you when you use AutoUpdate=registry. To enable the system-wide nightly run:
# Rootful
sudo systemctl enable --now podman-auto-update.timer
# Rootless (per user)
systemctl --user enable --now podman-auto-update.timer
systemctl list-timers podman-auto-update.timer shows the next firing time (typically once a day).
AutoUpdate=local is the alternative: only restart the unit when the local image changes (e.g. after a podman build). Useful for .build-managed images.
Auto-update is a footgun if you don't pin or don't test: a registry-side breaking change will roll out at 03:00. Use it for things that are genuinely backward-compatible (security-patch releases), pin to digests or specific tags for everything else.
12. Operations — the daily loop¶
# Edit a Quadlet file
vim ~/.config/containers/systemd/api.container
# Regenerate the actual systemd unit
systemctl --user daemon-reload
# (Optional but recommended) Verify the generated unit
/usr/libexec/podman/quadlet -dryrun -user 2>&1 | less
# Start / enable
systemctl --user start api.service
systemctl --user enable --now api.service
# Observe
systemctl --user status api.service
journalctl --user -u api.service -f
journalctl --user -u api.service --since "1 hour ago"
# Iterate
vim ~/.config/containers/systemd/api.container
systemctl --user daemon-reload
systemctl --user restart api.service
# Cross-check Podman side
podman ps
podman inspect systemd-api # default name
Two recurring debugging moves:
# 1. "Why didn't my change take effect?" Almost always: forgot daemon-reload.
systemctl --user daemon-reload && systemctl --user restart api.service
# 2. "Unit doesn't exist." The file must end with the right suffix and live in the right dir.
ls -la ~/.config/containers/systemd/
/usr/libexec/podman/quadlet -dryrun -user
journalctl --user -u api.service -b # this boot
13. Worked example — Postgres + API + Caddy on a single RHEL host¶
End-to-end. Rootful Quadlets (because Caddy wants :443), one private network, one persistent volume per stateful container.
Files under /etc/containers/systemd/:
/etc/containers/systemd/
├── webapp.network
├── pgdata.volume
├── caddydata.volume
├── postgres.container
├── api.container
└── caddy.container
webapp.network:
[Unit]
Description=Webapp private network
[Network]
NetworkName=webapp
Subnet=10.89.0.0/24
[Install]
WantedBy=multi-user.target
pgdata.volume:
[Unit]
Description=Postgres data volume
[Volume]
VolumeName=pgdata
Driver=local
[Install]
WantedBy=multi-user.target
caddydata.volume:
[Unit]
Description=Caddy data volume (certs, state)
[Volume]
VolumeName=caddydata
Driver=local
[Install]
WantedBy=multi-user.target
postgres.container:
[Unit]
Description=Postgres database
After=network-online.target webapp-network.service pgdata-volume.service
Requires=webapp-network.service pgdata-volume.service
[Container]
Image=docker.io/library/postgres:16-alpine
ContainerName=postgres
Network=webapp.network
Volume=pgdata.volume:/var/lib/postgresql/data:Z
Environment=POSTGRES_PASSWORD=secret
Environment=POSTGRES_DB=demo
HealthCmd=pg_isready -U postgres -d demo
HealthInterval=10s
HealthRetries=5
AutoUpdate=registry
[Service]
Restart=always
RestartSec=10
TimeoutStartSec=120
[Install]
WantedBy=multi-user.target
api.container:
[Unit]
Description=Demo API
After=network-online.target postgres.service
Requires=postgres.service
[Container]
Image=registry.example.com/demo-app:1.0
ContainerName=api
Network=webapp.network
Environment=DATABASE_URL=postgres://postgres:secret@postgres:5432/demo
Environment=PORT=3000
HealthCmd=wget -qO- http://localhost:3000/healthz || exit 1
HealthInterval=30s
AutoUpdate=registry
[Service]
Restart=always
RestartSec=5
TimeoutStartSec=60
[Install]
WantedBy=multi-user.target
caddy.container:
[Unit]
Description=Caddy reverse proxy
After=network-online.target api.service
Requires=api.service
[Container]
Image=docker.io/library/caddy:2-alpine
ContainerName=caddy
Network=webapp.network
PublishPort=80:80
PublishPort=443:443
Volume=/etc/caddy/Caddyfile:/etc/caddy/Caddyfile:ro,Z
Volume=caddydata.volume:/data:Z
AutoUpdate=registry
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Sidecar — /etc/caddy/Caddyfile:
demo.example.com {
reverse_proxy api:3000
}
Run it:
# 0. Drop files into place; ensure host firewall allows :80 + :443
sudo firewall-cmd --add-service=http --add-service=https --permanent
sudo firewall-cmd --reload
# 1. Regenerate units
sudo systemctl daemon-reload
# 2. Inspect what got generated (optional)
sudo /usr/libexec/podman/quadlet -dryrun 2>&1 | less
# 3. Enable + start the whole stack (the dependency graph pulls in the others)
sudo systemctl enable --now caddy.service
# 4. Verify
systemctl status webapp-network.service pgdata-volume.service \
postgres.service api.service caddy.service
journalctl -u api.service -n 50 --no-pager
podman ps
curl -i https://demo.example.com/healthz
# 5. Iterate on api
sudo vim /etc/containers/systemd/api.container
sudo systemctl daemon-reload
sudo systemctl restart api.service
journalctl -u api.service -f
# 6. Auto-update
sudo systemctl enable --now podman-auto-update.timer
sudo systemctl list-timers podman-auto-update.timer
# Force a one-shot:
sudo podman auto-update --dry-run
sudo podman auto-update
# 7. Decommission
sudo systemctl disable --now caddy.service api.service postgres.service
sudo systemctl disable webapp-network.service pgdata-volume.service caddydata-volume.service
sudo rm /etc/containers/systemd/{webapp.network,pgdata.volume,caddydata.volume,postgres.container,api.container,caddy.container}
sudo systemctl daemon-reload
sudo podman volume rm pgdata caddydata
sudo podman network rm webapp
When that pipeline behaves end-to-end — boot the host, the stack comes up; reboot, it comes back — you have the 80%.
14. What's intentionally out of scope here¶
| Topic | Pointer when you need it |
|---|---|
podman generate systemd |
Deprecated since Podman 4.7. Migrate any hand-edited units to Quadlet. |
| Custom systemd generators | systemd.generator(7). Write one only when Quadlet truly doesn't fit (e.g. generating units from an external API at boot). |
Kubernetes-on-Podman beyond .kube |
A.Task 76 (LearnHelmfile) + 77 (LearnHelm). Real Kubernetes is the answer past single-host. |
| Podman internals | A.Task 82 (LearnPodman). Quadlet wraps Podman; the engine itself is covered there. |
| systemd internals (drop-in dirs, slices) | A.Task 81 (LearnRHEL). Slices, scopes, drop-in .d/ overrides matter once you tune resource limits. |
| Quadlet templates | Not stable; track upstream Podman changelog if you have a many-instances-of-the-same-container pattern. |
| Rootless port-binding < 1024 details | Pointer above (sysctl + setcap options). The deep version: rootlesskit(1), slirp4netns(1), pasta(1). |
.image + private-registry auth |
containers-auth.json(5), AuthFile= directive. Per-Quadlet auth file is the standard pattern. |
| Resource limits | MemoryHigh=, MemoryMax=, CPUQuota= in [Service]. Systemd resource controls apply unchanged. |
15. Reference card — daily commands¶
# Locations
ls -l /etc/containers/systemd/ # rootful Quadlet sources
ls -l ~/.config/containers/systemd/ # rootless Quadlet sources
ls -l /run/systemd/generator/ # generated units (don't edit!)
# Regenerate after editing
sudo systemctl daemon-reload # rootful
systemctl --user daemon-reload # rootless
# Inspect what Quadlet would generate
sudo /usr/libexec/podman/quadlet -dryrun
/usr/libexec/podman/quadlet -dryrun -user
# Lifecycle
sudo systemctl start|stop|restart|enable|disable myapp.service
systemctl --user start|stop|restart|enable|disable myapp.service
# Status + logs
systemctl status myapp.service
journalctl -u myapp.service -f
journalctl -u myapp.service --since "1 hour ago"
journalctl -u myapp.service -p err # errors only
# Cross-check Podman
podman ps
podman inspect systemd-myapp
podman logs systemd-myapp
# Auto-update
sudo systemctl enable --now podman-auto-update.timer
sudo systemctl list-timers podman-auto-update.timer
sudo podman auto-update --dry-run
sudo podman auto-update
# Rootless persistence
loginctl enable-linger $USER # one-time per rootless user
loginctl show-user $USER --property=Linger # confirm
That's the 80%. Go write a .container file and systemctl --user daemon-reload && systemctl --user start <name>.
16. How this connects¶
- Every
[Container]directive in §4's table is a systemd-declared name for a Podman (module 03, Containers) CLI flag —podman runis the thing Quadlet's generator is building on your behalf; see the Podman course's mental model, where a running container's monitor process (conmon) already reports back "to whoever cares — your shell, or systemd via Quadlet." - §2's rule that the generated
.servicefile is throwaway — edit the source,daemon-reload, never hand-edit the output — is the same discipline systemd & journald (module 09, Linux (RHEL)) teaches for any vendor-shipped unit: usesystemctl editfor a drop-in instead of forking the file, because the next package update (or here, the nextdaemon-reload) silently overwrites a hand edit. - The three submodules that follow this overview — The
.containerunit, Pods/networks/volumes as quadlets, and Auto-update & rollout — go one level deeper into §4, §5–§7, and §11 than this page's 80/20 pass; start there for the mechanics this page only sketches.