Podman for Testing: Rootless Containers in CI/CD Pipelines
Podman is a daemonless, rootless container engine that's fully compatible with Docker CLI syntax. For testing pipelines, this means you can run containers without a Docker daemon, without root privileges, and without the security concerns that come with a socket-exposed daemon. This guide covers how to use Podman for testing, where it differs from Docker, and how to migrate existing Docker-based test setups.
Why Podman for Testing
No daemon required. Docker requires the Docker daemon (dockerd) running as root. Podman runs containers directly as child processes. In CI environments, this means fewer moving parts, no daemon startup time, and no privileged socket to manage.
Rootless by default. Podman containers run as your actual user ID. This prevents privilege escalation — if a container escapes, it has your user's permissions, not root. On shared CI runners, this matters.
Docker CLI compatibility. The podman binary is a drop-in replacement for docker in most cases. You can alias docker=podman in your scripts and most things work.
Pod support. Podman supports pods — groups of containers sharing a network namespace — which aligns with Kubernetes pod semantics. This makes local testing more representative of your production cluster.
Installing Podman
# macOS
brew install podman
podman machine init
podman machine start
# Ubuntu
sudo apt-get install -y podman
# Fedora / RHEL
sudo dnf install -y podman
# Verify rootless setup
podman info | grep -A5 rootlessBasic Container Testing with Podman
Podman's CLI is compatible with Docker:
# Pull and run (same as docker)
podman run --rm postgres:15 postgres --version
# Build image
podman build -t myapp:test -f Dockerfile .
# Run test container
podman run --rm \
-e DATABASE_URL=postgresql://test:test@localhost/test \
-v $(pwd)/tests:/tests:ro \
myapp:test \
npm testKey difference: networking. Rootless Podman containers use slirp4netns for networking by default. localhost inside a container doesn't reach the host. Use the host's actual IP or use --network=host for direct access:
# Get host IP as seen from container
HOST_IP=$(podman run --rm alpine ip route | awk '/default/ { print $3 }')
# Or use host networking (rootless users can use this on Linux)
podman run --rm --network=host myapp:testPodman Pods for Integration Testing
Pods are Podman's equivalent of Docker Compose for local integration testing:
# Create a pod with shared networking
podman pod create --name test-pod -p 5432:5432 -p 6379:6379
# Start services in the pod
podman run -d --pod test-pod \
-e POSTGRES_PASSWORD=test \
-e POSTGRES_DB=testdb \
postgres:15
podman run -d --pod test-pod redis:7
# Wait for services
until podman exec $(podman ps -q --filter "pod=test-pod" --filter "ancestor=postgres") \
pg_isready -U postgres; do sleep 1; done
# Run tests against services in the pod
podman run --rm --pod test-pod \
-e DATABASE_URL=postgresql://postgres:test@localhost/testdb \
-e REDIS_URL=redis://localhost:6379 \
myapp:test npm test
# Cleanup
podman pod rm -f test-podThis is more explicit than Docker Compose but maps directly to how Kubernetes runs your containers in production.
Generating Kubernetes YAML from Pods
A key Podman feature: generate Kubernetes pod manifests from your test pod:
# Generate k8s manifest from your test pod
podman generate kube test-pod > test-pod.yaml
# Later: apply to a real cluster
kubectl apply -f test-pod.yamlThis closes the gap between local testing and Kubernetes deployment. Your test pod becomes a deployable spec.
Testcontainers with Podman
Testcontainers supports Podman via the Docker socket compatibility layer:
# Start Podman's Docker-compatible socket
podman system service --time=0 unix:///tmp/podman.sock &
# Set DOCKER_HOST for Testcontainers
export DOCKER_HOST=unix:///tmp/podman.sock
export TESTCONTAINERS_RYUK_DISABLED=true # Ryuk doesn't work with rootless Podman// Java — Testcontainers with Podman
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.utility.DockerClientFactory;
// Set before tests run
System.setProperty("DOCKER_HOST", "unix:///tmp/podman.sock");
@Testcontainers
class DatabaseTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Test
void testDatabaseConnection() {
String jdbcUrl = postgres.getJdbcUrl();
// Test using jdbcUrl
}
}# Python — Testcontainers with Podman
import os
os.environ['DOCKER_HOST'] = 'unix:///tmp/podman.sock'
os.environ['TESTCONTAINERS_RYUK_DISABLED'] = 'true'
from testcontainers.postgres import PostgresContainer
with PostgresContainer("postgres:15") as postgres:
engine = create_engine(postgres.get_connection_url())
# Run testsCI/CD with Podman
GitHub Actions — Rootless Podman:
name: Tests with Podman
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Podman
run: |
sudo apt-get update
sudo apt-get install -y podman
- name: Create test pod
run: |
podman pod create --name ci-pod -p 5432:5432
podman run -d --pod ci-pod \
-e POSTGRES_PASSWORD=test \
-e POSTGRES_DB=ci \
postgres:15
# Wait for Postgres
for i in $(seq 1 30); do
podman exec $(podman ps -q --filter "pod=ci-pod") \
pg_isready -U postgres && break
sleep 2
done
- name: Build and test
run: |
podman build -t myapp:ci .
podman run --rm --pod ci-pod \
-e DATABASE_URL=postgresql://postgres:test@localhost/ci \
myapp:ci npm test
- name: Cleanup
if: always()
run: podman pod rm -f ci-podGitLab CI with Podman (rootless on shared runners):
test:
image: quay.io/podman/stable
variables:
STORAGE_DRIVER: vfs # Required for rootless in containers
BUILDAH_FORMAT: docker
before_script:
- podman info
script:
- podman build -t myapp:$CI_COMMIT_SHA .
- podman run --rm myapp:$CI_COMMIT_SHA npm testPodman vs Docker: Key Differences for Testing
| Feature | Docker | Podman |
|---|---|---|
| Daemon | Required (dockerd) | None |
| Root required | For daemon | No |
| Networking | Bridge via daemon | slirp4netns (rootless) |
localhost in container |
Reaches host | Doesn't reach host (rootless) |
| Compose | Docker Compose | podman-compose or pods |
| Testcontainers | Native | Via socket emulation |
| K8s integration | Docker Desktop | Native pod generation |
| macOS | Docker Desktop / Colima | Podman machine |
Common Migration Issues
Issue: containers can't reach each other by name
In Docker Compose, containers reach each other by service name. In Podman pods, use localhost since all containers share the pod's network namespace:
# Docker Compose: postgres reachable as "postgres"
DATABASE_URL=postgresql://postgres:test@postgres/db
# Podman pod: use localhost since all containers share network
DATABASE_URL=postgresql://postgres:test@localhost/dbIssue: volume permissions
Rootless Podman maps your UID to UID 0 inside the container. Files created in volumes may appear owned by your user on the host:
# Add :z or :Z for SELinux relabeling
podman run -v $(pwd)/data:/data:z myapp
# Or use --userns=keep-id to preserve UID mapping
podman run --userns=keep-id -v $(pwd)/data:/data myappIssue: Ryuk fails with Testcontainers
Ryuk (Testcontainers' cleanup mechanism) requires Docker socket access that rootless Podman doesn't fully support:
export TESTCONTAINERS_RYUK_DISABLED=true
# Clean up containers manually in test teardownPodman in Security-Sensitive Environments
For organizations with strict security policies, Podman's rootless model is a major advantage:
# Verify container runs as non-root
podman run --rm myapp id
# Should show: uid=0(root) gid=0(root) but mapped to your UID on host
# Check actual host UID
podman unshare cat /proc/self/uid_map
# Shows the UID mapping: 0 → your_uid inside → outside
# Run with specific user
podman run --rm --user 1000:1000 myapp idPodman doesn't require a privileged socket that any process with Docker group membership can use to escalate to root. For shared CI infrastructure, this is a meaningful security improvement over the traditional Docker daemon setup.