Quadlets manage Podman containers with Systemd by creating unit files for the containers
It is declarative similar to Docker Compose.
Resource:
- https://github.com/dwedia/podmanQuadlets
- https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html
Create the directories:
mkdir -p ~/.config/containers/systemd/
Create or modify a .container file:
nano ~/.config/containers/systemd/myapp.container
Reload systemd:
systemctl --user daemon-reload
Create any peristent storage needed:
mkdir -p /path/to/storage/containerfolder
Start the container:
systemctl --user start myapp.service
Resource: https://blog.nerdon.eu/podman-quadlet-getting-started/
These account for most of the time lost to Quadlet.
A systemd generator turns each Quadlet file into a .service. You always act on the
generated service name, never on the source file name:
| Quadlet file | Generated unit |
|---|---|
myapp.container |
myapp.service |
myapp.kube |
myapp.service |
foo.pod |
foo-pod.service |
app.network |
app-network.service |
data.volume |
data-volume.service |
So systemctl --user start myapp.container fails — it has to be myapp.service.
Note the .pod case gains a -pod suffix.
The units are generated at runtime, so there is no file for systemd to symlink:
$ systemctl --user enable myapp.service
Failed to enable unit: Unit /run/user/1000/systemd/generator/myapp.service is transient or generatedTo start a Quadlet at boot, put it in the Quadlet file itself and reload:
[Install]
WantedBy=default.targetsystemctl --user daemon-reload
systemctl --user start myapp.serviceFor a rootless (--user) unit the target is default.target. multi-user.target
does not exist in the user manager — use it only for system-wide Quadlets in
/etc/containers/systemd/.
Also enable linger, or the units stop when you log out:
loginctl enable-linger "$USER"Only whole-line comments are valid. This silently breaks the value:
# WRONG — the comment becomes part of the value and systemd cannot parse it
TimeoutStartSec=900 # Systemd standard timeout is 90s, often too little to pull an image
# RIGHT
# Systemd standard timeout is 90s, which can be too little to pull the image.
TimeoutStartSec=900Quadlet does not catch this — it copies the line verbatim into the generated unit, where systemd rejects it at load time.
The same applies to values containing spaces, which need quoting:
# WRONG — generates: --env Angeles --env TZ=America/Los
Environment=TZ=America/Los Angeles
# RIGHT (and the IANA zone name uses an underscore)
Environment=TZ=America/Los_AngelesRun the generator by hand. It prints every unit it would produce and names the file and key for anything it rejects:
# rootless
/usr/libexec/podman/quadlet -dryrun -user
# system-wide
sudo /usr/libexec/podman/quadlet -dryrunPodman 5.6+ also has a quadlet subcommand:
podman quadlet list # what Quadlets exist and their status
podman quadlet print myapp.containerA key Quadlet does not recognise is a hard failure — the whole unit is skipped and no service appears, which looks identical to the file not being read at all:
converting "db.container": unsupported key 'Name' in group 'Container'
[Unit]
Description=Quick, one line description
# List of targets to start before
# Before=
# List of targets to start after
After=local-fs.target
Wants=network-online.target
After=network-online.target
[Container]
Image=registry.access.redhat.com/ubi9/ubi
ContainerName=TestContainer1
Environment=DATABASE_USER=dbsuer
Timezone=local
Volume=/path/to/storage/containerfolder:/config:rw,Z
PublishPort=8088:8080/tcp
[Service]
# Systemd standard timeout is 90s, which can be too little to pull the image.
TimeoutStartSec=900
Restart=always
[Install]
WantedBy=default.targetNote PublishPort= — not Port=, which Quadlet rejects. And ContainerName= —
not Name=.
Network=host was removed from this example: with host networking the container
shares the host's network stack, so PublishPort= has nothing to do and the two
keys contradict each other. Use one or the other.
oxitraffic.container
[Container]
Image=docker.io/mo8it/oxitraffic:latest
AutoUpdate=registry
Network=traefik.network
Network=oxitraffic.network
Volume=%h/volumes/oxitraffic/config.toml:/volumes/config.toml:Z,ro
Volume=%h/volumes/oxitraffic/logs:/var/log/oxitraffic:Z
[Unit]
Requires=oxitraffic-db.service
After=oxitraffic-db.service
[Service]
Restart=always
[Install]
WantedBy=default.targetoxitraffic-db.container
[Container]
Image=docker.io/library/postgres:16
AutoUpdate=registry
Network=oxitraffic.network
Volume=%h/volumes/oxitraffic/db:/var/lib/postgresql/data:Z
EnvironmentFile=%h/volumes/oxitraffic/.postgres.env
Environment=TZ=America/Los_Angeles
Environment=PGTZ=America/Los_Angeles
# Password belongs in the EnvironmentFile above, or better, a Podman secret:
# Secret=postgres_password,type=env,target=POSTGRES_PASSWORD
[Service]
Restart=always
[Install]
WantedBy=default.target%h expands to the user's home directory. Other useful specifiers: %t runtime
directory, %n unit name.
oxitraffic.network
[Network]An empty [Network] section is valid — it creates a network with defaults named
after the file.
traefik.container
[Container]
Image=docker.io/library/traefik:latest
AutoUpdate=registry
Network=traefik.network
PublishPort=8000:80
PublishPort=4430:443
Volume=%h/sync/volumes/traefik:/etc/traefik:Z,ro
Volume=%h/volumes/traefik/logs:/volumes/logs:Z
Volume=%h/volumes/traefik/certs:/volumes/certs:Z
[Service]
Restart=always
[Install]
WantedBy=default.targetRootless containers cannot bind ports below 1024 by default, which is why this maps 80 to 8000. To allow the low ports instead:
sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80
# persist it
echo 'net.ipv4.ip_unprivileged_port_start=80' | sudo tee /etc/sysctl.d/99-rootless-ports.conftraefik.network
[Network]Source: https://blog.while-true-do.io/podman-quadlets/
This one is system-wide — the files go in /etc/containers/systemd/ and the
commands take no --user. That is why multi-user.target is correct here.
wordpress.network
[Unit]
Description=WordPress Container Network
[Network]
Label=app=wordpresswordpress-app.volume
[Unit]
Description=WordPress Container Volume
[Volume]
Label=app=wordpresswordpress-db.volume
[Unit]
Description=WordPress Database Container Volume
[Volume]
Label=app=wordpresswordpress-db.container
[Unit]
Description=WordPress Database Container
[Container]
Label=app=wordpress
ContainerName=wordpress-db
Image=docker.io/library/mariadb:10
Network=wordpress.network
Volume=wordpress-db.volume:/var/lib/mysql
Environment=MARIADB_RANDOM_ROOT_PASSWORD=1
Environment=MARIADB_USER=wordpress
Environment=MARIADB_DATABASE=wordpress
# This one should be a secret — see the Secrets section
Secret=wordpress_db_password,type=env,target=MARIADB_PASSWORD
[Install]
WantedBy=multi-user.targetwordpress-app.container
[Unit]
Description=Wordpress App Container
Requires=wordpress-db.service
After=wordpress-db.service
[Container]
Label=app=wordpress
ContainerName=wordpress-app
Image=docker.io/library/wordpress:6
Network=wordpress.network
Volume=wordpress-app.volume:/var/www/html
Environment=WORDPRESS_DB_HOST=wordpress-db
Environment=WORDPRESS_DB_USER=wordpress
Environment=WORDPRESS_DB_NAME=wordpress
# This one should be a secret — see the Secrets section
Secret=wordpress_db_password,type=env,target=WORDPRESS_DB_PASSWORD
PublishPort=8080:80
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
# See the generated unit
systemctl cat wordpress-app.service
sudo systemctl start wordpress-app.servicehttps://github.com/containers/podlet
- Podlet generates Podman Quadlet files from a Podman command, compose file, or existing object
Create the secret, then reference it by name. Keep the two commands separate:
echo "secretdata" > secretfile
podman secret create secretname secretfile
rm secretfile # the secret is stored now; delete the sourceThen in ~/.config/containers/systemd/test-secret.container:
[Container]
Image=docker.io/library/debian:12-slim
Secret=secretname,type=env,target=SECRET_ENV
Exec=bash -c 'echo $SECRET_ENV'Start it with systemctl --user start test-secret.service, then
systemctl --user status test-secret.service shows secretdata in the output,
confirming the environment variable was injected.
Mount as a file instead of an env var when the app can read a path — env vars are
visible in podman inspect and in the process environment:
Secret=secretname,type=mount,target=/run/secrets/secretnamePodman secrets are not encrypted under the default file driver — they are
base64-encoded in ~/.local/share/containers/storage/secrets/. That is better than
a password in a git-tracked unit file, but it is not encryption at rest. For real
encryption use the pass or shell driver, or an external manager.
[Container]
Image=docker.io/library/nginx:latest
AutoUpdate=registryThen enable the timer once per user:
systemctl --user enable --now podman-auto-update.timerAutoUpdate=registry checks the registry for a newer digest on the same tag.
AutoUpdate=local only uses images already pulled. Note that podman auto-update
rolls back automatically if the new image fails to start.
Pull=newer on the Image= line is the other option — it checks for a newer image
every time the unit starts, rather than on a timer.
# Why did my unit not appear at all?
/usr/libexec/podman/quadlet -dryrun -user
# What did the generator actually produce?
systemctl --user cat myapp.service
# Why did it fail to start?
journalctl --user -u myapp.service -n 50 --no-pager| Symptom | Cause |
|---|---|
Unit myapp.service not found |
Generator rejected the file — run the dry-run |
is transient or generated |
You tried to enable it; use [Install] instead |
unsupported key 'X' in group 'Y' |
Wrong key name; see the table below |
| Unit starts, container exits instantly | Check Exec=, and journalctl for the container's own output |
| Works until logout | loginctl enable-linger $USER |
| Wrong | Right | Section |
|---|---|---|
Name= |
ContainerName= |
[Container] |
Name= |
PodName= |
[Pod] |
Port= |
PublishPort= |
[Container], [Pod] |
HostPath=, MountOptions= |
Device=, Options= |
[Volume] |
Options=o=bind alone |
also needs Device=/path and Driver=local |
[Volume] |
Source: https://blog.nerdon.eu/podman-quadlet-getting-started/
[Unit]
# (Optional) A brief description of the service
Description=
# (Optional) Services you want to run with this one
Wants=
# (Optional) Services that need to start before this one
After=
[Container]
# (Mandatory) The container's name
ContainerName=
# (Mandatory) The container image to use (e.g., docker.io/library/alpine)
Image=
# (Optional) Path to an .env file
EnvironmentFile=
# (Optional) Key=value pairs for environment variables. Quote values with spaces.
Environment=
# (Optional) Persistent storage paths (host:container)
Volume=
# (Optional) Custom network for the container
Network=
# (Optional) Ports to publish (host:container)
PublishPort=
# (Optional) Inject a Podman secret
Secret=
# (Optional) Custom command to run in the container
Exec=
# (Optional) Additional Podman arguments, for anything Quadlet has no key for
PodmanArgs=
# (Optional) Extra capabilities to add to the container
AddCapability=
# (Optional) Drop capabilities. DropCapability=all then add back is the safer default.
DropCapability=
# (Optional) Add host devices to the container
AddDevice=
# (Optional) Disable SELinux labels
SecurityLabelDisable=
# (Optional) Run as a specific user inside the container
User=
# (Optional) Map the calling user into the container, so bind mounts stay writable
UserNS=
# (Optional) Autoupdate. Also enable podman-auto-update.timer
AutoUpdate=registry
# (Optional) Add metadata labels to the container
Label=
# (Optional) User ID mapping. Example: 0:10000:10 (Inside:Outside:Range)
UIDMap=
# (Optional) Group ID mapping Example: 0:10000:10 (Inside:Outside:Range)
GIDMap=
# (Optional) Health check command and what to do when it fails
HealthCmd=
HealthOnFailure=
[Service]
# (Optional) Set to 'always' or 'on-failure' to restart on failure
Restart=
# (Optional) Time to wait before considering a failure
TimeoutStartSec=
[Install]
# (Optional) default.target for --user units, multi-user.target for system-wide
WantedBy=