One Compose File, Two Runtimes: Podman Locally, Docker in CI
Why Mezite's end-to-end suite runs on rootless Podman on developer machines and Docker on CI runners, and the small seam in the test harness that lets one compose file serve both.
Mezite’s end-to-end suite is big. A single compose file describes more than sixty services: Postgres and SQLite-backed hubs, dozens of agent variants, a mock identity provider, OpenLDAP, MinIO, k3s, and real database targets from MySQL to Cassandra. Every E2E test brings up the slice of that world it needs, drives it through public interfaces, and tears it down.
On a developer machine, that suite runs on Podman. On CI, it runs on Docker. The tests, the compose file, and the Makefile targets are the same. This post is about why we split it that way, and the small seam in the harness that makes it work.
Why Podman on Developer Machines
Podman is daemonless. When you run podman run, the container is a child of your own process rather than of a long-running root daemon, and by default it runs rootless: user namespaces map UID 0 inside the container to your unprivileged UID on the host.
For a laptop that is a meaningful difference. With Docker, being able to talk to the daemon socket is effectively root on the machine, so every developer, script, and dependency that can reach docker.sock has root-equivalent access. A rootless Podman setup has no such socket to protect. A misbehaving test fixture is confined to the developer’s own account.
So the repository’s default is Podman, and the conventions follow from it: the compose file is podman-compose.yml, the Containerfiles live in build/, and every local target goes through podman-compose:
e2e-up: e2e-build
podman-compose -f podman-compose.yml up -d
# Run E2E tests against running cluster
e2e: e2e-up
go test -v -tags=e2e -timeout=10m -count=1 ./e2e/...
$(MAKE) e2e-downWhy Docker in CI
Our CI runners are self-hosted machines that ship Docker with the Compose plugin, not Podman. We could install and maintain Podman on them, but that is not what CI is for. CI’s job is to exercise the same tests against the same compose definitions a developer ran, not to reproduce their workstation. Running a second, CI-only copy of the fixtures would guarantee drift. So instead of choosing one runtime, the harness supports both.
The Seam: E2E_RUNTIME
The entire runtime abstraction is one environment variable and a handful of lines in e2e/harness/harness.go:
// containerRuntime returns "docker" or "podman" based on the E2E_RUNTIME env
// var. Defaults to "podman" for local development; CI sets E2E_RUNTIME=docker.
func containerRuntime() string {
if r := os.Getenv("E2E_RUNTIME"); r != "" {
return r
}
return "podman"
}
// composeCmd returns the compose command and its base args.
func composeCmd() (string, []string) {
if containerRuntime() == "docker" {
return "docker", []string{"compose"}
}
return "podman-compose", nil
}Every compose invocation in the harness goes through composeCmd(). Every direct container command, such as inspect for health checks, exec to run commands inside a container, or port to discover a dynamically mapped port, uses containerRuntime() as the binary name. The Podman and Docker CLIs are compatible enough that the arguments do not change.
CI opts in with one line in the job environment:
- name: Run SQLite E2E tests
env:
SKIP_E2E_BUILD: "1"
E2E_RUNTIME: "docker"
run: go test -v -tags=e2e -timeout=20m -count=1 -run="Test(SQLite|Phase12_SQLite)" ./e2e/...The Makefile honours the same variable for targets CI calls directly, so there is exactly one switch:
KMS_COMPOSE := $(if $(filter docker,$(E2E_RUNTIME)),docker compose,podman-compose)Where “Compatible” Stops
“Podman is a drop-in replacement for Docker” is mostly true. The places where it isn’t are exactly where a dual-runtime harness breaks, so they are worth writing down.
Container names. podman-compose names containers project_service_1; Docker Compose v2 uses project-service-1. Any helper that addresses a container by name, whether to exec into it or inspect its health, has to know which convention applies:
func (c *Cluster) containerName(service string) string {
if containerRuntime() == "docker" {
return fmt.Sprintf("%s-%s-1", c.ProjectName, service)
}
return fmt.Sprintf("%s_%s_1", c.ProjectName, service)
}Get this wrong and the failure is confusing: compose up succeeds, then every health check reports “no such container” until the test times out.
Ignore files. Podman reads .containerignore; Docker reads .dockerignore. We keep one real file, .dockerignore, and make .containerignore a symlink to it. Both runtimes see the same build context, and an edit to one is an edit to both.
Isolation between tests. Each test cluster gets its own compose project name, mezite-e2e-<pid>-<n>, so containers, networks, and volumes from one test never collide with another’s. Port release after teardown is not instantaneous on either runtime, so Cluster.Start retries compose up with a short backoff, running a full down -v --remove-orphans between attempts.
Build ordering. Several services share a Containerfile but are tagged as separate images. If they are not built up front, compose up will build them on demand, concurrently, and compiling the pure-Go SQLite driver several times in parallel is a reliable way to run a CI runner out of memory. Targets that need those images build them explicitly, one service at a time, before any test starts.
Conclusion
We did not have to pick a single container runtime. Developers get rootless, daemonless Podman by default; CI gets the Docker its runners already ship; and both drive the same compose file through the same test harness.
The cost of supporting both is one environment variable, a container-naming helper, and a symlinked ignore file. That is far cheaper than maintaining two sets of fixtures, and it means a test that passes on a laptop is running against the same stack it will meet in CI.
Mezite Team
Engineering