Notes on seeking wisdom and crafting software

Migrating from docker to podman

Table of contents

It was a week long journey to migrate my lab from Ubuntu 20.04 (end of life in May 2025) to Ubuntu 24.04 and run everything in Podman. Ubuntu upgrade was forced because Podman requires kernel 5.x.

This note is a summary of pain points and how I’d approach this today :)

Mental model

We run 12 selfhosted services on an Odroid M1 4GB model. There are three variations:

  • Two services require Host networking i.e., open up ports on the host. Adguard’s port 53 for DNS, and Traefik on port 80/443 for reverse proxy.
  • Two services used Docker volumes - Postgres and Nextcloud.
  • Other services are mostly self sufficient either running on sqlite or a host mounted volume for data.

Networking

These are the network flows.

  1. DNS requests: machines in LAN -> 192.168.1.111:53 (selfhost box) -> Podman (pasta) network -> Adguard home (container). Uses pasta network.
  2. HTTP requests: machines in LAN -> news.domain.com -> 192.168.1.111:443 (selfhost box) -> Traefik on 80/443 -> 10.89.0.3 (miniflux)
  3. Internal requests: 10.89.0.3 (miniflux) -> postgres:5432 -> 10.89.0.2 (postgres). Uses a custom network.

Note that both (1) and (3) networks will listen on port 53 for DNS resolution. We’re using (1) for LAN wide DNS resolution. (3)‘s DNS uses Podman’s netavark for container-to-container communication using container names. We cannot have both DNS servers (netavark) and Adguard on the same network.

TL;DR - Docker to Podman

Let’s do a summary for the entire process.

  1. You’ll need Linux Kernel 5.x. If you’re running Ubuntu, I’d recommend 24.04.

  2. Install Podman. # apt install podman or equivalent.

  3. Configure Podman

    • Open port 53 onwards for rootless containers. How to: link. Run # sysctl --system after the config change.
    • Add your registries to Podman. How to: link.

    Enable podman.socket, auto update and $USER lingering to allow auto start of containers.

    systemctl --user enable podman.socket
    systemctl --user enable podman-auto-update
    loginctl enable-linger $USER
  4. Test if podman is working

    podman run -dt -p 8080:80/tcp docker.io/library/httpd
    podman ps # verify the container is running
    curl http://localhost:8080
  5. Optional: At this step, you can try automated migration with https://github.com/Edu4rdSHL/fly-to-podman.

  6. Migrate volumes: use rsync or similar to copy /var/lib/docker/volumes/<volume_name>/_data to ~/.local/share/containers/storage/volumes/<volume_name>/_data.

  7. Setup networking: read the excellent podman networking docs to learn all options. Here’s I did:

    • Create an internal network for all containers except Adguard.
    • Use pasta network for Adguard to expose port 53. Details in the note below.
  8. Permissions for host mounted volumes require additional gymnastics.

    # identify the USER:GROUP running the worker process in container
    podman exec -it <container> sh
    top # note the process user:group, run inside container
    
    # or, check the permissions in directories _without_ mount
    podman exec -it <container> sh -c 'ls -la /var/www/html'
    
    # Now, we need to chown/chmod for the same user in host mounted directories
    podman unshare chown -R 82:82 <host shared dir> # run on the host

    Podman creates subuids for your user, e.g. above command will give 100082:100082 ownership to the directory, assuming 1000 is your uid.

  9. Create the quadlets. https://github.com/containers/podlet can help create the skeletal *.container files. I did three additional changes:

    • Add AutoUpdate=true for the container images to be auto pulled from registry.
    • Configure Traefik Label=traefik.enable=true and other labels.
    • Add an environment file EnvironmentFile=%h/path/to/.env to load secrets inside the container.

    Example quadlet is shared below.

  10. You’re going to spend a good amount of time in starting/stopping systemd units.

    systemctl --user daemon-reload # load changes from container files
    systemctl --user start <container>.service
    journalctl --user -xeu <container> # see logs if something goes wrong

    Or, use the systemctl-tui for all these commands.

Artifacts

Now, let’s look at the quadlets and related configurations that worked for me. I’ve annotated these with pointers wherever relevant.

Example: network quadlet

[Unit]
Description=selfhost network
After=network-online.target

# DNS ensures we hit the Adguard service with all queries from containers.
# Adguard is listening on 192.168.1.111:53
[Network]
NetworkName=selfhost
Subnet=10.89.0.0/24
Gateway=10.89.0.1
DNS=192.168.1.111
IPv6=false

[Install]
WantedBy=default.target

Example: container with custom network and traefik

[Unit]
Requires=selfhost-postgres.service
After=selfhost-postgres.service

[Container]
ContainerName=miniflux
Image=docker.io/miniflux/miniflux:latest
AutoUpdate=registry

