Run Podman Pods with Quadlet `.pod` Units

Tested on Red Hat Enterprise Linux 10.2 (Coughlan)
Package podman-5.8.2-5.el10_2.x86_64
Applies to Any Linux host with Podman 5.x and systemd
Privilege sudo for rootful examples on the lab host
Scope Quadlet .pod units and how .container files attach with Pod= — multi-container application wiring, StartWithPod=, automatic and manual dependencies, pod-level ports and user namespaces, ExitPolicy, cgroup mode interaction, and the clean-exit restart trap. Assumes you already understand Podman pods and Quadlet basics. Does not cover generic pod concepts, full .container directive reference, or Kubernetes YAML.

You already know a Podman pod groups containers that share a network namespace. Quadlet adds a .pod file type so systemd owns that pod declaratively, and member .container units join through Pod=. This guide builds a complete five-file application — one network, one volume, one pod, and two containers — then walks dependency translation, StartWithPod=, and the restart trap that catches production stacks when every container exits cleanly.


Podman version requirement

.pod Quadlet units need modern Podman 5.x. I tested every command on Podman 5.8.2 on RHEL 10.2. Before copying these examples on an older host, confirm your build actually ships pod Quadlet support:

bash
podman --version

Sample output:

output
podman version 5.8.2

If your package is below Podman 5.0, upgrade or verify podman-system-generator lists .pod among supported unit types after systemctl daemon-reload. The rest of this guide assumes a 5.x generator that understands [Pod] sections.


What is a Quadlet .pod unit?

A .pod file tells Quadlet to create and manage a Podman pod through systemd. The filename drives the service name:

text
app.pod  →  app-pod.service

Unless you override it, the Podman pod itself is named systemd-app:

ini
[Pod]
PodName=quadlet-app

Containers attach by referencing the source file, not the runtime pod name:

ini
[Container]
Pod=app.pod

Quadlet translates Pod=app.pod into --pod quadlet-app on podman run and wires systemd dependencies between web.service and app-pod.service. You get pod lifecycle and service ordering from one declaration instead of hand-maintaining Requires= lines for every member.


Build a complete multi-container Quadlet application

The lab application uses four Quadlet resource types. Network and volume are prerequisites; the pod sits in the middle; containers hang off the pod; the volume mounts into one member:

text
app.network
app.pod ──────┬── web.container
              └── sidecar.container

app-data.volume
      └── web.container

All files live under /etc/containers/systemd/ on a rootful host. The same keys work in rootless Quadlet paths with systemctl --user.


Create the .network file

The pod attaches to a user-defined network declared in its own Quadlet file. Keep network-specific directives in the network unit — this article only needs the name:

ini
[Network]
NetworkName=quadlet-app

Save as app.network. Quadlet generates app-network.service and creates the network before the pod starts. See Create Podman networks when you need subnets, DNS, or driver options.


Create the .volume file

Persistent data for the web member uses a named volume:

ini
[Volume]
VolumeName=quadlet-app-data

Save as app-data.volume. The web container references this file in its Volume= line so Quadlet can infer Requires=app-data-volume.service automatically.


Create the .pod file

The pod owns shared networking and host port publishing:

ini
[Unit]
Description=Application pod

[Pod]
PodName=quadlet-app
Network=app.network
PublishPort=18080:8080

[Service]
Restart=always

[Install]
WantedBy=multi-user.target

A pod Quadlet unit carries networking decisions that belong at the pod layer:

  • Network=app.network attaches the shared namespace to your user-defined network.
  • PublishPort=18080:8080 maps host port 18080 to container port 8080 inside the pod network namespace. The lab image is ubi9/httpd-24, which listens on 8080 — not port 80.
  • Containers that join this pod should not publish the same ports independently.

[Service] Restart=always is intentional. The default generated pod service uses Restart=on-failure, which interacts badly with ExitPolicy=stop — I reproduce that trap in a dedicated section below.


Attach a .container to the pod with Pod=

The web member mounts the volume and joins the pod:

ini
[Unit]
Description=Web container in app pod

[Container]
Image=registry.access.redhat.com/ubi9/httpd-24
ContainerName=quadlet-web
Pod=app.pod
Volume=app-data.volume:/var/lib/app

[Service]
Restart=on-failure

Save as web.container. Pod=app.pod does more than append --pod to podman run. Quadlet also emits BindsTo=app-pod.service and After=app-pod.service on the generated web.service, and Requires=app-data-volume.service from the Volume= reference.

