How to Use Podman Compose: A Practical Guide for RHEL Admins
Published On: 24 April 2026
Objective
If you're coming to Podman Compose from a Docker background, the first thing worth saying clearly is this: Podman Compose is not a first-class Red Hat product. It's a community Python tool that reimplements Docker Compose behavior on top of Podman. It works well for a lot of use cases, but it's not what Red Hat would point you toward if you asked them how to run multi-container workloads in production on RHEL. That distinction matters because it affects how you should use it. For local development, testing, and migrating existing Docker Compose workflows to a Podman environment, it's genuinely useful. For production on RHEL, Podman Quadlet the native systemd integration for containers is the supported path. Both tools have their place. This guide covers Podman Compose specifically: what it does, how to set it up, and how to use it effectively without running into its limitations blind.
That said, if you're an admin who inherited a Docker Compose-based application stack and you're moving it to RHEL without Docker, Podman Compose is often the fastest route to getting things running again.
What Podman Compose Actually Does
- Docker Compose reads a
docker-compose.ymlfile and manages the full lifecycle of a multi-container application building images, creating networks, mounting volumes, starting containers in the right order, and wiring everything together. Podman Compose does the same thing, reading the same YAML format, but calls Podman instead of Docker under the hood. - The practical benefit is that you get the familiar Compose workflow without a daemon running in the background. Podman is daemonless, which matters on RHEL both for security reasons and because Red Hat has been explicit about not shipping Docker as a supported component. If your organization has standardized on RHEL and wants to move away from Docker without rewriting every application's container configuration from scratch, Podman Compose makes that transition significantly less painful.
- One important architectural note: Podman Compose runs containers using Podman directly rather than through a daemon. Each service in your Compose file becomes a Podman container. Networking between containers uses Podman's CNI or Netavark networking stack depending on your RHEL version. The behavior is close to Docker Compose but not identical subtle differences in networking, volume handling, and certain Compose features do exist and will occasionally bite you.
Installing Podman Compose on RHEL
Podman itself ships with RHEL 9 and 10 and is installed by default in most configurations. Podman Compose is separate and needs to be installed explicitly.
Check Podman is present first
podman --version
Install via pip (recommended for latest version)
# Install pip if not already available
sudo dnf install python3-pip -y
# Install podman-compose
pip3 install podman-compose --user
# Verify installation
podman-compose --version
Install via dnf (RHEL 9 and later)
# Enable EPEL repository first if not already enabled
sudo dnf install epel-release -y
# Install podman-compose from EPEL
sudo dnf install podman-compose -y
# Verify
podman-compose --version
The pip install gives you a newer version with better compatibility. The dnf/EPEL route gives you a package that integrates cleanly with the system and gets updates through your normal package management. For a development workstation, pip is fine. For a server where you want predictable updates through subscription channels, EPEL is cleaner.
If you installed via pip with --user, the binary lands in ~/.local/bin/. Make sure that's in your PATH:
echo 'export PATH=$PATH:$HOME/.local/bin' >> ~/.bashrc
source ~/.bashrc
The Compose File Format: What Transfers From Docker
Podman Compose supports Docker Compose file versions 2 and 3. If you have an existing docker-compose.yml, it will usually work without modification. The main sections you'll use are services, volumes, and networks. Here's a complete, annotated example that demonstrates the most commonly used features:
# docker-compose.yml
version: "3.8"
services:
web:
image: docker.io/library/nginx:1.25
container_name: myapp-web
ports:
- "8080:80"
volumes:
- ./html:/usr/share/nginx/html:Z
- ./nginx.conf:/etc/nginx/nginx.conf:Z,ro
networks:
- frontend
depends_on:
- app
restart: unless-stopped
environment:
- NGINX_HOST=localhost
app:
image: docker.io/library/node:20-alpine
container_name: myapp-app
working_dir: /app
volumes:
- ./app:/app:Z
networks:
- frontend
- backend
restart: unless-stopped
environment:
- NODE_ENV=production
- DB_HOST=db
- DB_PORT=5432
env_file:
- .env
depends_on:
- db
command: ["node", "server.js"]
db:
image: docker.io/library/postgres:16
container_name: myapp-db
volumes:
- db-data:/var/lib/postgresql/data:Z
networks:
- backend
restart: unless-stopped
environment:
- POSTGRES_DB=myapp
- POSTGRES_USER=myapp
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
secrets:
- db_password
volumes:
db-data:
networks:
frontend:
backend:
secrets:
db_password:
file: ./secrets/db_password.txt
A few RHEL-specific things to notice in that example. The :Z on volume mounts sets the correct SELinux label for the mounted directory. Without it, SELinux will block container access to the host directory and you'll spend time puzzling over why a container can't read files that are clearly there. This is the single most common gotcha for people moving from Docker on non-SELinux systems to Podman on RHEL. Use :Z for single-container mounts and :z when multiple containers share the same volume.
Core Commands You'll Use Daily
Starting and stopping
# Start all services defined in docker-compose.yml
podman-compose up
# Start in detached mode (background)
podman-compose up -d
# Start only specific services
podman-compose up -d web db
# Stop all services (containers remain, just stopped)
podman-compose stop
# Stop and remove containers, networks
podman-compose down
# Stop and remove containers, networks, AND volumes
podman-compose down -v
# Restart a specific service
podman-compose restart app
Checking status and logs
# See running containers for this Compose project
podman-compose ps
# Follow logs for all services
podman-compose logs -f
# Follow logs for a specific service only
podman-compose logs -f app
# Show last 50 lines of logs for a service
podman-compose logs --tail=50 db
Running commands inside containers
# Open a shell in a running service container
podman-compose exec app /bin/sh
# Run a one-off command in a new container (doesn't affect running services)
podman-compose run --rm app node --version
# Run a database migration as a one-off command
podman-compose run --rm app node migrate.js
Building images
# Build images defined with 'build:' in the Compose file
podman-compose build
# Build a specific service
podman-compose build app
# Build without using cache
podman-compose build --no-cache app
# Pull latest base images then build
podman-compose pull
podman-compose build
Using a Build Context in Your Compose File
When you're developing an application rather than just pulling pre-built images, your Compose file can reference a local Dockerfile to build the image:
services:
app:
build:
context: ./app
dockerfile: Dockerfile
args:
- NODE_VERSION=20
- BUILD_ENV=development
image: myapp:latest
ports:
- "3000:3000"
volumes:
- ./app:/app:Z
restart: unless-stopped
When you run podman-compose up, if the image doesn't exist locally it will build it automatically. To force a rebuild even when the image exists, use podman-compose up --build.
Environment Variables and .env Files
Hardcoding values like database passwords directly in docker-compose.yml is a bad habit even in development. Podman Compose supports .env files and variable substitution in the Compose file.
Create a .env file in the same directory as your Compose file:
# .env
POSTGRES_PASSWORD=dev_password_only
APP_PORT=3000
NODE_ENV=development
DB_NAME=myapp_dev
Reference these variables in the Compose file using ${VARIABLE_NAME} syntax:
services:
db:
image: docker.io/library/postgres:16
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${DB_NAME}
ports:
- "5432:5432"
app:
image: myapp:latest
ports:
- "${APP_PORT}:3000"
environment:
- NODE_ENV=${NODE_ENV}
Add .env to your .gitignore. It's for local values and secrets that shouldn't be committed. Use a .env.example file with placeholder values to document what variables are needed without exposing real values.
Networking Between Containers
Containers in the same Compose project can reach each other by service name. In the earlier example, the app service connects to the database using db as the hostname because that's the service name defined in the Compose file. Podman Compose creates a shared network for the project automatically.
# Verify the network was created
podman network ls
# Inspect which containers are on a network
podman network inspect myproject_default
When you define explicit named networks in your Compose file, you get more control over which services can communicate with which. In the earlier three-service example, the web and app containers share the frontend network, and the app and db containers share the backend network. The web container has no direct network path to the database, which is the intended isolation. One thing that works differently from Docker: Podman Compose on RHEL 9 and 10 uses Netavark as the default network backend (replacing CNI in newer versions). This is mostly transparent but worth knowing if you're reading older troubleshooting guides that reference CNI-specific behavior.
Persistent Storage With Named Volumes
Named volumes in Compose persist data beyond the container lifecycle. When you run podman-compose down, containers are removed but named volumes survive. Only podman-compose down -v removes volumes as well.
# List volumes created by Podman Compose
podman volume ls
# Inspect a specific volume to find where data is stored
podman volume inspect myproject_db-data
# Manually back up a volume's data
podman run --rm \
-v myproject_db-data:/data:Z \
-v $(pwd):/backup:Z \
docker.io/library/alpine \
tar czf /backup/db-backup.tar.gz -C /data .
For volumes containing database data, always stop the relevant container before backing up to avoid backing up a partially-written state. The backup command above works fine for filesystem data but for a running PostgreSQL database, use pg_dump through podman-compose exec instead.
SELinux: The Part Everyone Forgets Until It Breaks
RHEL runs SELinux in enforcing mode. This is a good thing for security but it creates friction with container volume mounts that trips up people moving from Docker on non-SELinux systems. The symptom: your container starts, the volume mount is correct, but the container reports permission denied when trying to read or write files in the mounted directory. The files are there and the Unix permissions look right. SELinux is the culprit.
The fix is the :Z (or :z) label on volume mounts:
volumes:
- ./data:/app/data:Z # private label, single container
- ./shared:/app/shared:z # shared label, multiple containers
When you add :Z, Podman relabels the host directory with the correct SELinux context so the container process can access it. The difference between :Z (uppercase) and :z (lowercase) matters: uppercase creates a private, unshared label suitable for one container; lowercase creates a shared label that multiple containers can access. Using :Z on a directory shared between containers will cause one container to relabel it away from the other.
If you're still getting permission errors after adding the label, check for SELinux denials:
sudo ausearch -m avc -ts recent | grep podman
Rootless Containers and What They Mean for Compose
Podman's rootless mode runs containers as your regular user without requiring root privileges. Podman Compose works rootlessly by default when you run it as a non-root user, which is the recommended approach on RHEL for anything that doesn't explicitly need elevated privileges. A few things behave differently in rootless mode:
Port numbers below 1024 require elevated privileges on Linux. A rootless container can't bind to port 80 or 443 directly. The workaround is to map to a higher port in your Compose file and handle the forwarding at the firewall or load balancer level:
services:
web:
image: docker.io/library/nginx:1.25
ports:
- "8080:80" # Map to 8080 instead of 80 for rootless
If you genuinely need port 80 in a rootless setup, you can adjust the kernel's unprivileged port start:
# Allow unprivileged users to bind ports >= 80
sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80
# Make it persistent
echo "net.ipv4.ip_unprivileged_port_start=80" | sudo tee /etc/sysctl.d/99-unprivileged-ports.conf
Volume paths in rootless mode also differ slightly. The storage location for rootless containers and volumes is under your home directory at ~/.local/share/containers/storage/ rather than the system-wide location used by root.
Specifying a Different Compose File
By default, Podman Compose looks for docker-compose.yml or docker-compose.yaml in the current directory. To use a different file, or to layer multiple files together:
# Use a specific file
podman-compose -f production.yml up -d
# Override the project name (affects container and network naming)
podman-compose -p myproject up -d
# Layer multiple files (second file overrides the first)
podman-compose -f docker-compose.yml -f docker-compose.override.yml up -d
The layering approach is useful for managing environment-specific differences. Keep a base docker-compose.yml with shared configuration and separate override files for development, staging, and production that change only what needs to differ between environments.
Migrating an Existing Docker Compose Project to RHEL
If you're taking an existing Docker Compose project and moving it to RHEL with Podman, here's the practical checklist of what usually needs attention:
- Image references: Docker Compose files often use short image names like
nginx:latestorpostgres:16. Podman defaults to searching multiple registries and may pull from a different one than Docker did. Be explicit with full registry paths to avoid ambiguity:
# Ambiguous -- could come from multiple registries
image: nginx:1.25
# Explicit -- always from Docker Hub
image: docker.io/library/nginx:1.25
# Or from Red Hat's registry for RHEL-certified images
image: registry.access.redhat.com/ubi9/nginx-120:latest
- Volume mounts: Add
:Zto every bind mount. This is the most common fix needed on RHEL. - Privileged ports: If any service maps to ports below 1024, decide whether to remap to higher ports or adjust
ip_unprivileged_port_start. - Healthchecks: Docker Compose healthchecks work in Podman Compose, but the integration with
depends_on: condition: service_healthyhas had inconsistent support across versions. Test this specifically if your project relies on it. - Docker-specific extensions: Fields starting with
x-(custom extensions) and some Docker-specific options likeplatform:may not be supported. Remove or replace these.
Troubleshooting Common Problems
Containers exit immediately after starting
# Check logs from a stopped container
podman-compose logs app
# Or directly with Podman
podman logs myapp-app
Service can't reach another service by name
# Verify both services are on the same network
podman network inspect myproject_default
# Test DNS resolution from inside a container
podman-compose exec app ping db
podman-compose exec app nslookup db
Volume permission denied despite correct Unix permissions
# Check for SELinux denials
sudo ausearch -m avc -ts recent | grep podman
# Fix context on the directory
sudo restorecon -Rv ./data
# Or relabel manually
chcon -Rt svirt_sandbox_file_t ./data
Port already in use
# Find what's using the port
sudo ss -tlnp | grep :8080
# Check if a previous Compose run left containers running
podman ps -a
Changes to docker-compose.yml not taking effect
Podman Compose doesn't always detect configuration changes automatically. Bring the stack down and back up to ensure changes are applied:
podman-compose down podman-compose up -d
Podman Compose vs Quadlet: When to Use Which
This comes up often enough to address directly. They're not competing tools they solve slightly different problems.
- Use Podman Compose when you have an existing Docker Compose workflow you want to run on RHEL without rewriting it. It's fast to adopt, the YAML format is familiar, and it handles multi-container development stacks well. For local development and CI environments, it's a practical choice.
- Use Quadlet when you're defining long-running services on RHEL that need to behave like proper system services start at boot, restart on failure, integrate with journald, respect systemd dependency ordering, and be manageable with
systemctl. Quadlet is the production-grade, Red Hat-supported approach for containers as services on RHEL.
For a lot of workflows, you use Podman Compose during development and migrate the service definitions to Quadlet when something goes into production. The two aren't mutually exclusive.
Conclusion
Podman Compose is a solid tool for what it does: bringing Docker Compose workflows to a daemonless, rootless, RHEL-native environment without requiring a complete rewrite of your container configuration. The SELinux volume labeling is the main thing to internalize before you start using it on RHEL get that right and most of the other behavior translates cleanly from Docker experience. Know what it is and what it isn't. It's a community tool, not a Red Hat-supported product. For development and migration work it earns its place. For long-running production services on RHEL, take the time to learn Quadlet it's the right tool for that job and the investment pays off.