Podman Quadlet: Run Containers as systemd Services on RHEL
Published On: 3 April 2026
Objective
If you're managing containers in production and you're still writing podman run commands in shell scripts wrapped in a hand-crafted systemd unit, you've probably felt the friction. The unit file grows, the restart logic gets complicated, dependencies between containers are messy to express, and half the time something subtle breaks after a reboot and you spend an hour figuring out why a container didn't come back up in the right order.
Podman Quadlet fixes this. It landed in Podman 4.4 and ships natively in RHEL 9.1 and RHEL 10. The idea is simple: you write a small, declarative file describing your container - image, ports, volumes, environment, restart policy - drop it in the right directory, and systemd generates a proper service unit automatically. From that point on, you use systemctl and journalctl like you would for any other service. No new tools to learn. No daemon running in the background. No Compose wrapper sitting between you and the OS.
This guide covers the whole thing: how Quadlet works, all six unit file types, real-world examples including rootless containers, multi-container pods, secret management, and what you need to know for production.
What Quadlet Actually Is ?
Quadlet is a systemd generator. That's a specific thing in the systemd world - a program that runs early in the boot process and produces unit files on the fly before systemd starts bringing services up. When you place a .container file (or .pod, .volume, .network, .image, or .kube) in the right directory, systemd finds it, calls the Quadlet generator, and the generator spits out a complete .service unit. Systemd then manages that service like anything else.
"One thing worth being clear about: Quadlet doesn't replace Podman." Podman still does the actual container work. Quadlet just bridges Podman and systemd, so systemd handles lifecycle, dependency ordering, logging, and restart behavior. If you know systemd, you already know how to manage a Quadlet-based container.
How It Compares to the Alternatives
| Feature | Docker Compose | Systemd Unit (manual) | Podman Quadlet |
|---|---|---|---|
| Rootless support | Limited | Complex | Native |
| Systemd integration | External wrapper | Manual | Native, automatic |
| Auto-start on boot | Requires extras | Manual setup | Built-in |
| Dependency ordering | Limited | Manual | Declarative |
| Secret management | Env vars | Manual | Podman secrets |
| Healthcheck + restart | Yes | Manual ExecStart | Built-in |
| Pod grouping | via Compose | Complex | Native .pod files |
| Daemon required | Yes (dockerd) | No | No |
| RHEL support | Extra install | Built-in | RHEL 9.1+ / 10 |
The old podman generate systemd approach generated static unit files from running containers. It worked, but it was fragile - you'd generate the file once, the container config would drift, and the unit file wouldn't reflect reality anymore. Quadlet regenerates the service definition fresh at every boot from your source file. It's declarative rather than imperative, which is how it should work.
Version and Availability
Quadlet is included in the podman package starting with version 4.4. RHEL 9.1 ships it, RHEL 10 ships it. Nothing extra to install. Verify you're on a recent enough version:
podman -version
# Quadlet requires Podman 4.4 or later
RHEL 8 doesn't have it. If you're still on RHEL 8 and need this functionality, podman generate systemd is the fallback - but the real answer is upgrading to 9.1 or later.
The Six Unit File Types
| Extension | What It Does | Typical Use |
|---|---|---|
.container |
Run a single container | Web server, database, app service |
.pod |
Group containers into a pod | Multi-container apps sharing a network |
.volume |
Declare a named Podman volume | Persistent data storage |
.network |
Declare a Podman network | Custom container networking |
.image |
Pull and manage an image | Pre-pulling images at boot |
.kube |
Deploy a Kubernetes YAML manifest | Migrating from Kubernetes |
Where to Put Your Files
For system-wide (root) containers:
/etc/containers/systemd/
# or for vendor/package-provided units
/usr/share/containers/systemd/
For rootless (per-user) containers:
~/.config/containers/systemd/
Rootless is the recommended approach on RHEL for anything that doesn't need elevated privileges. Rootless Quadlet services run as your user and are managed with systemctl -user. They start when you log in - or at boot if you enable lingering, which you almost certainly want for server workloads:
loginctl enable-linger $USER
Your First Quadlet Container: Nginx
Here's a complete working example. We'll run Nginx as a rootless systemd service.
Step 1: Create the unit file
mkdir -p ~/.config/containers/systemd/
vi ~/.config/containers/systemd/nginx.container
[Unit]
Description=Nginx Web Server
After=network-online.target
[Container]
Image=docker.io/library/nginx:latest
PublishPort=8080:80
Volume=%h/html:/usr/share/nginx/html:Z
Environment=NGINX_HOST=localhost
ContainerName=nginx-web
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
Step 2: Create the web root
mkdir -p ~/html
echo '<h1>Hello from Podman Quadlet!</h1>' > ~/html/index.html
Step 3: Reload systemd and start
systemctl -user daemon-reload
systemctl -user start nginx.service
Step 4: Verify and enable at boot
systemctl -user status nginx.service
systemctl -user enable nginx.service
# Test it
curl http://localhost:8080
Notice you never wrote a .service file. Quadlet named the generated service after your unit file - nginx.container becomes nginx.service. That's how it works every time.
The [Container] Section: Options Worth Knowing
Image and pull policy
[Container]
Image=registry.access.redhat.com/ubi9/ubi:latest
# missing (default), always, never, newer
Pull=newer
Networking
PublishPort=443:443
PublishPort=80:80
# Attach to a named network defined in a .network file
Network=myapp.network
Volumes
# Host directory mount - always use :Z on RHEL for SELinux
Volume=/data/app:/app/data:Z
# Named volume from a .volume Quadlet file
Volume=app-data.volume:/var/lib/app:Z
Environment and secrets
Environment=APP_ENV=production
EnvironmentFile=/etc/myapp/env
Secret=db-password,type=env,target=DB_PASSWORD
Security and resource limits
User=1001
NoNewPrivileges=true
ReadOnly=true
PodmanArgs=-memory=512m -cpus=1.0
Health checks
HealthCmd=CMD-SHELL curl -f http://localhost/ || exit 1
HealthInterval=30s
HealthRetries=3
HealthStartPeriod=10s
Persistent Storage with .volume Files
Rather than bind-mounting host directories, define named volumes as their own Quadlet unit. It's cleaner, more portable, and Quadlet handles the dependency ordering automatically - you don't need to add After= or Requires= to wire them together.
# ~/.config/containers/systemd/app-data.volume
[Volume]
Label=app=myapp
Label=env=production
Reference it in your container file:
Volume=app-data.volume:/var/lib/myapp:Z
Custom Networking with .network Files
If you have containers that need to talk to each other without exposing ports to the host, define a shared network. Containers on the same Quadlet network can reach each other by container name, the same way Docker Compose service names work.
# ~/.config/containers/systemd/myapp.network
[Network]
Subnet=10.89.1.0/24
Gateway=10.89.1.1
Label=project=myapp
Then in each container that should share this network:
Network=myapp.network
Multi-Container Pods with .pod Files
A Podman pod groups containers that share a network namespace - same idea as a Kubernetes Pod. Use this when containers genuinely need to communicate over localhost, or when you want them treated as a single deployable unit.
Define the pod
# ~/.config/containers/systemd/webapp.pod
[Pod]
PublishPort=8080:80
App container
# ~/.config/containers/systemd/webapp-app.container
[Unit]
Description=Web Application
[Container]
Image=registry.example.com/myapp:latest
Pod=webapp.pod
Volume=app-data.volume:/app/data:Z
[Service]
Restart=always
[Install]
WantedBy=default.target
Redis sidecar
# ~/.config/containers/systemd/webapp-redis.container
[Unit]
Description=Redis Cache for Web Application
[Container]
Image=docker.io/library/redis:7-alpine
Pod=webapp.pod
[Service]
Restart=always
[Install]
WantedBy=default.target
Because they share the pod's network namespace, the app container reaches Redis at localhost:6379. Quadlet creates pod-webapp.service automatically and makes both containers depend on it - no manual wiring needed.
Deploying Kubernetes YAML with .kube Files
Already have Kubernetes manifests? The .kube file type lets you deploy them directly with Podman, no cluster required. This is useful for teams migrating workloads from OpenShift or Kubernetes to standalone RHEL servers, and for developers who want to run against a real manifest locally without spinning up a cluster.
# ~/.config/containers/systemd/myapp.kube
[Kube]
Yaml=/etc/myapp/deployment.yaml
[Service]
Restart=always
[Install]
WantedBy=default.target
Secret Management: Do It Right
Don't put passwords or API keys in your unit files. Podman has a secret store built in - use it.
Create secrets
# From a string
echo -n 'mysecretpassword' | podman secret create db-password -
# From a file
podman secret create tls-cert /path/to/cert.pem
# List what's stored
podman secret ls
Reference in a .container file
[Container]
Image=docker.io/library/postgres:16
# Mount as a file at /run/secrets/db-password
Secret=db-password
# Or inject as an environment variable
Secret=db-password,type=env,target=POSTGRES_PASSWORD
Secrets stored this way never show up in the unit file, the process list, or podman inspect output as plaintext. That's the point.
Day-to-Day systemd Commands
Always reload after editing unit files
systemctl -user daemon-reload
Start, stop, restart
systemctl -user start nginx.service
systemctl -user stop nginx.service
systemctl -user restart nginx.service
View logs
# Live follow
journalctl -user -u nginx.service -f
# Last hour
journalctl -user -u nginx.service -since '1 hour ago'
Inspect what Quadlet actually generated
systemctl -user cat nginx.service
Debug Quadlet generation without applying it
# For system units
/usr/lib/systemd/system-generators/podman-system-generator -dryrun
# For user units
QUADLET_UNIT_DIRS=~/.config/containers/systemd /usr/lib/systemd/user-generators/podman-user-generator -dryrun
SELinux and Volume Mounts on RHEL
RHEL runs SELinux in enforcing mode. If you mount a host directory into a container without the right SELinux label, access gets blocked - and the error messages aren't always obvious about why. The fix is simple: always add :Z or :z to your volume mounts.
# :Z - private label, for a single container
Volume=/home/user/data:/app/data:Z
# :z - shared label, for multiple containers sharing the same volume
Volume=/shared/data:/app/data:z
If something still isn't working, check for denials and fix the context:
sudo ausearch -m avc -ts recent | grep podman
sudo restorecon -Rv /path/to/volume
Firewall Configuration
Publishing a port in your Quadlet file doesn't automatically open it in firewalld. Do that separately:
sudo firewall-cmd -permanent -add-port=8080/tcp
sudo firewall-cmd -reload
For rootless containers, stick to ports above 1024 unless you've adjusted net.ipv4.ip_unprivileged_port_start. It's less hassle than fighting the kernel over privileged port binding.
Automatic Image Updates
Quadlet integrates with podman auto-update. Add a label to your container and the auto-update timer handles the rest:
[Container]
Image=docker.io/library/nginx:latest
Label=io.containers.autoupdate=registry
systemctl -user enable -now podman-auto-update.timer
The timer runs daily by default, checks the registry for newer images, pulls them, and restarts affected containers. Good for keeping non-critical services patched without manual work. For production services where you want control over what version runs, pin the image tag explicitly and don't use this.
Troubleshooting
Service won't start after daemon-reload
systemctl -user status nginx.service
journalctl -user -u nginx.service -n 50
Quadlet file isn't being picked up
Check the file extension and directory, then reload:
ls -la ~/.config/containers/systemd/
systemctl -user daemon-reload
Volume permission denied
Almost always SELinux. Add :Z to the mount and make sure the directory exists with the right ownership:
chmod 755 ~/mydata
# Then in your unit file:
# Volume=%h/mydata:/data:Z
Container starts then exits immediately
journalctl -user -u mycontainer.service -no-pager
podman logs mycontainer
Port already in use
ss -tlnp | grep 8080
Production Checklist
- Run rootlessdedicated non-root user per service, least privilege by default
- Pin image versions
image:1.25.3notimage:latestin production; surprises are bad - Use Podman secrets no passwords in unit files or environment variables in plaintext
- Always add :Z to volume mounts saves a lot of SELinux debugging later
- Set Restart=always with RestartSec containers need to come back after transient failures
- Add health checks gives systemd real signal on whether the container is actually working
- Use .network files isolate container communication, don't leave everything on the default bridge
- Enable lingering rootless services need
loginctl enable-lingerto survive reboots without an interactive session - Review generated unit files
systemctl -user catshows exactly what Quadlet produced; audit it periodically - Enable auto-update selectively useful for non-critical services, risky for anything where the image version matters
Conclusion
Quadlet is one of those features that seems small until you've actually used it. Writing a 20-line .container file and getting a fully managed systemd service out of it - with proper restart behavior, logging via journald, dependency ordering, and SELinux integration - is a genuinely better experience than any of the alternatives that came before it. If you're still writing podman generate systemd output into hand-maintained unit files, stop. If you're wrapping Compose in a systemd unit, stop. Quadlet handles both cases more cleanly, it's the supported path on RHEL going forward, and it plays properly with the rest of the OS in a way that bolt-on solutions never quite did. Drop your .container files in the right directory, run daemon-reload, and let systemd take it from there.