Setup
Requirements
- Docker Engine 24+ with the Compose plugin (
docker compose, notdocker-compose). Podman 5 withpodman composeworks too. - About 1 GB of RAM and 2 GB of disk for the three images and the database.
- Ports 9191 (the panel) and 53 (DNS) free on the host. Port 53 is the one that usually is not — see below.
Nothing else. There is no Node, Python or PowerDNS installation on the host: it all happens in the images.
Podman and short image names
Every FROM in this repository names its registry in full (docker.io/library/postgres:18-alpine). Podman, unlike Docker, has no implicit docker.io, so a bare postgres:18-alpine fails with short-name … did not resolve to an alias and no unqualified-search registries are defined unless the host sets unqualified-search-registries in /etc/containers/registries.conf. Keep the registry in place if you swap a base image for one of your own.
podman compose build also reports that failure at the very end of the run, after the services that did build, so the real error is well above the summary line — build one service at a time to see it.
Install
git clone https://github.com/timothestoifl24/powerdns.git
cd powerdns
./scripts/generate-secrets.sh
docker compose up -d --buildThe first build takes a few minutes — Debian packages for PowerDNS, Python wheels for the panel, and the Tabler theme, which is downloaded once at build time and baked into the image.
When it finishes, open http://localhost:9191 and sign in as admin with the password the script printed. If the output scrolled away:
cat secrets/webui_admin_passwordChange that password under My profile straight away. It is only used while the user table is empty, but it is sitting in a file on disk.
What the script created
| Path | Contents |
|---|---|
secrets/db_superuser_password | PostgreSQL bootstrap superuser, used only by initdb |
secrets/pdns_db_password | PostgreSQL password for the PowerDNS role |
secrets/webui_db_password | PostgreSQL password for the panel's role |
secrets/pdns_api_key | Shared secret for the PowerDNS HTTP API |
secrets/webui_secret_key | Flask session signing key |
secrets/webui_admin_password | Password for the first-run administrator |
.env | Non-secret settings, copied from .env.example |
secrets/ and .env are gitignored. The script is safe to re-run: it never overwrites an existing secret, and it repairs the file permissions if an older checkout left them wrong.
Secrets can be plain environment variables instead
Every secret accepts both FOO and FOO_FILE, and the file wins when both are set — which is what makes the same images work unchanged with Docker Swarm secrets or Kubernetes secret mounts.
Check that it actually works
Three things are worth confirming, in this order.
The panel is up and can reach everything it needs:
curl -s http://localhost:9191/readyzYou want {"status": "ok", "database": true, "powerdns": true}. A 503 with "powerdns": false means the panel is running but the API is unreachable.
DNS answers on port 53:
dig @127.0.0.1 example.com SOAA REFUSED here is the correct answer before you have created any zone — it proves PowerDNS is listening and authoritative for nothing yet.
A zone you create is really served. Create one in the panel (Zones → New zone), add an A record for www, then:
dig @127.0.0.1 www.example.com A +shortThat round trip — panel → HTTP API → PostgreSQL → DNS answer — is the whole system working.
Forwarding, if you need it
Out of the box the recursor answers for your zones and resolves everything else from the root servers. Two things are worth setting under Forwarding:
- Global forwarders — your upstream resolvers, if you would rather not resolve from the root yourself.
- Forward zones — an internal domain or reverse zone that another server owns, such as an Active Directory domain.
Forwarders must be IP addresses. See the guide.
Configuration you will want on day one
Everything lives in .env, and .env.example documents every key. The values most people change first:
SITE_NAME=DNS at example.com
WEBUI_PORT=9191
# Pre-fills the "new zone" form.
DEFAULT_NAMESERVERS=ns1.example.com,ns2.example.com
# The SOA used for new zones that do not override it.
PDNS_DEFAULT_SOA_CONTENT=ns1.example.com hostmaster.example.com 0 10800 3600 604800 3600After editing .env:
docker compose up -dCompose recreates only the containers whose configuration changed. The database volume is untouched, so no zones are lost.
Single sign-on does not need .env at all: LDAP, OAuth/OIDC and SAML providers can be added under Administration → Sign-in providers while the stack is running. See the guide for how the two sources interact, and advanced configuration for the settings themselves.
Something already owns port 53
failed to bind host port for 0.0.0.0:53: address already in use means a local resolver has it. On most Ubuntu and Fedora systems that is systemd-resolved, which binds 127.0.0.53:53.
Either publish DNS on a different host port:
echo 'DNS_PORT=5353' >> .env
docker compose up -d
dig @127.0.0.1 -p 5353 example.com SOA…or free port 53 by turning off the stub listener — set DNSStubListener=no in /etc/systemd/resolved.conf and restart systemd-resolved.
The container always serves on 53 internally; DNS_PORT only affects the host side. For a nameserver that the outside world will query, 53 is not optional — free it properly rather than remapping.
Podman: bind an address, never 0.0.0.0
On Podman there is a second listener on port 53, and it is not optional: it is how your containers resolve each other. Podman's network stack runs aardvark-dns, which is what makes db a name the panel and PowerDNS can look up, and it binds port 53 on the bridge's own address — 172.29.0.1 for this stack's backend network. The port is netavark's dns_bind_port, which defaults to 53.
DNS_BIND_ADDRESS=0.0.0.0 claims port 53 on every address the host has, including that one. So the two cannot both start, and which one loses depends on the order:
- With
systemd-resolvedrunning,127.0.0.53:53is already taken and the recursor simply fails to publish — the error above. - With
systemd-resolvedstopped, the recursor binds0.0.0.0:53successfully, takes172.29.0.1:53along with everything else, and aardvark-dns cannot start. The stack comes up and then the panel cannot resolvedb.
That second failure is the confusing one, because stopping the resolver looks like it fixed the first problem when it has really traded it for a worse one.
Name the address instead:
# .env — the host address clients will query, not 0.0.0.0
DNS_BIND_ADDRESS=192.168.1.50
DNS_PORT=53systemd-resolved can stay exactly as it is: 127.0.0.53:53 is a different address, and so is the bridge. Check who holds what:
sudo ss -tulpn | grep ':53'A healthy Podman host shows aardvark-dns on 172.29.0.1:53, the recursor on the address you named, and systemd-resolved on 127.0.0.53:53.
If you genuinely need every interface, move aardvark-dns instead — in /etc/containers/containers.conf:
[network]
dns_bind_port = 5533then recreate the network so it takes effect. That is the more invasive option, and it still leaves you needing DNSStubListener=no.
Docker is unaffected: its embedded DNS answers on 127.0.0.11 inside each container's network namespace, so it never competes for a host address.
DNS_PORT publishes the recursor, which is the front door: it answers for your zones and forwards everything else. The authoritative server sits behind it and is unpublished by default; AUTH_DNS_PORT=5300 exposes it as well, which is useful when a zone answers wrongly and you want to know which half is at fault.
To keep DNS on the loopback interface while you are still testing:
DNS_BIND_ADDRESS=127.0.0.1Behind a reverse proxy
This is the normal way to run it in production: TLS terminates at the proxy, the panel stays on an internal network.
BASE_URL=https://dns.example.com
SESSION_COOKIE_SECURE=true
TRUSTED_PROXY_COUNT=1Three settings, three reasons:
BASE_URLis the public URL, without a trailing slash. OAuth and SAML have to hand the identity provider an absolute redirect URL; without this the panel derives it from the request it sees, which behind a proxy is the internal hostname, and the provider's exact-match check fails.SESSION_COOKIE_SECURE=trueis required over HTTPS and breaks login over plain HTTP — a secure cookie is never sent back, so sign-in appears to succeed and then bounces you to the login page.TRUSTED_PROXY_COUNTis the number of proxies in front of the panel. It defaults to0, meaningX-Forwarded-Foris ignored entirely: any client can set that header, and trusting it blindly would let them forge the address in the audit log. Set it to the real number — not higher.
A minimal nginx server block:
server {
listen 443 ssl http2;
server_name dns.example.com;
ssl_certificate /etc/letsencrypt/live/dns.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dns.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:9191;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Then bind the panel to the loopback interface so it is only reachable through the proxy — in compose.yml:
webui:
ports:
- "127.0.0.1:${WEBUI_PORT:-9191}:8080"Exposing it to the internet
If these nameservers are going to be public, a few things change:
- Port 53 must be reachable over both UDP and TCP. TCP is not optional: it is how large answers and zone transfers work, and DNSSEC answers are large.
- Publish the panel on a private address or behind a VPN where you can. It is an administrative interface; there is no reason for it to face the internet.
- Register the nameservers as glue records at your registrar, and point the zone's
NSset at the names you actually run. - Keep
PDNS_VERSION_STRING=anonymous(the default) soversion.binddoes not advertise your exact build. - Consider rate limiting. PowerDNS Authoritative does not do response rate limiting itself; put it behind a firewall rule or a front-end that does if you are worried about being used for amplification.
Where things live
.
├── compose.yml the four services, wired together
├── .env / .env.example every setting, documented
├── secrets/ generated, gitignored, never in the images
├── db/
│ ├── schema/powerdns.sql the PowerDNS 4.9 gpgsql schema, verbatim
│ └── initdb/ roles and schema, applied on the first start only
├── pdns/ Dockerfile, entrypoint, pdns.conf template
├── webui/ the Flask panel, its tests and Dockerfile
└── scripts/generate-secrets.shThe PostgreSQL data lives in the named volume pgdata, not in the project directory. docker compose down keeps it; docker compose down -v deletes it, and with it every zone.
Next
- Advanced configuration — LDAP, OAuth, SAML, PowerDNS settings passthrough, running the panel outside compose.
- Guide — how zones, records, DNSSEC and the audit log behave.
- FAQ — the things that go wrong first.