Dev Encyclopedia
ArticlesToolsContactAbout

Get notified when new content drops

No spam. Just new articles, tools, and updates straight to your inbox.

Dev Encyclopedia

A reference for builders

Dev.to
Discord
WhatsApp Channel
daily.dev
Hashnode
X

Content

  • Articles
  • Tools
  • About
  • Contact

Connect

  • support@devencyclopedia.com
  • RSS Feed

Legal

  • Privacy Policy
  • Terms of Service
  • Disclaimer

© 2026 Dev Encyclopedia

Back to top ↑
  1. Home
  2. /Blog
  3. /Docker Compose to Podman Quadlet: A Practical Migration Guide
devops32 min read

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.

Zeeshan Tofiq
Zeeshan Tofiq
July 15, 2026
On this page

On this page

  • The Deprecation Warning You Just Hit
  • What a Quadlet Actually Is
  • The Example Stack
  • Converting Service by Service
  • Compose Directive to Quadlet Directive Map
  • podlet as a Starting Point
  • Networking: Compose Networks vs Podman Networks
  • Volumes and SELinux Labelling
  • Handling Secrets
  • Health Checks and Readiness
  • Auto-Updating Images
  • Systemd Integration Proper
  • Gotchas
  • Rootful vs Rootless
  • Rootless Containers and UID Mapping
  • Debugging Quadlet Units
  • Compose Features With No Quadlet Equivalent
  • Frequently Asked Questions

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

text — terminal
$ 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 unit types you'll actually use

.container for a single container, .volume for a named volume, .network for a network, and .pod for grouping containers into a pod. Each type maps to its own systemd generator section ([Container], [Volume], [Network], [Pod]).

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.

yaml — docker-compose.yml
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. 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.target

    Notice the mapping: image becomes Image, environment becomes Environment, and each volume and network line points at a matching .volume or .network unit rather than a raw name. Use the full registry path for the image, docker.io/library/postgres:16 rather than just postgres, so the generated service doesn't stall resolving an ambiguous short name.

  2. 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.target

    That depends_on in compose turns into Requires= plus After= in the [Unit] section. Requires makes the dependency hard, After controls ordering. Quadlet appends .service to the unit name for you, so referencing db.service (matching the db.container file) is enough.

  3. 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 named systemd-pgdata. appnet.network:

    ini — ~/.config/containers/systemd/appnet.network
    [Network]

    ⚠ Warning

    Don't skip the network unit. If your compose file has no explicit network and you let a converter drop it, your containers land on separate default networks and can't talk to each other. Define the network unit and reference it from every container that needs it.

  4. 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 --user flag. Tell systemd to regenerate the service units from your Quadlet files, then start the web service (its Requires= pulls in the database automatically):

    bash
    systemctl --user daemon-reload
    systemctl --user start web.service

    You start the .service, not the .container. Forgetting daemon-reload after editing a Quadlet file is the single most common reason "my change didn't take."

  5. 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:

    bash
    sudo loginctl enable-linger $USER

    ⚠ Warning

    Skip this step and your rootless containers stop the moment your session ends, which surprises almost everyone the first time they reboot a server running rootless Quadlets.

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.

