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.
- DNS requests:
machines in LAN -> 192.168.1.111:53 (selfhost box) -> Podman (pasta) network -> Adguard home (container)
. Uses pasta network. - HTTP requests:
machines in LAN -> news.domain.com -> 192.168.1.111:443 (selfhost box) -> Traefik on 80/443 -> 10.89.0.3 (miniflux)
- 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.
-
You’ll need Linux Kernel 5.x. If you’re running Ubuntu, I’d recommend 24.04.
-
Install Podman.
# apt install podman
or equivalent. -
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
- Open port 53 onwards for rootless containers. How to:
link. Run
-
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
-
Optional: At this step, you can try automated migration with https://github.com/Edu4rdSHL/fly-to-podman.
-
Migrate volumes: use
rsync
or similar to copy/var/lib/docker/volumes/<volume_name>/_data
to~/.local/share/containers/storage/volumes/<volume_name>/_data
. -
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.
-
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, assuming1000
is your uid. -
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.
- Add
-
You’re going to spend a good amount of time in starting/stopping systemd units.
- Get yourself familiar with https://github.com/rgwood/systemctl-tui
- Name your quadlets with a prefix (I used
selfhost-<service name>.container
) to help filter easily.
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.