PowerDNS in a Box: a podman-compose stack for trying authoritative DNS
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:
dnsdiston host port53/tcp+udpas the frontend.pdns-authbehind it on the compose network, with the gmysql backend.MariaDB 10.11as 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’ssedblock keeps them in sync. mysql-init/01-init.sqlruns once, on first initialization of an empty data dir. Edits after that don’t take effect unless you wipemysql-data/.VARCHAR(64000)×utf8mb4exceeds MariaDB’s row-size limit. The schema usesMEDIUMTEXTfordomains.optionsandrecords.contentto dodgeERROR 1074.dnsdist’snewServer()needs an IP literal, not a hostname — it parses the config before container DNS is reachable. The compose pinspdnsto a static10.89.1.10so 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) to127.0.0.1instead and you reach them via an SSH tunnel, leaving the public internet seeing only port53. firewall-cmd --reloadwipes netavark’s per-container DNAT rules. Any reload has to be followed bypodman 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:
- Creating the first PowerDNS-Admin admin user via
flask shell(the pda-legacy image has no env-based bootstrap for this). - Seeding the PDA
settingtable so the Settings → PDNS page is pre-filled at first login. - 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.