Common Docker Compose keys and the Quadlet directives that replace them
docker-compose keyQuadlet keySection
imageImage=[Container]
container_nameContainerName=[Container]
commandExec=[Container]
environmentEnvironment=[Container]
env_fileEnvironmentFile=[Container]
portsPublishPort=[Container]
volumesVolume=[Container]
networksNetwork=[Container]
userUser= and Group=[Container]
working_dirWorkingDir=[Container]
hostnameHostName=[Container]
cap_add / cap_dropAddCapability= / DropCapability=[Container]
devicesAddDevice=[Container]
extra_hostsAddHost=[Container]
dnsDNS=[Container]
labelsLabel=[Container]
read_onlyReadOnly=[Container]
tmpfsTmpfs=[Container]
sysctlsSysctl=[Container]
shm_sizeShmSize=[Container]
ulimitsUlimit=[Container]
secretsSecret=[Container]
healthcheckHealthCmd= and friends[Container]
pull_policyPull=[Container]
stop_grace_periodStopTimeout=[Container]
depends_onRequires= plus After=[Unit]
restartRestart=[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:

ini
[Container]
Image=docker.io/library/postgres:16
PodmanArgs=--memory=512m --cpus=1.5

💡 Use PodmanArgs sparingly

Anything you put in PodmanArgs= is unvalidated by Quadlet and will not be checked when the generator runs. Prefer a real directive when one exists, and keep PodmanArgs= for the handful of flags that genuinely have no key.

If 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:

bash
podlet compose docker-compose.yml

It'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.

🚫 Always set ContainerName explicitly

If your application config, connection string, or another container refers to a service by its compose name, add ContainerName=db to the [Container] section. Otherwise the container is registered in DNS as systemd-db and every hostname in your existing config is wrong.

ini — ~/.config/containers/systemd/db.container
[Container]
Image=docker.io/library/postgres:16
ContainerName=db
Network=appnet.network

There 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:

bash
podman network ls
podman network inspect systemd-appnet
podman exec -it web getent hosts db

One 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:

ini
[Container]
Image=docker.io/library/caddy:2
Volume=/srv/caddy/config:/etc/caddy:z
Volume=/srv/caddy/data:/data:Z
SELinux relabelling suffixes for bind mounts
SuffixWhat it doesUse when
:zApplies a shared SELinux label to the host pathMore than one container mounts the same directory
:ZApplies a private, unshared label to the host pathExactly one container will ever mount that directory
(none)No relabelling at allNamed volumes, or SELinux is not enforcing

🚫 Never put :Z on a shared or system path

Relabelling is recursive and it rewrites the labels on the host, not inside the container. Mounting /home, /usr, /var, or your entire home directory with :Z will relabel every file underneath it and can leave the host unable to log in or start services. Mount a specific, dedicated subdirectory instead.

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:

bash
printf 'a-real-password' | podman secret create pg_password -
podman secret ls
ini — ~/.config/containers/systemd/db.container
[Container]
Image=docker.io/library/postgres:16
ContainerName=db
Secret=pg_password,type=env,target=POSTGRES_PASSWORD
Network=appnet.network

type=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.

⚠ The default secret driver is not encryption

Podman's default file driver stores secrets under your containers storage directory protected only by filesystem permissions. It keeps credentials out of unit files and out of Git, which is the main win, but it is not a vault. If you need encryption at rest or centralised rotation, point Podman at an external driver instead.

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:

ini
[Container]
Image=docker.io/library/postgres:16
ContainerName=db
HealthCmd=pg_isready -U postgres
HealthInterval=10s
HealthTimeout=5s
HealthRetries=5
HealthStartPeriod=30s
HealthOnFailure=kill

HealthOnFailure= 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.

ℹ Readiness gating

Quadlet generates Type=notify units, and the Notify= key controls what counts as ready. Recent Podman versions accept a value that delays the ready signal until the container's health check passes, which is the closest equivalent to condition: service_healthy. Check man podman-systemd.unit on your installed version before relying on it, since the accepted values have changed across releases.

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:

ini
[Container]
Image=docker.io/library/caddy:2
ContainerName=web
AutoUpdate=registry
bash
podman auto-update --dry-run
podman auto-update
systemctl --user enable --now podman-auto-update.timer

Run 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 plus a floating tag is a bad combination

AutoUpdate=registry with :latest means a breaking major version can land on your server unattended. Pin to a major tag like postgres:16 so updates stay inside a compatible range, and keep auto-update off entirely for stateful services where a bad upgrade means a migration you cannot reverse.

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:

Mapping compose restart policies to systemd Restart= values
compose restart:[Service] Restart=Notes
nonosystemd's default when the key is absent
alwaysalwaysRestarts on clean exit and on failure
on-failureon-failureOnly restarts on a non-zero exit code
unless-stoppedalwaysClosest 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:

ini
[Unit]
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
Restart=always
RestartSec=10
TimeoutStartSec=90

TimeoutStartSec= 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:

ini — ~/.config/systemd/user/mystack.target
[Unit]
Description=My application stack

[Install]
WantedBy=default.target
ini
[Unit]
PartOf=mystack.target

[Install]
WantedBy=mystack.target

Note 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.

bash
sudo loginctl enable-linger $USER
loginctl show-user $USER --property=Linger

The 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, not postgres:latest. A Quadlet with AutoUpdate=registry plus a latest tag can pull a breaking major version update at 3am with nobody watching.
  • Use full registry paths. docker.io/library/postgres:16 instead of postgres, 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-update managing 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 .network file needs systemctl --user daemon-reload (or without --user for system units) before it takes effect.
  • Check logs with journalctl, not scattered files. journalctl --user -u web.service puts 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

RootlessRootful
Unit file location~/.config/containers/systemd//etc/containers/systemd/
Manage withsystemctl --user ...systemctl ... (no --user)
Survives logout by defaultNo, needs enable-lingerYes, always
Runs asYour userroot
Best forPersonal services, homelabs, least privilegeSystem-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.

bash
grep "$USER" /etc/subuid /etc/subgid
podman unshare cat /proc/self/uid_map

That 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:

ini
[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:

bash
podman unshare chown -R 999:999 /srv/appdata
podman unshare rm -rf /srv/appdata/tmp

That 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.

⚠ Ports below 1024

A rootless container cannot bind a privileged port. PublishPort=80:80 fails until you either lower the threshold system-wide with sysctl net.ipv4.ip_unprivileged_port_start=80, publish to a high port and put a reverse proxy in front, or run that particular unit rootful from /etc/containers/systemd/. Mixing rootless application units with one rootful proxy unit is a common and reasonable layout.

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:

bash
systemctl --user list-unit-files "web*"
systemctl --user status web.service

If 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:

bash
systemctl --user daemon-reload

💡 See exactly what Quadlet generated

Run the generator by hand in dry-run mode: /usr/libexec/podman/quadlet -dryrun -user prints the full .service file it would produce for every unit, plus parse errors for the ones it rejects. Drop -user for system units. This is the fastest way to find a typo in a directive name, because an unrecognised key is a hard error, not a warning.

If 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:

bash
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 err

For 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 to Exec=, so check you carried it over.
  • Permission denied on a bind mount. SELinux label (:z/:Z) or a UID mapping mismatch. Check getenforce first, then the UID.
  • Container cannot resolve a sibling. Missing .network unit, missing Network= line, or the container name is systemd-<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 capabilities that do not map cleanly onto Quadlet
Compose featureQuadlet statusWorkaround
profiles:No equivalentSeparate unit directories, or a target unit per profile
deploy.replicas / scaleNo equivalentSystemd template units, or move to Kubernetes
extends: and YAML anchorsNo equivalentSystemd drop-in files under a `.d/` directory
${VAR} interpolation from a .env fileNot supported the same way`EnvironmentFile=`, or systemd specifiers such as `%h`
depends_on: condition: service_healthyPartial`Notify=` readiness gating on recent Podman, or retries in the app
A single command for the whole stackNo direct equivalentA `.target` unit with `PartOf=` on each container
build:Supported via a separate unit typeA `.build` file on Podman 5.0 and later; otherwise build in CI
docker compose logs across servicesNo 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):

ini
[Unit]
Requires=db.service
After=db.service

Requires= 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.

  1. No user-defined network. Podman's built-in podman network has DNS resolution turned off. Create a .network unit and add Network=appnet.network to every container in the stack.
  2. The container name is not what you think. Quadlet names containers systemd-<unit>, so db.container becomes systemd-db in DNS. Add ContainerName=db to keep the compose name.
  3. The units are on different networks. Confirm with podman network inspect systemd-appnet and check that every container you expect is listed.
bash
podman exec -it web getent hosts db

If 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 appliedShared content labelPrivate, container-specific label
Other containers can accessYesNo
Use forConfig directories mounted by several containersData directories owned by exactly one container
RiskSlightly broader access than necessaryRecursively 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:

bash
printf 'a-real-password' | podman secret create pg_password -
ini
[Container]
Secret=pg_password,type=env,target=POSTGRES_PASSWORD

type=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.

ini
[Unit]
PartOf=mystack.target

[Install]
WantedBy=mystack.target
bash
systemctl --user restart mystack.target

The 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.

bash
systemctl --user daemon-reload
systemctl --user restart web.service

If 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.

Zeeshan Tofiq

Zeeshan Tofiq

Full Stack Developer

Full stack developer with over 6 years of experience building production applications. Writes practical guides on JavaScript, TypeScript, React, Node.js, and cloud infrastructure. Focused on helping developers solve real problems with clean, maintainable code.

Enjoyed this article?

Get practical dev guides, tool updates, and new articles delivered straight to your inbox. No spam, unsubscribe anytime.

Related Articles

devops

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.

Jun 26, 2026·40 min read
devops

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.

Jun 15, 2026·58 min read

On this page

  • The Deprecation Warning You Just Hit
  • What a Quadlet Actually Is
  • The Example Stack
  • Converting Service by Service
  • Compose Directive to Quadlet Directive Map
  • podlet as a Starting Point
  • Networking: Compose Networks vs Podman Networks
  • Volumes and SELinux Labelling
  • Handling Secrets
  • Health Checks and Readiness
  • Auto-Updating Images
  • Systemd Integration Proper
  • Gotchas
  • Rootful vs Rootless
  • Rootless Containers and UID Mapping
  • Debugging Quadlet Units
  • Compose Features With No Quadlet Equivalent
  • Frequently Asked Questions