Skip to content
CONTAINERS

From Docker Compose to Podman quadlets without downtime

The one-service-at-a-time rollout plan for moving from Docker Compose to rootless Podman + systemd quadlets. Real files, real failures, one service downtime budget.

published
author
read
4 min (~856 words)
Close-up of blue shipping containers stacked high at Rotterdam Port under a clear blue sky.
0%

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.service to tail logs — same command I use for every other service.
  • systemctl --user restart immich.service — one verb, one tool.
  • Restart ordering via After= and Wants= 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 up long-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:

  1. Pick one leaf service — nothing depends on it. Start with something recoverable.
  2. Convert its compose service block to a .container quadlet.
  3. Stop the Docker Compose service only. Everything else keeps running.
  4. Start the Podman quadlet. Verify.
  5. If it breaks, I have 20 minutes to revert: systemctl --user stop foo.service, bring the Docker Compose entry back, done.
  6. 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 :Z suffix on volumes is SELinux-aware labeling. If SELinux is enforcing on your host, use it. On my Debian host it's harmless no-op.
  • %h expands to the user's home directory.
  • The io.containers.autoupdate=registry label 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 (not multi-user.target) because this is a user-level unit. Root-level quadlets go in /etc/containers/systemd/ and use multi-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.

# add an issue

Comments are moderated. Links are capped. Be kind, be specific.