After you add or change any Quadlet file, reload systemd so the generator picks up new units:

bash
sudo systemctl daemon-reload

daemon-reload exits silently when it succeeds.


Add a second container and start the stack

A sidecar member shares the pod network without its own volume:

ini
[Unit]
Description=Sidecar container in app pod

[Container]
Image=registry.access.redhat.com/ubi9/ubi-minimal
ContainerName=quadlet-sidecar
Pod=app.pod
Exec=sleep 3600

[Service]
Restart=on-failure

Save as sidecar.container, reload systemd again, then start the pod service. Starting app-pod.service pulls up the pod infra container and every member with default StartWithPod=true:

bash
sudo systemctl daemon-reload

Bring the application pod online:

bash
sudo systemctl start app-pod.service

Confirm the pod is running:

bash
sudo podman pod ps --filter name=quadlet-app

Sample output:

output
NAME         STATUS      CREATED
quadlet-app  Running     41 seconds ago

List members and shared port mapping:

bash
sudo podman ps --pod --filter pod=quadlet-app

Sample output:

output
NAMES              PODNAME      STATUS         PORTS
quadlet-app-infra  quadlet-app  Up 45 seconds  0.0.0.0:18080->8080/tcp
quadlet-sidecar    quadlet-app  Up 44 seconds  0.0.0.0:18080->8080/tcp
quadlet-web        quadlet-app  Up 44 seconds  0.0.0.0:18080->8080/tcp, 8443/tcp

Every member shows the pod-published port because they share one network namespace. A host connection to the mapped port reaches the web listener:

bash
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18080/

Sample output:

output
403

HTTP 403 means the connection reached httpd inside the pod. An empty reply or connection refused would mean the port map or listener is wrong.


Understand StartWithPod=

When a .container file sets Pod=app.pod, Quadlet defaults StartWithPod=true. With that default:

  • the container starts when the pod service starts
  • the container stops when the pod stops
  • the container restarts when the pod restarts

You normally omit the key because true is already implied.

StartWithPod=false

Set StartWithPod=false when a member should stay out of the automatic pod start but still belong to the pod when you start it yourself:

ini
[Container]
Image=registry.access.redhat.com/ubi9/ubi-minimal
ContainerName=quadlet-maint
Pod=app.pod
StartWithPod=false
Exec=sleep 3600

Save as maint.container. After daemon-reload, only the web and sidecar members came up when I started app-pod.servicequadlet-maint was absent from podman ps. Starting its own service joined it to the running pod:

bash
sudo systemctl start maint.service

Sample output from podman ps --filter name=quadlet-maint:

output
quadlet-maint Up 1 second

Typical uses:

  • maintenance worker you trigger on demand
  • debug companion during an incident
  • optional sidecar that should not block pod start

The container is still stopped with the pod. If it was running before a pod restart, it is restarted with the pod; if it was already stopped, the pod restart does not start it.


The StartWithPod= dependency is usually better than hand-written ordering

If Pod=app.pod is already set, Quadlet understands the pod-to-container relationship. You do not need duplicate Requires=app-pod.service lines on every member — the generator emits BindsTo and After for you.

Reserve manual [Unit] dependencies for relationships Quadlet cannot infer — for example, an application container that must start after a separate database container unit even though they are not in the same pod.


Add application dependencies with After= and Requires=

When one Quadlet unit must wait for another, reference the source filename in [Unit]:

ini
[Unit]
Requires=database.container
After=database.container

The difference matters:

  • Requires= — start and availability coupling. If database.container fails to start, dependent units fail too.
  • After= — ordering only. The dependent unit starts later but does not require the other unit to be active.

Current Quadlet translates dependencies that reference other Quadlet source names such as database.container or app.pod into the generated service names. Use those source-unit references rather than guessing runtime names like systemd-database.


Automatic dependencies from resource references

Quadlet infers systemd dependencies when a [Container] or [Pod] key references another Quadlet file by suffix:

ini
Pod=app.pod
Volume=app-data.volume:/var/lib/app
Network=app.network

Referencing app-data.volume is better than hard-coding systemd-app-data in hand-written Requires= lines — the generator maps the source name to the correct service and keeps dependencies aligned when you rename files.

The generated web.service on the lab host shows the result:

bash
systemctl cat web.service

Relevant excerpt:

output
Requires=app-data-volume.service
After=app-data-volume.service
BindsTo=app-pod.service
After=app-pod.service
ExecStart=/usr/bin/podman run ... --pod quadlet-app ...

