Docker vs Podman for Self-Hosted Apps: When Compose Wins, When Quadlet Wins
If you run more than a handful of self-hosted services, you eventually ask the question that starts a fight in every homelab forum: Docker or Podman? I've been running servers for years, and the honest answer is that this is not a religion war.
Both tools run the same OCI images, both will happily host your n8n, your reverse proxy, and your monitoring stack, and both have rough edges you will discover at 2 AM. The real differences are operational: who owns the process, what privileges it has, and how it restarts when something dies.
This post is the comparison I wish someone had written for me (many) years ago. I cover Docker Compose vs Podman Quadlet, rootful vs rootless security in real depth (including what containerd actually does in that story and how you debug a rootless container with the host's own tools), daemon vs daemonless architecture, systemd integration, upgrades and restarts, resource limits, Watchtower vs podman auto-update, Docker API compatibility, and the genuinely annoying operational cost of managing many Quadlet files.
It ends with a decision rule for when Compose, rootless Podman, or Kubernetes is actually the right call.
- Podman
6.0.0 - Docker Engine
29.7.x - Quadlet
since Podman 4.4
Podman 6.0.0 (July 2026) and Docker Engine 29.x were current when this was checked against the official release notes. Quadlet has shipped in Podman since 4.4 (January 2023). Commands assume a modern distro with systemd >= 247 and cgroup v2.
The short version
- Docker is a client/server system.
dockerdruns as a root daemon, containerd manages the runtime underneath it, and Compose is the de-facto standard file format for multi-container stacks. Mature, portable, and the path of least resistance for almost every tutorial you will find. - Podman is daemonless and rootless by default. Each container is a process forked from your session and supervised by systemd or conmon. Quadlet turns a container into a systemd unit, which is either exactly what you want or a different way to think entirely.
- Rootless is a real security win, not a magic one. It removes the root daemon and its socket from the attack surface, and a compromised rootless container lands as an unprivileged user, not root. It does not sandbox the kernel, and it comes with networking and resource-limit costs.
- Compose wins for multi-service application stacks and portability. Quadlet wins for single long-running services that should behave like systemd units. They are different tools for different jobs, and you will probably end up running both.
- Watchtower and `podman auto-update` embody the difference in one command each: Watchtower pulls and recreates containers automatically;
podman auto-updatepulls the image, and *you* own the restart. - Kubernetes is not the upgrade path for a one-box homelab. It is the answer when you have multiple hosts and need the platform, and it runs containerd under the hood anyway.
What each tool actually is
Docker: a client/server stack with a root daemon
Docker Engine is three pieces bolted together. The docker CLI talks to dockerd, the daemon, over a socket or network API. The daemon handles images, networks, volumes, and the container lifecycle API. Below it, containerd (the same runtime that powers Kubernetes), does the grunt work of pulling image layers, managing storage, and running containers through a low-level runtime like runc. There is also a per-container containerd-shim that keeps the container alive independently of the CLI.

The important part for the security discussion: in a standard install, every one of those layers runs as root, and the daemon is a single root-owned process with an API. Adding a user to the docker group is effectively giving them root, because the API can mount host paths, run privileged containers, and write to the daemon's root-owned state. That is a feature when you want it and a footgun on a shared box.
Rootless Docker exists (rootlesskit plus newuidmap/newgidmap), but it is opt-in, less well-tested, and still runs the whole daemon-plus-containerd stack, just as your user instead of root. Podman's rootless mode is a different architecture, not a flag on the same one.
Podman: daemonless, fork/exec, rootless by default
Podman has no daemon. When you run podman run, the CLI talks to the runtime via conmon, a small supervisor that owns the container's stdio and lifecycle, which forks crun or runc to start the actual container process. No central daemon, no socket with API-level root, no containerd in the stack at all. Each container is just a child of your session, which is why it integrates with systemd so naturally: the service manager becomes the process supervisor.
On a distro with working user namespaces, rootless is the default. Your containers run inside a user namespace with a mapped UID range (/etc/subuid and /etc/subgid), so the container's root maps to *your* unprivileged UID on the host.
Networking is handled in userspace with pasta. Quadlet, shipped since Podman 4.4, generates systemd units from .container, .volume, and .network files, which is the piece that makes Podman feel like a first-class citizen of a Linux box instead of a sidecar.
Daemon vs daemonless: what actually changes for you
The architecture difference is not abstract. It shows up in three places.
Who owns the process tree. With Docker, the daemon owns everything. If dockerd crashes or you restart it for an upgrade, containers with a restart policy come back when the daemon does, but they all go through the daemon's lifecycle, and a hung daemon is a single point of failure for every container on the box. With Podman, each container is an ordinary process with an ordinary parent. If that parent is a systemd unit, systemd supervises it, restarts it, and tracks it the way it tracks any other service. Kill the daemon scenario entirely; there is no daemon.
What restarts mean. systemctl restart on a Quadlet unit is a clean, systemd-native restart with journald logging and dependency ordering. With Compose, docker compose restart and docker compose up -d do different things; the latter recreates containers whose configuration changed. You are managing the stack through Compose's lifecycle, not the init system's, unless you wrap it in a unit.
Blast radius on upgrades. Upgrading Docker means upgrading a root daemon and restarting it. Upgrading Podman means swapping binaries. 6.0.0 is current as of this writing, and the release notes for it are a useful reminder that Podman does breaking changes too (v6.0.0 must be used with matching Buildah, Skopeo, and Netavark versions). But the upgrade is a package update, not a daemon restart that can take your whole container fleet with it.
Rootful vs rootless security, in depth
This is where most comparisons get either preachy or shallow. Let's be precise about what each model protects and what it does not.
What rootful actually means
In a default Docker install, dockerd and containerd run as root system services. The risk surface is not the containers themselves; it is the API and the group membership around it:
- Anyone with access to the Docker socket (
/var/run/docker.sock) can issue API calls that are effectively root: mounting host filesystems, launching privileged containers, reading secrets from other containers' environments. - Members of the
dockergroup get that access by default. On a single-user homelab box that is fine; on a shared server it is a root hole wearing a convenience hat. - If a container is compromised, the *container's* root is still constrained by namespaces and cgroups, but the daemon's root is not, and the daemon is the juiciest target on the host.
The mitigation ladder is well-known: don't expose the socket, don't add users to the group, run containers as non-root users, drop capabilities, read-only root filesystems. All of it helps. None of it changes the fact that a root daemon with an API is a large, root-owned attack surface that must be kept patched and locked down.
What rootless actually means
Podman's rootless model removes that layer by construction:
- No daemon, so there is no root-owned process with API-level power to compromise. The
docker group ≈ rootproblem disappears because there is no group and no socket. - Containers run in a user namespace. UID 0 inside the container is mapped to your unprivileged UID on the host, and the rest of the container's UIDs map into a subordinate range assigned to you. A container that "gets root" gets root *inside its own namespace*, which is a bounded, non-privileged user on the host.
- Networking is userspace NAT (
pasta), so the container does not create kernel-level interfaces owned by root.
And here is the part about containerd people skip: in a rootful Docker stack, containerd is part of the root-owned layer. It is the runtime that actually launches container processes, and it runs as root, so even if you harden the daemon, you still have a root system service that talks to runc. Rootless Docker keeps containerd too, just demoted to run under rootlesskit as your user. Podman does not use containerd at all; conmon talks to crun/runc directly in your namespace. That is the structural difference, and it is why rootless Podman is genuinely simpler to reason about: there is no root daemon, no root containerd, no root shims.
The honest limits of rootless
Rootless is not a sandbox, and pretending otherwise is how people get hurt:
- Kernel exploits still land on the host kernel. A container escape in a rootless setup escapes *namespaces*, not the kernel. The blast radius is one unprivileged user's files and processes instead of root; that is a huge reduction, but it is not zero.
- Same-user visibility. Your rootless containers run as your UID. Host files and processes that your user can access are in the same privilege class. On a single-user homelab that is mostly a non-issue; on a multi-user box, user isolation is the boundary, and that is a much better boundary than the docker group, but it is still a boundary between users, not a sandbox.
- It costs you something. Userspace networking is slower for high-throughput traffic, published ports bind on loopback by default (fine for most self-hosted apps, surprising if you expected LAN-exposed ports), binding ports below 1024 needs extra setup, and some images that insist on privileged operations simply will not run rootless.
Debugging a rootless container with the host's own tools
Here is the part that surprises people coming from rootful Docker: rootless containers are easier to debug with host tools, not harder, because they are your processes. You do not need to exec into the container to inspect it; you can reach into it from the host with the same tools you already know.
$ podman ps
CONTAINER ID IMAGE COMMAND ...
9f3c... docker.io/vaultwarden/server vaultwarden
$ PID=$(podman inspect -f '{{.State.Pid}}' vaultwarden)
$ ps -fp $PID # it is an ordinary process on the host
$ ls -l /proc/$PID/root # the container's rootfs, reachable from /proc
$ nsenter -t $PID -m -u -i -n -p # jump into its namespaces (no sudo needed
# for containers you own: you are mapped root in the user ns)
$ sudo nsenter -t $PID -p # host root can enter any namespace, rootless or not
$ strace -p $PID # it's your process: strace works without sudo
$ podman unshare # operate inside your user namespace if neededThe container's main process has a real PID on the host: podman inspect -f '{{.State.Pid}}' <name> gives it to you. From there, everything you know about ps, /proc, nsenter, and strace applies. The neat detail: because the container's user namespace is *your* namespace and you are mapped as root inside it, you can nsenter into the mount, PID, and network namespaces of your own containers without sudo. Host root can enter any namespace, rootless or not. And strace -p $PID works because the process is genuinely yours, which was never true for rootful containers without root.
For filesystem-level poking, /proc/$PID/root is a window into the container's root filesystem from the host. For anything that needs to run inside the user namespace itself, podman unshare drops you into that context. The only things you cannot do from the host are things that require the container's own view of the world, and for those, podman exec still exists. Debugging a rootless container is not the maze people assume; it is the same /proc archaeology you already do, with fewer permission walls.
systemd integration
This is Podman's home turf, and it is the reason the comparison gets interesting.
Quadlet: containers as systemd units
Drop a file like this in ~/.config/containers/systemd/ (rootless) or /etc/containers/systemd/ (system-wide):
# ~/.config/containers/systemd/vaultwarden.container
[Unit]
Description=Vaultwarden
[Container]
Image=docker.io/vaultwarden/server:latest
Volume=vaultwarden-data:/data
PublishPort=8080:80
[Service]
Restart=on-failure
MemoryMax=1G
[Install]
WantedBy=default.targetThen systemctl --user daemon-reload && systemctl --user enable --now vaultwarden. You get the full systemd contract: systemctl start/stop/restart/status, journalctl --user -u vaultwarden for logs, dependency ordering with After= and Requires=, and boot startup (with loginctl enable-linger $USER so user services start without a login session). The container is no longer a special snowflake; it is a service like any other, which is exactly the mental model sysadmins want.
Quadlet also understands .volume, .network, and (newer Podman) .pod units, so the supporting cast is declared in systemd terms too. Since Podman 4.4 this has been the recommended way to run containers under systemd, replacing the older podman generate systemd workflow, which still works if you are maintaining legacy units.
Compose: restart policies instead of units
Compose has its own lifecycle: docker compose up -d, restart: unless-stopped, healthchecks, and depends_on. It is self-contained and portable: the same docker-compose.yml runs on your laptop, your VPS, and CI. The cost is that systemd sees one opaque process (the Compose-managed containers) and cannot reason about individual services. If you want systemd-level supervision you wrap the whole stack in a unit that runs docker compose up -d, and systemd can only restart the stack as a unit, not per-service.
That is the trade in one line: Compose gives you stack-level management and portability; Quadlet gives you unit-level management and native init integration. Choose the one whose failure mode you would rather debug at 3 AM.
Upgrades and restarts
Docker Compose flow: edit docker-compose.yml, run docker compose pull && docker compose up -d. Compose recreates only the containers whose configuration or image changed. Rollback is a git revert of the file plus the same command. Simple, and the whole app stack moves together, which is what you want for a multi-service app with a shared network.
Podman flow: podman pull <image>, then systemctl --user daemon-reload && systemctl --user restart <unit> to apply the new image. Quadlet units are regenerated on daemon-reload, so config changes to the .container file apply on the next restart. Rollback is pointing the unit at the old tag or digest and restarting. Per-service, which is what you want when one service misbehaves and the other eleven should stay up.
A practical warning from the 2026 release train: Podman 6.0.0 introduced breaking changes (it requires matching Buildah, Skopeo, and Netavark/Aardvark versions), and Docker Engine 29.x is a "foundational" release that changes some long-standing internals. Both projects do breaking changes; pin your images by digest for anything you cannot afford to re-test, and read release notes before distro auto-updates pull you forward.
Resource limits
Both tools give you the same cgroup-based knobs; the difference is where you set them and how well they are enforced.
| Feature | Docker (run / Compose) | Podman (run / Quadlet) |
|---|---|---|
| Memory | `--memory` / `mem_limit` | `--memory` / `[Service] MemoryMax=` |
| Swap | `--memory-swap` / `memswap_limit` | `--memory-swap` / `MemorySwapMax=` |
| CPU | `--cpus` / `cpus` | `--cpus` / `CPUQuota=` |
| Processes | `--pids-limit` / `pids_limit` | `--pids-limit` / `TasksMax=` |
Memory
- Docker (run / Compose)
- `--memory` / `mem_limit`
- Podman (run / Quadlet)
- `--memory` / `[Service] MemoryMax=`
Swap
- Docker (run / Compose)
- `--memory-swap` / `memswap_limit`
- Podman (run / Quadlet)
- `--memory-swap` / `MemorySwapMax=`
CPU
- Docker (run / Compose)
- `--cpus` / `cpus`
- Podman (run / Quadlet)
- `--cpus` / `CPUQuota=`
Processes
- Docker (run / Compose)
- `--pids-limit` / `pids_limit`
- Podman (run / Quadlet)
- `--pids-limit` / `TasksMax=`
Two caveats worth knowing. First, rootless Podman needs cgroup v2 and delegation for limits to actually bite. On a modern distro with systemd user sessions this usually works out of the box, but if you see memory limits being ignored, check whether the cgroup is delegated to your user session (systemd-run --user-managed units get this; ad-hoc podman run from a shell may not). Second, `--cpus` and CPU quotas behave differently under user namespaces. CPU limits are generally fine, but always verify with podman stats or docker stats rather than assuming the flag worked.
Compose users get one extra nicety: deploy.resources works in the Compose spec and is honored by recent Compose v2, which keeps limits in the same file as the stack. Quadlet puts limits in the [Service] section, which means they are enforced by systemd's own resource control, arguably cleaner, because systemd-cgtop and friends can see them.
Auto-updates: Watchtower vs podman auto-update
The philosophy gap in one comparison:
| Feature | Watchtower | podman auto-update |
|---|---|---|
| Trigger | Polls registries on a schedule | Run manually or from a systemd timer |
| Default behavior | Pulls new image, recreates the container | Pulls new image, updates the unit's image reference |
| Restart | Performed automatically | Yours; restart the unit yourself |
| Image selection | All containers, or opt out per-container via label | Opt-in per unit via io.containers.autoupdate=registry label |
| Rollback | --rollback-restart on recent versions (restart old image if health check fails) | --rollback reverts a failed update |
| Notifications | Yes, via shoutrrr (email, Slack, Discord, ...) | Journald; wire your own notifications |
| Scope | Works with Compose and plain Docker | Quadlet / generated systemd units only |
Trigger
- Watchtower
- Polls registries on a schedule
- podman auto-update
- Run manually or from a systemd timer
Default behavior
- Watchtower
- Pulls new image, recreates the container
- podman auto-update
- Pulls new image, updates the unit's image reference
Restart
- Watchtower
- Performed automatically
- podman auto-update
- Yours; restart the unit yourself
Image selection
- Watchtower
- All containers, or opt out per-container via label
- podman auto-update
- Opt-in per unit via io.containers.autoupdate=registry label
Rollback
- Watchtower
- --rollback-restart on recent versions (restart old image if health check fails)
- podman auto-update
- --rollback reverts a failed update
Notifications
- Watchtower
- Yes, via shoutrrr (email, Slack, Discord, ...)
- podman auto-update
- Journald; wire your own notifications
Scope
- Watchtower
- Works with Compose and plain Docker
- podman auto-update
- Quadlet / generated systemd units only
Watchtower is the "set and forget" option: it pulls, stops, and recreates containers with the same options, and can notify you. That is exactly what you want on a box where you trust the image stream and have backups. podman auto-update is deliberately more conservative: it refreshes the image and leaves the restart to you, because the restart is the dangerous step. The documented pattern with Quadlet is a systemd timer that runs podman auto-update and then restarts units, or you run it by hand on a maintenance window.
Which one is right depends on your tolerance for surprise restarts. My n8n box runs Compose with Watchtower and daily backups, and I covered exactly that setup in my six-month n8n review; the automation workload tolerates a 2-3 minute blip. If any of your services is stateful and cheap to corrupt, the podman auto-update model (separate the pull from the restart) is the safer default.
Docker API and ecosystem compatibility
Podman can wear a Docker costume, and it mostly fits:
- Docker-compatible socket. Enable the user service
podman.socket(systemctl --user enable --now podman.socket), setDOCKER_HOST=unix:///run/user/$UID/podman/podman.sock, and tools that speak the Docker API (Portainer, Traefik's Docker provider, health-check agents) will talk to Podman. The API is a compatibility layer, not a clone (though Podman 6.0 shipped with stronger Docker API parity): most endpoints work, some return stubbed data, and occasional edge cases break. Test your specific tools. - CLI drop-in.
alias docker=podmancovers the common verbs, and Podman shipspodman dockerfor exactly this. Builds work viapodman build(Buildah under the hood). - Compose compatibility.
podman composedelegates todocker-composeor the Pythonpodman-composebackend. A plaindocker-compose.ymlwith standard services usually just works; the friction appears in advanced features:depends_onconditions beyond start-ordering, some healthcheck semantics, host and macvlan networking (not available rootless), and port binding behavior. Treat Compose-on-Podman as "mostly compatible" and test the stack before you bet the box on it.
If your entire ecosystem (Terraform modules, CI templates, vendor docs) is written around Docker, that compatibility is worth real money. It is also the honest reason many self-hosters never leave Docker even after reading every Podman fan post: the ecosystem has already standardized on the Docker API, and being able to point Portainer at a socket is not the same as first-class support.
The operational downside of many Quadlet files
For balance, here is the part the Podman evangelists leave out. Running twenty services as Quadlet files is genuinely more administratively expensive than one well-organized Compose file:
- File sprawl with no project concept. Compose gives you one file per stack,
docker compose up -dfor the whole thing, anddocker compose psfor a single view. Quadlet gives you one.containerfile per service plus.volumeand.networkfiles, and no equivalent of "this is project X." Discovery becomessystemctl --user list-units | grep container, which is fine until you have forty units and forgot whatgitea-redis-3belongs to. - No native multi-service stack semantics. Compose's
depends_onand shared default network are built into the model. Quadlet approximates them withAfter=,Requires=, andPartOf=; those are start ordering, not readiness.After=db.servicemeans the database unit was started, not that Postgres is accepting connections. You will hand-roll healthcheck waits withExecStartPreor a small script where Compose gives youdepends_on: condition: service_healthyfor free. - Env and secrets handling is clunkier. Compose has
env_file,${VAR}interpolation, and asecretssection. Quadlet uses systemd'sEnvironmentFile=andEnvironment=plus%t-style specifiers; there is no variable interpolation of the Compose kind, and per-unit secrets mean extra units or file paths to manage. - Twenty diffs instead of one. A Compose stack is one diffable artifact; rolling back is a one-file revert. A fleet of Quadlet files is twenty files, each with its own image reference, and a change to the fleet is a patch across files. Versioning the
~/.config/containers/systemd/directory in git helps, but the review surface is larger. - Upgrades are per-service by default. That is a feature when one service misbehaves and a tax when you want to bump everything: you are restarting units one by one or writing your own loop, where Compose pulls and recreates the whole stack in one command.
None of this is disqualifying. The mitigation is to use each tool where it fits: Compose for multi-service application stacks, Quadlet for single long-running services that should behave like systemd units. My own box runs Compose for the n8n stack and its companions, and the reverse-proxy front door (the same Pangolin/Newt layout I wrote about in the NetBird comparison) is deployed as containers that are more naturally supervised as units. Mixed setups are common and sane.
Which should you pick?
Docker Compose when: you have multi-service app stacks (app + database + cache + worker) that should deploy as one unit; you want the file to be portable across machines, CI, and other people's setups; your ecosystem assumes the Docker API; your host is single-tenant and you are comfortable locking down the socket; or you just want the most documented path in existence.
Rootless Podman with Quadlet when: you run a fleet of independent long-running services on one Linux host; you want systemd to own the lifecycle (journald logs, dependency ordering, boot startup with linger); the box is shared or multi-user and the rootless security model matters; you like the podman auto-update pull-then-restart discipline; and you can absorb the ecosystem friction: no Compose interpolation, socket compatibility instead of a real Docker API, rootless networking quirks.
Kubernetes when: you have multiple hosts, need rolling updates and self-healing across nodes, or the thing you are deploying is designed as a k8s manifest. Kubernetes is a platform, not a container runtime; under the hood it runs containerd (or CRI-O), so it is not "Podman for many machines." If you are running one server, k8s is a second job, not an upgrade. And before you containerize something at all, remember that a plain systemd service from the distro package is still the lowest-maintenance option for software that does not need the isolation.
The pragmatic answer most people land on after a year of both: default to Compose for stacks, migrate single services you want supervised by systemd to Quadlet, keep the Docker socket compatibility layer for the tools that need it, and stop worrying about the choice: they coexist on the same box without fighting. The containers you run matter more than the runner you run them in.
What is running your stack: Compose, Quadlet, or a mix? Drop it in the comments.
Until next time, keep your systems thoughtful.




No comments yet