Podman Quadlets: Systemd-Native Containers
Describe a container in a unit file and let systemd own it properly

Contents
For years the answer to “how do I start my Podman container at boot” was
podman generate systemd, which produced a unit file containing a very long podman run
command. It worked. It also produced dead code the moment you changed anything, because
the generated unit was a snapshot — edit the container, regenerate, remember to
daemon-reload, discover you had hand-edited the old one, sigh. That subcommand is
deprecated now, and the replacement is better in a way I did not expect.
Quadlet inverts it. You write a small declarative file describing the container, drop it in a directory systemd watches, and a generator turns it into a real unit at boot time — every boot, freshly. The file you maintain is the description. The unit is derived and disposable. This is the correct shape for the problem and it took the project a while to get there.
Why this beats a compose file for infrastructure
I have nothing against compose. Most of my stacks are compose files and will stay that way. But compose has one structural weakness that bites on the boxes you care about most: it has no relationship with the rest of the system. Compose knows about containers. It does not know that your media server should start after the NFS mount is up, or that the reverse proxy needs the certificate renewal timer to have run, or that a container should be socket-activated rather than sitting idle.
Systemd knows all of that already, and has known it for a decade. Quadlet lets you write
After=mnt-media.mount and Requires= on a container the same way you would on any other
service, and the container becomes an ordinary citizen of the boot sequence. Dependency
ordering, restart policy, resource limits via cgroups, journald integration, systemctl status — all of it comes for free because the thing is genuinely a unit.
The other half is daemonlessness. Podman’s whole argument, which I made in more detail in Podman for people who distrust the Docker daemon, is that there is no long-running root process supervising your containers. With Quadlet the supervisor is PID 1, which is already there and already good at supervising.
The basics
A Quadlet file for the root instance lives in /etc/containers/systemd/. Here is a
complete one:
| |
Reload and start it:
| |
Note “generated” in the Loaded line — there is no vaultwarden.service file anywhere on
disk. The generator ran during daemon-reload, produced the unit in a tmpfs, and systemd
loaded it. Change the .container file, daemon-reload, restart. There is no artefact to
get out of sync.
The [Install] section is what systemctl enable would normally give you. With Quadlet,
enable is not available for generated units — the WantedBy= in the file is the
enablement, applied at generation time. This confuses everyone once.
The supporting resources are their own files:
| |
Referencing vaultwarden-data.volume from the container file creates an implicit
dependency: systemd will bring the volume unit up first. Same for the network. The
ordering is inferred from the reference rather than written by hand, which is the single
most pleasant thing about the design.
Rootless, which is where you actually want this
Everything above works unprivileged. Drop the same files into
~/.config/containers/systemd/ and use systemctl --user instead. Two changes are
compulsory and both are easy to miss.
First, WantedBy=multi-user.target means nothing to a user manager. Use
WantedBy=default.target.
Second, and this is the one that costs people an evening:
| |
Without lingering, your user’s systemd manager starts when you log in and is torn down — along with every container it supervises — when your last session ends. You will test everything over SSH, watch it work perfectly, log out, and find the service gone. Enable lingering and the user manager runs from boot regardless of sessions.
Rootless plus Quadlet is the combination worth the effort. The container runs as an
unprivileged user, in a user namespace, supervised by a user-level systemd, with no root
daemon involved anywhere in the chain. On a SELinux system the :Z on the volume mount
relabels it correctly, which is the thing that
keeps SELinux from being the reason you turn it off.
Auto-update, with the usual caveat
AutoUpdate=registry in the container section, plus:
| |
…gives you a daily check: Podman resolves the tag, compares digests, and if it moved, pulls and restarts the unit. Crucially, it uses systemd’s rollback — if the new container fails its health check, Podman rolls back to the previous image and restarts. That is genuinely more than most update tools offer, and it is only possible because systemd is holding the state.
You still want a health check for that to mean anything:
| |
Without one, “the container started” is the entire success criterion, and a container that starts and immediately logs a fatal config error counts as success. My position on automatic updates has not changed — pinned tags for anything that holds data, and I let this loose only on stateless things where the health check is honest. See container image housekeeping for the longer argument.
Grouping things that belong together
The obvious objection to one-file-per-container is a service that genuinely needs three containers. Podman’s answer is a pod, and Quadlet has a unit type for it:
| |
| |
| |
Containers in a pod share a network namespace, so the web container reaches the database
on localhost — no service discovery, no container DNS, no network to define between
them. Ports are published on the pod rather than the container. Referencing
paperless.pod from a container file creates the dependency automatically, and stopping
the pod unit stops everything in it.
This is four files where compose would be one. In exchange, systemctl status paperless-db.service works, the ordering is expressed in systemd’s own vocabulary, and
the database comes up before the web container because I said so rather than because
compose’s depends_on waits for the process to exist and hopes.
Getting there from compose
Rewriting by hand is tedious and mechanical, which is what podlet is for:
| |
It reads a compose file or a docker run line and writes Quadlet units. The output is a
first draft — it does not know which service should wait for a mount, it maps
depends_on loosely, and it will not invent health checks. Take the drudgery from it and
do the judgement yourself.
Worth knowing what does not survive the trip: compose’s extends, profiles, and anything
relying on a .env file being interpolated at parse time. Quadlet has EnvironmentFile=,
which systemd reads at start time, and the difference matters if you have been using
variables inside image tags.
The parts you get for free
Two things stop being your problem the moment a container is a unit.
Logs. Podman writes container output to the journal, so journalctl -u vaultwarden.service -f works, journalctl --since "2 hours ago" -p err finds errors across containers and
host services in one query, and your existing log shipping picks it up with no
container-specific configuration. Docker’s json-file driver growing without bound until it
fills a disk is a rite of passage; the journal already has rotation, compression and size
caps configured, and applies them here too.
Resource limits. The [Service] section is a real systemd service section, so every
cgroup knob is available with the syntax you already know:
| |
MemoryHigh is the interesting one — it throttles and reclaims before MemoryMax kills
the process, which turns a hard OOM kill into gradual slowdown. Compose exposes a subset
of this through deploy.resources, awkwardly, and only some of it works outside Swarm.
Here it is just systemd, and it behaves exactly as it does for any other service on the
box.
Troubleshooting
The unit does not exist after daemon-reload. The generator failed and systemd said nothing useful. Run it by hand:
| |
This prints the generated units for everything it can parse and a clear error for
everything it cannot. It is the first thing to reach for, every time. Typos in key names
are silent otherwise, and the keys are PublishPort, not Ports, and Volume, not
Volumes.
Container runs as root despite being in the user directory. You put the file in
~/.config/containers/systemd/ and then ran sudo systemctl daemon-reload. The root
manager does not read your home directory; the user manager does. systemctl --user daemon-reload.
Everything dies on logout. Lingering. See above. It will happen to you once.
Volume=/some/path:/data gives permission denied under rootless. Host paths need to
be accessible to your unprivileged user after the user namespace mapping, and files
owned by your UID appear inside the container as root only if you map them. Either use
named volumes, which Podman handles, or add --userns=keep-id via UserNS=keep-id so the
container sees your UID unchanged.
A rootless container cannot bind port 80. Correct, and by design. Either publish high
and put a proxy in front, or lower net.ipv4.ip_unprivileged_port_start — the former is
right for essentially every homelab.
Auto-update never fires. It only considers containers with the
AutoUpdate=registry key and a tag it can resolve. Check with
podman auto-update --dry-run, which lists every candidate and its verdict without doing
anything.
Ordering against the rest of the machine
The reason my reverse proxy is a Quadlet and my media server is not comes down to one line. Here is the file for the container that serves files off a network mount:
| |
Requires=mnt-bulk.mount means the container will not start until the filesystem is
mounted. BindsTo= means that if the mount goes away later — a NAS reboots, a network
blip drops an NFS mount — the container is stopped rather than left serving an empty
directory. Compose has no vocabulary for this. Its depends_on only knows about other
compose services, and a compose stack that starts before its bind-mount source exists will
happily let Docker create an empty directory at the mount point, which is the specific
failure that has cost me the most time over the years.
The same trick works against timers, sockets and any other unit. A container that must run
after certificate renewal gets After=certbot.service. A container that should only start
once a VPN interface is up gets [email protected]. All of this is systemd
doing what it has always done, and the only new thing is that a container is now something
it can be asked about.
There is a mirror-image cost. Everything you express this way is expressed in systemd’s
vocabulary, and systemd’s vocabulary is large, occasionally surprising, and documented in
a manual page that assumes you already know it. The difference between Requires, Wants
and BindsTo, or between After and Requires — ordering and dependency being separate
concepts that people routinely conflate — is knowledge you now need. If you have been
running Linux servers for a while you have that knowledge already and this feels like
coming home. If you have only ever managed containers, it is a genuine second thing to
learn on top of the first.
What the migration actually cost
Numbers, since everything above is architecture. Six services moved from compose to Quadlet over three evenings. Twenty-two unit files, because the pod-based ones expand. Roughly 140 lines of compose became roughly 260 lines of INI, which is a real increase in volume and a real decrease in density — each file says one thing.
Two surprises worth passing on. The first is that daemon-reload regenerating every unit
from scratch means a syntax error in one file does not stop the others; systemd loads what
it can and logs the rest. That is a better failure mode than compose’s, where one bad key
fails the whole file, and it is also how you end up with five of six services running and
no obvious sign the sixth never generated. Check systemctl list-units 'myapp*' after
every reload until the habit sticks.
The second is that restarting a container now means systemctl restart foo.service, and
muscle memory says podman restart foo. Both work. Only one of them tells systemd, and
the other leaves the unit’s idea of the world briefly wrong — systemd notices the
container’s conmon process exiting and, depending on your Restart= policy, may start a
second one. Use systemctl for anything under Quadlet’s management and let podman be for
the ad-hoc things.
Is it worth it?
For a stack of a dozen loosely-related services that you think of as a unit, compose is
still easier and I would not migrate. Quadlet has no equivalent of docker compose up
bringing up eight things and their network in one command from one readable file — you get
eight files and an inference engine. There is a .kube unit type that takes a Kubernetes
YAML, and a podlet tool that converts compose files into Quadlets, and both are useful,
and neither makes this feel like compose.
For the small number of services on a machine that must come up in a specific order relative to mounts, networks and timers, and must be supervised by something you trust, Quadlet is straightforwardly better than anything else available. My reverse proxy, my DNS resolver and my backup runner are Quadlets. Everything downstream of them is compose. The split is not elegant, and it reflects a real difference in what those services are.
The thing that sold me was uneventfulness. A Quadlet container is a systemd unit: it shows
in systemctl list-units, its logs are in the journal alongside everything else, it
respects systemctl stop at shutdown, and it comes back at boot without a supervising
daemon having survived the reboot to make that happen. After a decade of container
runtimes inventing their own answers to problems init solved in 2011, that is restful.




