Tech Expert & Vibe Coder

With 14+ years of experience, I specialize in self-hosting, AI automation, and Vibe Coding – building applications using AI-powered tools like Google Antigravity, Dyad, and Cline. From homelabs to enterprise solutions.

Migrating Docker Compose Stacks to Rootless Podman with Quadlet Systemd Integration for Enhanced Security

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 sudo to 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=80 in /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.