Image=app.image would similarly pull in an .image unit when you build images declaratively. The pattern is the same: reference the Quadlet source file, not the runtime object name.


Pod port publishing

Host port maps belong on the pod when members share its network namespace:

ini
[Pod]
PublishPort=18080:8080

Traffic hits the shared namespace; any member with a listener on 8080 can answer. Do not repeat PublishPort= on .container files that set Pod=app.pod — you would be fighting the pod layer and duplicating maps. See Podman pod networking for localhost routing, infra containers, and DNS inside pods.


Pod-level user namespace

User namespace mode can be set once on the pod:

ini
[Pod]
UserNS=keep-id

Containers that join inherit the pod user namespace configuration. Do not set conflicting per-container UserNS= values on members after they join — pick pod-level or container-level deliberately. Detailed ID mapping belongs in Podman user namespaces.


Pod exit policy

Quadlet .pod units default to:

ini
[Pod]
ExitPolicy=stop

When the last regular container in the pod stops, the pod stops and the app-pod.service unit can exit. That is usually what you want for batch-style pods. The generated app-pod.service shows the flag on podman pod create:

bash
systemctl cat app-pod.service

Relevant excerpt:

output
ExecStartPre=/usr/bin/podman pod create ... --exit-policy stop ... --publish 18080:8080 --network quadlet-app --name quadlet-app

ExitPolicy=continue keeps the pod alive after application containers exit — useful when an infra holder or long-lived sidecar should outlast short jobs. General pod exit semantics are covered in Podman pods; here the focus is how that policy interacts with systemd restart behavior.


The silent pod restart trap

This is the production gotcha most Quadlet pod guides skip.

Quadlet generates pod services with:

ini
[Service]
Restart=on-failure

Combined with default ExitPolicy=stop, a normal application shutdown looks like success to systemd:

text
all app containers exit
pod stops with exit code 0
systemd records Result=success
Restart=on-failure does not fire
pod stays dead until manual intervention

I reproduced it with a one-shot quay.io/podman/hello container in trap.pod:

ini
[Unit]
Description=Restart trap demo pod

[Pod]
PodName=trap-pod
ExitPolicy=stop

Member trap-web.container:

ini
[Container]
Image=quay.io/podman/hello
ContainerName=trap-web
Pod=trap.pod

After daemon-reload, start the trap pod and inspect systemd state:

bash
sudo systemctl start trap-pod.service

Wait a few seconds for hello to print and exit, then read the service properties:

bash
systemctl show trap-pod.service -p ActiveState,SubState,Result,NRestarts,Restart --no-pager

Sample output with defaults:

output
Restart=on-failure
Result=success
NRestarts=0
ActiveState=inactive
SubState=dead

The pod stopped cleanly and systemd did not restart it — exactly the silent failure mode for a workload that should have come back.

Fix with Restart=always

Quadlet supports overriding the generated restart policy directly in the .pod source:

ini
[Service]
Restart=always
RestartSec=3

Add that block to trap.pod, then reload and verify the effective value:

bash
sudo systemctl daemon-reload
bash
systemctl show trap-pod.service -p Restart --no-pager

If it still shows on-failure, troubleshoot the generated unit or stale drop-in configuration before adding another override. Check systemctl cat trap-pod.service and any files under /etc/systemd/system/trap-pod.service.d/.

On one lab run, the expected override did not appear until the generated configuration was rechecked; always verify the effective systemd property.

When Restart=always is in effect, start the trap pod again:

bash
sudo systemctl reset-failed trap-pod.service
sudo systemctl start trap-pod.service

After hello exits, systemd restarts the pod. With a one-shot image, restarts eventually hit the start limit:

bash
systemctl show trap-pod.service -p ActiveState,NRestarts,Result --no-pager

Sample output:

output
NRestarts=2
Result=start-limit-hit
ActiveState=failed

NRestarts=2 proves systemd retried — unlike the default where NRestarts=0 and the unit stayed inactive (dead). For real long-running images, Restart=always on the pod service keeps the stack up without hitting the limit.

Set Restart=always on the .pod unit for always-on applications. Keep Restart=on-failure only when a clean permanent stop after success is what you intend.


Resource limits and CgroupsMode

