Skip to content
Go back

Podman Compose for Docker-Free Workflows

By SumGuy 9 min read
Podman Compose for Docker-Free Workflows

You Don’t Actually Need a Daemon

Here’s the thing: Docker feels inevitable. It’s so easy to apt-get install docker.io, let it run as root, and forget about it until someone points out you’re six kernel versions behind and the privilege escalation CVE is already being traded on the dark web.

Podman flips the script. No daemon. No root. No sudo for every podman run you write. Just containers, orchestration, and the warm fuzzy feeling that your home lab isn’t a security dumpster fire waiting to catch someone’s attention.

And the kicker? You can run almost everything from Docker Compose YAML with barely a blink of effort. Drop-in. Minimal friction. The kind of thing that makes your 2 AM self actually grateful you’re awake.


The Rootless Revolution

Docker runs as root. Full stop. That daemon owns the socket, manages the network, touches the filesystem. One container goes sideways, and they’re potentially looking at your whole machine.

Podman inverted that: your user runs containers as your user. Rootless by default (though you can run it with sudo if you’re feeling nostalgic for bad decisions).

Here’s the mental model:

Docker:
dockerd (root daemon) ← owns network, storage, process mgmt
├─ container process (runs in namespace)
└─ container process (runs in namespace)
Podman:
user process (unprivileged)
├─ container process (runs in user namespace)
└─ container process (runs in user namespace)
(kernel handles the isolation, podman is just orchestrating)

When you run podman run, Podman forks, sets up the Linux namespaces (PID, network, mount, UTS, ipc, user), and your container lives in that sandbox. Networking? Handled via slirp4netns or gvproxy on rootless. Port forwarding still works. Volumes still work. But it’s all happening in your user context, not as root.

This gets weird in places (we’ll get there), but the security model is cleaner. Full stop.


Podman Compose vs. Docker Compose: What Works

You’ve got two ways to do compose with Podman:

  1. podman-compose — Standalone Python script. Reads Docker Compose YAML, translates to Podman commands. Works, but it’s a thin wrapper. Some things fall through the cracks.

  2. podman compose (Docker Compose plugin)** — Docker released their Compose as a Go binary plugin system. Podman ships with the same plugin interface. You install docker-compose-plugin package, point it at Podman, and it “just works” like Docker Compose does for Docker.

Terminal window
# Install podman-compose (thin Python wrapper)
sudo dnf install podman-compose # or apt, etc.
podman-compose up -d
# Install Docker Compose plugin (better compatibility)
sudo dnf install docker-compose-plugin
podman compose up -d # Uses the same binary as Docker, but talks to Podman

The plugin approach is tighter. Fewer surprises. I’d lean toward that if your distro packages it.

Both read your docker-compose.yml and spin up containers. Both support networks, volumes, environment variables, healthchecks. The gaps are subtle and usually reveal themselves at 3 AM when you’re debugging why your service won’t start.


Where Podman Compose Gets Spicy

Network Aliases and DNS

Docker: You declare a service in docker-compose.yml, and the name becomes the DNS entry for other containers. postgres:5432 just works inside the network.

Podman rootless: Slirp4netns (the default networking backend) does DNS differently. Network aliases exist, but they sometimes resolve weirdly from one container to another. It’s not broken, but it’s fiddly.

The workaround: Use networks: and aliases: explicitly, or switch to gvproxy if your system supports it (Fedora ≥37, RHEL ≥9, etc.).

services:
postgres:
image: postgres:15
networks:
- backend
environment:
POSTGRES_PASSWORD: example
app:
image: myapp:latest
depends_on:
- postgres
networks:
- backend
environment:
DATABASE_URL: postgresql://postgres:5432/mydb
networks:
backend:
driver: bridge

This version is explicit. Podman Compose handles it fine. When in doubt, be verbose.

Ports Below 1024

Docker (running as root): "80:8080" works. Bind to port 80, no problem.

Podman rootless: Port 80 requires CAP_NET_BIND_SERVICE. You don’t have it. So either:

# Workaround: bind to 8080, let a proxy handle 80
services:
app:
image: myapp:latest
ports:
- "8080:8080" # High port, rootless-friendly

Then run Caddy or nginx on the host (or in a separate rootful container, if you’re feeling weird) to proxy to 8080.

Healthchecks

Docker: Healthchecks work as documented. Container reports healthy/unhealthy. Compose respects depends_on: { service: condition: service_healthy }.

Podman Compose: Healthchecks exist, but some versions of podman-compose ignore them in depends_on conditions. Use the Docker Compose plugin if this matters to you.

services:
db:
image: postgres:15
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
environment:
POSTGRES_PASSWORD: example
app:
image: myapp:latest
depends_on:
db:
condition: service_healthy # May not work with podman-compose

Volumes and SELinux

Rootless Podman + SELinux = your volumes might be inaccessible inside the container. The UID/GID mapping (part of rootless) collides with SELinux context enforcement.

Workaround: Append :Z to the volume mount (tells Podman to relabel the volume for the container’s context) or :z (shared context across containers). Use :Z for most cases.

