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:

  • dnsdist on host port 53/tcp+udp as the frontend.
  • pdns-auth behind 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, no ALTER USER.
  • pdns-init is a one-shot container that chowns the SQLite volume to uid 953 (the pdns user 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’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.)

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.