This post is part of the series about my personal BeagleBone Black project. The project’s CI/CD infrastructure runs on Drone CI — but not with Docker, rather with Podman as the container backend. What sounds simple on paper has a few rough edges in practice.

Why Podman instead of Docker?

The short answer: rootless operation and no daemon.

Docker requires a privileged daemon running permanently — dockerd — and access to the Docker socket effectively means root privileges on the host. That’s an acceptable tradeoff for many environments, but not for my private server.

Podman runs without a daemon:

# Docker: client talks to a daemon running as root
docker run ubuntu echo hello

# Podman: no daemon, direct fork/exec as a normal user
podman run ubuntu echo hello

Concrete advantages in operation:

  • No permanently running privileged process

  • Containers run under the calling user’s own UID

  • podman-auto-update instead of Docker watchers

  • Drop-in compatible with Docker for most cases

Podman is not a perfect Docker replacement. Where there are differences, I describe them further below under the pitfalls.

Installing the Drone CI server with Podman

The Drone server and runner themselves run as containers — started with Podman.

Drone server

podman run \
  --detach \
  --name=drone \
  --restart=always \
  --env=DRONE_GITEA_SERVER=https://gitea.example.com \
  --env=DRONE_GITEA_CLIENT_ID=<client-id> \
  --env=DRONE_GITEA_CLIENT_SECRET=<secret> \
  --env=DRONE_RPC_SECRET=<shared-secret> \
  --env=DRONE_SERVER_HOST=drone.example.com \
  --env=DRONE_SERVER_PROTO=https \
  --publish=80:80 \
  --publish=443:443 \
  --volume=/var/lib/drone:/data \
  docker.io/drone/drone:2

Drone runner (Podman backend)

The standard Drone Docker runner talks to the Docker socket. For Podman, there are two approaches:

Option A — expose a Docker-compatible socket:

Podman can provide a Docker-compatible socket:

systemctl --user enable --now podman.socket
# Socket is located at: /run/user/<UID>/podman/podman.sock

Then start the runner with the Podman socket:

podman run \
  --detach \
  --name=drone-runner \
  --restart=always \
  --env=DRONE_RPC_PROTO=https \
  --env=DRONE_RPC_HOST=drone.example.com \
  --env=DRONE_RPC_SECRET=<shared-secret> \
  --env=DRONE_RUNNER_CAPACITY=2 \
  --volume=/run/user/1000/podman/podman.sock:/var/run/docker.sock \
  docker.io/drone/drone-runner-docker:1

Option B — exec runner (no container socket needed):

The exec runner runs steps directly on the host — simpler, less isolated:

podman run \
  --detach \
  --name=drone-runner-exec \
  --restart=always \
  --env=DRONE_RPC_PROTO=https \
  --env=DRONE_RPC_HOST=drone.example.com \
  --env=DRONE_RPC_SECRET=<shared-secret> \
  --volume=/var/run/drone-runner:/data \
  docker.io/drone/drone-runner-exec:latest

I use Option A because the Docker runner offers better step isolation and .drone.yml files work without adjustment.

Pitfalls

Volume mounts and file permissions

The biggest problem with rootless operation: file permissions.

Rootless Podman maps UIDs inside the container to subuid ranges of the host user. A process running as root (UID 0) inside the container runs on the host as the user’s UID plus an offset.

Problem: A build step writes files as root inside the container — after the build, these files belong to a UID on the host that doesn’t exist.

Fix: Explicit UID mapping or --userns=keep-id:

# .drone.yml
steps:
  - name: build
    image: ubuntu:22.04
    volumes:
      - name: workspace
        path: /workspace
    environment:
      DRONE_WORKSPACE_BASE: /workspace

volumes:
  - name: workspace
    host:
      path: /tmp/drone-workspace

Or start the Drone runner with --userns=keep-id, so the container user matches the host user.

Network isolation between steps

Docker runner steps in the same pipeline run share a network. With Podman in a rootless setup, the network backend (slirp4netns vs pasta) can lead to different results.

Problem: A database container in step A is not reachable from step B under localhost.

Fix: Name service containers explicitly and address them by hostname:

services:
  - name: postgres
    image: postgres:15
    environment:
      POSTGRES_DB: testdb
      POSTGRES_PASSWORD: test

steps:
  - name: test
    image: golang:1.21
    commands:
      # The hostname is the service name, not localhost
      - go test -db-host=postgres ./...

Secrets and environment variables

Drone secrets work unchanged — that’s not a Podman-specific problem. But: with rootless Podman, secrets must not be written to mounted host directories that are accessible to other users.

Best practice: Secrets only as environment variables, never as files in /tmp.

steps:
  - name: deploy
    image: alpine
    environment:
      SSH_KEY:
        from_secret: ssh_private_key
    commands:
      # Keep the key in memory, never on disk
      - eval $(ssh-agent -s)
      - echo "$SSH_KEY" | ssh-add -
      - ssh user@host "systemctl restart myservice"

Cross-compilation and privileged operations

My build setup cross-compiles for ARMv7. The cross-compilation container doesn’t need any special privileges — but I initially fell into the trap of setting --privileged because another step needed it.

Rule: Reduce every step to minimal privileges. Only use --privileged when truly necessary (e.g. loading kernel modules). Cross-compilation never needs it.

A working .drone.yml configuration

This is my actual pipeline — simplified but functional:

kind: pipeline
type: docker
name: beaglebone-build

steps:
  - name: build-c
    image: ubuntu:22.04
    commands:
      - apt-get update -q && apt-get install -y cmake gcc-arm-linux-gnueabihf
      - cmake -DCMAKE_TOOLCHAIN_FILE=cmake/armv7-toolchain.cmake -B build-arm
      - cmake --build build-arm

  - name: build-rust
    image: rust:1.75
    commands:
      - rustup target add armv7-unknown-linux-gnueabihf
      - cd rust
      - cargo build --release --target armv7-unknown-linux-gnueabihf

  - name: build-go
    image: golang:1.21
    commands:
      - cd go-api
      - go build ./...
      - go test ./...

  - name: deploy
    image: alpine
    environment:
      SSH_KEY:
        from_secret: bbb_ssh_key
    commands:
      - apk add --no-cache openssh-client rsync
      - eval $(ssh-agent -s)
      - echo "$SSH_KEY" | ssh-add -
      - rsync -avz build-arm/ pi@beaglebone:/opt/bbb-hal/
      - ssh pi@beaglebone "systemctl restart bbb-hal"
    when:
      branch:
        - main

Conclusion

Podman as a Drone CI backend works well — with a few adjustments.

When Podman pays off:

  • No Docker daemon should run permanently

  • Rootless operation is a requirement

  • Private server without complex container orchestration

When Docker is simpler:

  • Large teams with existing Docker workflows

  • Many images that use the Docker socket directly

  • When compatibility issues cost more time than running the daemon

For my private project, Podman is the right choice. The pitfalls were solvable, and the result is a CI infrastructure that doesn’t rely on privileged background services.