services:
app:
image: myapp:latest
volumes:
- ./data:/data:Z # Podman relabels for container access

This is a Podman-ism. Docker doesn’t care because it’s running as root and SELinux can’t stop it. Podman has to play nice.

Secrets Handling

Docker Compose: Secrets are a first-class feature if you’re using Docker Swarm (old). Otherwise, just environment variables.

Podman Compose: No native secrets support in the old Python wrapper. Use environment files or bind mounts. The Docker Compose plugin does support the secrets: key, but they’re read from the host filesystem (not Swarm-synced).

Honestly, for home lab stuff, just use a .env file and don’t commit it:

Terminal window
# .env (gitignored)
DB_PASSWORD=your_secret_here
API_KEY=another_secret
# docker-compose.yml
services:
app:
env_file: .env
environment:
- DATABASE_PASSWORD=${DB_PASSWORD}

A Real-World Podman Compose Stack

Here’s a working example: Caddy reverse proxy + a small Flask app + PostgreSQL. All rootless, all in Podman Compose.

version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: app-db
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: securepassword
POSTGRES_DB: appdb
volumes:
- db_data:/var/lib/postgresql/data:Z
networks:
- backend
healthcheck:
test: ["CMD", "pg_isready", "-U", "appuser"]
interval: 10s
timeout: 5s
retries: 5
app:
image: myapp:latest
container_name: app-web
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgresql://appuser:securepassword@postgres:5432/appdb
FLASK_ENV: production
ports:
- "8000:5000"
volumes:
- ./app:/app:Z
networks:
- backend
restart: unless-stopped
caddy:
image: caddy:latest
container_name: app-proxy
ports:
- "8080:80"
- "8443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data:Z
- caddy_config:/config:Z
networks:
- backend
restart: unless-stopped
volumes:
db_data:
caddy_data:
caddy_config:
networks:
backend:
driver: bridge

And a Caddyfile:

example.local:8080 {
reverse_proxy app:5000
}

To run it:

Terminal window
podman compose -f docker-compose.yml up -d
# or with the plugin:
podman compose up -d
# Check status
podman compose ps
# View logs
podman compose logs -f app
# Tear it down
podman compose down -v # -v removes volumes

Everything works the same as Docker Compose. No daemon lurking. No root. Just containers doing container things.


Systemd Integration and Autostart

Here’s where Podman gets genuinely better than Docker.

Podman plays nice with systemd. You can generate systemd service files from your running containers or compose stacks:

Terminal window
# Run something, then export it as a service
podman run -d --name my-service myimage:latest
podman generate systemd --name my-service > ~/.config/systemd/user/my-service.service
# Then:
systemctl --user daemon-reload
systemctl --user enable my-service
systemctl --user start my-service

For compose stacks, the cleanest approach is to generate a unit per container after bringing the stack up, then enable each one:

Terminal window
podman-compose up -d
# Generate a unit for each container in the stack
podman generate systemd --name app-db > ~/.config/systemd/user/app-db.service
podman generate systemd --name app-web > ~/.config/systemd/user/app-web.service
systemctl --user daemon-reload
systemctl --user enable --now app-db app-web

Alternatively, use Quadlets (Podman 4.4+) to define the whole stack as .container files — they’re systemd-native and survive reboots without any generate step. See the Quadlets section below.

Your containers now start at login, restart on crash, integrate with your systemctl commands. No cron jobs, no hacky init scripts. It’s clean.

This is a Podman advantage over Docker. Docker doesn’t integrate with systemd like this.


The Quadlets Option (Brief Mention)

If you want to go full systemd-native, Podman has Quadlets — a systemd-native way to define containers directly as .container files in ~/.config/containers/systemd/.

~/.config/containers/systemd/myapp.container
[Unit]
Description=My App
After=network-online.target
[Container]
Image=myapp:latest
PublishPort=8080:5000
Environment=DATABASE_URL=postgresql://localhost/mydb
Volume=./data:/data:Z
[Install]
WantedBy=default.target

Then systemctl --user enable myapp and it’s alive. No YAML. No compose file. Just a systemd unit file that Podman understands.

We’ve got a full article planned on this (it’s deeper than it sounds), so I’ll leave it at: Quadlets exist, they’re powerful, and they’re worth exploring if you’re already living in systemd land.


When to Stay on Docker

Podman isn’t a religion. Sometimes Docker is the right tool:

For home lab? Single-server stuff? Self-hosting your own services? Podman Compose is genuinely better. No daemon. Fewer permissions. Systemd integration. Drop-in compatibility that covers 95% of real-world use cases.


The Real Win

Podman Compose isn’t a hack. It’s not a workaround. It’s a legitimate alternative that trades the simplicity of “one daemon for everything” for the security of “everything runs as you.”

Your container orchestration becomes part of your user environment, not a trusted daemon running as root. That’s a meaningful shift in how you think about infrastructure security.

And the compose files? They’re identical. You’re not learning new syntax, rewriting your stacks, or fighting incompatibilities. Just slightly different wiring under the hood.

That’s a win worth the minor gotchas.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Argo Workflows vs Tekton

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts