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
Compose Directive to Quadlet Directive Map
Most of the migration is mechanical once you know which Quadlet key replaces which compose key. The table below covers the directives that appear in almost every real compose file, plus the section each key belongs in.
Keys live in the [Container] section unless the table says otherwise. Anything in [Unit], [Service], or [Install] is plain systemd, not Podman-specific, so the normal systemd documentation applies.
| docker-compose key | Quadlet key | Section |
|---|---|---|
| image | Image= | [Container] |
| container_name | ContainerName= | [Container] |
| command | Exec= | [Container] |
| environment | Environment= | [Container] |
| env_file | EnvironmentFile= | [Container] |
| ports | PublishPort= | [Container] |
| volumes | Volume= | [Container] |
| networks | Network= | [Container] |
| user | User= and Group= | [Container] |
| working_dir | WorkingDir= | [Container] |
| hostname | HostName= | [Container] |
| cap_add / cap_drop | AddCapability= / DropCapability= | [Container] |
| devices | AddDevice= | [Container] |
| extra_hosts | AddHost= | [Container] |
| dns | DNS= | [Container] |
| labels | Label= | [Container] |
| read_only | ReadOnly= | [Container] |
| tmpfs | Tmpfs= | [Container] |
| sysctls | Sysctl= | [Container] |
| shm_size | ShmSize= | [Container] |
| ulimits | Ulimit= | [Container] |
| secrets | Secret= | [Container] |
| healthcheck | HealthCmd= and friends | [Container] |
| pull_policy | Pull= | [Container] |
| stop_grace_period | StopTimeout= | [Container] |
| depends_on | Requires= plus After= | [Unit] |
| restart | Restart= | [Service] |
If a compose option has no dedicated Quadlet key, you are not stuck. PodmanArgs= passes raw flags straight through to podman run, so anything the CLI supports is reachable:
[Container]
Image=docker.io/library/postgres:16
PodmanArgs=--memory=512m --cpus=1.5If you would rather not translate a large compose file by hand, our QuadletGen converter does the first pass in your browser and prints a coverage report showing which compose keys it could not map. Use it the same way you would use podlet: as a scaffold you then read line by line.
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.
Networking: Compose Networks vs Podman Networks
This is where most migrations silently break. In compose, every service on a shared network can reach every other service by its service name. web connects to db:5432 and it just works, because compose registers each service name as a DNS alias on the network it creates.
Quadlet gives you the same capability but different names. Two things change at once, and if you miss either one, your web container cannot resolve the database.
First, the network itself gets a prefix. A file called appnet.network creates a Podman network named systemd-appnet, not appnet. The same rule applies to volumes: pgdata.volume becomes systemd-pgdata. You can override both with NetworkName= and VolumeName= if you need to attach to a network that already exists.
Second, and far more damaging, the container name gets the same treatment. Quadlet names the container after the unit with a systemd- prefix, so db.container produces a container called systemd-db. DNS on a Podman network resolves container names, so db:5432 fails while systemd-db:5432 works.
[Container]
Image=docker.io/library/postgres:16
ContainerName=db
Network=appnet.networkThere is a third difference worth knowing. Podman's built-in default network, the one literally called podman, has DNS resolution disabled. Only user-defined networks run aardvark-dns and resolve container names. That is why a container with no Network= line at all can never reach a sibling by name, no matter how the names line up.
So the rule is simple: define a .network unit, reference it from every container in the stack, and set ContainerName= on anything another container talks to. You can confirm the wiring after starting the units:
podman network ls
podman network inspect systemd-appnet
podman exec -it web getent hosts dbOne more rootless-specific note. Rootless containers reach the outside world through a userspace network stack (pasta on current Podman versions, slirp4netns on older ones) rather than a kernel bridge on the host. Container-to-container traffic on a user-defined network still goes through a normal bridge inside your user network namespace, so inter-container DNS and performance are unaffected. What changes is that the host cannot reach a rootless container by its container IP, only through a published port.
Volumes and SELinux Labelling
Named volumes migrate cleanly. Bind mounts are where people lose an afternoon, and on Fedora, RHEL, CentOS Stream, or any other distribution with SELinux in enforcing mode, the reason is almost always labelling.
SELinux blocks a container process from reading or writing host files unless those files carry a label the container is allowed to access. Docker users often never hit this because many Docker setups run with SELinux disabled or with a permissive policy. Podman on a Red Hat family distribution does not give you that escape hatch, and the error you get is a bare Permission denied even though the file permissions look correct.
The fix is a suffix on the Volume= line. Podman relabels the host path for you, exactly as Docker does with the same syntax:
[Container]
Image=docker.io/library/caddy:2
Volume=/srv/caddy/config:/etc/caddy:z
Volume=/srv/caddy/data:/data:Z| Suffix | What it does | Use when |
|---|---|---|
| :z | Applies a shared SELinux label to the host path | More than one container mounts the same directory |
| :Z | Applies a private, unshared label to the host path | Exactly one container will ever mount that directory |
| (none) | No relabelling at all | Named volumes, or SELinux is not enforcing |
Named volumes do not need a suffix. Podman creates them inside its own storage directory, which already carries the correct container label. That is one practical argument for converting bind mounts to named volumes during the migration rather than after it.
If you need to check whether SELinux is actually the cause, getenforce tells you the current mode and sudo ausearch -m avc -ts recent lists the denials that fired in the last few minutes. A denial naming your container process and the mounted path confirms it.
Handling Secrets
The example stack has POSTGRES_PASSWORD=secret sitting in plain text inside a unit file. That is fine for a walkthrough and wrong for anything real, because Quadlet files are readable by anyone who can read the directory and the value ends up in the generated service unit too.
Podman has a secrets store, and Quadlet exposes it through the Secret= key. Create the secret once from the CLI, then reference it by name:
printf 'a-real-password' | podman secret create pg_password -
podman secret ls[Container]
Image=docker.io/library/postgres:16
ContainerName=db
Secret=pg_password,type=env,target=POSTGRES_PASSWORD
Network=appnet.networktype=env injects the secret as an environment variable named by target=. type=mount (the default) writes it to a file inside the container, at /run/secrets/<name> unless you set a different target= path. Use the mount form when the application can read a password from a file, since environment variables show up in podman inspect output and in the process environment.
Secrets are per user. A secret created as your unprivileged user is invisible to a rootful unit and vice versa, which catches people who develop with rootless units and deploy with rootful ones.
A middle-ground option is EnvironmentFile=, the direct replacement for compose's env_file. Point it at a file outside your unit directory with chmod 600 permissions. It is weaker than the secrets store but it maps one to one from an existing compose setup, which makes it a reasonable first step.
Health Checks and Readiness
Compose health checks translate to Quadlet directly. Each field of the compose healthcheck block has a matching key:
[Container]
Image=docker.io/library/postgres:16
ContainerName=db
HealthCmd=pg_isready -U postgres
HealthInterval=10s
HealthTimeout=5s
HealthRetries=5
HealthStartPeriod=30s
HealthOnFailure=killHealthOnFailure= is the one that needs thought. It accepts none, kill, restart, and stop. Under systemd you almost always want kill: Podman kills the unhealthy container, the unit enters a failed state, and your [Service] Restart=always policy brings it back. Choosing restart puts Podman and systemd in competition over the same container, which produces confusing restart loops.
There is a harder problem underneath. Compose lets you write depends_on with condition: service_healthy, which holds a dependent service back until the dependency reports healthy. Plain Requires= plus After= does not do that. systemd considers a unit started as soon as Podman reports the container running, which for a database is several seconds before it accepts connections.
If your Podman is too old for that, the portable answer is to make the application retry its own connections at startup. That is better engineering anyway: a database can restart at any point during the container's life, not only at boot, and a dependent service that only handles the startup case will still fall over later.
Before you migrate, it is worth auditing the compose file you already have. Our ComposeHealthCheck validator flags services missing condition: service_healthy, health check commands that call a binary the image does not ship, and circular dependency chains. Fixing those in compose first means you are not porting broken ordering into systemd.
Auto-Updating Images
Compose has no built-in update mechanism, so most people bolt on Watchtower or a cron job that runs docker compose pull && docker compose up -d. Podman ships the equivalent, and Quadlet turns it on with a single line.
Adding AutoUpdate=registry labels the container so podman auto-update will manage it. The command checks the registry for a newer digest of each labelled container's tag, pulls it, and restarts the corresponding systemd unit:
[Container]
Image=docker.io/library/caddy:2
ContainerName=web
AutoUpdate=registrypodman auto-update --dry-run
podman auto-update
systemctl --user enable --now podman-auto-update.timerRun the dry run first. It lists every labelled container and whether a newer image is available, without changing anything. Once you trust the output, enable the timer so updates happen on a schedule instead of whenever you remember.
AutoUpdate=local is the other mode. It skips the registry entirely and restarts the container when a newer image with the same tag appears in local storage, which suits images you build yourself on the same host.
Auto-update pairs well with health checks. When an updated container fails to reach a healthy state, Podman can roll the unit back to the previous image rather than leaving you with a crash loop. That safety net only exists if the container actually has a health check defined, which is another reason to port the compose healthcheck block rather than dropping it.
If you run image builds in CI and want the server to pick them up automatically, our GitHub Actions tutorial covers the push side of that pipeline: build, tag, and publish on merge, then let podman-auto-update.timer handle the deployment.
Systemd Integration Proper
Once the containers run, you inherit everything systemd knows how to do. This is the actual payoff of the migration, and it is worth spending time on rather than treating systemd as a dumb process supervisor.
Start with restart behaviour. Quadlet does not set a restart policy for you, so systemd's default of Restart=no applies unless you say otherwise. Compose's restart: values map like this:
| compose restart: | [Service] Restart= | Notes |
|---|---|---|
| no | no | systemd's default when the key is absent |
| always | always | Restarts on clean exit and on failure |
| on-failure | on-failure | Only restarts on a non-zero exit code |
| unless-stopped | always | Closest match; a manual `systemctl stop` keeps it stopped until you start it again or reboot |
There is a rate limit you will meet eventually. systemd gives up restarting a unit after 5 attempts within 10 seconds and marks it failed permanently. A container that crashes instantly on a bad config hits that limit in under a second, and the unit stays dead even after you fix the problem. Add a delay and widen the window:
[Unit]
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Restart=always
RestartSec=10
TimeoutStartSec=90TimeoutStartSec= matters more than it looks. Quadlet units are Type=notify, so systemd waits for a readiness signal before considering the unit started. A large image that has to be pulled on first boot can easily exceed the default timeout and get killed mid-pull, which then looks like a broken image rather than a slow one.
Next, ordering. Requires= plus After= covers the common case, but the full systemd vocabulary is more precise than depends_on ever was:
- `Requires=` makes the dependency mandatory. If it fails to start or is stopped, this unit is stopped too.
- `Wants=` is the soft version. The dependency is started alongside this unit, but a failure there does not stop this one.
- `After=` controls ordering only. It says nothing about whether the other unit must exist or succeed.
- `BindsTo=` is stricter than
Requires=: if the other unit stops for any reason at all, including a crash, this unit stops immediately. - `PartOf=` propagates stop and restart downward, which is how you build a stack you can restart with one command.
PartOf= is the answer to "how do I get docker compose restart back?" Create a plain target unit, point every container at it, and you have a single handle for the whole stack:
[Unit]
Description=My application stack
[Install]
WantedBy=default.target[Unit]
PartOf=mystack.target
[Install]
WantedBy=mystack.targetNote the directory. The .target file is a normal systemd unit, so it goes in ~/.config/systemd/user/, not the Quadlet directory. Only Quadlet's own file types (.container, .volume, .network, .pod, and friends) belong in ~/.config/containers/systemd/. After that, systemctl --user restart mystack.target cycles the whole stack.
Quadlet also wires up some dependencies for you. Reference appnet.network or pgdata.volume from a container and the generator adds the appropriate Requires= and After= on the generated appnet-network.service and pgdata-volume.service units automatically. You do not need to declare those by hand.
Finally, decide deliberately between user units and system units. It is not only a permissions question, it changes the entire lifecycle. User units live in your session, are managed with systemctl --user, and are torn down when your last session ends unless lingering is enabled. System units live in /etc/containers/systemd/, start during boot alongside everything else, and never depend on a login.
sudo loginctl enable-linger $USER
loginctl show-user $USER --property=LingerThe second command is the one to remember. Linger=yes confirms the setting took effect, which saves you from discovering the problem during an unplanned reboot at 2am.
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.
Rootless Containers and UID Mapping
Rootless is the right default, and it is also the source of the single most confusing class of migration bug: files the container cannot write, on a directory that looks perfectly writable from the host.
The cause is user namespaces. In a rootless container, UID 0 inside the container maps to your own UID on the host. Every other container UID maps into a range allocated to you in /etc/subuid and /etc/subgid, typically starting at 100000. So a process running as UID 999 inside the container writes files that the host sees as owned by UID 100998.
grep "$USER" /etc/subuid /etc/subgid
podman unshare cat /proc/self/uid_mapThat mapping produces two symmetrical failures. A host directory owned by your user is not writable by a container process running as a non-root user, because that process is really UID 100998 as far as the kernel is concerned. And files a container writes into a bind mount appear on the host owned by a UID that does not correspond to any account, so ls -l shows a bare number and you cannot edit them without sudo.
There are three ways out, and which one you want depends on the image.
The first is UserNS=keep-id, which maps your host UID to the same UID inside the container instead of to 0. This is the cleanest fix for an image that runs as a normal user and mounts a directory from your home:
[Container]
Image=docker.io/library/node:22
UserNS=keep-id
Volume=%h/projects/app:/app:z%h is a systemd specifier for your home directory, which keeps the unit portable across machines and users. It works in Quadlet files because they are expanded by systemd like any other unit.
The second option is to fix the ownership on the host using the container's UID semantics. podman unshare runs a command inside your user namespace, so a chown there uses the numbers the container will see:
podman unshare chown -R 999:999 /srv/appdata
podman unshare rm -rf /srv/appdata/tmpThat second line is the one people search for after a container writes files they cannot delete. podman unshare rm works where plain rm gives permission denied, because inside the namespace you are root over that subuid range.
The third option is to avoid bind mounts entirely and use named volumes. Podman creates them inside your own storage with ownership that matches, so the whole class of problem disappears. If a directory does not need to be edited from the host, make it a named volume.
Two smaller rootless differences are worth noting. Your subuid range must be large enough for the highest UID the image uses; a range of 65536 is the usual allocation and covers almost everything, but an image that runs as UID 100000 will fail to start. And rootless containers cannot see the host's full process table or load arbitrary kernel modules, so monitoring agents and anything that expects CAP_SYS_ADMIN generally need a rootful unit.
Debugging Quadlet Units
When a unit does not start, the failure is in one of two places: the Quadlet file failed to generate a service at all, or the service generated fine and the container failed at runtime. They look identical from systemctl start and they are diagnosed completely differently.
Start by checking whether the service exists. If systemd has never heard of web.service, the generator did not produce it, and no amount of reading container logs will help:
systemctl --user list-unit-files "web*"
systemctl --user status web.serviceIf the unit is missing, the usual cause is the one every Quadlet user hits at least once: you edited the file and did not reload. Generators only run during a daemon reload, so an edited .container file has no effect until you tell systemd to regenerate:
systemctl --user daemon-reloadIf the service exists but the container dies, everything you need is in the journal. Quadlet units log through systemd, which means one query gets you the unit's own messages and the container's stdout and stderr together:
journalctl --user -u web.service -n 100 --no-pager
journalctl --user -u web.service -f
journalctl --user -u web.service --since "10 minutes ago" -p errFor system units, drop --user and add sudo. The -p err filter is useful on a noisy unit because it hides the routine startup chatter and leaves the actual error.
A few more commands cover the remaining cases. podman ps -a shows containers that exited, including the exit code. podman logs systemd-web reads the container log directly if you suspect the journal is truncating. And podman healthcheck run <container> executes the health check once and prints the result, which tells you whether a failing check is the application's fault or a broken command.
- Unit not found after an edit. Run
systemctl --user daemon-reload. This is the answer roughly half the time. - Unit exists but immediately fails. Check
journalctl --user -u <unit>for the podman error line, usually an image pull failure or an invalid flag. - Container starts then exits cleanly. The image's default command finished. Compose's
command:maps toExec=, so check you carried it over. - Permission denied on a bind mount. SELinux label (
:z/:Z) or a UID mapping mismatch. Checkgetenforcefirst, then the UID. - Container cannot resolve a sibling. Missing
.networkunit, missingNetwork=line, or the container name issystemd-<unit>and you expected the compose name. - Everything stops when you log out. Lingering is not enabled. Confirm with
loginctl show-user $USER --property=Linger.
Compose Features With No Quadlet Equivalent
Be honest with yourself about the gaps before you commit to the migration. Quadlet is a better fit for long-running services, but compose does a few things it simply does not do, and discovering that halfway through is unpleasant.
| Compose feature | Quadlet status | Workaround |
|---|---|---|
| profiles: | No equivalent | Separate unit directories, or a target unit per profile |
| deploy.replicas / scale | No equivalent | Systemd template units, or move to Kubernetes |
| extends: and YAML anchors | No equivalent | Systemd drop-in files under a `.d/` directory |
| ${VAR} interpolation from a .env file | Not supported the same way | `EnvironmentFile=`, or systemd specifiers such as `%h` |
| depends_on: condition: service_healthy | Partial | `Notify=` readiness gating on recent Podman, or retries in the app |
| A single command for the whole stack | No direct equivalent | A `.target` unit with `PartOf=` on each container |
| build: | Supported via a separate unit type | A `.build` file on Podman 5.0 and later; otherwise build in CI |
| docker compose logs across services | No equivalent | `journalctl --user -u 'web*' -u 'db*'` or a shared unit prefix |
The interpolation gap is the one that surprises people most. Compose files are commonly parameterised with ${TAG} or ${DB_HOST} read from a .env file at the project root, and Quadlet has no equivalent of that at the unit-file level. You can use EnvironmentFile= for values the container consumes, but you cannot use a variable to choose the image tag in Image=.
If that pattern is central to how you deploy, the pragmatic answer is to generate the unit files from a template in CI and copy them onto the host, rather than trying to make one unit file serve every environment.
One thing worth saying plainly: none of this makes Quadlet a Kubernetes replacement, and it is not trying to be. If you need scheduling across more than one machine, rolling updates, or horizontal scaling, that is a different tool. Quadlet is the right answer for a single host running a fixed set of services, which describes most homelabs, most small production deployments, and a surprising number of internal tools. If you are weighing the jump to a cluster instead, our look at Kubernetes swap support and the Kubernetes fundamentals guide cover what that step actually involves.
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.
Why can't my Quadlet containers reach each other by name?
Three causes account for nearly every instance of this. Work through them in order.
- No user-defined network. Podman's built-in
podmannetwork has DNS resolution turned off. Create a.networkunit and addNetwork=appnet.networkto every container in the stack. - The container name is not what you think. Quadlet names containers
systemd-<unit>, sodb.containerbecomessystemd-dbin DNS. AddContainerName=dbto keep the compose name. - The units are on different networks. Confirm with
podman network inspect systemd-appnetand check that every container you expect is listed.
podman exec -it web getent hosts dbIf that command returns an address, DNS is fine and the problem is in the application's connection string or the port. If it returns nothing, one of the three causes above still applies.
What is the difference between :z and :Z on a volume mount?
Both tell Podman to relabel the host path so an SELinux-confined container can access it. The difference is whether the resulting label is shareable.
| :z (lowercase) | :Z (uppercase) | |
|---|---|---|
| Label applied | Shared content label | Private, container-specific label |
| Other containers can access | Yes | No |
| Use for | Config directories mounted by several containers | Data directories owned by exactly one container |
| Risk | Slightly broader access than necessary | Recursively relabels the path and can break host services |
Never apply either suffix to a broad system path such as /home, /var, or /usr. Relabelling is recursive and it changes the labels on the host filesystem, not inside the container. Mount a narrow, dedicated subdirectory instead. Named volumes need no suffix at all because Podman creates them already correctly labelled.
How do I keep passwords out of my Quadlet files?
Use Podman's secrets store and reference the secret by name from the unit. Create it once from the CLI, then the unit file contains only the name:
printf 'a-real-password' | podman secret create pg_password -[Container]
Secret=pg_password,type=env,target=POSTGRES_PASSWORDtype=mount is the alternative and generally the safer one, since it writes the value to a file inside the container rather than into the process environment where podman inspect can read it. Secrets are scoped per user, so a rootless secret is not visible to a rootful unit. If you only need a quick improvement over inline values, EnvironmentFile= pointed at a chmod 600 file outside the unit directory is a reasonable intermediate step.
How do I start or restart the whole stack with one command?
Quadlet has no concept of a compose project, so there is no built-in up or down for a group of units. Create a systemd target and attach each container to it with PartOf=, which propagates stop and restart to every member.
[Unit]
PartOf=mystack.target
[Install]
WantedBy=mystack.targetsystemctl --user restart mystack.targetThe mystack.target file itself is a plain systemd unit, so it belongs in ~/.config/systemd/user/ rather than the Quadlet directory. A .pod unit is the other option when the containers should genuinely share a network namespace, though it is a stronger coupling than most compose stacks need.
I edited a .container file and nothing changed. Why?
Quadlet files are read by a systemd generator, and generators only run during a daemon reload. Until you reload, systemd is still using the service unit it generated from the previous version of your file.
systemctl --user daemon-reload
systemctl --user restart web.serviceIf the unit still does not reflect your change after a reload, the generator probably rejected the file. Run it in dry-run mode to see the parse errors and the exact service unit it produces: /usr/libexec/podman/quadlet -dryrun -user, or without -user for system units. An unrecognised directive name is a hard failure there, which makes typos easy to spot.
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.