Why I Moved from Docker Compose to Rootless Podman with Quadlet
I've been running containerized services on my home infrastructure for years—mostly on Proxmox VMs and directly on bare metal Linux hosts. Docker Compose was my default tool. It worked, it was familiar, and the ecosystem was mature. But over time, I started noticing friction points that weren't going away:
- The Docker daemon running as root felt increasingly uncomfortable
- Managing container lifecycles through systemd required awkward wrapper units
- Keeping containers updated meant manual intervention or custom scripts
- I wanted tighter integration with systemd for logging, dependencies, and restarts
When I learned about Podman's Quadlet feature—which generates systemd units from container definitions—I decided to migrate my core services. This is what that process looked like, what worked, and where I hit roadblocks.
My Starting Setup
Before the migration, I had around a dozen services running under Docker Compose on a dedicated Ubuntu VM. These included:
- n8n for workflow automation
- Uptime Kuma for monitoring
- Cronicle for scheduled jobs
- A few internal web apps with PostgreSQL backends
- Nginx as a reverse proxy
Each service had its own docker-compose.yml file in /opt/services/<name>/. I managed them with simple bash scripts that wrapped docker-compose up -d and docker-compose pull.
The VM ran Docker in rootful mode because that's what I set up years ago. I knew rootless Docker was possible, but I never prioritized it. Podman's rootless-by-default approach was part of what made me curious.
What Quadlet Actually Does
Quadlet is not a separate daemon or orchestrator. It's a generator built into Podman that reads .container, .volume, and .network files and converts them into systemd service units at boot or reload.
You place these files in specific directories:
/etc/containers/systemd/for system-wide (rootful) services~/.config/containers/systemd/for user (rootless) services
When systemd starts or reloads, Quadlet generates the actual .service files in /run/systemd/ and registers them with systemd. This means you manage containers using systemctl commands, just like any other service.
The key insight: you're not running a separate orchestrator. You're using systemd itself to manage container lifecycles.
Converting a Simple Service
I started with Uptime Kuma because it's a single-container service with no complex dependencies. Here's what the original Docker Compose file looked like:
version: '3.8'
services:
uptime-kuma:
image: louislam/uptime-kuma:1
container_name: uptime-kuma
volumes:
- ./data:/app/data
ports:
- "3001:3001"
restart: unless-stopped
To convert this to Quadlet, I created ~/.config/containers/systemd/uptime-kuma.container:
[Container] Image=louislam/uptime-kuma:1 ContainerName=uptime-kuma Volume=/home/vipin/containers/uptime-kuma/data:/app/data PublishPort=3001:3001 AutoUpdate=registry [Service] Restart=always [Install] WantedBy=default.target
After saving the file, I ran:
systemctl --user daemon-reload systemctl --user start uptime-kuma.service systemctl --user enable uptime-kuma.service
The container started immediately. Logs appeared in journalctl --user -u uptime-kuma.service. I could stop, restart, and check status using standard systemd commands.
This felt cleaner than wrapping Docker Compose in a systemd service file. No intermediate layer—just systemd managing Podman directly.
Handling Multi-Container Stacks
Most of my services weren't standalone. n8n, for example, needed PostgreSQL. In Docker Compose, I defined both in one file with depends_on.
Quadlet doesn't have a native equivalent to Docker Compose's multi-service files. Instead, you create separate .container files and use systemd's dependency directives.
For n8n and PostgreSQL, I created two files:
n8n-postgres.container:
[Container] Image=postgres:15 ContainerName=n8n-postgres Volume=/home/vipin/containers/n8n/postgres:/var/lib/postgresql/data Environment=POSTGRES_USER=n8n Environment=POSTGRES_PASSWORD=n8npass Environment=POSTGRES_DB=n8n Network=n8n.network [Service] Restart=always [Install] WantedBy=default.target
n8n.container:
[Container] Image=n8nio/n8n:latest ContainerName=n8n Volume=/home/vipin/containers/n8n/data:/home/node/.n8n PublishPort=5678:5678 Environment=DB_TYPE=postgresdb Environment=DB_POSTGRESDB_HOST=n8n-postgres Environment=DB_POSTGRESDB_PORT=5432 Environment=DB_POSTGRESDB_DATABASE=n8n Environment=DB_POSTGRESDB_USER=n8n Environment=DB_POSTGRESDB_PASSWORD=n8npass Network=n8n.network [Unit] Requires=n8n-postgres.service After=n8n-postgres.service [Service] Restart=always [Install] WantedBy=default.target
n8n.network:
[Network]
The Requires and After directives ensure PostgreSQL starts before n8n. If PostgreSQL fails, n8n won't start. This is how systemd handles dependencies—no magic, just explicit ordering.
After reloading and enabling both services, everything worked. The containers could communicate over the shared network, and systemd enforced the startup order.
What I Lost in Translation
Not everything from Docker Compose maps cleanly to Quadlet:
Environment Files
Docker Compose supports env_file to load variables from a file. Quadlet has EnvironmentFile, but it doesn't support inline variable substitution the way Compose does.
I worked around this by explicitly defining each environment variable in the .container file. For services with many variables, this made the files longer, but it also made them more explicit.
Build Directives
Docker Compose can build images from Dockerfiles. Quadlet supports .build files, but I haven't used them yet. For now, I build images manually with podman build and reference them in .container files.
This adds a manual step, but I rarely rebuild images—most of my services use upstream images.
Health Checks
Docker Compose health checks define when a container is "ready." Quadlet supports health checks, but systemd doesn't wait for them before starting dependent services.
I haven't found a clean way to replicate Docker Compose's condition: service_healthy behavior. For now, I rely on application-level retries and timeouts.
Rootless Mode: The Real Benefit
The biggest win from this migration wasn't Quadlet itself—it was running everything rootless.
In Docker, rootless mode is an opt-in feature with its own daemon and socket. In Podman, rootless is the default. Containers run under my user account, not as root. This means:
- No privilege escalation if a container is compromised
- No need for
sudoto manage containers - Easier to audit what's running under my user
I did hit one issue: rootless containers can't bind to ports below 1024 without additional configuration. I solved this by either:
- Using higher ports and mapping them in my reverse proxy
- Enabling
net.ipv4.ip_unprivileged_port_start=80in/etc/sysctl.d/
The second option felt cleaner for my setup, but it's a system-wide change. I made sure I understood the implications before applying it.
Auto-Updates with Quadlet
One feature I didn't expect to use immediately was AutoUpdate=registry. When enabled, Podman checks for newer image tags and restarts the service automatically.
I set this up for a few non-critical services to test it. After a week, I checked the logs and saw that Uptime Kuma had updated itself without any intervention from me.
This isn't something I'd enable for everything—especially not for services with complex migration steps—but for simple, stateless containers, it's useful.
What Didn't Work
Migrating Everything at Once
I initially tried to convert all my services in one session. This was a mistake. Each service had quirks—different volume paths, network configurations, environment variables. Debugging multiple failures at once was frustrating.
I should have migrated one service at a time, verified it worked, and moved on. That's what I ended up doing after the first failed attempt.
Assuming Docker Compose Files Would "Just Work"
I found a tool called podman-compose that claims to run Docker Compose files with Podman. I tried it briefly, but it felt like a compatibility shim rather than a native solution.
Quadlet forces you to rethink your setup in systemd terms, which I think is better long-term. But it's not a drop-in replacement—you have to do the conversion work.
Cross-Container Networking
In Docker Compose, all services in a file automatically join the same network and can resolve each other by service name. In Quadlet, you have to explicitly define networks and attach containers to them.
This isn't hard, but it's one more thing to remember. I missed this on my first conversion and spent 20 minutes debugging why n8n couldn't connect to PostgreSQL.
Key Takeaways
- Quadlet is not a Docker Compose replacement—it's a different way of thinking about container management through systemd
- Rootless Podman is easier to set up than rootless Docker and feels more natural
- Migrating incrementally, one service at a time, avoids confusion and makes debugging easier
- systemd's dependency model works well for container orchestration once you understand it
- Auto-updates are convenient but need careful consideration for stateful services
- Not all Docker Compose features have direct equivalents—some require rethinking your approach
I'm still running a few services under Docker Compose because they have complex multi-container setups that I haven't had time to convert. But for new services, I default to Quadlet now. It feels more aligned with how Linux systems are supposed to work.