Part 50: DevLab on Podman — The Variant
"Same DevLab. Different daemon. Or rather, no daemon at all."
Why
Part 17 and Part 18 wrapped Podman and podman-compose. Part 27 added the Podman host VM image. This part puts them together: standing up a real DevLab on Podman, end to end, and documenting which parts work transparently and which need adjustment.
The thesis of this part is: the same homelab commands bring up DevLab on Podman, with two changes: engine: podman in the config, and a small set of compose-spec adjustments the wrapper handles automatically. Most services work without modification. GitLab Omnibus on rootless Podman has a known issue that requires sudo podman (rootful) — we document that explicitly.
What changes in the config
# config-homelab.yaml — Podman variant
name: devlab
topology: single
engine: podman # ← the only meaningful change
acme:
name: frenchexdev
tld: lab
packer:
distro: alpine
version: "3.21"
kind: podmanhost # ← uses Packer.Alpine.PodmanHost instead of DockerHost
vos:
box: frenchexdev/alpine-3.21-podmanhost# config-homelab.yaml — Podman variant
name: devlab
topology: single
engine: podman # ← the only meaningful change
acme:
name: frenchexdev
tld: lab
packer:
distro: alpine
version: "3.21"
kind: podmanhost # ← uses Packer.Alpine.PodmanHost instead of DockerHost
vos:
box: frenchexdev/alpine-3.21-podmanhostTwo fields. The rest of the config is identical to the Docker variant.
What HomeLab does differently when engine: podman
The composition root (from Part 17) selects PodmanContainerEngine instead of DockerContainerEngine. Every consumer that talks to "the engine" goes through IContainerEngine, so the wrappers above the engine layer do not change. Below it, the changes are:
| Concern | Docker variant | Podman variant |
|---|---|---|
| Host overlay | DockerHostContributor (from Part 26) |
PodmanHostContributor (from Part 27) |
| Host engine in VM | dockerd on tcp 2375 | podman system service on unix socket |
| Compose CLI | docker compose (v2) |
podman-compose |
| Daemon socket Traefik watches | /var/run/docker.sock |
/run/podman/podman.sock |
Strip version: from compose YAML |
no | yes |
| Volume permissions | root:root or user-namespaces | rootless subuid mapping |
Healthcheck condition: service_healthy |
works | requires podman-compose ≥ 1.0.6 |
The compose contributors do not change. The Traefik static config from Part 23 reads _config.Engine and points its docker provider at the right socket path. The compose serializer reads _config.Engine and strips version: when emitting for Podman.
The known issue: GitLab Omnibus on rootless Podman
GitLab Omnibus is the one service that does not work cleanly on rootless Podman. The reasons:
- Port 22 binding. GitLab listens on port 22 for git-ssh pushes. Rootless Podman cannot bind to ports below 1024. Workarounds: change GitLab's SSH port to 2222 (the simplest fix), or run rootful Podman, or use systemd socket activation (complex).
/var/opt/gitlabownership. The Omnibus image expects to manage user IDs inside/var/opt/gitlab. Under rootless Podman with user-namespace mapping, the UIDs inside the container are mapped to subuids on the host, and Omnibus's chown calls fail. Workaround: use a named volume instead of a bind mount (Podman handles the chown inside the namespace), or use rootful Podman.- Kernel capabilities. Some of GitLab's internal services (Gitaly, Workhorse) want capabilities (
CAP_SYS_PTRACE,CAP_SYS_RESOURCE) that rootless Podman cannot grant.
The honest answer is: rootful Podman. HomeLab supports it via podman.rootful: true:
podman:
rootful: true # use sudo podman, not rootlesspodman:
rootful: true # use sudo podman, not rootlessWhen rootful: true, the PodmanHostContributor configures the podman.socket to listen as root on /run/podman/podman.sock, the user is added to a podman group, and IPodmanClient uses sudo for invocations. This loses Podman's rootless advantage but gives the GitLab compatibility.
For users who want rootless and not GitLab, that is also fine: drop GitLab from the compose contributors and run a smaller DevLab without it.
What works transparently
The vast majority of services work without any contributor change:
- Postgres: works rootless. Volume mount with subuid mapping.
- MinIO: works rootless. UID 1000 inside the container maps cleanly.
- baget: works rootless.
- Vagrant Registry: works rootless.
- Traefik: works rootless if it binds to ports ≥ 1024 internally and the host maps 80/443 via firewall (we use this approach when rootless is enabled).
- PiHole: works rootless except for binding port 53 — use port 5353 internally and a host-level redirect.
- Prometheus / Grafana / Loki / Alertmanager: all work rootless.
- Meilisearch: works rootless.
- The docs site (static via MinIO): works rootless (it is just MinIO + Traefik).
So the realistic Podman variants are:
- Rootless without GitLab — works completely. Useful for the 80% of homelab use cases that do not need a self-hosted GitLab.
- Rootful with GitLab — works completely. Loses the rootless advantage but matches the Docker variant feature-for-feature.
HomeLab supports both. The user picks via two config fields.
The compose service adjustments
The serializer from DockerCompose.Bundle has an engine parameter. When it serialises for Podman:
public sealed class ComposeSerializer
{
private readonly string _engine;
public ComposeSerializer(string engine = "docker") => _engine = engine;
public string Serialize(ComposeFile compose)
{
var output = new ComposeFileForSerialization(compose);
if (_engine == "podman")
{
// 1. Strip `version:` (podman-compose ignores it but warns)
output.Version = null;
// 2. Translate `condition: service_healthy` for old podman-compose
// (we already validated the version in the validation stage)
// 3. Convert privileged port bindings if rootless
if (_isRootless)
output = RewritePrivilegedPorts(output);
// 4. Translate `secrets:` to bind mounts
// (podman-compose's secrets handling differs)
output = TranslateSecretsToBindMounts(output);
}
return _yaml.Serialize(output);
}
}public sealed class ComposeSerializer
{
private readonly string _engine;
public ComposeSerializer(string engine = "docker") => _engine = engine;
public string Serialize(ComposeFile compose)
{
var output = new ComposeFileForSerialization(compose);
if (_engine == "podman")
{
// 1. Strip `version:` (podman-compose ignores it but warns)
output.Version = null;
// 2. Translate `condition: service_healthy` for old podman-compose
// (we already validated the version in the validation stage)
// 3. Convert privileged port bindings if rootless
if (_isRootless)
output = RewritePrivilegedPorts(output);
// 4. Translate `secrets:` to bind mounts
// (podman-compose's secrets handling differs)
output = TranslateSecretsToBindMounts(output);
}
return _yaml.Serialize(output);
}
}Four adjustments. None of them change the contributor's intent — the compose contributor authored the same ComposeService, the serializer adapted it for the target engine.
Bringing up DevLab on Podman
The user runs the same eight commands from Part 37:
$ homelab init --name devlab-podman --acme-name frenchexdev --acme-tld lab
$ cd devlab-podman
# edit config-homelab.yaml: engine: podman, packer.kind: podmanhost
$ homelab packer init
$ homelab packer build # builds the Podman host box
$ homelab box add --local
$ homelab vos init
$ homelab vos up
$ homelab dns add gitlab.frenchexdev.lab 192.168.56.10
$ homelab tls init --provider native
$ homelab tls install
$ homelab compose init
$ homelab compose deploy # uses podman-compose under the hood
$ homelab gitlab configure # works (rootful Podman) or fails clearly (rootless without GitLab disabled)$ homelab init --name devlab-podman --acme-name frenchexdev --acme-tld lab
$ cd devlab-podman
# edit config-homelab.yaml: engine: podman, packer.kind: podmanhost
$ homelab packer init
$ homelab packer build # builds the Podman host box
$ homelab box add --local
$ homelab vos init
$ homelab vos up
$ homelab dns add gitlab.frenchexdev.lab 192.168.56.10
$ homelab tls init --provider native
$ homelab tls install
$ homelab compose init
$ homelab compose deploy # uses podman-compose under the hood
$ homelab gitlab configure # works (rootful Podman) or fails clearly (rootless without GitLab disabled)Same commands. Different binaries underneath. Same result.
The test
[Fact]
public void compose_serializer_strips_version_for_podman_engine()
{
var compose = new ComposeFile
{
Version = "3.8",
Services = new() { ["postgres"] = new() { Image = "postgres:16-alpine" } }
};
var dockerYaml = new ComposeSerializer(engine: "docker").Serialize(compose);
dockerYaml.Should().Contain("version:");
var podmanYaml = new ComposeSerializer(engine: "podman").Serialize(compose);
podmanYaml.Should().NotContain("version:");
podmanYaml.Should().Contain("services:");
}
[Fact]
[Trait("category", "e2e")]
[Trait("category", "slow")]
public async Task devlab_podman_rootless_brings_up_postgres_and_minio_without_gitlab()
{
using var lab = await TestLab.NewAsync(name: "podman-rootless", topology: "single", engine: "podman");
await lab.WriteConfig(@"
engine: podman
podman: { rootful: false }
compose:
gitlab: false
postgres: true
minio: true
");
var result = await lab.RunFullPipeline();
result.ExitCode.Should().Be(0);
lab.Service("postgres").Should().BeHealthy();
lab.Service("minio").Should().BeHealthy();
}
[Fact]
[Trait("category", "e2e")]
[Trait("category", "slow")]
public async Task devlab_podman_rootful_brings_up_full_lab_including_gitlab()
{
using var lab = await TestLab.NewAsync(name: "podman-rootful", topology: "single", engine: "podman");
await lab.WriteConfig(@"
engine: podman
podman: { rootful: true }
");
var result = await lab.RunFullPipeline();
result.ExitCode.Should().Be(0);
lab.Service("gitlab").Should().BeHealthy();
}[Fact]
public void compose_serializer_strips_version_for_podman_engine()
{
var compose = new ComposeFile
{
Version = "3.8",
Services = new() { ["postgres"] = new() { Image = "postgres:16-alpine" } }
};
var dockerYaml = new ComposeSerializer(engine: "docker").Serialize(compose);
dockerYaml.Should().Contain("version:");
var podmanYaml = new ComposeSerializer(engine: "podman").Serialize(compose);
podmanYaml.Should().NotContain("version:");
podmanYaml.Should().Contain("services:");
}
[Fact]
[Trait("category", "e2e")]
[Trait("category", "slow")]
public async Task devlab_podman_rootless_brings_up_postgres_and_minio_without_gitlab()
{
using var lab = await TestLab.NewAsync(name: "podman-rootless", topology: "single", engine: "podman");
await lab.WriteConfig(@"
engine: podman
podman: { rootful: false }
compose:
gitlab: false
postgres: true
minio: true
");
var result = await lab.RunFullPipeline();
result.ExitCode.Should().Be(0);
lab.Service("postgres").Should().BeHealthy();
lab.Service("minio").Should().BeHealthy();
}
[Fact]
[Trait("category", "e2e")]
[Trait("category", "slow")]
public async Task devlab_podman_rootful_brings_up_full_lab_including_gitlab()
{
using var lab = await TestLab.NewAsync(name: "podman-rootful", topology: "single", engine: "podman");
await lab.WriteConfig(@"
engine: podman
podman: { rootful: true }
");
var result = await lab.RunFullPipeline();
result.ExitCode.Should().Be(0);
lab.Service("gitlab").Should().BeHealthy();
}What this gives you that bash doesn't
A bash script that supports both Docker and Podman is two scripts that drift, or one script with if [[ "$ENGINE" == "podman" ]]; then ... fi everywhere. Each new edge case is a new condition.
The IContainerEngine abstraction with two backing wrappers gives you, for the same surface area:
- One config field to switch
- Two parallel host overlays (Docker, Podman) selected by config
- Two compose-engine adapters (
docker compose,podman-compose) - Engine-aware compose serialisation (version stripping, secret translation, port rewriting)
- Two posture modes (rootless without GitLab, rootful with GitLab)
- The same eight-command DevLab bring-up for both engines
- E2E tests for both engines on every release
The bargain pays back the first time a security-conscious user says "we cannot have a privileged daemon" and you switch one config field and watch DevLab come up rootless.