This repository runs an authoritative DNS stack where all normal records and all GeoDNS records live in PostgreSQL. GeoDNS is implemented with PowerDNS Lua records. The old YAML service-record model is removed.
The geoip backend is still loaded, but only because PowerDNS Lua geographical functions need it. The file geo/lua-bootstrap.yml is intentionally empty. Do not put production records there.
- PowerDNS Authoritative 5.x.
- Generic PostgreSQL backend for normal DNS records and
LUArecords. - GeoIP backend loaded only as the MMDB lookup provider for Lua functions such as
continent(),country(),countryCode(),continentCode(), andpickclosest(). - PostgreSQL 17 primary with generated TLS certificates and physical streaming replica support.
- One-shot
db-initcontainer for roles, schema, grants, Poweradmin schema, and optional seed records. - Daily MMDB downloader. Default source is DB-IP City Lite through a direct CDN URL and needs no token.
- Poweradmin through the official
poweradmin/poweradmin:stableline by default. - Optional Caddy HTTPS overlay for Poweradmin.
- Example PowerDNS API scripts using
curlandjq.
The startup path is clean:
docker compose build
docker compose up -dNo repair script is required. No manual restart is required after first boot.
pdns-auth waits for three things before starting PowerDNS:
- PostgreSQL schema exists.
- PostgreSQL TLS CA is readable, when TLS verification is enabled.
- MMDB file exists.
mmdb-updater has its own healthcheck, so PowerDNS does not start while the first MMDB download is still running.
docker-compose.yml Primary stack
docker-compose.https.yml Optional HTTPS overlay for Poweradmin
docker/postgres-primary/ PostgreSQL primary image, TLS, and replication config
docker/postgres-init/ Idempotent database and seed initializer
docker/pdns-auth/ PowerDNS image, Lua/GeoIP config, wait logic, healthcheck
docker/mmdb-updater/ MMDB downloader image and healthcheck
docker/poweradmin/ Poweradmin wrapper and healthcheck
geo/lua-bootstrap.yml Empty GeoIP backend bootstrap file, not a records file
examples/api/ curl and jq examples for normal and Lua records
replica/docker-compose.replica.yml PostgreSQL-only replica node
replica/docker-compose.dns-node.yml Full DNS node with local replicated PostgreSQL
scripts/bootstrap.sh Optional .env generator with random secrets
scripts/check.sh Post-start health and DNS checks
scripts/show-replica-bundle.sh Shows generated replica TLS files
Install Docker Engine and the Docker Compose plugin. The modern command is:
docker compose versionThe legacy Python command is:
docker-compose --versionThe legacy docker-compose command can print a KeyError: 'id' traceback after a container becomes unhealthy. That traceback is from the old Compose CLI event watcher. Prefer docker compose when possible.
Install client tools for checks and API examples:
apt-get update
apt-get install -y dnsutils curl jq opensslOpen only the ports you need:
53/tcp and 53/udp Authoritative DNS
8081/tcp PowerDNS API and webserver
8080/tcp Poweradmin HTTP
5432/tcp PostgreSQL, only for replica nodes
80/tcp and 443/tcp Optional Poweradmin HTTPS overlay
Unpack the repository:
unzip pdns-geo-postgres-stack-lua.zip
cd pdns-geo-postgres-stackCreate .env. You can either copy the example manually:
cp .env.example .env
nano .envOr generate random secrets first:
chmod +x scripts/*.sh examples/api/*.sh
./scripts/bootstrap.sh
nano .envChange every production secret and every public address. At minimum, edit:
POSTGRES_SUPERUSER_PASSWORD=change-this
PDNS_DB_PASSWORD=change-this
POWERADMIN_DB_PASSWORD=change-this
REPLICATION_PASSWORD=change-this
PDNS_API_KEY=change-this
PDNS_WEBSERVER_PASSWORD=change-this
PA_ADMIN_PASSWORD=change-this
PA_SESSION_KEY=change-this
DNS_BASE_DOMAIN=example.com
DNS_NS1=ns1.example.com
DNS_NS2=ns2.example.com
DNS_NS3=ns3.example.com
DNS_HOSTMASTER=hostmaster.example.com
NS1_IPV4=YOUR_NS1_IPV4
NS2_IPV4=YOUR_NS2_IPV4
NS3_IPV4=YOUR_NS3_IPV4
NS1_IPV6=
NS2_IPV6=
NS3_IPV6=Set the default Lua example targets if you keep SEED_LUA_EXAMPLES=true:
SEED_LUA_EXAMPLES=true
LUA_GEO_DEFAULT_IPV4=YOUR_DEFAULT_ORIGIN_IPV4
LUA_GEO_EU_IPV4=YOUR_EU_ORIGIN_IPV4
LUA_GEO_NA_IPV4=YOUR_NA_ORIGIN_IPV4
LUA_GEO_AS_IPV4=YOUR_ASIA_ORIGIN_IPV4
LUA_GEO_HEALTH_PORT=443If you do not want example Lua records seeded on first boot:
SEED_LUA_EXAMPLES=falseIf PostgreSQL replica nodes will connect to this primary, include the primary server IP or DNS name in PG_TLS_SAN before first boot. PostgreSQL certificates are generated only when the TLS files do not already exist.
PG_TLS_SAN=DNS:postgres,DNS:pdns-postgres-primary,DNS:ns1.example.com,DNS:ns2.example.com,DNS:ns3.example.com,IP:127.0.0.1,IP:YOUR_PRIMARY_SERVER_IPBuild and start:
docker compose build
docker compose up -dWith legacy Compose:
docker-compose build
docker-compose up -dCheck status:
docker compose psExpected result:
pdns-postgres-primaryis healthy.pdns-mmdb-updateris healthy.pdns-authis healthy.poweradminis healthy.pdns-db-initexited with code0. This is normal because it is a one-shot initializer.
Run the included checks:
./scripts/check.shPoweradmin:
http://SERVER-IP:8080
PowerDNS API:
http://SERVER-IP:8081/api/v1/servers/localhost
Authoritative DNS:
dig @SERVER-IP example.com SOA +short
dig @SERVER-IP www.lua.example.com A +short
dig @SERVER-IP country-code.lua.example.com TXT +shortPowerDNS stores a Lua record as DNS type LUA. The content starts with the DNS type that will be synthesized.
Example database content:
A ";if continent('EU') then return '203.0.113.40' else return '203.0.113.10' end"
A query for A www.lua.example.com does not return the Lua source. It returns the synthesized A answer.
The stack enables Lua records globally with:
enable-lua-records=yesThe seed zone also gets this metadata:
ENABLE-LUA-RECORDS = 1
Keep the PowerDNS API private. Lua records execute server-side logic. Do not expose the API to untrusted users.
When SEED_BASE_ZONE=true and SEED_LUA_EXAMPLES=true, the initializer creates these records under DNS_BASE_DOMAIN:
www.lua.DOMAIN LUA A using continent() routing
country.lua.DOMAIN LUA A using country() routing
closest.lua.DOMAIN LUA A using pickclosest()
ha.lua.DOMAIN LUA A using ifportup() with pickclosest selector
country-code.lua.DOMAIN LUA TXT returning countryCode()
continent-code.lua.DOMAIN LUA TXT returning continentCode()
Query examples:
dig @127.0.0.1 www.lua.example.com A +short
dig @127.0.0.1 country.lua.example.com A +short
dig @127.0.0.1 closest.lua.example.com A +short
dig @127.0.0.1 ha.lua.example.com A +short
dig @127.0.0.1 country-code.lua.example.com TXT +short
dig @127.0.0.1 continent-code.lua.example.com TXT +shortGeo decisions are based on the recursive resolver IP unless EDNS Client Subnet is present and accepted. This repository sets:
edns-subnet-processing=yesFor controlled tests, use a resolver or query path that sends ECS. Plain local dig @127.0.0.1 normally geolocates as localhost or the resolver address, not the end user.
The examples below assume:
export PDNS_API_BASE="http://127.0.0.1:8081/api/v1/servers/localhost"
export PDNS_API_KEY="$(grep '^PDNS_API_KEY=' .env | cut -d= -f2-)"
export ZONE="example.com."curl -fsS -X POST "${PDNS_API_BASE}/zones" \
-H "X-API-Key: ${PDNS_API_KEY}" \
-H "Content-Type: application/json" \
--data "$(jq -n \
--arg name "$ZONE" \
'{name:$name, kind:"Native", nameservers:["ns1.example.com.","ns2.example.com."]}')" | jq .curl -fsS -X PATCH "${PDNS_API_BASE}/zones/${ZONE}" \
-H "X-API-Key: ${PDNS_API_KEY}" \
-H "Content-Type: application/json" \
--data "$(jq -n \
--arg name "www.${ZONE}" \
--arg ip "203.0.113.10" \
'{rrsets:[{name:$name,type:"A",ttl:300,changetype:"REPLACE",records:[{content:$ip,disabled:false}]}]}')"LUA='A ";if continent('\''EU'\'') then return '\''203.0.113.40'\'' elseif continent('\''NA'\'') then return '\''203.0.113.50'\'' else return '\''203.0.113.10'\'' end"'
curl -fsS -X PATCH "${PDNS_API_BASE}/zones/${ZONE}" \
-H "X-API-Key: ${PDNS_API_KEY}" \
-H "Content-Type: application/json" \
--data "$(jq -n \
--arg name "www.lua.${ZONE}" \
--arg lua "$LUA" \
'{rrsets:[{name:$name,type:"LUA",ttl:60,changetype:"REPLACE",records:[{content:$lua,disabled:false}]}]}')"LUA='A ";if country({'\''NL'\'','\''DE'\'','\''FR'\'','\''GB'\''}) then return '\''203.0.113.40'\'' elseif country({'\''US'\'','\''CA'\''}) then return '\''203.0.113.50'\'' else return '\''203.0.113.10'\'' end"'
curl -fsS -X PATCH "${PDNS_API_BASE}/zones/${ZONE}" \
-H "X-API-Key: ${PDNS_API_KEY}" \
-H "Content-Type: application/json" \
--data "$(jq -n \
--arg name "country.lua.${ZONE}" \
--arg lua "$LUA" \
'{rrsets:[{name:$name,type:"LUA",ttl:60,changetype:"REPLACE",records:[{content:$lua,disabled:false}]}]}')"LUA='A "pickclosest({'\''203.0.113.40'\'','\''203.0.113.50'\'','\''203.0.113.30'\'','\''203.0.113.10'\''})"'
curl -fsS -X PATCH "${PDNS_API_BASE}/zones/${ZONE}" \
-H "X-API-Key: ${PDNS_API_KEY}" \
-H "Content-Type: application/json" \
--data "$(jq -n \
--arg name "closest.lua.${ZONE}" \
--arg lua "$LUA" \
'{rrsets:[{name:$name,type:"LUA",ttl:60,changetype:"REPLACE",records:[{content:$lua,disabled:false}]}]}')"LUA='A "ifportup(443, {{'\''203.0.113.40'\'','\''203.0.113.50'\''}, {'\''203.0.113.10'\''}}, {selector='\''pickclosest'\''})"'
curl -fsS -X PATCH "${PDNS_API_BASE}/zones/${ZONE}" \
-H "X-API-Key: ${PDNS_API_KEY}" \
-H "Content-Type: application/json" \
--data "$(jq -n \
--arg name "ha.lua.${ZONE}" \
--arg lua "$LUA" \
'{rrsets:[{name:$name,type:"LUA",ttl:60,changetype:"REPLACE",records:[{content:$lua,disabled:false}]}]}')"The first query that hits an ifportup() or ifurlup() record may use fallback behavior because PowerDNS performs health checks in the background.
curl -fsS -X PATCH "${PDNS_API_BASE}/zones/${ZONE}" \
-H "X-API-Key: ${PDNS_API_KEY}" \
-H "Content-Type: application/json" \
--data "$(jq -n \
--arg name "ha.lua.${ZONE}" \
'{rrsets:[{name:$name,type:"LUA",changetype:"DELETE"}]}')"cd examples/api
cp 00-env.sh.example 00-env.sh
nano 00-env.sh
./01-create-zone.sh
./02-add-basic-records.sh
./03-add-lua-geo-records.sh
./04-query-tests.shPoweradmin is useful for ordinary records and zone administration. For Lua records, use the PowerDNS API examples above. Some Poweradmin versions may show LUA records, but API control is more predictable because the content contains nested quoting.
The default .env uses:
MMDB_PROVIDER=dbip-jsdelivr
MMDB_TARGET_FILE=GeoLite2-City.mmdbThe downloader stores the file at:
runtime/mmdb/GeoLite2-City.mmdb
PowerDNS reads it as:
/var/lib/powerdns/mmdb/GeoLite2-City.mmdb
When the MMDB file changes, pdns-auth exits cleanly and Docker restarts it. This makes PowerDNS reopen the memory-mapped database.
Supported providers in this repo:
dbip-jsdelivr Default, no token
dbip-official DB-IP monthly URL, no token
generic Use MMDB_DOWNLOAD_URL
ip66 Country/continent/ASN MMDB, no token
ip2location Legacy token mode
Show containers:
docker compose psFollow logs:
docker compose logs -f postgres
docker compose logs -f db-init
docker compose logs -f mmdb-updater
docker compose logs -f pdns-auth
docker compose logs -f poweradminRestart one service:
docker compose restart pdns-auth
docker compose restart poweradminRebuild after changing Dockerfiles or entrypoints:
docker compose build
docker compose up -dCheck the PowerDNS API:
curl -fsS -H "X-API-Key: ${PDNS_API_KEY}" \
"http://127.0.0.1:${PDNS_API_PORT:-8081}/api/v1/servers/localhost" | jq .List zones:
curl -fsS -H "X-API-Key: ${PDNS_API_KEY}" \
"http://127.0.0.1:${PDNS_API_PORT:-8081}/api/v1/servers/localhost/zones" | jq '.[].name'Inspect generated PowerDNS config:
docker compose exec pdns-auth cat /etc/powerdns/pdns.d/00-local.confCreate a database backup:
mkdir -p backups
docker compose exec -T postgres pg_dump -U postgres -d pdns > backups/pdns-$(date -u +%Y%m%dT%H%M%SZ).sqlRestore into a new empty stack only after stopping services that write to the database:
docker compose stop poweradmin pdns-auth db-init
cat backups/pdns.sql | docker compose exec -T postgres psql -U postgres -d pdns
docker compose up -dBack up TLS material for replicas:
tar -czf backups/postgres-tls-$(date -u +%Y%m%dT%H%M%SZ).tar.gz runtime/postgres-tlsReplica nodes use physical streaming replication from the primary PostgreSQL server. DNS nodes should read from their local replica.
On the primary node, first boot must already have generated TLS files:
ls runtime/postgres-tlsShow the files needed by replica nodes:
./scripts/show-replica-bundle.shCopy these files to the replica node under replica/tls:
ca.crt
replicator.crt
replicator.key
On each replica node:
cd replica
cp .env.example .env
nano .env
mkdir -p tls runtime/mmdb
# copy ca.crt, replicator.crt, and replicator.key into ./tls
docker compose -f docker-compose.dns-node.yml build
docker compose -f docker-compose.dns-node.yml up -dUse a unique replication slot per node:
REPLICATION_SLOT_NAME=nsg_02_replicaFor another node:
REPLICATION_SLOT_NAME=nsg_03_replicaWrite DNS changes only on the primary node through primary Poweradmin or the primary PowerDNS API. Replica PostgreSQL nodes are hot standby replicas. The local PowerDNS service on a replica node is read-only because its database is read-only.
For a database replica without DNS service:
cd replica
cp .env.example .env
nano .env
docker compose -f docker-compose.replica.yml build
docker compose -f docker-compose.replica.yml up -dSet a real FQDN and email:
POWERADMIN_FQDN=dns.example.com
ACME_EMAIL=admin@example.comMake sure the FQDN points to this server and ports 80/tcp and 443/tcp are open.
Start with the HTTPS overlay:
docker compose -f docker-compose.yml -f docker-compose.https.yml build
docker compose -f docker-compose.yml -f docker-compose.https.yml up -dPoweradmin will be available at:
https://dns.example.com
Check first-boot logs:
docker compose logs db-init
docker compose logs mmdb-updater
docker compose logs pdns-auth
docker compose logs poweradminIf pdns-auth waits for MMDB, check:
docker compose logs mmdb-updater
ls -lh runtime/mmdbIf pdns-auth waits for PostgreSQL schema, check:
docker compose logs db-init
docker compose exec postgres psql -U postgres -d pdns -c '\dt'If Lua answers do not vary by location, remember that authoritative DNS usually sees the recursive resolver IP. Use EDNS Client Subnet or test from resolvers in different networks.
If Lua records return no answer, inspect the stored record:
curl -fsS -H "X-API-Key: ${PDNS_API_KEY}" \
"http://127.0.0.1:${PDNS_API_PORT:-8081}/api/v1/servers/localhost/zones/${DNS_BASE_DOMAIN}." | jq '.rrsets[] | select(.type=="LUA")'Check PowerDNS runtime config:
docker compose exec pdns-auth grep -E 'launch=|enable-lua-records|geoip|edns-subnet' /etc/powerdns/pdns.d/00-local.confExpected lines include:
launch=gpgsql,geoip
enable-lua-records=yes
geoip-database-files=mmdb:/var/lib/powerdns/mmdb/GeoLite2-City.mmdb
geoip-zones-file=/etc/powerdns/geo/lua-bootstrap.yml
edns-subnet-processing=yesDo not expose port 8081 publicly unless it is behind a strict firewall or VPN. Anyone with the API key can change records. With Lua records enabled, that means they can also change server-side Lua logic.
For production, set:
PDNS_WEBSERVER_ALLOW_FROM=127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,YOUR_ADMIN_IP/32Also firewall port 8081/tcp at the host or cloud firewall layer.
Pull or copy the new repository, keep your existing .env, then run:
docker compose build
docker compose up -d
./scripts/check.shDo not run docker compose down -v unless you intentionally want to delete the PostgreSQL data volume.
PowerDNS Lua records:
https://doc.powerdns.com/authoritative/lua-records/
PowerDNS Lua functions:
https://doc.powerdns.com/authoritative/lua-records/functions.html
PowerDNS HTTP zone API:
https://doc.powerdns.com/authoritative/http-api/zone.html
PowerDNS authoritative settings:
https://doc.powerdns.com/authoritative/settings.html