Every container setup eventually runs into the same question: what starts this after a reboot?
The traditional answers are all a second mechanism bolted onto the first — a restart policy inside a daemon, a @reboot cron entry, or a hand-maintained unit file that duplicates what your Compose file already says.
Quadlet answers it differently. You describe a container in a file that looks like a systemd unit, and a generator turns it into a real service at boot. The container is a systemd service, with proper dependency ordering, restart handling, and logs in the journal alongside everything else on the machine.
This is the reference I wanted when I started. It assumes Podman 4.4 or later; the examples were written against Podman 5 on Debian 13.
What Quadlet Actually Is
Not a daemon, not a tool you run. It is a systemd generator — a binary systemd executes early in boot, which reads your files and writes real unit files into a runtime directory.
# rootful
/usr/lib/systemd/system-generators/podman-system-generator
# rootless
/usr/lib/systemd/user-generators/podman-user-generatorIf those exist, Quadlet is installed. There is nothing to enable.
The consequence of the generator model is the rule that trips up everybody: generators only run on daemon-reload or boot. Editing a unit file changes nothing until you reload. More on that below, because it is worth repeating.
The older podman generate systemd command did something superficially similar but produced a static unit file you then had to maintain by hand. It is deprecated. Quadlet replaces it.
Where Files Go
| Mode | Directory |
|---|---|
| Rootless (per user) | ~/.config/containers/systemd/ |
| Rootful | /etc/containers/systemd/ |
| Rootful, package-provided | /usr/share/containers/systemd/ |
| Admin-provided, for a user | /etc/containers/systemd/users/$UID/ |
For a personal server, rootless is the one you want:
mkdir -p ~/.config/containers/systemdThe File Types
| Extension | Creates | Generated service name |
|---|---|---|
.container | A container | name.service |
.volume | A named volume | name-volume.service |
.network | A network | name-network.service |
.pod | A pod | name-pod.service |
.build | An image built from a Containerfile | name-build.service |
.image | A pulled image | name-image.service |
.kube | Resources from a Kubernetes YAML | name.service |
The one to note is .container: a file named web.container becomes web.service, and that is what you type into systemctl.
Anatomy of a .container File
[Unit]
Description=My web application
Wants=network-online.target
After=network-online.target
[Container]
ContainerName=web
Image=docker.io/library/caddy:2-alpine
Network=apps.network
PublishPort=8080:80
Volume=/home/admin/site:/srv:ro
Environment=TZ=Asia/Jakarta
[Service]
Restart=always
TimeoutStartSec=300
[Install]
WantedBy=default.targetFour sections, and understanding which is which prevents most early mistakes.
[Unit] and [Install] are ordinary systemd sections. They mean exactly what they mean in any other unit — description, ordering, dependencies, and what pulls the service in at boot.
[Container] is Quadlet’s own section. Every key here becomes a podman run argument. This is the only section with a Podman-specific vocabulary.
[Service] is where systemd service directives go. Restart=always belongs here, not in [Container]. Putting restart handling in the Podman section is the most common formatting error, and it fails silently — the service just does not restart.
Keys Worth Knowing
Inside [Container]:
| Key | Purpose |
|---|---|
Image= | Fully qualified image, or a .build / .image unit reference |
ContainerName= | The container’s name — also its DNS name on a network |
Exec= | Override the image’s command |
Environment= | One variable; repeat the key for more |
EnvironmentFile= | Load variables from a file |
Secret= | Mount a Podman secret |
Volume= | HOST:CONTAINER[:opts], or name.volume:/path |
PublishPort= | HOST:CONTAINER[/protocol] |
Network= | name.network, or host, or none |
User= / Group= | UID/GID inside the container |
ReadOnly=true | Read-only root filesystem |
NoNewPrivileges=true | Block privilege escalation |
DropCapability= | Remove Linux capabilities |
AutoUpdate= | registry or local |
HealthCmd= | Health check command |
Pull= | always, missing, never, newer |
PodmanArgs= | Escape hatch for anything without a dedicated key |
PodmanArgs= is the pressure valve. If a podman run flag has no Quadlet key, pass it there rather than abandoning the approach.
The Lifecycle
This is the loop, and it is short:
# 1. write or edit a unit file
nano ~/.config/containers/systemd/web.container
# 2. regenerate — ALWAYS
systemctl --user daemon-reload
# 3. start it
systemctl --user start web
# 4. check
systemctl --user status web
journalctl --user -u web -fTwo things about this loop deserve emphasis.
Step 2 is not optional and is not intuitive. Nothing about editing a text file suggests you must reload a system service afterwards. Skipping it — then wondering why the change had no effect — is the single most common Quadlet mistake, and everyone makes it at least twice.
You cannot systemctl --user enable a Quadlet service. The generated unit lives in /run, not on disk, so there is nothing for enable to symlink. Boot startup comes from [Install] WantedBy=default.target plus a daemon-reload. If you try to enable one, the error message about a missing unit file is confusing rather than wrong.
To stop and remove something, delete the file and reload:
systemctl --user stop web
rm ~/.config/containers/systemd/web.container
systemctl --user daemon-reloadNetworks and Container DNS
A .network unit is usually two lines:
[Unit]
Description=Shared application network
[Network]
NetworkName=appsReference it from a container by filename, not network name:
[Container]
Network=apps.networkQuadlet resolves the reference and adds the dependency, so the network exists before the container starts. You never write an After= for it.
Set NetworkName= explicitly. Without it, Quadlet names the network after the unit with a prefix — systemd-apps — and that prefixed name is what you then type in every podman network inspect for the rest of the project.
The payoff is DNS. Containers on a shared user-defined network resolve each other by container name:
# in caddy.container
Network=apps.network
ContainerName=caddy
# in web.container
Network=apps.network
ContainerName=webCaddy can now reach http://web:3000. No IP addresses, no host ports, no link flags.
This requires aardvark-dns to be installed. Without it, containers start fine, appear healthy, and cannot resolve each other — an error that looks like a networking bug and is actually a missing package.
sudo apt install aardvark-dns netavark # Debian/Ubuntu
sudo dnf install aardvark-dns netavark # Fedora/RHELVolumes
Named volumes get their own unit:
[Volume]
VolumeName=app-dataReferenced the same way — by filename:
[Container]
Volume=app-data.volume:/var/lib/dataBind mounts use absolute paths:
Volume=/home/admin/apps/caddy/Caddyfile:/etc/caddy/Caddyfile:roTwo notes. First, Quadlet does not reliably expand %h in every field, so write /home/admin/... in full rather than relying on specifiers. Second, on SELinux systems (Fedora, RHEL) add :Z to bind mounts so the label is set correctly; on Debian and Ubuntu it is unnecessary.
The Permission Problem
The confusion everyone hits with rootless: a bind-mounted directory owned by your user shows up as owned by nobody inside the container.
That is user namespace mapping working correctly. Your UID 1000 maps to a different UID inside. Two fixes:
# let Podman chown the volume to match the container user
Volume=/home/admin/data:/data:UOr shift ownership on the host, from inside the namespace:
podman unshare chown -R 1000:1000 /home/admin/dataEnvironment and Secrets
[Container]
Environment=NODE_ENV=production
Environment=PORT=3000
EnvironmentFile=/home/admin/apps/web/.envFor anything sensitive, use Podman secrets rather than an environment file sitting in your home directory:
printf 'super-secret-value' | podman secret create db_password -[Container]
Secret=db_password,type=env,target=DB_PASSWORDtype=mount places it as a file instead, which is better still — environment variables leak through /proc and process listings.
Health Checks
[Container]
HealthCmd=curl -f http://localhost:3000/health || exit 1
HealthInterval=30s
HealthRetries=3
HealthStartPeriod=10s
Notify=healthyNotify=healthy is the interesting one. It tells systemd not to consider the service started until the health check passes. Anything ordered After= this unit then genuinely waits for the application to be ready, not just for the container process to exist.
Building Images Declaratively
A .build unit builds an image as part of the dependency chain:
[Build]
ImageTag=localhost/myapp:latest
File=/home/admin/apps/myapp/Containerfile
SetWorkingDirectory=/home/admin/apps/myapp/srcReference it from the container by filename:
[Container]
Image=myapp.buildQuadlet builds the image before starting the container. It is elegant, and it means a fresh machine can go from unit files to running services with one daemon-reload.
I still build manually in my own deploys, because I want to see build output and control exactly when a rebuild happens. Worth knowing it exists, though, especially for reproducible provisioning.
Pods
For containers that should share a network namespace:
# web.pod
[Pod]
PodName=web
PublishPort=8080:80# app.container
[Container]
Pod=web.pod
Image=localhost/myapp:latestContainers in a pod reach each other over localhost and ports are published at the pod level. This is the Kubernetes pod concept, and it maps cleanly onto sidecar patterns — an app and its metrics exporter, say.
For unrelated services, a shared network is the better fit. Pods are for things that genuinely belong together.
Automatic Updates
[Container]
Image=docker.io/library/caddy:2-alpine
AutoUpdate=registryThen enable the timer:
systemctl --user enable --now podman-auto-update.timerAutoUpdate=registry checks the registry for a newer image with the same tag and restarts the container if one exists. AutoUpdate=local only reacts to a newer locally built image, which is what you want for your own applications.
Preview before trusting it:
podman auto-update --dry-runIf the new image fails its health check, Podman rolls back to the previous one — but only if the container has a health check. Without one, “started successfully” is the only signal available, and a container that starts and then misbehaves will not trigger a rollback.
My rule: registry for third-party infrastructure I want patched without waiting for me, local for my own code, which should only change when I deploy it.
Rootless Requirements
Everything above assumes rootless. Four things must be true, and each fails differently.
1. Subordinate UID ranges must exist.
grep "$USER" /etc/subuid /etc/subgidExpect something like admin:100000:65536. If missing:
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 "$USER"
podman system migrate2. Lingering must be enabled, or your services stop when you log out and never start at boot:
sudo loginctl enable-linger "$USER"
loginctl show-user "$USER" --property=LingerThis is the one that makes an otherwise perfect setup look broken. Everything works while you are logged in, and dies the moment you disconnect.
3. Ports below 1024 need a decision. A rootless container cannot bind 80 or 443 by default:
echo 'net.ipv4.ip_unprivileged_port_start=80' | sudo tee /etc/sysctl.d/99-ports.conf
sudo sysctl --systemUnderstand the trade-off: this lets any unprivileged process bind those ports. Fine on a single-admin box, wrong on a shared system.
4. Run systemctl --user from a real session as that user. Reaching for sudo -u admin systemctl --user ... fails, because that shell has no XDG_RUNTIME_DIR pointing at the user’s session bus.
Debugging
See the unit that was actually generated:
/usr/libexec/podman/quadlet -dryrun -userThis prints what Quadlet would produce, without touching anything. It is the fastest way to find a key that was silently ignored — usually because it is in the wrong section or misspelled.
Find the generated files on disk:
ls /run/user/$UID/systemd/generator/Validate a unit:
systemd-analyze --user verify web.serviceWatch a start attempt in full:
journalctl --user -u web -fErrors Worth Recognising
| Symptom | Cause |
|---|---|
Unit web.service not found | No daemon-reload after creating the file |
| Edits have no effect | Same — reload after every change |
Failed to connect to bus | Ran systemctl --user via sudo -u |
| Containers die on logout | Lingering not enabled |
no such host between containers | Different networks, or aardvark-dns missing |
short-name resolution enforced | Use the fully qualified image name |
Files owned by nobody in container | User namespace mapping — use :U or podman unshare chown |
bind: permission denied on port 80 | ip_unprivileged_port_start not lowered |
| Service restarts in a loop | Container exits immediately; check journalctl for the real error |
Coming From Compose
The translation is mostly mechanical:
| Compose | Quadlet |
|---|---|
image: | Image= |
container_name: | ContainerName= |
ports: | PublishPort= (one per line) |
volumes: | Volume= (one per line) |
environment: | Environment= (one per line) |
env_file: | EnvironmentFile= |
networks: | Network=name.network |
restart: always | Restart=always in [Service] |
depends_on: | After= / Requires= in [Unit] |
build: | A .build unit, or build manually |
healthcheck: | HealthCmd= and friends |
The structural difference is that Compose puts every service in one file, while Quadlet uses one file per resource. That feels like more files at first. In practice it means a service can be added, changed, or removed without touching anything else, and each file is short enough to read at a glance.
When Not to Use Quadlet
Local development. The edit–reload–restart loop is slower than podman run or compose up while you are iterating. I use Compose locally and Quadlet on the server, and see no contradiction in that.
Non-systemd hosts. Alpine with OpenRC, most containers-in-containers setups, macOS. Quadlet is a systemd generator and needs systemd.
Multi-host orchestration. Quadlet manages containers on one machine. If you need scheduling across nodes, you need Kubernetes or Nomad, and this is not a substitute.
Anything Docker-specific. Obviously, but worth stating: this is a Podman feature with no Docker equivalent.
Why It Was Worth Learning
The unit file format took an afternoon. What I got back was one supervision mechanism instead of two.
Boot ordering, restart policy, dependencies, resource limits, and logs are all handled by systemd — which was designed for exactly this and has been doing it reliably for years. There is no daemon to keep alive, no orchestration layer to update, and no second place to look when something has not started.
On a single server that I maintain alone, being able to read the entire deployment as a handful of systemd units is worth more than any individual feature.
Related: Why I Chose Podman Over Docker for the runtime comparison, and Setting Up a VPS, Part 3 for this applied to a real server from scratch.