← Tutorials
Server stack intermediate · ~30 min ·

Let's Encrypt wildcard certificates with DNS-01 and PowerDNS on AlmaLinux and Rocky Linux

Issue and renew a *.example.com certificate with certbot's DNS-01 challenge, publishing the proof in your own PowerDNS server with two small hook scripts.

A wildcard certificate for *.example.com covers every direct subdomain at once: www, mail, shop, and any name you add next year. Let’s Encrypt issues wildcards only through the DNS-01 challenge. Instead of serving a file over HTTP, you prove you control the domain by publishing a TXT record at _acme-challenge.example.com.

That makes DNS-01 a natural fit when the DNS for the domain runs on your own server. This tutorial sets up PowerDNS as the authoritative server for example.com, then has certbot publish and remove the challenge record through two small hook scripts. Renewals then happen on their own, with nobody editing DNS by hand.

DNS-01 has a second advantage: it works for names that are not websites. A mail hostname with no document root can still get a certificate.

Install PowerDNS and create the zone

PowerDNS keeps zones in a database; SQLite is plenty for one server.

Terminal window
dnf -y install dnf-plugins-core
dnf config-manager --set-enabled crb
dnf -y install epel-release
dnf -y install pdns pdns-backend-sqlite sqlite bind-utils
sqlite3 /var/lib/pdns/pdns.sqlite3 < /usr/share/doc/pdns/schema.sqlite3.sql
chown -R pdns:pdns /var/lib/pdns
cat > /etc/pdns/pdns.conf <<'EOF'
launch=gsqlite3
gsqlite3-database=/var/lib/pdns/pdns.sqlite3
local-address=0.0.0.0, ::
setuid=pdns
setgid=pdns
zone-cache-refresh-interval=0
default-soa-content=ns1.@ hostmaster.@ 0 10800 3600 604800 3600
EOF
systemctl enable --now pdns
firewall-cmd --permanent --add-service=dns
firewall-cmd --reload

zone-cache-refresh-interval=0 matters here more than anywhere. Without it, PowerDNS only notices new zones every five minutes, and answers REFUSED in between.

Create the zone. 203.0.113.10 stands in for your server’s public IP:

Terminal window
pdnsutil zone create example.com
pdnsutil rrset add example.com example.com NS ns1.example.com.
pdnsutil rrset add example.com ns1.example.com A 203.0.113.10
pdnsutil rrset add example.com example.com A 203.0.113.10
dig +short @127.0.0.1 NS example.com | grep -x 'ns1.example.com.'

Write the two hook scripts

certbot’s --manual mode calls a script before validation, with the domain in CERTBOT_DOMAIN and the value to publish in CERTBOT_VALIDATION, and calls a second script afterwards to clean up. The first adds the TXT record:

cat > /usr/local/bin/certbot-pdns-auth <<'EOF'
#!/bin/bash
# certbot DNS-01 auth hook: publish _acme-challenge in the local PowerDNS.
set -euo pipefail
name="_acme-challenge.${CERTBOT_DOMAIN}"
# The zone is the longest zone PowerDNS serves that the domain ends with.
zone=$(pdnsutil zone list-all | awk -v d="$CERTBOT_DOMAIN" \
'd == $0 || substr(d, length(d) - length($0)) == "." $0 { print length($0), $0 }' | sort -n | tail -1 | cut -d' ' -f2)
[ -n "$zone" ] || { echo "no PowerDNS zone for $CERTBOT_DOMAIN" >&2; exit 1; }
pdnsutil rrset add "$zone" "$name" TXT 60 "\"${CERTBOT_VALIDATION}\""
EOF
cat > /usr/local/bin/certbot-pdns-cleanup <<'EOF'
#!/bin/bash
# certbot DNS-01 cleanup hook: remove _acme-challenge from the local PowerDNS.
set -euo pipefail
name="_acme-challenge.${CERTBOT_DOMAIN}"
zone=$(pdnsutil zone list-all | awk -v d="$CERTBOT_DOMAIN" \
'd == $0 || substr(d, length(d) - length($0)) == "." $0 { print length($0), $0 }' | sort -n | tail -1 | cut -d' ' -f2)
# A wildcard plus its apex validate the same name twice; the first cleanup already
# removed both values, so a missing record is not an error.
pdnsutil rrset delete "$zone" "$name" TXT 2>/dev/null || true
EOF
chmod 700 /usr/local/bin/certbot-pdns-auth /usr/local/bin/certbot-pdns-cleanup

A certificate for example.com plus *.example.com validates the same name, _acme-challenge.example.com, twice with two different values. rrset add appends, so both values are published together, which is what Let’s Encrypt expects.

Issue the wildcard certificate

Terminal window
dnf -y install certbot
certbot certonly --manual --preferred-challenges dns \
--manual-auth-hook /usr/local/bin/certbot-pdns-auth \
--manual-cleanup-hook /usr/local/bin/certbot-pdns-cleanup \
-d example.com -d '*.example.com' \
--non-interactive --agree-tos -m postmaster@example.com
systemctl enable --now certbot-renew.timer

The certificate and key are in /etc/letsencrypt/live/example.com/, ready to use in Apache, Nginx, Postfix or Dovecot. certbot saves the hooks with the certificate, so the renewal timer runs them again every time, without any manual step.

Check it

The certificate must name both the wildcard and the apex, the challenge record must be gone from the zone, and a renewal must work end to end:

Terminal window
openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -noout -ext subjectAltName | grep -F 'DNS:*.example.com'
openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -noout -ext subjectAltName | grep -F 'DNS:example.com'
pdnsutil zone list example.com > /tmp/zone.txt
test "$(grep -c _acme-challenge /tmp/zone.txt)" = 0
certbot renew --cert-name example.com --dry-run

The check reads the zone with pdnsutil rather than asking with dig. PowerDNS caches its answers for a few seconds, so a dig right after the cleanup can still show the challenge record that was just removed. That is harmless, but it makes a confusing test.

The CorePanel way

This is how CorePanel issues every certificate. Its certificate manager uses the DNS-01 challenge only, against the local PowerDNS, and requests one certificate per domain covering *.example.com and example.com. That single certificate covers the website, mail., webmail. and ftp. at once. It is issued as soon as the domain is added and renewed in the background, with no hooks to write.

See SSL certificates and DNS.