Podman for Testing: Rootless Containers in CI/CD Pipelines

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 rootless

Basic 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 test

Key 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:test

Podman 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-pod

This 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.yaml

This 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 tests

CI/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-pod

GitLab 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 test

Podman 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/db

Issue: 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 myapp

Issue: 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 teardown

Podman 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 id

Podman 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.

Read more

Start now free