# 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.

Source: https://www.corepanel.net/tutorials/letsencrypt-wildcard-dns01-powerdns-almalinux-rocky
Author: CorePanel Team
Published: 2026-10-05
Track: Server stack · intermediate · ~30 min
Tested on: AlmaLinux 9.8 (2026-10-05)
Tested on: AlmaLinux 10.2 (2026-10-05)
Tested on: Rocky Linux 9.8 (2026-10-05)
Tested on: Rocky Linux 10.2 (2026-10-05)
Before you start: https://www.corepanel.net/tutorials/initial-server-setup-almalinux-rocky
CorePanel edition needed: Personal (free)
Tags: letsencrypt, certbot, wildcard, dns-01, powerdns, almalinux, rocky-linux
From CorePanel Tutorials — https://www.corepanel.net/tutorials

---

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.

> **Your server must be authoritative for the domain**
>
> Let's Encrypt looks up the TXT record through public DNS. That only reaches this server
> if it is the domain's nameserver — registered at your registrar, with a glue record if
> the nameserver's name is inside the domain. The [Perfect Server](https://www.corepanel.net/tutorials/perfect-server-almalinux-rocky)
> tutorial covers that setup in full. If your DNS is hosted elsewhere, use certbot's plugin
> for that provider instead of these hooks.
## Install PowerDNS and create the zone

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

```bash
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:

```bash
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:

```bash
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

```bash
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.

> **Running secondary nameservers?**
>
> Let's Encrypt may ask any of the domain's nameservers. If you have secondaries, the TXT
> record has to reach them before validation starts, and these hooks do not wait for that.
> Add a short `sleep` at the end of the auth hook, long enough for your zone transfers,
> or make sure the challenge names are answered by the primary only.
## 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:

```bash
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](https://www.corepanel.net/docs/ssl-certificates) and [DNS](https://www.corepanel.net/docs/dns).
