PowerDNS in a Box: a Docker 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 the UI 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 docker 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 built-in gsqlite3 backend — no external database.ghcr.io/powerdns-authadmin/powerdns-authadmin(Next.js, SQLite) as the management UI.
Three containers, no MariaDB. PowerDNS stores zone data in SQLite on a named volume; AuthAdmin stores its own data (users, settings, audit log) in a separate SQLite volume. Neither writes to the other’s storage — all zone changes go through the PowerDNS HTTP API.
Everything runs in Docker containers on a Linux host — any distro with
Docker Compose (or Podman + podman-compose). Tear-down is docker compose down -v 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 PowerDNS API key is the only secret that spans multiple files
(
.env,pdns/pdns.conf,provisioning.yaml). Everything else is single-file. No database passwords, no init scripts, noALTER USER. pdns-initis a one-shot container that chowns the SQLite volume to uid 953 (thepdnsuser inside the official PowerDNS image). It runs before pdns starts and exits with success. If the volume is ever wiped or recreated, this runs again automatically.- PowerDNS with gsqlite3 creates its schema on first boot when the database file doesn’t exist — no init scripts or SQL files needed.
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.)
What changed (MariaDB → gsqlite3)
The original version of this stack ran MariaDB as a shared backend for both PowerDNS (gmysql) and the old PowerDNS-Admin (Flask). When I swapped the admin UI for PowerDNS-AuthAdmin (which uses its own SQLite file), MariaDB was left serving a single consumer — PowerDNS zone storage. That’s a lot of overhead (~300MB RAM, healthcheck wait, init-script trap) for what SQLite handles trivially.
So MariaDB is gone. PowerDNS now uses the built-in gsqlite3 backend. The stack dropped from four containers to three, and the only multi-file secret left is the PDNS API key.
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 (only one key spans multiple files)
PDNS_API_KEY=$(openssl rand -hex 24)
PDA_SECRET_KEY=$(openssl rand -base64 32)
PDA_ENCRYPTION_KEY=$(openssl rand -base64 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 into .env, pdns.conf and provisioning.yaml
sed -i \
-e "s|CHANGE_ME_API_KEY|${PDNS_API_KEY}|" \
-e "s|CHANGE_ME_PDA_SECRET|${PDA_SECRET_KEY}|" \
-e "s|CHANGE_ME_PDA_ENCRYPTION|${PDA_ENCRYPTION_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_API_KEY|${PDNS_API_KEY}|g" pdns/pdns.conf
sed -i "s|CHANGE_ME_API_KEY|${PDNS_API_KEY}|g" provisioning.yaml
# 4. Free port 53 (see README), then:
docker compose up -d
After up -d the admin UI bootstraps itself — admin user from env vars,
PDNS backend from provisioning.yaml. No manual steps.
From there: SSH-tunnel 9090 to your laptop
(ssh -L 9090:127.0.0.1:9090 root@<host>), log into PowerDNS-AuthAdmin at
http://localhost:9090/ with the email and password you set, 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.
