The problem

You’ve decided to run your own authoritative DNS. Maybe you want to delegate a subdomain to a VPS for some experiment, maybe you’ve outgrown what your registrar’s nameservers let you do, or maybe you just want to learn the internals. PowerDNS is the obvious pick. Then you start reading: gmysql backend, schema file, pdns.conf, dnsdist sitting in front for ACLs and load balancing, a web UI for sanity, an API key wired between PDA and pdns, firewall, port 53 conflicts with whatever else is on the host…

You don’t want to commit to a setup yet. You want to wire it together, add a zone, query it from a real recursive resolver, then take it apart and do it again. Most tutorials make that hard — they install packages on the host, drop config in /etc, and leave systemd units behind that you have to chase down later.

What’s in the box

besmirzanaj/powerdns-in-a-box is a config-only repo with a podman-compose stack that pins down everything you actually need to get an authoritative server answering queries:

  • dnsdist on host port 53/tcp+udp as the frontend.
  • pdns-auth behind it on the compose network, with the gmysql backend.
  • MariaDB 10.11 as the shared backend.
  • powerdnsadmin/pda-legacy (PowerDNS-Admin, the Flask UI) for zone management.

Everything runs in podman containers on a Rocky Linux 9 host, with podman-compose orchestrating. Tear-down is one podman-compose down && rm -rf mysql-data away — nothing leaks onto the host.

A few things the README is opinionated about, because they were “learned the hard way” during the first deploy:

  • The same password must live in three files (.env, pdns/pdns.conf, mysql-init/01-init.sql) or the stack fails to authenticate on first boot. The Quickstart’s sed block keeps them in sync.
  • mysql-init/01-init.sql runs once, on first initialization of an empty data dir. Edits after that don’t take effect unless you wipe mysql-data/.
  • VARCHAR(64000) × utf8mb4 exceeds MariaDB’s row-size limit. The schema uses MEDIUMTEXT for domains.options and records.content to dodge ERROR 1074.
  • dnsdist’s newServer() needs an IP literal, not a hostname — it parses the config before container DNS is reachable. The compose pins pdns to a static 10.89.1.10 so the Lua config has something stable to point at.
  • firewalld rich rules don’t filter podman-published ports — rich rules apply to the INPUT chain, but DNAT’d container traffic traverses FORWARD. The stack binds management ports (8081, 8083, 9090) to 127.0.0.1 instead and you reach them via an SSH tunnel, leaving the public internet seeing only port 53.
  • firewall-cmd --reload wipes netavark’s per-container DNAT rules. Any reload has to be followed by podman ps -q | xargs podman restart, or every published port on the host stops responding. (I learned this the hard way when I took down a co-tenant stack on the same box.)

Quickstart

Full version is in the README; the abridged path:

git clone https://github.com/besmirzanaj/powerdns-in-a-box /root/git/powerdns-in-a-box
cd /root/git/powerdns-in-a-box

cp .env.example .env
chmod 600 .env

# 1. Generate random secrets
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 16)
PDNS_DB_PASS=$(openssl rand -hex 16)
PDNS_API_KEY=$(openssl rand -hex 24)
PDA_DB_PASS=$(openssl rand -hex 16)
PDA_SECRET_KEY=$(openssl rand -hex 32)
PDA_ADMIN_PASSWORD=$(openssl rand -hex 16)
DNSDIST_WEBSERVER_PASSWORD=$(openssl rand -hex 16)
DNSDIST_API_KEY=$(openssl rand -hex 24)

# 2. Set the public IP that dnsdist will bind port 53 to
DNSDIST_BIND_IP="$(curl -s4 ifconfig.me)"

# 3. Substitute everything into .env, pdns.conf and 01-init.sql in one shot
sed -i \
  -e "s|CHANGE_ME_MARIADB_ROOT|${MYSQL_ROOT_PASSWORD}|" \
  -e "s|CHANGE_ME_PDNS_DB|${PDNS_DB_PASS}|"             \
  -e "s|CHANGE_ME_API_KEY|${PDNS_API_KEY}|"             \
  -e "s|CHANGE_ME_PDA_DB|${PDA_DB_PASS}|"               \
  -e "s|CHANGE_ME_PDA_SECRET|${PDA_SECRET_KEY}|"        \
  -e "s|CHANGE_ME_PDA_ADMIN|${PDA_ADMIN_PASSWORD}|"     \
  -e "s|CHANGE_ME_DNSDIST_WEB|${DNSDIST_WEBSERVER_PASSWORD}|" \
  -e "s|CHANGE_ME_DNSDIST_API|${DNSDIST_API_KEY}|"      \
  -e "s|^DNSDIST_BIND_IP=.*|DNSDIST_BIND_IP=${DNSDIST_BIND_IP}|" \
  .env

sed -i "s|CHANGE_ME_PDNS_DB|${PDNS_DB_PASS}|g; s|CHANGE_ME_API_KEY|${PDNS_API_KEY}|g" pdns/pdns.conf
sed -i "s|CHANGE_ME_PDNS_DB|${PDNS_DB_PASS}|g; s|CHANGE_ME_PDA_DB|${PDA_DB_PASS}|g"   mysql-init/01-init.sql

# 4. Free port 53 (see README), then:
podman-compose up -d

After up -d, the README walks through:

  1. Creating the first PowerDNS-Admin admin user via flask shell (the pda-legacy image has no env-based bootstrap for this).
  2. Seeding the PDA setting table so the Settings → PDNS page is pre-filled at first login.
  3. Opening port 53 in firewalld and re-bouncing containers afterwards so the reload doesn’t strand them.

From there: SSH-tunnel 9090 to your laptop (ssh -L 9090:127.0.0.1:9090 root@<host>), log into PowerDNS-Admin at http://localhost:9090/, create a zone, dig it from your laptop, get the parent zone to delegate, and you have an authoritative server on the public internet. Tearing it all back down is one command.

Why bother

The point isn’t the specific stack — Technitium does most of the same job with one container — it’s that authoritative DNS has more moving parts than it has any right to, and trying things on the fly without a known-good starting position is how you lose an afternoon to MySQL 1045 errors and gmysql-host=mysql typos. This repo is what I wish I had when I started.

If you find a sharp edge that isn’t documented yet, open an issue or a PR — that’s exactly the kind of edge the README is for.