Key Takeaway
Podman Quadlets let you describe containers as native systemd units. You write a plain .container file, and the podman-system-generator turns it into a service systemd manages directly. That gives you rootless containers, auto-restart, boot ordering, journald logs, and built-in registry auto-update without the Docker daemon or a separate Compose tool.
I ran my personal server on Docker Compose for years. It works, but it always felt like a second init system bolted on top of the first: the Docker daemon runs as root, containers restart because a policy string tells the daemon to, and logs live in a JSON file that only the docker CLI knows how to read. When the box reboots, systemd starts the Docker daemon, and the daemon starts my containers. Two supervisors, one job.
Podman Quadlets collapse that stack. Instead of a docker-compose.yml interpreted by a long-running daemon, you write a declarative unit file that systemd itself understands. There is no daemon, the containers can run entirely rootless, and every feature systemd already has for ordinary services applies to them too. This is how I now run everything on my VPS, and I have not missed Compose once.
What a Quadlet actually is
A Quadlet is a small INI-style file that describes a container the way a systemd unit describes a service. Podman ships a generator called podman-system-generator that runs early in the boot sequence, scans a set of directories, and translates each Quadlet into a real .service unit on the fly. You never write the messy service file by hand, and you never commit generated files to disk — the generator regenerates them on every daemon-reload.
Quadlet handles more than single containers. Alongside the .container file there are .network, .volume, and .pod files for the networks, named volumes, and pods your app needs, plus .kube, .image, .build, and .artifact for more advanced cases. Because a newer Podman release ships an improved generator, your services quietly pick up the fixes the next time daemon-reload runs — the description stays the same while the plumbing underneath gets better.
A real .container unit file
Here is a working Quadlet for a Ghost blog, dropped into the rootless user directory. It reads almost like the environment section of a Compose file, but the sections map straight onto systemd concepts you may already know: Unit for ordering and dependencies, Container for the Podman-specific options, Service for the restart policy, and Install for what enables the unit at boot.
# ~/.config/containers/systemd/ghost.container
[Unit]
Description=Ghost blog
After=network-online.target
Wants=network-online.target
[Container]
Image=docker.io/library/ghost:5-alpine
AutoUpdate=registry
PublishPort=2368:2368
Volume=ghost-content.volume:/var/lib/ghost/content
Environment=NODE_ENV=production
Environment=url=https://blog.example.com
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=default.targetSave that file, then let systemd discover it. A daemon-reload triggers the generator, and from then on the container is an ordinary service you start, stop, enable, and inspect with the same commands you use for anything else systemd runs. The service name is the file name with the extension swapped, so ghost.container becomes ghost.service.
# register the generated service, then start it
systemctl --user daemon-reload
systemctl --user start ghost.service
journalctl --user -u ghost.service -fRootless Quadlets for a user go in the directory at the top of the sample, which maps to XDG_CONFIG_HOME under containers and systemd. System-wide rootful units live under etc, containers, systemd instead. Put the file in the right place first — the generator only scans a fixed set of search paths, so a unit in the wrong directory is silently ignored.
Rootless, restarts, boot ordering, and journald
The single biggest reason I switched is rootless. A Quadlet in your user directory runs as your unprivileged user with no daemon and no root, so a container breakout lands inside an ordinary user account rather than on the host as root. Everything else follows from being a first-class systemd unit rather than a daemon-managed process.
Because the container is a real unit, the tools you already trust just work, and there is nothing container-specific to learn:
- Auto-restart is Restart=always in the Service section — the same directive every other systemd service uses, not a bespoke policy string.
- Boot ordering uses After= and Wants= in the Unit section, so a database Quadlet can start before the app that depends on it.
- Logs stream to journald, so journalctl --user -u ghost.service shows container output next to every other service log, with the same filtering and rotation.
- WantedBy=default.target in the Install section makes a rootless container start with your user session, so it comes back after a reboot.
Rootless services stop when the user logs out unless lingering is enabled. Run loginctl enable-linger for the account, or your containers will die the moment your SSH session closes and never survive a reboot. This is the one gotcha that catches everyone moving self-hosted workloads to rootless Podman — enable linger before you rely on it.
Auto-update without a CI pipeline
Set AutoUpdate=registry in the Container section, as the sample does, and Podman can update the container for you. The podman-auto-update.timer fires daily at midnight, checks the registry for a newer image behind each auto-update label, pulls it, and restarts the unit. It only works with a fully qualified image reference, because Podman needs an exact name to re-check — a bare tag or an image ID gives it nothing to compare against.
The safety net is rollback. If the unit fails to restart cleanly after an update, Podman reverts to the previous image and restarts the service again, so a bad release does not leave you with a down container at midnight. There is also AutoUpdate=local for images you build on the host yourself, where Podman compares the container against your local image store instead of a remote registry.
Quadlet versus Docker Compose, side by side
Compose and Quadlet solve overlapping problems, but they sit at different layers. Compose is a tool that talks to a daemon; Quadlet is a description that systemd consumes directly. This table is the comparison I wish I had when I started the migration.
| Concern | Docker Compose | Podman Quadlet |
|---|---|---|
| Process model | Long-running Docker daemon, usually root | No daemon; rootless, run directly by systemd |
| Definition format | One docker-compose.yml in YAML | Separate .container, .network, .volume unit files |
| Lifecycle manager | The compose CLI wraps the daemon | systemd manages the generated services natively |
| Restart on boot | restart policy plus the daemon starting first | WantedBy=default.target plus user lingering |
| Logs | docker logs reads a per-container JSON file | journald, queried with journalctl like any service |
| Auto-update | External tool such as Watchtower | Built-in AutoUpdate=registry plus the daily timer |
| Dependency ordering | depends_on inside the compose file | After= and Requires= from systemd |
Quadlet is not a drop-in replacement for every Compose workflow — there is no single up command that reads a whole project, and multi-container apps become several unit files rather than one document. But for a self-hosted server that should boot, restart, log, and update like the rest of the operating system, running containers as systemd units is the cleaner model. Start by porting one service, prove the reboot and auto-update paths, then move the rest.

