This post is part of the My Lab series.
Let's Encrypt Certificate Renewal with RFC2136
Table of Contents
Let’s Encrypt provides a DNS-01 challenge to verify domain ownership before issuing SSL certificates. The challenge requires adding TXT records to your DNS zone. Instead of manually adding DNS records, we use RFC2136 (DNS Dynamic Updates) with BIND9 to automate the process.
Installation Instructions
BIND9 configuration
1-Key creation
Use the following command to generate a TSIG key that Certbot will use for authentication:
sudo rndc-confgen -a -A hmac-sha512 -k "certbot." -c /etc/bind/certbot.key
This creates /etc/bind/certbot.key
with content:
key "certbot." {
algorithm hmac-sha512;
secret "SECRET_KEY_HERE";
};
2-Configure BIND9 to Allow Dynamic Updates
Edit /etc/bind/named.conf.local
and add:
key "certbot." {
algorithm hmac-sha512;
secret "SECRET_KEY_HERE";
};
zone "_acme-challenge.jvetter.net" {
type master;
file "/var/cache/bind/_acme-challenge.jvetter.net.zone";
allow-query { any; };
check-names ignore; // required for `_acme-challenge`
update-policy {
grant certbot. name _acme-challenge.jvetter.net. txt;
};
};
Verify you have this file taking into account in the main named.conf
In the file /etc/bind/named.conf
verify the presence of
include "/etc/bind/named.conf.options";
include "/etc/bind/named.conf.local";
3-Create the DNS Zone File
Create the file /var/cache/bind/_acme-challenge.jvetter.net.zone
:
$ORIGIN .
$TTL 3600 ; 1 hour
_acme-challenge.jvetter.net IN SOA ns1.jvetter.net. hostmaster.jvetter.net. (
44 ; serial
14400 ; refresh (4 hours)
3600 ; retry (1 hour)
604800 ; expire (1 week)
3600 ; minimum (1 hour)
)
NS ns1.jvetter.net.
4-Verify BIND Configuration
Run:
sudo named-checkconf # Verify named.conf syntax
sudo named-checkzone _acme-challenge.jvetter.net /var/cache/bind/_acme-challenge.jvetter.net.zone
Reload BIND:
sudo systemctl reload named
Confirm zone visibility:
dig SOA _acme-challenge.jvetter.net @ns1.jvetter.net +short
5-Test DNS Updates Using nsupdate
Let’s test if DNS Dynamic Update for the new zone is accepted.
On the server that is running certbot (it can be where Bind is present or another server, in my case Bind and Certbot are in two different server), you need:
- The TSIG key previously created
nsupdate
installed (part of the packagebind9-dnsutils
)
Verify if you have nsupdate
:
$ which nsupdate
/usr/bin/nsupdate
If not present, install it with:
$ sudo apt install bind9-dnsutils
On the Bind server, before the nsupdate
test:
$ sudo named-journalprint /var/cache/bind/_acme-challenge.jvetter.net.zone.jnl
...
add _acme-challenge.jvetter.net. 3600 IN SOA ns1.jvetter.net. hostmaster.jvetter.net. 44 14400 3600 604800 3600
You can also run that command to check during the modification:
$ sudo tail -f _acme-challenge.jvetter.net.zone.jnl
Check of the SOA serial:
$ dig SOA _acme-challenge.jvetter.net @ns1.jvetter.net +short
ns1.jvetter.net. hostmaster.jvetter.net. 44 14400 3600 604800 3600
Check TXT record for the zone:
$ dig TXT _acme-challenge.jvetter.net @ns1.jvetter.net +short
No TXT record on that zone.
Now let’s try to send a DNS update using nsupdate
:
Run the following command:
echo -e "server ns1.jvetter.net\nzone _acme-challenge.jvetter.net\nupdate add _acme-challenge.jvetter.net 300 TXT \"test1\"\nsend\nquit" | sudo nsupdate -k certbot.key
After the nsupdate
test:
$ sudo tail -f _acme-challenge.jvetter.net.zone.jnl
d_acme-challengejvetternet=ns1jvetternet
hostmasterjvetternet,8@ :d_acme-challengejvetternet=ns1jvetternet
hostmasterjvetternet-8@ :-_acme-challengejvetternet,test1
$ sudo named-journalprint /var/cache/bind/_acme-challenge.jvetter.net.zone.jnl
...
add _acme-challenge.jvetter.net. 3600 IN SOA ns1.jvetter.net. hostmaster.jvetter.net. 44 14400 3600 604800 3600
del _acme-challenge.jvetter.net. 3600 IN SOA ns1.jvetter.net. hostmaster.jvetter.net. 44 14400 3600 604800 3600
add _acme-challenge.jvetter.net. 3600 IN SOA ns1.jvetter.net. hostmaster.jvetter.net. 45 14400 3600 604800 3600
add _acme-challenge.jvetter.net. 300 IN TXT "test1"
$ dig SOA _acme-challenge.jvetter.net @ns1.jvetter.net +short
ns1.jvetter.net. hostmaster.jvetter.net. 45 14400 3600 604800 3600
$ dig TXT _acme-challenge.jvetter.net @ns1.jvetter.net +short
"test1"
The following command delete only test1 TXT record:
$ echo -e "server ns1.jvetter.net\nzone _acme-challenge.jvetter.net\nupdate delete _acme-challenge.jvetter.net. IN TXT \"test1\"\nsend\nquit" | sudo nsupdate -v -d -k /etc/letsencrypt/certbot.key
The following command delete all TXT records:
$ echo -e "server ns1.jvetter.net\nzone _acme-challenge.jvetter.net\nupdate delete _acme-challenge.jvetter.net TXT\nsend\nquit" | sudo nsupdate -v -d -k /etc/letsencrypt/certbot.key
Now we confirmed the certbot server can push DNS Dynamic Update (RFC2136) to the server that manage the _acme-challenge
zone, let’s continue with the certbot configuration.
Certbot Configuration with RFC2136
6-Install the Certbot RFC2136 Plugin
On the Certbot server:
sudo apt install python3-certbot-dns-rfc2136
Verify:
$ certbot plugins
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
* dns-rfc2136
Description: Obtain certificates using a DNS TXT record (if you are using BIND
for DNS).
Interfaces: Authenticator, Plugin
Entry point: dns-rfc2136 =
certbot_dns_rfc2136._internal.dns_rfc2136:Authenticator
* standalone
Description: Spin up a temporary webserver
Interfaces: Authenticator, Plugin
Entry point: standalone = certbot._internal.plugins.standalone:Authenticator
* webroot
Description: Place files in webroot directory
Interfaces: Authenticator, Plugin
Entry point: webroot = certbot._internal.plugins.webroot:Authenticator
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
7-Configure Certbot to Use RFC2136
Create a credentials file:
sudo vi /etc/letsencrypt/renewal/rfc2136-credentials.ini
Add:
# Target DNS server (IP address of the DNS server)
dns_rfc2136_server = 141.94.168.204
# Target DNS port
dns_rfc2136_port = 53
# TSIG key name (matches BIND config)
dns_rfc2136_name = certbot.
# TSIG key secret (from certbot.key)
dns_rfc2136_secret = "SECRET_KEY_HERE"
# TSIG key algorithm
dns_rfc2136_algorithm = HMAC-SHA512
Set permissions:
sudo chmod 600 /etc/letsencrypt/renewal/rfc2136-credentials.ini
Certbot also needs information about how it will request a certificate.
Edit the your_domain.conf
file in the letsencrypt/renewal
directory:
$ sudo cat /etc/letsencrypt/renewal/jvetter.net.conf
# renew_before_expiry = 30 days
version = 2.1.0
archive_dir = /etc/letsencrypt/archive/jvetter.net
cert = /etc/letsencrypt/live/jvetter.net/cert.pem
privkey = /etc/letsencrypt/live/jvetter.net/privkey.pem
chain = /etc/letsencrypt/live/jvetter.net/chain.pem
fullchain = /etc/letsencrypt/live/jvetter.net/fullchain.pem
# Options used in the renewal process
[renewalparams]
account = 0123456789abcdef0123456789abcdef
authenticator = dns-rfc2136
dns_rfc2136_credentials = /etc/letsencrypt/renewal/rfc2136-credentials.ini
server = https://acme-v02.api.letsencrypt.org/directory
key_type = ecdsa
8-Request SSL Certificates Using RFC2136
First test with dry-run
if you would be able to request a certificate:
$ sudo certbot certonly --dns-rfc2136 --dns-rfc2136-credentials /etc/letsencrypt/renewal/rfc2136-credentials.ini -d "jvetter.net" -d "*.jvetter.net" --dry-run
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Simulating renewal of an existing certificate for jvetter.net and *.jvetter.net
Waiting 60 seconds for DNS changes to propagate
The dry run was successful.
Have a look to the bind log, you should be able to see new TXT records adding (even during the renewal simulation)
$ sudo named-journalprint /var/cache/bind/_acme-challenge.jvetter.net.zone.jnl
...
del _acme-challenge.jvetter.net. 300 IN TXT "test1"
add _acme-challenge.jvetter.net. 3600 IN SOA ns1.jvetter.net. hostmaster.jvetter.net. 46 14400 3600 604800 3600
del _acme-challenge.jvetter.net. 3600 IN SOA ns1.jvetter.net. hostmaster.jvetter.net. 46 14400 3600 604800 3600
add _acme-challenge.jvetter.net. 3600 IN SOA ns1.jvetter.net. hostmaster.jvetter.net. 47 14400 3600 604800 3600
add _acme-challenge.jvetter.net. 120 IN TXT "Hf0RzqaSx6KNIHKGYmj6pZx9CzlLqqI6VJOsBCgZ3BU"
del _acme-challenge.jvetter.net. 3600 IN SOA ns1.jvetter.net. hostmaster.jvetter.net. 47 14400 3600 604800 3600
add _acme-challenge.jvetter.net. 3600 IN SOA ns1.jvetter.net. hostmaster.jvetter.net. 48 14400 3600 604800 3600
add _acme-challenge.jvetter.net. 120 IN TXT "7x9vDgMr7WJcyI1IXNd-rIVkmCc8GsbhGN2uIgQR2AU"
del _acme-challenge.jvetter.net. 3600 IN SOA ns1.jvetter.net. hostmaster.jvetter.net. 48 14400 3600 604800 3600
del _acme-challenge.jvetter.net. 120 IN TXT "Hf0RzqaSx6KNIHKGYmj6pZx9CzlLqqI6VJOsBCgZ3BU"
add _acme-challenge.jvetter.net. 3600 IN SOA ns1.jvetter.net. hostmaster.jvetter.net. 49 14400 3600 604800 3600
del _acme-challenge.jvetter.net. 3600 IN SOA ns1.jvetter.net. hostmaster.jvetter.net. 49 14400 3600 604800 3600
del _acme-challenge.jvetter.net. 120 IN TXT "7x9vDgMr7WJcyI1IXNd-rIVkmCc8GsbhGN2uIgQR2AU"
add _acme-challenge.jvetter.net. 3600 IN SOA ns1.jvetter.net. hostmaster.jvetter.net. 50 14400 3600 604800 3600
We can see adds of 2 new TXT records, one token per certificate asked, one for the apex jvetter.net, and one for all subdomains for this domain *.jvetter.net.
This is why you see in the log the addition of 2 Tokens:
Hf0RzqaSx6KNIHKGYmj6pZx9CzlLqqI6VJOsBCgZ3BU
7x9vDgMr7WJcyI1IXNd-rIVkmCc8GsbhGN2uIgQR2AU
The serial goes from 46 to 48 because it adds TXT record one by one,
so the initial state is SOA SERIAL 46, then it added TXT Record Hf0RzqaSx6KNIHKGYmj6pZx9CzlLqqI6VJOsBCgZ3BU
(serial 47)
then it added TXT record 7x9vDgMr7WJcyI1IXNd-rIVkmCc8GsbhGN2uIgQR2AU
(serial 48)
When the renewal validated the challenge, it deletes one by one the TXT records:
first deletation of Hf0RzqaSx6KNIHKGYmj6pZx9CzlLqqI6VJOsBCgZ3BU
, (serial 49)
then deletation of 7x9vDgMr7WJcyI1IXNd-rIVkmCc8GsbhGN2uIgQR2AU
, (serial 50)
Now you can request a certificate, run the previsous command without dry-run
:
$ sudo certbot certonly --dns-rfc2136 --dns-rfc2136-credentials /etc/letsencrypt/renewal/rfc2136-credentials.ini -d "jvetter.net" -d "*.jvetter.net"
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Renewing an existing certificate for jvetter.net and *.jvetter.net
Waiting 60 seconds for DNS changes to propagate
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/jvetter.net/fullchain.pem
Key is saved at: /etc/letsencrypt/live/jvetter.net/privkey.pem
This certificate expires on 2025-05-19.
These files will be updated when the certificate renews.
Certbot has set up a scheduled task to automatically renew this certificate in the background.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
If you like Certbot, please consider supporting our work by:
* Donating to ISRG / Let's Encrypt: https://letsencrypt.org/donate
* Donating to EFF: https://eff.org/donate-le
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Automating Certificate Renewal
When using Certbot, no manual intervention is needed for certificate renewal. Certbot automatically installs a scheduled task that checks and renews certificates twice a day — at 00:00 and 12:00 UTC.
Certbot sets up for you two things to make sure the renwal is scheduled:
- a cronjob task
- a systemd.timer
Check the cron job:
$ sudo cat /etc/cron.d/certbot
# /etc/cron.d/certbot: crontab entries for the certbot package
#
# Upstream recommends attempting renewal twice a day
#
# Eventually, this will be an opportunity to validate certificates
# haven't been revoked, etc. Renewal will only occur if expiration
# is within 30 days.
#
# Important Note! This cronjob will NOT be executed if you are
# running systemd as your init system. If you are running systemd,
# the cronjob.timer function takes precedence over this cronjob. For
# more details, see the systemd.timer manpage, or use systemctl show
# certbot.timer.
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
0 */12 * * * root test -x /usr/bin/certbot -a \! -d /run/systemd/system && perl -e 'sleep int(rand(43200))' && certbot -q renew --no-random-sleep-on-renew
☝️ The condition ! -d /run/systemd/system
ensures the cron job is skipped if systemd is managing the service, avoiding duplicate renewals.
The systemd timer unit (preferred when systemd is present):
$ sudo systemctl cat certbot.timer
# /lib/systemd/system/certbot.timer
[Unit]
Description=Run certbot twice daily
[Timer]
OnCalendar=*-*-* 00,12:00:00
RandomizedDelaySec=43200
Persistent=true
[Install]
WantedBy=timers.target
Reloading Nginx Docker After Renewal
After renewing an SSL certificate, Nginx must be reloaded to use the new certificate. However, when Nginx is running inside a Docker container, Certbot doesn’t automatically reload it.
To handle this, we’ll configure a systemd override for the certbot.service to include a --post-hook
that restarts the Nginx container after a successful renewal.
Create a systemd override for certbot service unit:
sudo mkdir -p /etc/systemd/system/certbot.service.d
Create the override file:
sudo vi /etc/systemd/system/certbot.service.d/override.conf
Add the following content:
[Service]
ExecStart=
ExecStart=/usr/bin/certbot -q renew --post-hook "/usr/bin/docker-compose -f /home/debian/nginx-redirect/docker-compose.yml restart nginx"
Reload systemd and restart the timer:
sudo systemctl daemon-reload
sudo systemctl restart certbot.timer
Verify the override config is applied:
The base unit and the override should be visible:
$ sudo systemctl cat certbot.service
# /lib/systemd/system/certbot.service
[Unit]
Description=Certbot
Documentation=file:///usr/share/doc/python-certbot-doc/html/index.html
Documentation=https://certbot.eff.org/docs
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot -q renew --no-random-sleep-on-renew
PrivateTmp=true
# /etc/systemd/system/certbot.service.d/override.conf
[Service]
ExecStart=
ExecStart=/usr/bin/certbot -q renew --post-hook "/usr/bin/docker-compose -f /home/debian/nginx-redirect/docker-compose.yml restart nginx"
☝️ The --post-hook
only runs after a successful renewal.
Verify Renewal and Deployment
After the renewal process, you should verify both:
- That Certbot successfully renewed the certificate
- That Nginx is now serving the updated certificate
Check Certificate Status with Certbot
This shows the certificates managed by Certbot:
$ sudo certbot certificates
Saving debug log to /var/log/letsencrypt/letsencrypt.log
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Found the following certs:
Certificate Name: jvetter.net
Serial Number: 386c024c9cc53219a2512be3b026f94e46f
Key Type: ECDSA
Domains: jvetter.net *.jvetter.net
Expiry Date: 2025-05-20 21:53:59+00:00 (VALID: 85 days)
Certificate Path: /etc/letsencrypt/live/jvetter.net/fullchain.pem
Private Key Path: /etc/letsencrypt/live/jvetter.net/privkey.pem
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
☝️ Be careful, Certbot displays the certificate it created, not what is actively served by Nginx.
Check the File Directly Using OpenSSL
You can verify the dates on the actual certificate file that Certbot generated:
$ sudo openssl x509 -noout -dates -in /etc/letsencrypt/live/jvetter.net/fullchain.pem
notBefore=Feb 19 21:54:00 2025 GMT
notAfter=May 20 21:53:59 2025 GMT
Verify What Nginx is Actually Serving
To ensure Nginx is using the renewed certificate, connect to the server directly over TLS:
$ echo | openssl s_client -connect jvetter.net:443 -servername jvetter.net 2>/dev/null | openssl x509 -noout -dates
notBefore=Feb 19 21:54:00 2025 GMT
notAfter=May 20 21:53:59 2025 GMT
☝️ These dates should match the ones displayed by Certbot and the certificate file.
Use Online SSL Checkers
You can also use website dedicated for that like :
- https://www.ssllabs.com
- https://www.sslshopper.com/ssl-checker.html
- https://web-check.xyz (my favorite for its UI ♥️)
📋 Notes
- Certbot version 2.1.0 (on Debian 12 Bookworm)
- DNS server runs BIND version 9.16.50 (on Debian 11 Bullseye)
- Source : https://github.com/vttrj/certbot-rfc2136
In this series:
- Let's Encrypt Certificate Renewal with RFC2136