Podman as a Docker Alternative
Comprehensive comparison of Podman and Docker for Node.js developers, covering rootless containers, daemonless architecture, pod support, migration strategies, and practical workflow differences.
Podman as a Docker Alternative
Docker is not the only container runtime. Podman — developed by Red Hat — runs containers without a daemon, supports rootless operation by default, and is command-for-command compatible with Docker. If your organization has security policies against running a root-level daemon, if you are on RHEL/Fedora where Docker is not the default, or if you simply want a more secure container workflow, Podman is a production-ready alternative. This guide covers what is different, what is identical, and how to migrate.
Prerequisites
- Linux (Podman is native to Linux; macOS/Windows use a VM via
podman machine) - Basic Docker experience
- Node.js project with an existing Dockerfile
What Makes Podman Different
No Daemon
Docker runs a long-lived daemon (dockerd) as root. Every docker command talks to this daemon via a socket. If the daemon crashes, all running containers are affected.
Podman has no daemon. Each podman command is a direct process. Containers run as child processes of the podman command, or as independent systemd services. No single point of failure.
# Docker: client → daemon → container
docker run myapp
# Talks to /var/run/docker.sock (root-owned daemon)
# Podman: command → container (direct)
podman run myapp
# No daemon. The container is a child process.
Rootless by Default
Docker traditionally requires root privileges. The daemon runs as root, and containers run as root unless explicitly configured otherwise. Docker's rootless mode exists but is opt-in.
Podman runs rootless by default. Your containers run under your user's UID, with no escalated privileges anywhere in the chain.
# Check who owns the container process
podman run --rm alpine id
# uid=0(root) gid=0(root) — root INSIDE the container
# But on the host:
ps aux | grep alpine
# youruser 12345 ... — runs as YOUR user on the host
The root user inside the container maps to your unprivileged user on the host via user namespaces. Even if an attacker escapes the container, they land as an unprivileged user.
Pod Support
Podman natively supports pods — groups of containers that share network and IPC namespaces, just like Kubernetes pods.
# Create a pod
podman pod create --name myapp -p 3000:3000
# Add containers to the pod
podman run -d --pod myapp --name api node:20-alpine node -e "
var http = require('http');
http.createServer(function(req, res) {
res.end('Hello from API');
}).listen(3000);
"
podman run -d --pod myapp --name redis redis:7-alpine
Containers in the same pod share localhost. The API container can reach Redis at localhost:6379 without any network configuration.
Installation
Linux
# Fedora/RHEL/CentOS
sudo dnf install podman
# Ubuntu/Debian
sudo apt-get install podman
# Verify
podman --version
# podman version 4.9.0
macOS
# Install via Homebrew
brew install podman
# Initialize the Podman VM
podman machine init
podman machine start
# Verify
podman run --rm alpine echo "Hello from Podman"
Windows
# Install via winget
winget install RedHat.Podman
# Or download from: https://github.com/containers/podman/releases
# Initialize the Podman VM (uses WSL2)
podman machine init
podman machine start
Command Compatibility
Podman is a drop-in replacement for Docker. The CLI is intentionally identical.
# These commands are identical between Docker and Podman
podman build -t myapp .
podman run -p 3000:3000 myapp
podman ps
podman images
podman logs myapp
podman exec -it myapp sh
podman stop myapp
podman rm myapp
podman rmi myapp
podman pull node:20-alpine
podman push myregistry.com/myapp:latest
You can create an alias and most scripts work unchanged:
alias docker=podman
Compose Support
Podman supports Docker Compose files via podman-compose or the native podman compose command:
# Using podman-compose (Python-based)
pip install podman-compose
podman-compose up -d
# Using podman compose (built-in, delegates to docker-compose)
podman compose up -d
# Your existing docker-compose.yml works as-is
version: "3.8"
services:
api:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://user:pass@postgres:5432/myapp
depends_on:
- postgres
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: myapp
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Building Images
Podman uses Buildah as its build engine. Dockerfiles work without modification.
# Standard build
podman build -t myapp .
# Multi-stage build with target
podman build --target production -t myapp:prod .
# Build with secret (same syntax as Docker BuildKit)
podman build --secret id=npmrc,src=$HOME/.npmrc -t myapp .
# Multi-platform build
podman build --platform linux/amd64,linux/arm64 -t myapp .
Buildah Direct
Buildah is the underlying tool Podman uses for builds. You can use it directly for scriptable, non-Dockerfile builds:
# Create a container from base image
container=$(buildah from node:20-alpine)
# Run commands
buildah run $container -- npm ci --only=production
buildah copy $container . /app
# Configure
buildah config --workingdir /app $container
buildah config --cmd '["node", "app.js"]' $container
buildah config --port 3000 $container
# Commit to image
buildah commit $container myapp:latest
This is useful for CI pipelines where you want fine-grained control over each build step without a Dockerfile.
Rootless Containers in Practice
User Namespace Mapping
Podman maps container UIDs to host UIDs via /etc/subuid and /etc/subgid:
cat /etc/subuid
# youruser:100000:65536
# This means:
# Container UID 0 (root) → Host UID youruser (unprivileged)
# Container UID 1 → Host UID 100000
# Container UID 1000 → Host UID 101000
Port Binding
Rootless containers cannot bind to ports below 1024 without configuration:
# This fails rootless
podman run -p 80:3000 myapp
# Error: rootlessport cannot expose privileged port 80
# Solutions:
# 1. Use a high port
podman run -p 8080:3000 myapp
# 2. Allow unprivileged port binding (Linux)
sudo sysctl net.ipv4.ip_unprivileged_port_start=80
# 3. Use rootful mode for specific containers
sudo podman run -p 80:3000 myapp
Volume Permissions
Rootless volumes need care with UID mapping:
# The container's root (UID 0) maps to your host UID
# Files created by root in the container are owned by you on the host
podman run --rm -v ./data:/data alpine touch /data/test.txt
ls -la data/test.txt
# -rw-r--r-- 1 youruser youruser 0 Feb 13 10:00 test.txt
For containers that run as non-root (like PostgreSQL with UID 70):
podman run -v ./pgdata:/var/lib/postgresql/data postgres:16-alpine
# May fail: permission denied
# Fix: use --userns=keep-id to map your UID into the container
podman run --userns=keep-id -v ./pgdata:/var/lib/postgresql/data postgres:16-alpine
Or use named volumes which handle permissions automatically:
podman volume create pgdata
podman run -v pgdata:/var/lib/postgresql/data postgres:16-alpine
Pods: Kubernetes-Style Grouping
Pods group containers that need to share network and lifecycle.
# Create a pod with port mappings
podman pod create \
--name myapp-pod \
-p 3000:3000 \
-p 5432:5432
# Add Node.js API
podman run -d \
--pod myapp-pod \
--name api \
-e DATABASE_URL=postgresql://user:pass@localhost:5432/myapp \
myapp:latest
# Add PostgreSQL (shares localhost with API)
podman run -d \
--pod myapp-pod \
--name db \
-e POSTGRES_USER=user \
-e POSTGRES_PASSWORD=pass \
-e POSTGRES_DB=myapp \
postgres:16-alpine
# API can connect to PostgreSQL at localhost:5432
# because they share the same network namespace
# Manage pods
podman pod ls
podman pod inspect myapp-pod
podman pod stop myapp-pod
podman pod rm myapp-pod
Generate Kubernetes YAML from Pods
Podman can export pod definitions as Kubernetes YAML:
podman generate kube myapp-pod > myapp-pod.yaml
# Generated YAML
apiVersion: v1
kind: Pod
metadata:
name: myapp-pod
spec:
containers:
- name: api
image: myapp:latest
ports:
- containerPort: 3000
env:
- name: DATABASE_URL
value: postgresql://user:pass@localhost:5432/myapp
- name: db
image: docker.io/library/postgres:16-alpine
env:
- name: POSTGRES_USER
value: user
And play Kubernetes YAML back:
podman play kube myapp-pod.yaml
This bridges local development and Kubernetes deployment. Develop with Podman pods, export to YAML, deploy to Kubernetes.
Systemd Integration
Podman integrates with systemd for container lifecycle management, replacing Docker's restart policies.
Generate Systemd Unit Files
# Generate a systemd service from a running container
podman generate systemd --new --name api > ~/.config/systemd/user/container-api.service
# Enable and start
systemctl --user daemon-reload
systemctl --user enable --now container-api.service
# Check status
systemctl --user status container-api.service
Quadlet (Podman 4.4+)
Quadlet is the modern approach — declarative container definitions in systemd:
# ~/.config/containers/systemd/api.container
[Container]
Image=myapp:latest
PublishPort=3000:3000
Environment=NODE_ENV=production
Environment=DATABASE_URL=postgresql://user:pass@db:5432/myapp
Volume=app-data:/app/data
Network=myapp.network
[Service]
Restart=always
TimeoutStartSec=30
[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user start api
Migration from Docker to Podman
Step 1: Install Podman and Alias
sudo dnf install podman # or apt-get
alias docker=podman
# Test with existing commands
docker ps
docker images
docker build -t myapp .
Step 2: Migrate Compose Files
# Install podman-compose
pip install podman-compose
# Or use podman compose (requires docker-compose installed)
podman compose up -d
# Test your existing docker-compose.yml
podman-compose -f docker-compose.yml up -d
Step 3: Handle Docker Socket Dependencies
Some tools expect the Docker socket at /var/run/docker.sock. Podman provides a compatible socket:
# Enable the Podman socket (systemd)
systemctl --user enable --now podman.socket
# The socket lives at:
# /run/user/$(id -u)/podman/podman.sock
# Create symlink for compatibility
sudo ln -sf /run/user/$(id -u)/podman/podman.sock /var/run/docker.sock
# Or set DOCKER_HOST
export DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock
Step 4: Update CI Pipelines
# GitHub Actions - use Podman instead of Docker
- name: Build image
run: podman build -t myapp .
- name: Run tests
run: podman run --rm myapp npm test
- name: Push image
run: |
podman login -u ${{ secrets.REGISTRY_USER }} -p ${{ secrets.REGISTRY_TOKEN }} ghcr.io
podman push myapp ghcr.io/myorg/myapp:${{ github.sha }}
Docker vs Podman Comparison
| Feature | Docker | Podman |
|---|---|---|
| Daemon | Required (dockerd) | No daemon |
| Root required | Yes (rootless is opt-in) | Rootless by default |
| Compose | docker compose (built-in) | podman-compose / podman compose |
| Pods | Not supported | Native pod support |
| Kubernetes YAML | Third-party tools | Built-in generate/play |
| Systemd integration | Limited | Native (Quadlet) |
| OCI compliance | Yes | Yes |
| BuildKit | Yes (default) | Via Buildah |
| Image format | OCI/Docker | OCI/Docker |
| CLI compatibility | N/A | Drop-in replacement |
| macOS/Windows | Docker Desktop | Podman Machine |
| Container runtime | containerd | crun/runc |
Complete Working Example
#!/bin/bash
# setup-podman-dev.sh — Set up a Node.js development environment with Podman
# Create a pod for the application stack
podman pod create \
--name dev-pod \
-p 3000:3000 \
-p 5432:5432 \
-p 6379:6379 \
-p 8025:8025
# Start PostgreSQL
podman run -d \
--pod dev-pod \
--name dev-postgres \
-e POSTGRES_USER=devuser \
-e POSTGRES_PASSWORD=devpass \
-e POSTGRES_DB=myapp_dev \
-v pgdata:/var/lib/postgresql/data \
postgres:16-alpine
# Start Redis
podman run -d \
--pod dev-pod \
--name dev-redis \
redis:7-alpine
# Start MailHog
podman run -d \
--pod dev-pod \
--name dev-mailhog \
mailhog/mailhog:latest
# Wait for PostgreSQL
echo "Waiting for PostgreSQL..."
until podman exec dev-postgres pg_isready -U devuser; do
sleep 1
done
# Build and start the application
podman build -t myapp:dev --target development .
podman run -d \
--pod dev-pod \
--name dev-api \
-v .:/app:Z \
-e NODE_ENV=development \
-e DATABASE_URL=postgresql://devuser:devpass@localhost:5432/myapp_dev \
-e REDIS_URL=redis://localhost:6379 \
-e SMTP_HOST=localhost \
-e SMTP_PORT=1025 \
myapp:dev
echo "Development environment ready:"
echo " API: http://localhost:3000"
echo " MailHog: http://localhost:8025"
echo " PostgreSQL: localhost:5432"
echo " Redis: localhost:6379"
Note the :Z suffix on the volume mount. On SELinux-enabled systems (Fedora, RHEL), this relabels the volume for container access. Without it, you get permission denied errors.
// app.js — works identically under Docker and Podman
var express = require('express');
var pg = require('pg');
var app = express();
var pool = new pg.Pool({
connectionString: process.env.DATABASE_URL
});
app.get('/health', function(req, res) {
pool.query('SELECT 1', function(err) {
if (err) {
return res.status(503).json({ status: 'unhealthy', error: err.message });
}
res.json({ status: 'healthy', runtime: 'podman' });
});
});
app.listen(3000, function() {
console.log('Server running on port 3000');
});
Common Issues and Troubleshooting
1. Permission Denied on Volume Mount (SELinux)
Error: EACCES: permission denied, open '/app/data/file.txt'
On Fedora/RHEL with SELinux, add :Z to volume mounts:
podman run -v ./data:/app/data:Z myapp
The :Z flag relabels the host directory for exclusive container access. Use :z (lowercase) for shared access between multiple containers.
2. Cannot Connect to Podman Socket
Cannot connect to Podman. Is the podman socket active?
Enable the socket:
systemctl --user enable --now podman.socket
export DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock
3. Image Pull Requires Full Registry Path
podman pull node
# Error: short-name resolution enforced
podman pull docker.io/library/node:20-alpine
# Success
Podman enforces fully-qualified image names by default. Configure short-name aliases:
# /etc/containers/registries.conf.d/shortnames.conf
[aliases]
"node" = "docker.io/library/node"
"postgres" = "docker.io/library/postgres"
"redis" = "docker.io/library/redis"
Or set unqualified search registries:
# /etc/containers/registries.conf
unqualified-search-registries = ["docker.io"]
4. Rootless Port Binding Fails Below 1024
Error: rootlessport cannot expose privileged port 80
Options:
# Allow low ports for all users
sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80
# Or use rootful podman for this specific container
sudo podman run -p 80:3000 myapp
# Or use a reverse proxy on a high port
podman run -p 8080:3000 myapp
Best Practices
- Use rootless mode for everything. There is rarely a legitimate reason to run containers as root in development or production.
- Use pods for multi-container applications. Pods simplify networking by sharing localhost and are directly exportable to Kubernetes YAML.
- Add
:Zto volume mounts on SELinux systems. This is the most common source of permission errors on Fedora and RHEL. - Use fully-qualified image names.
docker.io/library/node:20-alpineis unambiguous and works everywhere. - Leverage
podman generate kubefor Kubernetes migration. Develop locally with pods, then export YAML for production deployment. - Use Quadlet for production container management. Systemd integration provides proper process supervision, logging, and boot-time startup.
- Keep your Dockerfiles compatible. Podman uses the same Dockerfile format. Write Dockerfiles that work with both Docker and Podman.
- Set
DOCKER_HOSTfor tools that expect the Docker socket. This enables compatibility with Docker-dependent CI tools and IDE extensions.