Recently, a friend of mine showed me a project that lets people run a private World of Warcraft server. I saw a great opportunity to learn how to use Podman. What an adventure this has been, and I’m not talking about playing WoW.
First things first, what is AzerothCore?
AzerothCore is an open-source implementation of a World of Warcraft 3.3.5 server. If you connect an unmodified client (not included in AzerothCore) to it, then you can play on your very own server with your own rules.
Interestingly, AzerothCore does not provide pre-built binaries. Instead, users are instructed to build it from source themselves. This makes the barrier to entry quite high, especially for non-technical people. However, I have to give a huge shout-out to the documentation AzerothCore has created. I must say that I have never seen such a well-documented project of this caliber. Every little step is described, and no gaps are left open. That way, we were able to get the server running on Windows on my friend’s PC quite quickly.
After seeing the pain of setting everything up on Windows, though, I was
motivated to do it right on Linux: installing random executables just to get
libraries, dealing with files spread all over the place, and copying .dlls
from OpenSSL, among other things.
Preamble
The hardware and OS
I’m mainly including this section because I wanted to highlight how usable old hardware can be. My rather old PC, with an AMD Ryzen 1700X and Nvidia GTX 970, was collecting dust, and I never dared to use it because of its power consumption. Sure enough, even while idle and doing nothing, it consumed around 80W. At full CPU load, it consumed up to 160W. With some undervolting, I got this down by around 10W.
But the big change was getting rid of the GPU. Mind you, the Ryzen 1700X does not include integrated graphics, so I always assumed it would not work. But apart from the motherboard speaker beeping at boot, it is actually fully usable. After removing the GPU, idle power consumption was down to 25W and reached 100W under load. That’s actually quite usable for what you get, in my opinion!
On to the operating system. Since I’ve used it before, I installed openSUSE MicroOS. I like having an immutable OS as a base because it lets me upgrade the OS without much hassle or fear of breaking things. But this also leads me to…
Why Podman?
For years now, Docker has become the de facto standard for the entire container lifecycle. From building to running, everything knows Docker. So why diverge?
First of all, Docker runs containers as root by default. Personally, I don’t like running code I didn’t write through something that has root privileges, even if the container itself does not run as root. The daemon still adds to the attack surface. Rootless Docker does exist, but it has edge cases. I’ve personally run into permission issues with volumes and networks. So far, everything has been fixable, but you definitely feel that rootless Docker is not the “happy path” everyone uses. Podman, on the other hand, is primarily rootless and does not use a daemon process. Podman describes this as “one less single point of failure,” but honestly, I have never heard of a Docker daemon causing availability issues. To me, a leaner approach to managing containers is the main goal here.
Then, Podman has first-class support from both openSUSE and Fedora. Fedora I use daily on my workstation, openSUSE MicroOS is currently my toy-server OS of choice. So I thought there must be some backing to it from very smart people.
Finally, I just like learning things. Betting all your money on a single horse is never a good idea. Diversifying basically always pays off. So maybe I’m about to strike gold?
Journey to production
AzerothCore essentially consists of two services and one database:
worldserveris responsible for the game logicauthserverhandles loginmysqlis shared betweenworldserverandauthserver
Both worldserver and authserver interact with a properly initialized
database. Thus, they include migration scripts, which are executed on boot. For
Docker deployments, however, initializing the database is done via a separate
container. The project calls this container db-import, so I’ll use the same
name here. Furthermore, worldserver requires the “client data.” This data
contains information about the map for tasks such as line-of-sight checks and
pathfinding. The client data is not included in AzerothCore, but can be freely
downloaded from another GitHub repo.
Hosting and/or downloading the client data might be illegal! There was no mention about this in AzerothCore or the linked repo, but I want to highlight that the client data could be assets owned by Blizzard. Depending on your jurisdiction, just downloading the data might already have consequences. As for me, I’m pretty confident that where I live, I’m safe as long as I don’t redistribute it.
Luckily, however, if you already have a World of Warcraft 3.3.5, Wrath of the Lich King client (which you’ll need to play anyway), AzerothCore provides a script to extract the data directly from it. I’m not a lawyer, so please do your own research.
In total, we end up with this dependency graph:
block-beta
columns 5
world["worldserver"] space userdata["user-data"] space:2
space:5
space:2 dbinit["db-import"] space db[("DB")]
space:5
auth["authserver"]
world --> userdata
world --> dbinit
auth --> dbinit
dbinit --> db
world --> db
auth --> db
block-beta
columns 5
world["worldserver"] space userdata["user-data"] space:2
space:5
space:2 dbinit["db-import"] space db[("DB")]
space:5
auth["authserver"]
world --> userdata
world --> dbinit
auth --> dbinit
dbinit --> db
world --> db
auth --> db
The important part here is that worldserver does not commit changes to the
database all the time. Instead, they get aggregated and written out every now
and then. This is important because latency is king for a game server1.
However, this introduces unique consistency challenges. Most importantly, if we do a
controlled shutdown of the worldserver, we have to wait until the latest data
is written out.
However, this adds an interesting challenge: We must introduce a fixed shutdown
order of our services. If the database halts before or while the worldserver
is still writing changes, data might be lost or, worse, corrupted.
systemd to the rescue
For those unaware, systemd is a really powerful and by now the de facto
standard init system for Linux. It is responsible for orchestrating all the
different services you might need. Most importantly for us, you can define
dependencies between those services and those dependencies are respected during
boot, but also during shutdown. Since triggering a shutdown or a reboot is
always done via systemd, we therefore have quite good guarantees for the order
of execution as well as the order of termination.
Fortunately, Podman has excellent support for systemd services in the form of
“quadlets”. The idea here is simple: You define what containers you need. Podman
generates systemd services from them. The level of abstraction is very thin
here, though, as the quadlet definitions themselves are written like regular
systemd services. Most importantly, they use the same syntax.
Here is a simple example of a quadlet container:
[Unit]
Description=Minimal Demo
[Container]
# Podman-specific instructions - those are converted
# to systemd ExecStart instructions
Image=docker.io/library/ubuntu:26.04
Exec=echo "Hello from container" && sleep 60
[Install]
# Start this systemd unit when the server boots
WantedBy=multi-user.target
If you run podman quadlet install <file>, then this quadlet will be copied
over to $HOME/.config/containers/systemd/ (by default) and make the container
available as a systemd service. You can get the actual content of the
systemd service by inspecting what the podman-system-generator does:
# /usr/lib/systemd/system-generators/podman-system-generator --user --dryrun
---00-minimal.service---
[Unit]
Wants=podman-user-wait-network-online.service
After=podman-user-wait-network-online.service
Description=Minimal Demo
SourcePath=/home/shayden/.config/containers/systemd/00-minimal.container
RequiresMountsFor=%t/containers
[X-Container]
# Podman-specific instructions - those are converted to systemd ExecStart instructions
Image=docker.io/library/ubuntu:26.04
Exec=echo "Hello from container" && sleep 60
[Install]
# Start this systemd unit when the server boots
WantedBy=multi-user.target
[Service]
Environment=PODMAN_SYSTEMD_UNIT=%n
KillMode=mixed
ExecStop=/usr/bin/podman rm -v -f -i systemd-%N
ExecStopPost=-/usr/bin/podman rm -v -f -i systemd-%N
Delegate=yes
Type=notify
NotifyAccess=all
SyslogIdentifier=%N
ExecStart=/usr/bin/podman run --name systemd-%N --replace --rm --cgroups=split --sdnotify=conmon -d docker.io/library/ubuntu:26.04 echo "Hello\x20from\x20container" && sleep 60
Creating quadlets from a Docker Compose file
Since AzerothCore already provides a very well-written docker-compose.yml, the
first and most sensible approach seemed to be converting it to quadlets. A
third-party tool called podlet
promises to do most of the work.
However, the provided docker-compose.yml is quite advanced (for example, it
uses environment variable interpolation such as
USER_ID: ${DOCKER_USER_ID:-1000}), so podlet fails to convert it
automatically. After trying to remove everything that kept failing, I pivoted
to using AI instead. After all, AI should be great at systemd services and, by
extension, at Podman quadlets too.
So I asked Claude Opus to whip something up for me, and whip something up it
did. It created .container and .volume quadlets for everything, as well as
shell scripts to help me push the quadlets to my server from my work PC, install
them, create database backups via a systemd timer, initialize secrets, and
more. This was very valuable. Without it as a starting point, I think I would
have given up before even starting to write the first quadlet.
One reason is that the syntax of quadlets isn’t very nice. With a Docker Compose file, I’m used to having everything in one easy-to-follow place. You can read the file from top to bottom and know everything there is to know. With quadlets, all the dependencies are loosely linked via their service names.
For example, this is the final quadlet I ended up with for my database (comments mostly written by AI):
# MySQL 8.4 for AzerothCore.
#
# Notify=healthy makes systemd consider this unit "started" only once the
# healthcheck passes. Everything that declares After=ac-database.service
# therefore starts against a database that actually accepts queries -- this is
# the quadlet equivalent of Compose's `condition: service_healthy`.
#
# Installed to: ~/.config/containers/systemd/ac-database.container
[Unit]
Description=AzerothCore MySQL database
# Stop propagation and start-up come from the pod: quadlet gives this unit
# BindsTo=/After=azerothcore-pod.service automatically.
[Container]
ContainerName=ac-database
Image=docker.io/library/mysql:8.4
Pod=azerothcore.pod
# Non-secret settings only (TZ etc). The root password arrives as a mounted
# secret, never as an environment variable.
EnvironmentFile=%h/.config/azerothcore/ac.env
# Mounted at /run/secrets/mysql-root-password, mode 0444 so the in-container
# MySQL user (UID 999) can read it. Using the _FILE variant keeps the password
# out of the container environment entirely -- it will not show up in
# `podman inspect ac-database`, nor in /proc/<pid>/environ.
Secret=ac-mysql-root-password,type=mount,target=mysql-root-password,mode=0444
Environment=MYSQL_ROOT_PASSWORD_FILE=/run/secrets/mysql-root-password
Volume=ac-database.volume:/var/lib/mysql
Volume=%h/azerothcore/db-backup:/backup:Z
Volume=%h/.config/azerothcore/mysql/00-azerothcore.cnf:/etc/mysql/conf.d/00-azerothcore.cnf:ro,Z
Volume=%h/.config/azerothcore/mysql/healthcheck.sh:/usr/local/bin/ac-healthcheck.sh:ro,Z
# The healthcheck is a script rather than an inline command so that the secret
# is read inside the container, not expanded by systemd on the host.
HealthCmd=/usr/local/bin/ac-healthcheck.sh
HealthInterval=10s
HealthTimeout=5s
HealthRetries=30
HealthStartPeriod=20s
Notify=healthy
# Give InnoDB time to flush dirty pages on shutdown before podman escalates
# to SIGKILL. Raise this if you run a very large buffer pool.
# Deliberately NO AutoUpdate= here. A database restart is the heaviest and
# riskiest operation in this stack, and `local` policy would fire on any
# stray `podman pull mysql:8.4`. Bounce MySQL by hand, deliberately:
# systemctl --user restart ac-database.service
StopTimeout=300
User="%U:%G"
[Service]
Restart=on-failure
RestartSec=10
# First boot has to initialize the data directory; be patient.
TimeoutStartSec=900
TimeoutStopSec=330
[Quadlet]
DefaultDependencies=false
First of all, you may notice how verbose this configuration is compared with a
Docker Compose file, even though it achieves basically the same thing. Secondly,
notice how the container references a volume mount for ac-database.volume, but
there is no definition for it. That is because you need to define it in its own
quadlet file:
# MySQL data directory.
#
# Kept as a named volume so Podman owns the UID mapping in rootless mode.
# Inspect the real path with: podman volume inspect ac-database
[Volume]
VolumeName=ac-database
Compared with a Docker Compose file, this creates a very loose coupling between the two. You, as a system administrator, have to ensure that everything is in place in the correct order.
Now let’s move on to the next step. We want to start something that depends on
the database, like the db-import step from above:
# One-shot: creates the databases if missing and applies pending SQL updates.
#
# Runs to completion before authserver/worldserver are allowed to start, which
# is the quadlet equivalent of Compose's `condition: service_completed_successfully`.
# Both game servers are built with AC_UPDATES_ENABLE_DATABASES=0, so this
# container is the single writer of schema migrations -- no race between them.
#
# Installed to: ~/.config/containers/systemd/ac-db-import.container
[Unit]
Description=AzerothCore database import and migrations
Requires=ac-database.service
After=ac-database.service
[Container]
ContainerName=ac-db-import
Image=localhost/acore/ac-wotlk-db-import:prod
Pod=azerothcore.pod
# Non-secret settings only (TZ etc).
EnvironmentFile=%h/.config/azerothcore/ac.env
# AzerothCore has no *_FILE convention, so the DSNs must be environment
# variables. type=env still keeps them out of `podman inspect` output, unlike
# the plain EnvironmentFile values which are expanded into Config.Env.
Secret=ac-dsn-login,type=env,target=AC_LOGIN_DATABASE_INFO
Secret=ac-dsn-world,type=env,target=AC_WORLD_DATABASE_INFO
Secret=ac-dsn-characters,type=env,target=AC_CHARACTER_DATABASE_INFO
Secret=ac-dsn-playerbots,type=env,target=AC_PLAYERBOTS_DATABASE_INFO
Environment=AC_DATA_DIR=/azerothcore/env/dist/data
Environment=AC_LOGS_DIR=/azerothcore/env/dist/logs
# Create the schemas on a virgin database instead of bailing out.
Environment=AC_FORCE_CREATE_DB=1
Volume=%h/azerothcore/etc:/azerothcore/env/dist/etc:Z
Volume=%h/azerothcore/logs:/azerothcore/env/dist/logs:Z
# A new db-import image means new migrations, so re-run it on update. The game
# servers declare PartOf=ac-db-import.service, so they are stopped around it.
AutoUpdate=local
[Service]
Type=oneshot
RemainAfterExit=yes
# A first-time world DB import is long.
TimeoutStartSec=3600
Restart=no
[Quadlet]
DefaultDependencies=false
I need to specify that this container requires another service and must run
after it via the Requires and After directives respectively. Since these
directives are copied over to the final systemd service, they must reference
the actual systemd service name. We can’t write ac-database.container, the
name of the quadlet. This is just a minor annoyance, but to me it shows how
inconsistent the experience is within Podman quadlets.
Starting quadlets with systemctl
But the most annoying part is actually running the quadlets. And this, I have to
highlight, is not purely Podman’s fault; it’s systemd’s way of working.
Let’s assume that you have all your quadlets in place. For AzerothCore, this means something like this:
# podman quadlet list --format '{{.Name}}'
ac-authserver.container
ac-client-data-init.container
ac-client-data.volume
ac-database.container
ac-database.volume
ac-db-import.container
ac-worldserver.container
azerothcore.pod
Now you want to start them. What do you do? Since everything in my setup is
linked to a Podman “pod,” I should only have to run
systemctl --user start azerothcore-pod.service. And then… nothing.
systemctl, while powerful, does not give you any output when starting a
service. Instead, it waits for the service to become healthy. On the initial
start, Podman has to:
- download the MySQL image & client data
- start the MySQL container
- run the
db-importstep to execute all migrations - start the
worldserver - start the
authserver
This all takes time, and you don’t see any of it when starting
azerothcore-pod.service. Instead, you’re supposed to use
journalctl --user -u <unit-to-inspect>. But there is another catch:
journalctl does not distinguish between unit executions. It’s more like a
flat aggregation of logs with no knowledge of when a unit started or stopped.
This means that every time a container runs, you have to figure out for yourself
where the latest execution’s logs begin.
But as I mentioned, I’m not starting just one service; I’m starting quite a
few at the same time. How can I monitor all of them? How can I be sure they’re
starting? And how can I debug them when they fail to start? I ended up
installing
systemd-manager-tui to
get at least an overview over all the quadlets. But it wasn’t a silver bullet
either. For example, while the database is still starting, all the other
services will be marked as loaded, not as starting or waiting for dependencies.
I therefore have no feedback about whether (1) a container is waiting for its
dependencies, (2) it won’t start because some dependencies are misconfigured
(again, they’re loosely coupled by references to each other in the quadlet
files, which are easy to get wrong), or (3) there’s some other error.
Compared with docker compose up, I can see which services are starting, along
with their logs (if I don’t pass the -d flag). I can even see at a glance
which services are healthy. Fetching the logs of a single container is as easy
as running docker compose logs <service>.
Debugging nightmare with quadlets
Why all of this matters became very clear when I ran into permission issues with
my quadlets. The root cause was that my volume mounts did not have the correct
owner set. While there are multiple solutions you could use here, I wanted to
be able to edit the files inside and outside the
container without having to revert to using sudo nano on my host. For this,
there is a neat Podman feature called
userns.
It allows you to preserve the UID and GID from the host inside the container. So
user 1000 inside the container is user 1000 outside the container.
However, no documentation clearly explained whether I needed to set this option on the pod or on each container individually. Initially, AI set the flag for each container, which, as it turned out, was wrong.
But finding this out was far from easy. Normally, when I run into permission
issues with Docker containers, I can simply run docker compose exec -it <service> /bin/bash or docker compose run -it --entrypoint=/bin/bash <service> if the service isn’t starting properly. Then I can investigate the
situation and usually find a solution to my issues quite easily.
However, Podman does not provide any such niceties for quadlets. Instead, what I had to do was:
- Detect that a container didn’t start via a combination of
systemctl --user status <unit>andjournalctl --user -u <unit> - Investigate logs for this container via
journalctl --user -u <unit>. As mentioned, this means manually finding out which logs correspond to the latest run. I actually fell into the trap multiple times of viewing logs I thought were the latest run even though they weren’t. - Find a plan on what to change. Usually, this meant that I had to find some
way of figuring out how Podman was starting the container. I found the
easiest way to understand this was to use
systemd-manager-tuito “edit” the systemd unit specification. What I actually needed was copying theExecStartinstruction of the service, so that I could run it manually - after cleaning up all the systemd-specific syntax like%hresolving to$HOME.
I had to run this loop countless times because I kept running into issues that weren’t straightforward to solve. In the end, I did get it working, and I’m happy to share my quadlet setup in a ZIP file here.
This setup is highly specific to my environment. Most notably, monolith.local,
which is mentioned in the files, is the server that runs AzerothCore. You most
likely won’t be able to use it as-is for your own Podman setup. However, it
should be a very solid starting point. For example, you could ask AI to adapt
the setup to your own needs and servers.Final verdict
I have been running this setup for about two months now, and so far everything is running smoothly. Even though the path was bumpy, the final destination is clean and stable. But would I do it again? Probably not. Now I would probably try Podman kube instead. Sadly, I discovered it too late, and I don’t feel like migrating just for fun yet. But it does look promising: everything is defined in a single file. And with the backlog of documentation available for Kubernetes, I might no longer have to rely on Podman’s documentation alone.
For now, I am content. I’m happy that I took the chance to learn something new, but I’m still frustrated by how much more complicated it was than I expected. In the end, I think the much smaller community behind Podman stood out most. If more people used it, the documentation and tooling would probably be much better. But without good documentation and tooling, the community won’t grow either.
As it turns out, this might not actually be true. Nevertheless, it’s always a good idea to stop your database service last so as not to interrupt any pending writes. ↩︎
