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"]
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 .
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
Podman runs rootless without any configuration:
# Runs as your user, no daemon, no root
podman run --rm golang:1.22-alpine go version
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
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
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
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) orpodman-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)