Network=selfhost.network
HostName=miniflux
PublishPort=127.0.0.1:8080:8080
DNS=1.1.1.1
DNS=1.0.0.1

# %h = location to your $HOME in host. Store the credentials in this file.
# All env variables will be available inside the container.
EnvironmentFile=%h/containers/miniflux/miniflux.env

Environment=BASE_URL=https://domain.host.com/
Environment=RUN_MIGRATIONS=1
Environment=CLEANUP_ARCHIVE_UNREAD_DAYS=-1
Environment=CLEANUP_ARCHIVE_READ_DAYS=-1

# Remember to update the docker.network name to your custom network
Label=traefik.enable=true
Label=traefik.docker.network=selfhost
Label=traefik.http.routers.miniflux.rule=Host(`domain.host.com`)
Label=traefik.http.routers.miniflux.entrypoints=websecure
Label=traefik.http.routers.miniflux.tls.certresolver=letsencrypt

[Service]
Restart=always
TimeoutStartSec=300

[Install]
WantedBy=default.target

Example: Adguard quadlet using pasta network

[Unit]
Description=Adguard server

[Container]
ContainerName=adguardhome
Image=docker.io/adguard/adguardhome
AutoUpdate=registry

# Note the `pasta` network is critical. This ensures the container listens on
# port 53 on the host. Since it is outside the selfhost.network, we have added
# this to traefik-dynamic.yml. Other services are auto discovered from the
# docker socket.
# Port 53 can be opened by setting /etc/sysctl.conf
# net.ipv4.ip_unprivileged_port_start=53
# Run `sysctl --system` after the setting update.
Network=pasta
HostName=adguardhome
PublishPort=53:53/tcp
PublishPort=53:53/udp
PublishPort=3001:80/tcp

Volume=%h/containers/adguard/workdir:/opt/adguardhome/work
Volume=%h/containers/adguard/confdir:/opt/adguardhome/conf

Label=traefik.enable=true

[Service]
EnvironmentFile=%h/containers/.env
Restart=always
TimeoutStartSec=300

[Install]
WantedBy=default.target

Example: Traefik quadlet

Note how we’re exposing podman.socket as docker socket. This allows the traefik service to query for all Podman containers and automatically set the routers and services using the Label= values in the container.

To enable podman.socket, see docs.

systemctl --user enable podman.socket
loginctl enable-linger <USER>

Traefik quadlet is below.

[Unit]
Description=Traefik server

[Container]
ContainerName=traefik
Image=docker.io/traefik:v3.1
AutoUpdate=registry

EnvironmentFile=%h/containers/.env

Network=selfhost.network
HostName=traefik
PublishPort=80:80
PublishPort=443:443

# Note the dynamic config is required for services running outside the docker
# socket/selfhost network. E.g., adguardhome and traefik itself. Both are
# statically routed.
Volume=/run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
Volume=%h/configs/traefik/dynamic:/etc/traefik/dynamic:ro
Volume=%h/configs/traefik/traefik.yml:/traefik.yml:ro
Volume=%h/configs/traefik/logs:/logs
Volume=%h/configs/traefik/acme.json:/acme.json

# Keep the SELinux label of the container runtime.
# Required to communicate with the socket.
SecurityLabelDisable=true

[Service]
Restart=always
TimeoutStartSec=300

[Install]
WantedBy=default.target

Traefik configurations

Traefik and Adguard are the only services which listen directly on the host ports 80/443 and 3001 respectively.

We setup Traefik to directly route to admin.domain.com for Traefik admin dashboard and adguard.domain.com for Adguard web portal.

# traefik-dynamic.yml
http:
    routers:
        adguard:
            entryPoints: ["web"]
            rule: "Host(`adguard.domain.com`)"
            middlewares: ["adguard_redirect"]
            service: "adguard-pasta"
        adguard_https:
            entryPoints: ["websecure"]
            rule: "Host(`adguard.domain.com`)"
            tls:
                certResolver: "letsencrypt"
            service: "adguard-pasta"
        traefik:
            entryPoints: ["websecure"]
            rule: "Host(`admin.domain.com`)"
            service: "api@internal"
            tls:
                certResolver: "letsencrypt"
                domains:
                    - main: "domain.com"
                      sans: ["*.domain.com"]

    middlewares:
        adguard_redirect:
            redirectScheme:
                scheme: "https"

    services:
        adguard-pasta:
            loadBalancer:
                servers:
                    - url: "http://192.168.1.111:3001"

For all the other services we use the docker provider.

# traefik.yml snippet
providers:
    docker:
        watch: true
        exposedByDefault: false
    file:
        directory: "/etc/traefik/dynamic"

Conclusion

This migration was spread over a week of learning and sometimes the agony of things not working as expected. Moving over to Podman and running the containers rootless was worth the effort.

Adios, until next time.