ENG | Migrating docker-compose files to podman quadlets
Modernizing container stack to 2026
Three years ago, I read that Fedora uses podman, set alias docker=podman and forget about it. Your Docker muscle memory still works. You run the same commands, you keep your docker-compose.yml files, and nothing forces you to notice that the thing underneath is architecturally nothing like Docker.
This article is where that pretense ends. The goal is to replace every docker-compose file with podman quadlets — systemd unit files that manage containers as native services - and in doing so, stop translating Docker into podman and start writing podman-native: rootless, daemonless, with systemd tracking dependencies and podman secret holding the tokens.
This is a documentation of my migration, not a complex guide.
Ultimate goal is to replace all docker-compose files, some of which are still manually started. This simplifies having separate docker-compose files and systemd unit files. Further goal is to move systemd unit files and scripts into dotfiles directory which is tracked by git and create symlinks to them.
It’s just not great to have all files needed to run Forgejo only inside repository :-).
Preparation
- Create directory
mkdir -p .config/containers/systemd - Make sure to run
loginctl enable-linger [username]once after distribution install so services do not stop when user logs out.
Order of operation
- 2026-06-16:
- Tested
nginx-jekyllon notebook
- Tested
- 2026-06-17:
- Migrated
nginx-pe5 - Migrated
cloudflare-ddns
- Migrated
- 2026-06-18:
- Migrated
cloudflare-tunnel - Migrated
nginx-jekyll, modified deployment script - Migrated forgejo+mariadb, umami+postgres, upgraded postgres
- Migrated
Migrating Jekyll-blog (nginx)
This could be tested on notebook with _site directory generated by Jekyll static content generator. Jekyll creates static web content (this blog) from markdown files. Container volume is basically disposable - except currently it contained modified, undocummented config changes to make some WASM apps work, but I eventually gave up and forgot about them.
In .config/containers/systemd create nginx-jekyll.container (don’t make volume read-only with :ro, cause we need to write there to update content). Here we are tracking the latest nginx version 1.xx.xx
Here I also migrated from lscr.io version of nginx to more standard one.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[Unit]
Description=nginx (Jekyll blog)
[Container]
Image=docker.io/nginx:1-alpine-slim
ContainerName=nginx-jekyll
PublishPort=8080:80
Volume=nginx-jekyll:/usr/share/nginx/html
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=default.target
Also, create empty volume and reload systemd files
1
2
3
podman volume create nginx-jekyll
# At this point volume and container file must exist
systemctl --user daemon-reload
Now start jekyll and maybe explore state and logs if something goes wrong, eventually repeat commands from systemctl daemon-reload.
1
2
3
systemctl --user start nginx-jekyll
systemctl --user status nginx-jekyll
journalctl --user -xeu nginx-jekyll.service
After few iterations, such as forgotten volume mount point and missing docker.io in Image it runs.
1
podman ps
1
2
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
ea8f57a5efa3 docker.io/library/nginx:1-alpine-slim nginx -g daemon o... 13 minutes ago Up 13 minutes 0.0.0.0:8080->80/tcp nginx-jekyll
We should see welcome page
Now copy data there - and don’t forget that trailing dot. Old content is deleted first to remove stuff that got there by accient in the past.
1
2
podman exec nginx-jekyll sh -c "cd /usr/share/nginx/html && rm -rf *"
podman cp ~/tmp/_site/. nginx-jekyll:/usr/share/nginx/html/
That’s basically it. We got automatic updates, slighly simplified config compared to docker-compose.yml and we also completely removed systemd service file. Logging now goes to journal and nginx config can be edited by mounting file as a volume. Which is good, because I can track config by git.
Now we need to edit some scripts and finally put them into git repository.
- Deployment script (copies
~/dev-blog/_siteand “secret” files for Google and Bing intonginx-jekyll:/usr/share/nginx/html/) - Backup script - but for blog there is none, it is found in two places
- The git repository.
- Source files are archived as tar, because it creates instructions how to set up server, including git.
This was testing. In reality, there are more steps - deleting old container, testing deployment and if this all work.
Real deployment
1
2
3
4
5
# Remove old container
podman stop nginx
podman rm nginx
# Create new volume
podman volume create nginx-jekyll
File might have changed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[Unit]
Description=nginx (Jekyll blog)
[Container]
Image=docker.io/nginx:1-alpine-slim
ContainerName=nginx-jekyll
PublishPort=8080:80
#Not a good idea, we want deployment script and writable volume
#Volume=%h/dev-blog/_site:/usr/share/nginx/html:ro
Volume=nginx-jekyll:/usr/share/nginx/html
Network=shared_network
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=default.target
Deployment script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/usr/bin/sh
# Updates jekyll site in container
target="nginx-jekyll:/usr/share/nginx/html"
source="/home/pavel/dev-blog"
cd $source
echo "👷 Rebuilding static web content ..."
JEKYLL_ENV=production bundle exec jekyll build || exit
echo "🔥 Deleting old content, don't panic! ..."
podman exec nginx-jekyll sh -c "cd /usr/share/nginx/html && rm -rf *"
echo "🚚 Copying new content ..."
podman cp _site/. $target
podman cp ~/jekyll/googleb917f5e9242ac888.html $target
podman cp ~/jekyll/BingSiteAuth.xml $target
Now we can start the container
1
2
3
systemctl --user daemon-reload
systemctl --user start nginx-jekyll
systemctl --user status nginx-jekyll
And test it to get 502: Bad Gateway. Because we changed name of the container from nginx to nginx-jekyll which needs fixing in Cloudflare tunnel configuration.
Migrating other nginx instance(s)
Start with migrating volume
1
2
3
4
5
podman volume export docker_nginx-config-pe5 | zstd -11 > ~/backup/nginx-pe5-2026-06-18.tar.zst
podman stop nginx-pe5
podman rm nginx-pe5
podman volume ls
podman volume create nginx-pe5
From here follow jekyll blog guide … use midnight commander or something to extract /config/www from backup
Watch for messages such as from copy-paste errors
1
2
Jun 18 00:18:11 marten pasta[1840274]: Listen failed for HOST TCP port */8080: Address already in use
Jun 18 00:18:11 marten pasta[1840274]: Couldn't listen on requested ports
Once service is running copy content
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[pavel@marten -=- ~/dotfiles/.config/containers/systemd]$ podman exec nginx-pe5 sh -c "ls -la /usr/share/nginx/html"
total 8
drwxr-xr-x 1 root root 36 May 22 20:25 .
drwxr-xr-x 1 root root 8 May 22 20:25 ..
-rw-r--r-- 1 root root 497 May 22 15:47 50x.html
-rw-r--r-- 1 root root 896 May 22 15:47 index.html
[pavel@marten -=- ~/dotfiles/.config/containers/systemd]$ podman exec nginx-pe5 sh -c "rm /usr/share/nginx/html/*.html"
[pavel@marten -=- ~/dotfiles/.config/containers/systemd]$ mc
[pavel@marten -=- ~/tmp/20260618]$ ls -la
total 12
drwxr-xr-x. 1 pavel pavel 98 Jun 18 00:06 .
drwxr-xr-x. 1 pavel pavel 5930 Jun 18 00:03 ..
drwxr-xr-x. 1 pavel pavel 12 Oct 25 2023 assets
-rw-r--r--. 1 pavel pavel 53 May 16 2024 googleb917f5e9242ac888.html
-rw-r--r--. 1 pavel pavel 2191 May 12 2024 index.html
-rwxr-xr-x. 1 pavel pavel 142 Jun 19 2023 ip.php
[pavel@marten -=- ~/tmp/20260618]$ podman cp . nginx-pe5:/usr/share/nginx/html
[pavel@marten -=- ~/tmp/20260618]$ systemctl --user restart nginx-pe5.service
Here we had 502: Bad Gateway from Cloudflare - this was resolved by adding Network=shared_network to config.
Migrating Cloudflare DDNS
First create secrets.
Prefix printf with space, so it does not leak into shell history file.
1
2
3
4
5
6
# Read secrets
cat ~/docker/.env
# Backup secrets (this file is not in git)
cp !:1 ~/backup/.env-20260617
# Create podman secret
printf '%s' 'HSHs_*************Hmm' | podman secret create cloudflare-api-token -
Then stop and remove container
1
2
podman stop cloudflare-ddns
podman rm cloudflare-ddns
Create container file
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[Unit]
Description=Cloudflare DDNS
After=network-online.target
Wants=network-online.target
[Container]
Image=docker.io/favonia/cloudflare-ddns:latest
ContainerName=cloudflare-ddns
Network=host
ReadOnly=true
DropCapability=all
NoNewPrivileges=true
Secret=cloudflare-api-token,type=env,target=CLOUDFLARE_API_TOKEN
Environment=DOMAINS=pavelp.cz
Environment=PROXIED=false
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=default.target
And proceed with starting service
1
2
3
4
systemctl --user daemon-reload
systemctl --user start cloudflare-ddns.service
systemctl --user status cloudflare-ddns.service
podman ps
1
2
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1557b0753480 docker.io/favonia/cloudflare-ddns:latest 2 minutes ago Up 2 minutes cloudflare-ddns
Migrating Cloudflare tunnels
Create secrets
1
printf '%s' 'ey******J9' | podman secret create cloudflare-tunnel-token -
Remove old container
1
2
podman stop cloudflared
podman rm cloudflared
Create file
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[Unit]
Description=Cloudflare Tunnel
After=network-online.target
Wants=network-online.target
[Container]
Image=docker.io/cloudflare/cloudflared:latest
ContainerName=cloudflare-tunnel
Exec=tunnel --no-autoupdate run
Secret=cloudflare-tunnel-token,type=env,target=TUNNEL_TOKEN
Network=shared_network
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=default.target
Proceed with start, basically the same way as DDNS
1
2
3
systemctl --user daemon-reload
systemctl --user start cloudflare-tunnel.service
systemctl --user status cloudflare-tunnel.service
This one was I was afraid of, but in the end easiest, because it’s very similar to DDNS
Cleaning up
1
2
3
4
5
6
7
# Disable old/conflicting oneshot service with `ExecStart=/usr/bin/podman-compose -f docker-compose.yml up -d`
systemctl --user disable --now podman-compose.service
# Delete old nginx (jekyll volume)
podman volume rm docker_nginx-config
# 🔥🔥🔥 Trial by fire 🔥🔥🔥
sudo dnf up -y
sudo reboot
1
2
3
4
5
6
7
8
[pavel@marten -=- ~]$ podman ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
f44e2548e0b1 docker.io/library/nginx:1-alpine-slim nginx -g daemon o... 30 seconds ago Up 30 seconds 0.0.0.0:8090->80/tcp nginx-pe5
969fa1ead6ae docker.io/library/nginx:1-alpine-slim nginx -g daemon o... 30 seconds ago Up 30 seconds 0.0.0.0:8080->80/tcp nginx-jekyll
d990bbee9a29 docker.io/favonia/cloudflare-ddns:latest 30 seconds ago Up 30 seconds cloudflare-ddns
bf0e8649cdb7 docker.io/cloudflare/cloudflared:latest tunnel --no-autou... 30 seconds ago Up 30 seconds cloudflare-tunnel
[pavel@marten -=- ~]$ rm ~/.config/systemd/user/podman-compose.service
[pavel@marten -=- ~]$ rm ~/docker/docker-compose.yml
Migrating Forgejo (a fork of Gitea)
Here I decided migrate MariaDB to PostgreSQL, following
… and gave up. Listings are too long, I archived that to dev-blog/_drafts/. But it failed on Forgejo export -> PostgreSQL import because of referencing tables before they were created.
1
2
3
4
5
6
7
8
[pavel@marten -=- ~/.config/containers/systemd]$ ln -s ~/dotfiles/.config/containers/systemd/forgejo-database.network forgejo-database.network
[pavel@marten -=- ~/.config/containers/systemd]$ systemctl --user daemon-reload
[pavel@marten -=- ~/.config/containers/systemd]$ systemctl --user start forgejo-database-network.service
[pavel@marten -=- ~/.config/containers/systemd]$ podman network ls
NETWORK ID NAME DRIVER
8369acd0cbb4 forgejo-database bridge
2f259bab93aa podman bridge
d4ca0d004b3f shared_network bridge
It never hurts to do backup, which also reminds me that gitea config is obsolete, because with Forgejo 11 to Forgejo 15 upgrade it moved into data volume.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[pavel@marten -=- ~]$ backup-gitea.sh
🛑 Shutting down Gitea ...
gitea
gitea-mariadb
gitea
gitea-mariadb
146bc791a71dbe62864f22a520a755de0102548cd136eb15acae3f23b8038429
💾 Backing up Gitea config ...
💾 Backing up Gitea data files...
💾 Backing up Gitea database (be patient) ...
🟢 Bringing Gitea up ...
cb649c6f3d08d7edd8499dfcf6b6c9b5599698842d8ba32c124ebc28472698ff
1674acc57115237632e3ee699b83f2a4011e1e03982a083e9aebf9d9a85ccdcb
967bca25f0360175dacc4a0aee8bdd0d2599f2fff81d16bd19cbb113a1e45542
26444e039646d1586b4016e4fbdda6068209a106806a2ddcb0fbeed25097b4e9
9fe18923c26228f3f4a0323ff7d70533f8726b1aabb5e9fce411b198c39267d5
gitea-mariadb
gitea
Gitea backup finished
Now let’s look around using podman exec -it gitea bash
- Mounted data volume is
/var/lib/gitea/ - Config is
/var/lib/gitea/custom/conf/app.ini - Dababase is not
/var/lib/gitea/data/gitea.db, but remote host - Directory
/var/lib/gitea/git/repositories/pavel.perinacontains bare git repositories
Bare git repositories can be cloned from backup
git clone ~/recovery/git/repositories/pavel.perina/my-repo.git ̛&& cd my-repoin case we need dot files to recover Forgejo.
Also export sql:
1
2
podman exec gitea-mariadb mariadb-dump --user=gitea --password=gitea \
--all-databases > ~/backup/gitea-mariadb-dump-20260618.sql
Here it’s kind of copy-paste. Secrets, container files with environment, volume, network.
Gotchas are that forgejo must be on both shared_network and forgejo-database network. After spending like three hours trying to migrate mariadb to postgresql to have all databases unified, I’ve went by route of the least resistance. I only renamed containers from gitea to forgejo.
This means:
1
Cloudflare.com -> Zero trust -> Network -> Connectors -> Esprimo -> Published application routes on top.
It will change to 10 levels of redirection next month. Three years ago it was easy to find.
Backup script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#!/usr/bin/sh
#
# Pavel Perina
#
# Changes:
# * 2023-07-04 Initial version (based on backup-nextcloud.sh)
# * 2023-07-17 Increase compression
# * 2026-06-18 Abandoned docker-compose for podman quadlets, changed texts Gitea->Forgejo
#
#######################
# Setup variables
. $HOME/docker/.env
DATE=$(date +%Y-%m-%d)
TARGET=$HOME/backup
#################
# Backup Gitea
# https://docs.gitea.com/next/administration/backup-and-restore
backup_gitea() {
echo "🛑 Shutting down Forgejo ..."
systemctl --user stop forgejo
echo "💾 Backing up Gitea SQL dump (needs running db) ..."
podman exec forgejo-mariadb /usr/bin/mariadb-dump \
--user=gitea --password=gitea \
--single-transaction \
gitea | zstd -11 > $TARGET/gitea-mariadb-dump-$DATE.sql.zst
echo "🛑 Shutting down MariaDB ..."
systemctl --user stop forgejo-mariadb
echo "💾 Backing up Forgejo data files..."
podman volume export docker_gitea-data | zstd -11 > $TARGET/gitea-data-$DATE.tar.zst
echo "💾 Backing up Forgejo's MariaDB database (be patient) ..."
podman volume export docker_gitea-db | zstd -11 > $TARGET/gitea-db-$DATE.tar.zst
echo "🟢 Bringing Forgejo up ..."
systemctl --user start forgejo
echo "Forgejo backup finished"
}
backup_gitea
Migrating Umami
This is kind of stubborn container that takes long to start. After many attempts, still only way to start it is podman rm -f umami umami-postgresql and podman-compose -f docker-compose-umami-postgre.yml up -d. So I’m curious what migration will bring. At least I have working container with PostgreSQL 18 and some notes.
I considered this service kind of irrelevant so there are no backups at the moment, let’s fix it
1
2
3
4
5
podman exec umami-postgresql pg_dump -U umami -Fc umami > ~/backup/umami-$(date +%Y%m%d).dump
podman exec umami-postgresql pg_dump -U umami --clean umami > ~/backup/umami-$(date +%Y%m%d).sql
podman-compose -f ~/docker/docker-compose-umami-postgre.yml stop
podman volume export docker_umami-postgresql-db | zstd -11 > ~/backup/umami-db-$(date +%Y%m%d).tar.zst
podman volume create umami-postgresql-data
Remove unneeded containers
1
2
podman rm gitea umami
podman rm gitea-mariadb umami-postgresql
Then again, boring stuff … start
1
2
3
4
systemctl --user daemon-reload
systemctl --user start umami-database-network.service
systemctl --user start umami-postgres.service
systemctl --user status umami-postgres.service
1
Jun 18 18:57:44 marten umami-postgres[34177]: 2026-06-18 16:57:44.566 UTC [1] LOG: starting PostgreSQL 18.4 on x86_64-pc-linux-musl, compiled by gcc (Alpine 15.2.0) 15.2.0, 64-bit
Restore database
1
podman exec -i umami-postgres pg_restore -U umami -d umami < ~/backup/umami-20260618.dump
Wow, no error
Proceed with umami.container, secret, …
And again restarts … forgotten shared_network, then typo postgresql vs postgres in database connection string. Surprisingly wrong connection strings does not mean umami container fails.
Cleanup old SQL and also config is not used since update to Forgejo 15
1
podman volume rm docker_umami-postgresql-db docker_gitea-config
Summary
- Nginx: no more docker-compose.yml plus service file, abandoned LinuxServer.io image for more standard DockerHub image without PHP. Now log files are going to journal and
configwith multiple subdirectories (www, logs, etc?) is gone. - Cloudflare: Tunnels and DDNS were easy to migrate, introduced some security guards recommended by
favonia(maintainer). - Forgejo: This was hell due to failed attempt to replace MariaDB with PostgreSQL. After giving up and staying at MariaDB, it was quite easy.
- Umami: Quite easy after failed attempts to migrate Forgejo database. We updated PostreSQL from 15.14 to 18.4 with no issue.
