DEV Community

Ayi NEDJIMI
Ayi NEDJIMI

Posted on

Docker vs Podman vs nerdctl: Container Runtimes for Go Developers

When you containerize a Go service, the runtime choice is usually an afterthought — until something breaks in CI or a security audit flags your daemon running as root. Docker, Podman, and nerdctl each make different trade-offs around privilege, daemon architecture, and OCI compatibility. This article compares them from a Go developer's perspective.

What makes these runtimes different

All three can build and run OCI-compliant container images. The divergence is in how they do it.

Docker runs a daemon (dockerd) that listens on a Unix socket owned by root. The Docker CLI talks to this daemon. On Linux, any user in the docker group can send arbitrary commands to that socket — which is functionally equivalent to root access on the host. Docker Desktop on macOS/Windows wraps this in a VM, but on Linux servers and in CI, it is a real privilege escalation path.

Podman is daemonless. Each podman invocation forks the container process directly. It defaults to rootless — containers run as your user, mapped into a user namespace. There is no long-lived daemon to attack. The CLI is intentionally Docker-compatible, so aliasing docker=podman works for most workflows.

nerdctl is a thin CLI built by the containerd project. It wraps containerd directly instead of going through dockerd. Containerd is what Docker uses under the hood anyway, so nerdctl gives you direct access to that layer. It supports rootless via rootlesskit and accepts the same Dockerfiles and image formats.

Building a Go binary in a container

The Dockerfile is identical across all three runtimes:

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /bin/server ./cmd/server

FROM scratch
COPY --from=builder /bin/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
Enter fullscreen mode Exit fullscreen mode

The build commands are nearly identical:

# Docker
docker build -t myservice:latest .

# Podman — daemonless, rootless by default
podman build -t myservice:latest .

# nerdctl — wraps containerd directly
nerdctl build -t myservice:latest .
Enter fullscreen mode Exit fullscreen mode

The scratch base image plus a statically compiled Go binary results in a final image that is just your binary: no shell, no package manager, minimal attack surface. All three runtimes handle multi-stage builds identically.

Rootless containers and what they actually change

Rootless is not a security checkbox — it changes what an attacker lands as if a container escape happens.

In rootless mode (Podman's default, opt-in for Docker and nerdctl), container processes run as the invoking user. A user namespace maps UID 0 inside the container to a non-privileged UID on the host. A container escape puts an attacker inside an unprivileged user namespace.

With Docker's default setup, the daemon socket is owned by root. Any member of the docker group can mount host paths, read host filesystems, and run privileged containers. The privilege escalation path is short.

Enabling rootless Docker requires an explicit setup step:

dockerd-rootless-setuptool.sh install
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock
docker run --rm golang:1.22-alpine go version
Enter fullscreen mode Exit fullscreen mode

Podman runs rootless without any configuration:

# Runs as your user, no daemon, no root
podman run --rm golang:1.22-alpine go version
Enter fullscreen mode Exit fullscreen mode

If you are reviewing your container security posture, the container hardening checklists at ayinedjimi-consultants.fr/checklists cover rootless configuration, seccomp profiles, and capability dropping in a format you can run against your own deployments.

CI/CD integration for Go projects

This is where the practical differences matter most.

Docker in GitHub Actions usually requires Docker-in-Docker (DinD) with --privileged, or socket mounting — both are risky in shared CI environments.

# GitHub Actions — Docker, daemon-based
- name: Build
  uses: docker/build-push-action@v5
  with:
    context: .
    push: false
    tags: myservice:latest
Enter fullscreen mode Exit fullscreen mode

Podman in GitHub Actions does not need a daemon. Ubuntu runners have Podman pre-installed:

# GitHub Actions — Podman, daemonless
- name: Build with Podman
  run: |
    podman build -t myservice:latest .
    podman run --rm myservice:latest /server --version
Enter fullscreen mode Exit fullscreen mode

No privileged flag, no socket mounts. Podman forks container processes directly under the runner user. For Go projects that compile to a static binary and run smoke tests in CI, this is the lowest-friction secure option.

nerdctl with containerd fits naturally in Kubernetes-adjacent CI like kind or k3s, where containerd is already the cluster runtime. If your CI nodes run containerd, nerdctl avoids adding a separate daemon:

# GitHub Actions — nerdctl on a containerd-backed runner
- name: Build with nerdctl
  run: |
    nerdctl build -t myservice:latest .
    nerdctl run --rm myservice:latest /server --version
Enter fullscreen mode Exit fullscreen mode

One gotcha with nerdctl: it uses containerd namespaces (defaults to default). If the same node runs a Kubernetes cluster, images built by nerdctl are not visible to the kubelet unless you specify the k8s.io namespace explicitly: nerdctl --namespace k8s.io build ....

Compose workflows for local development

Go services almost always need dependencies — Postgres, Redis, a mock OAuth server. For local compose workflows:

  • Docker: docker compose (v2, built-in plugin) — widest compatibility
  • Podman: podman compose (wraps docker-compose binary) or podman-compose (Python package) — some gaps with complex volume and network configs
  • nerdctl: nerdctl compose (built-in, Docker Compose v2 syntax) — solid compatibility

In practice, nerdctl compose has the best parity with existing docker-compose.yml files after Docker itself. Podman-compose works for straightforward setups but can break on edge cases like network_mode: host and certain healthcheck configurations.

Which one to pick

Docker Podman nerdctl
Rootless by default No Yes No (opt-in)
Daemon required Yes No Yes (containerd)
CI without privileges Harder Easy Medium
Compose compatibility Best Partial Good
Kubernetes integration Via Docker Desktop Via podman-remote Native (containerd)

For Go developers building microservices who care about the security of their build pipeline: Podman's rootless-by-default model eliminates the daemon socket risk with minimal migration cost. The CLI compatibility means alias docker=podman in your shell profile covers most local workflows.

For teams embedded in the Kubernetes ecosystem where containerd is already the node runtime: nerdctl gives direct access to what is already running, with no added daemon layer.

Docker remains the default for good reasons — ecosystem depth, tooling, documentation. But if a security reviewer asks why your CI pipeline runs a root-owned socket, Podman is the answer that requires the least justification.

The takeaway

The container image format is the same across all three. The divergence is in privilege model, daemon architecture, and CI integration patterns. For Go services compiled to static binaries and deployed from scratch images, the runtime is almost invisible at build time — until it is not. If your current setup has the docker group granting implicit root on CI nodes, switching to Podman costs an afternoon and removes a real attack vector.


I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.

Top comments (0)