Docker Compose V2 is fine. It's not going anywhere. If you have a working Compose setup and nothing is broken, skip this essay. I'm moving off Compose for exactly one reason: Podman's systemd-native "quadlet" format lets me declare containers as first-class units next to every other service on the host, with the same lifecycle rules, the same logging pipeline, and the same restart policy. On a machine that already runs systemd, that's worth a weekend.
This is the rollout plan I used, the migration mistakes I made, and the downtime budget I stuck to. Part of the full stack.
Why quadlets beat compose, for me
A Podman quadlet is a single .container file that systemd reads and expands into a full unit file. Drop it in ~/.config/containers/systemd/, run systemctl --user daemon-reload, and systemd starts treating it like any other service. That means:
journalctl --user -u jellyfin.serviceto tail logs — same command I use for every other service.systemctl --user restart immich.service— one verb, one tool.- Restart ordering via
After=andWants=directives — the right way to express "this container needs Postgres up first". - Rootless by default on a per-user install. The user running the container is not root. Blast radius is limited if anything escapes.
- Zero
docker-compose uplong-running process to manage.
Compose does all of this too, just not natively to systemd. The difference is worth it on a host where I already lean on systemd heavily.
The rollout plan: one service at a time
My downtime budget is one service, one hour, one Sunday morning. That's my whole error envelope. The plan:
- Pick one leaf service — nothing depends on it. Start with something recoverable.
- Convert its compose service block to a
.containerquadlet. - Stop the Docker Compose service only. Everything else keeps running.
- Start the Podman quadlet. Verify.
- If it breaks, I have 20 minutes to revert:
systemctl --user stop foo.service, bring the Docker Compose entry back, done. - If it works, move on to the next service next Sunday. Parallel runs are fine.
Order I'm going in, roughly: Ntfy → Vaultwarden → Prowlarr → Sonarr → Radarr → qBittorrent → Jellyfin → Immich → Postgres (shared) → Home Assistant (probably never — HA has its own OS on an LXC).
A real quadlet: Jellyfin
Here's the Compose block I had:
# ~/stacks/media/compose.yml (excerpt)
services:
jellyfin:
image: jellyfin/jellyfin:10.9.11
restart: unless-stopped
ports:
- "8096:8096"
volumes:
- ./config:/config
- /tank/media:/media:ro
environment:
- JELLYFIN_PublishedServerUrl=https://jellyfin.tail-XXXX.ts.net
And the quadlet equivalent:
# ~/.config/containers/systemd/jellyfin.container
[Unit]
Description=Jellyfin media server
After=network-online.target
Wants=network-online.target
[Container]
Image=jellyfin/jellyfin:10.9.11
PublishPort=8096:8096
Volume=%h/stacks/jellyfin/config:/config:Z
Volume=/tank/media:/media:ro,Z
Environment=JELLYFIN_PublishedServerUrl=https://jellyfin.tail-XXXX.ts.net
Environment=TZ=America/New_York
Label=io.containers.autoupdate=registry
[Service]
Restart=on-failure
TimeoutStartSec=900
[Install]
WantedBy=default.target
The mapping is near-one-to-one. Four things to know:
- The
:Zsuffix on volumes is SELinux-aware labeling. If SELinux is enforcing on your host, use it. On my Debian host it's harmless no-op. %hexpands to the user's home directory.- The
io.containers.autoupdate=registrylabel is the Podman equivalent of Watchtower — a systemd timer checks the registry nightly and restarts if the digest moved. Opt-in per container, which I prefer. WantedBy=default.target(notmulti-user.target) because this is a user-level unit. Root-level quadlets go in/etc/containers/systemd/and usemulti-user.target.
The three mistakes I made
1. Port collision with the running Docker daemon. I forgot to docker compose stop jellyfin before starting the quadlet. Podman tried to bind 8096 and failed. 30 seconds wasted. Now I write a checklist: stop old, start new, verify.
2. Host networking vs. bridge. A couple of services — specifically anything using mDNS or Wake-on-LAN — need host networking. Add Network=host to the [Container] block. I spent an hour debugging why Home Assistant couldn't see my Chromecast before I remembered this.
3. Rootless can't bind to <1024 ports. If you need port 443 or 80, either bind high and reverse-proxy from Traefik, or set net.ipv4.ip_unprivileged_port_start=0 in sysctl. I chose Traefik.
Auto-updates, the part I was nervous about
Podman's auto-update mechanism is a systemd timer that checks registry digests and pulls+restarts if the image moved. It's idempotent: if the digest hasn't changed, nothing happens. For production-critical services I leave the label off and update manually with a ZFS snapshot first. For things I'm happy to let drift (ntfy, prowlarr), the label is on.
systemctl --user enable --now podman-auto-update.timer
systemctl --user list-timers | grep podman
What I'd do differently if I started today
I'd start with a single Postgres quadlet, then layer services on top. I didn't, because my existing Compose Postgres had data I didn't want to migrate. That was a dumb reason. The migration was painless when I did do it three weeks later.
Also: quadlets didn't exist until Podman 4.4 (2023). If you're on anything older — Debian 11, RHEL 8, Fedora < 38 — upgrade first. The migration is not worth doing on a box that doesn't natively support quadlets.
For the full container + infra stack, see The self-hosting stack I actually use in 2026. For the hypervisor under it: Proxmox vs. ESXi in 2026.
# issues (0)
$ no issues filed yet. be the first — the form is below.