Skip to content

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 systemd flow, 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 systemd with declarative source files — .container, .volume, .network, .pod, .kube — that systemd's generator turns into unit files at boot and on every daemon-reload; you author sources, never edit the generated units.
  • daemon-reload is the regenerate step — after any edit to a Quadlet file you must run systemctl daemon-reload for 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 with systemctl vs systemctl --user (rootless needs loginctl enable-linger to 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:

  1. You author sources, not units. .container, .volume, .network, etc. are the source format. The generated .service files live in /run/... and are throwaway — never check them into git, never edit them by hand.
  2. systemctl daemon-reload is the regenerate step. After any edit to a Quadlet file, you must daemon-reload for systemd to re-run the generator. Forgetting this is the #1 "why didn't my change take effect" cause.
  3. 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 with systemctl; rootless with systemctl --user (and need loginctl 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's Restart=no (a crashed container stays down) — add Restart=always or Restart=on-failure yourself.
  • 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. nginx reaches app via localhost:3000.
  • Ports are published on the pod, not the containers.
  • systemctl stop webapp-pod.service stops every member container. systemctl start brings 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:

  1. Resolves the image tag against the registry to get the current digest.
  2. Compares with the local digest.
  3. 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 run is 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 .service file 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: use systemctl edit for a drop-in instead of forking the file, because the next package update (or here, the next daemon-reload) silently overwrites a hand edit.
  • The three submodules that follow this overview — The .container unit, 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.