Docker Compose to Podman Quadlet: A Practical Migration Guide
Convert a docker-compose.yml to Podman Quadlet files step by step, with a full example, rootless setup, and the daemon-reload gotchas that trip people up.
On this page
If you run Podman and you've typed podman generate systemd lately, you got a warning telling you to use Quadlet instead. Nothing broke. The command still works, and the maintainers have said they won't remove it. But it's frozen: no new features, only urgent bug fixes, while the whole ecosystem moves to Quadlet. If you run containers on a server and want them to survive reboots, this is worth doing properly once.
The Deprecation Warning You Just Hit
$ podman generate systemd --new --name my-app
DEPRECATED command:
It is recommended to use Quadlet files to run Podman containers and pods under systemd.
Please refer to the podman-systemd.unit(5) man page for details.The official Podman docs mark generate systemd deprecated: no plans to remove it, but only urgent bug fixes, no new features. In a containers/podman GitHub Discussion, a maintainer explains the strategy directly: no new features will be added to generate systemd, and Quadlet aims for a Compose-and-Kubernetes-like declarative workflow instead. Quadlet became the recommended path once merged into Podman 4.4, which already shipped to CentOS Stream and Fedora, so this isn't an experimental feature you'd be migrating to early.
What a Quadlet Actually Is
A Quadlet is a small text file that looks like a systemd unit but is written in Podman's shorthand. You drop a file ending in .container into the right directory, run a daemon-reload, and systemd's Podman generator turns it into a real .service unit on the fly.
You never edit the generated service directly. You edit the short Quadlet file and reload. When Podman updates, the generated service updates with it automatically. That's the whole point, and it's exactly why the old generate-a-static-file approach is going away: a static generated .service file doesn't know when Podman's own defaults change underneath it.
The Example Stack
Let's convert a real stack instead of a single podman run command, since that single-container case is already well covered elsewhere. Say you have this docker-compose.yml running a Caddy web server in front of a PostgreSQL database, on a shared network, with a named volume for the database data.
services:
web:
image: docker.io/library/caddy:2
ports:
- "8080:80"
depends_on:
- db
networks:
- appnet
db:
image: docker.io/library/postgres:16
environment:
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- appnet
volumes:
pgdata:
networks:
appnet:In Quadlet land, one compose file becomes several unit files. That feels like more work at first. It pays off because each piece is independently managed by systemd: you can restart the database without touching the web server's unit, and systemctl status tells you exactly which piece is unhealthy instead of one opaque compose stack.
Converting Service by Service
- 1
Create the database Quadlet
Start with the database, since the web server depends on it. Create
~/.config/containers/systemd/db.container:ini — ~/.config/containers/systemd/db.container[Unit] Description=App PostgreSQL [Container] Image=docker.io/library/postgres:16 Environment=POSTGRES_PASSWORD=secret Volume=pgdata.volume:/var/lib/postgresql/data Network=appnet.network [Service] Restart=always [Install] WantedBy=default.targetNotice the mapping:
imagebecomesImage,environmentbecomesEnvironment, and each volume and network line points at a matching.volumeor.networkunit rather than a raw name. Use the full registry path for the image,docker.io/library/postgres:16rather than justpostgres, so the generated service doesn't stall resolving an ambiguous short name. - 2
Create the web server Quadlet
Now the web server,
web.container:ini — ~/.config/containers/systemd/web.container[Unit] Description=App Caddy Requires=db.service After=db.service [Container] Image=docker.io/library/caddy:2 PublishPort=8080:80 Network=appnet.network [Service] Restart=always [Install] WantedBy=default.targetThat
depends_onin compose turns intoRequires=plusAfter=in the[Unit]section.Requiresmakes the dependency hard,Aftercontrols ordering. Quadlet appends.serviceto the unit name for you, so referencingdb.service(matching thedb.containerfile) is enough. - 3
Create the volume and network units
The volume and network are their own tiny files.
pgdata.volume:ini — ~/.config/containers/systemd/pgdata.volume[Volume]An empty
[Volume]section is valid. It creates a Podman volume namedsystemd-pgdata.appnet.network:ini — ~/.config/containers/systemd/appnet.network[Network] - 4
Reload systemd and start the services
Because these are user units for rootless containers, they live under
~/.config/containers/systemd/and you manage them with the--userflag. Tell systemd to regenerate the service units from your Quadlet files, then start the web service (itsRequires=pulls in the database automatically):bashsystemctl --user daemon-reload systemctl --user start web.serviceYou start the
.service, not the.container. Forgettingdaemon-reloadafter editing a Quadlet file is the single most common reason "my change didn't take." - 5
Enable lingering so containers survive logout
If you want these containers to keep running after you log out and to start at boot, enable lingering once for your user:
bashsudo loginctl enable-linger $USER
podlet as a Starting Point
Don't hand-write all of this if you don't have to. podlet is a Rust CLI, roughly 1.5k GitHub stars, that reads a compose file and generates Quadlet units:
podlet compose docker-compose.ymlIt's a good starting point and saves typing. It's also not complete. Its own docs are blunt about it: if podlet hits an unsupported compose option, it errors out and you have to comment that option out to continue. And in a real multi-service compose file with no explicit network defined, podlet has been reported to generate no .network file at all, silently leaving Quadlet to fall back to separate default networks per container so they can't reach each other.
Treat podlet's output as a scaffold, not a finished config. Read every generated file line by line, and specifically check that a .network unit exists and is referenced by every container that needs to talk to the others.
Gotchas
- Pin image tags. Use
postgres:16, notpostgres:latest. A Quadlet withAutoUpdate=registryplus alatesttag can pull a breaking major version update at 3am with nobody watching. - Use full registry paths.
docker.io/library/postgres:16instead ofpostgres, so the generated service doesn't stall resolving an ambiguous short image name. - Only add `AutoUpdate=registry` if you actually want it. It opts the container into
podman auto-updatemanaging upgrades for you, which is convenient but should be a deliberate choice, not a default you copy-pasted. - daemon-reload after every edit. Any change to a
.container,.volume, or.networkfile needssystemctl --user daemon-reload(or without--userfor system units) before it takes effect. - Check logs with journalctl, not scattered files.
journalctl --user -u web.serviceputs your container's output next to everything else on the system, which is one of the real wins of moving off compose.
Rootful vs Rootless
| Rootless | Rootful | |
|---|---|---|
| Unit file location | ~/.config/containers/systemd/ | /etc/containers/systemd/ |
| Manage with | systemctl --user ... | systemctl ... (no --user) |
| Survives logout by default | No, needs enable-linger | Yes, always |
| Runs as | Your user | root |
| Best for | Personal services, homelabs, least privilege | System-wide services, privileged ports below 1024 |
Most self-hosted setups should default to rootless unless a specific container genuinely needs root or a privileged port. If you're validating your compose file's health checks before making this move, our ComposeHealthCheck tool can catch missing depends_on conditions and healthcheck issues in your existing Docker Compose setup first.
Once it's running, you have containers that start on boot, restart on failure, order themselves correctly via Requires=/After=, and log to one place, without a daemon and without root. That's a better deal than the compose setup you started with, and it's where Podman is going.
If part of what you're migrating off cron also includes scheduled jobs rather than long-running services, our Cron to systemd Timer Converter handles the other half of the systemd migration: paste a crontab line and get a ready-to-save .timer and .service pair.
Frequently Asked Questions
Is podman generate systemd removed?
No. It's deprecated and frozen, meaning it still ships and receives urgent bug fixes, but gets no new features. New setups should use Quadlet instead. Existing scripts using generate systemd will keep working for now, but you shouldn't build anything new on top of it.
Do I still need Docker Compose or podman-compose?
For local development, compose is still fine: the fast iteration loop of docker compose up is hard to beat when you're actively changing code. For services running long-term on a server, Quadlet plus systemd is the more robust choice, since you get restart policies, ordering, and logging that are managed by the same init system as everything else on the box.
Should I use rootful or rootless Quadlets?
Rootless files go in ~/.config/containers/systemd/ and are managed with systemctl --user. Rootful files go in /etc/containers/systemd/ and use plain systemctl without --user.
- Default to rootless for personal services and homelabs, since it runs as your user rather than root.
- Use rootful only when a container genuinely needs root privileges or a port below 1024 that rootless can't bind directly.
Why does one compose file turn into so many separate unit files?
Each Quadlet unit type maps to one systemd-managed resource: a .container per container, a .volume per named volume, a .network per network. It looks like more files up front, but each one is independently restartable and inspectable with systemctl status, instead of one opaque compose stack you can only manage as a whole.
How do I convert docker-compose's depends_on to Quadlet?
Add both Requires= and After= to the dependent container's [Unit] section, pointing at the generated service name of the dependency (the .container filename with .service instead of .container):
[Unit]
Requires=db.service
After=db.serviceRequires= makes systemd treat the dependency as mandatory (stopping db.service stops anything requiring it too). After= only controls startup ordering. Use both together to replicate what depends_on implies in compose.
Can I just trust podlet's output without checking it?
No. podlet is a good scaffold but its own documentation warns it errors out on unsupported compose options rather than approximating them, and it has been reported to skip generating a .network unit entirely when a compose file doesn't declare one explicitly. Always read the generated files, and specifically confirm a .network unit exists if your containers need to reach each other.
Related Articles
40 Kubernetes Interview Questions and Answers (2026)
40 Kubernetes interview questions covering Pods, Deployments, networking, RBAC, and real troubleshooting scenarios like CrashLoopBackOff and OOMKilled. Updated for 2026.
50 Cloud & DevOps Interview Questions and Answers (2026)
50 cloud and DevOps interview questions covering AWS Lambda, Docker, Microservices, API Gateway, S3, serverless, and Azure Entra ID. With code examples.