Quadlet .container units default to CgroupsMode=split, which places conmon and the payload in separate cgroups — good for per-container systemd services. When several containers join one pod and you need pod-level resource limits, consider aligning cgroup mode across members:

  • CgroupsMode=enabled — traditional unified cgroup for the container payload
  • CgroupsMode=no-conmon — keeps conmon in the systemd unit cgroup

Match modes across pod members when applying pod-scoped limits. This is not a full cgroup v2 tutorial — tune based on whether systemd, the pod, or individual containers should own the hierarchy.


Inspect generated dependencies

systemctl list-dependencies makes automatic wiring visible:

bash
systemctl list-dependencies app-pod.service --plain

Sample output:

output
app-pod.service
  app-network.service
  sidecar.service
  web.service

The pod unit sits above the network and member container services. Compare with the generated pod unit ordering:

bash
systemctl cat app-pod.service

Relevant excerpt:

output
Requires=app-network.service
After=app-network.service
Wants=sidecar.service
Before=sidecar.service
Wants=web.service
Before=web.service

Wants=/Before= on members reflects StartWithPod=true — the pod service orchestrates its children.


Startup ordering vs application readiness

After=database.container only guarantees systemd starts units in order. It does not prove PostgreSQL accepts connections, migrations finished, or an HTTP health endpoint returns 200.

For readiness, use:

  • container HealthCmd= / Notify= directives from Podman Quadlet container file
  • application retry logic on first connect
  • Type=notify services when the app supports sdnotify

Do not paper over readiness with ExecStartPre=/bin/sleep 10 — that only adds latency and still races on slow boots.


Troubleshooting

Symptom Likely cause Fix
Pod service inactive (dead) after containers exit ExitPolicy=stop plus Restart=on-failure on the generated pod service Set Restart=always on the .pod unit; verify with systemctl show -p Restart
Host port connection refused PublishPort= on a .container instead of the .pod Move port maps to [Pod]; confirm listener port inside the image
Container not running after app-pod.service start StartWithPod=false on that member Start the container service manually or set StartWithPod=true
systemctl start web.service fails with pod errors app.pod missing, failed to generate, or pod creation itself failed Check systemctl status app-pod.service, journalctl -u app-pod.service, and verify Pod=app.pod references an existing Quadlet file
Generated unit missing volume dependency Volume= uses a raw path instead of name.volume: syntax Reference app-data.volume:/mount so Quadlet can infer app-data-volume.service

References


Summary

Quadlet .pod files turn a Podman pod into a first-class systemd service — app.pod becomes app-pod.service, and members declare Pod=app.pod instead of repeating runtime pod names. Wiring app.network, app-data.volume, app.pod, web.container, and sidecar.container gives you a complete multi-container application where the pod owns the shared network, published ports, and exit policy while containers inherit automatic BindsTo and volume dependencies.

StartWithPod=true is the default and keeps members aligned with pod start, stop, and restart. Set StartWithPod=false for optional companions you will start manually. When you need ordering beyond what resource references infer, use Requires= and After= with other Quadlet source filenames — not guessed systemd-* runtime names.

The trap to remember is restart semantics: default ExitPolicy=stop lets a pod exit successfully when application containers finish, and Restart=on-failure on the generated pod service will not bring it back. Set Restart=always on always-on pod workloads and confirm the generated unit picked it up with systemctl show -p Restart. Port maps and UserNS= belong on the pod layer for joined containers. Check systemctl list-dependencies and systemctl cat when you want to see how Quadlet translated your declarations into real unit files.


Frequently Asked Questions

1. What systemd service does app.pod generate?

Quadlet turns app.pod into app-pod.service. The Podman pod name defaults to systemd-app unless you set PodName= in the [Pod] section.

2. Where do I publish ports for containers that join a Quadlet pod?

Put PublishPort= on the .pod file. Member containers share the pod network namespace, so per-container PublishPort= on joined units is the wrong layer and can fight the pod definition.

3. What does Pod=app.pod do in a container Quadlet file?

Quadlet passes --pod to podman run and generates systemd ordering and binding between the container service and app-pod.service. It is more than a CLI flag wrapper.

4. Why does my Quadlet pod stay stopped after a container exits cleanly?

Quadlet pod units default to ExitPolicy=stop and the generated service often uses Restart=on-failure. A clean exit code 0 is success to systemd, so on-failure does not restart the pod. Set Restart=always on the pod service and verify the generated unit actually picked it up.

5. When should I set StartWithPod=false?

Use it for optional companions such as maintenance workers or debug sidecars that should not start automatically when the pod service starts but still belong to the pod when you start them manually.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)