Post

ENG | Migrating docker-compose files to podman quadlets

Modernizing container stack to 2026

ENG | Migrating docker-compose files to podman quadlets

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-jekyll on notebook
  • 2026-06-17:
    • Migrated nginx-pe5
    • Migrated cloudflare-ddns
  • 2026-06-18:
    • Migrated cloudflare-tunnel
    • Migrated nginx-jekyll, modified deployment script
    • Migrated forgejo+mariadb, umami+postgres, upgraded postgres

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

Nginx 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/_site and “secret” files for Google and Bing into nginx-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.perina contains bare git repositories

Bare git repositories can be cloned from backup git clone ~/recovery/git/repositories/pavel.perina/my-repo.git ̛&& cd my-repo in 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 config with 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.
This post is licensed under CC BY 4.0 by the author.