Automate Certificate Management With Caddy

Aug 6, 2025 · 14 mins read
Automate Certificate Management With Caddy

In the video below, we show you how to automate certificate management using Caddy and ACME with a DNS challenge


Now I don’t know about you but when it comes to IT, I don’t look forward to manually managing TLS certificates

But certificate management is important for security

Fortunately for public facing servers at least, Let’s Encrypt came along and they gave us automated certificate management

And while it could be used for internal servers as well, it’s a terrible idea to provide your private IT information to a 3rd party public server

Fortunately, Caddy can be configured as an ACME server on your private network

Granted you still have to configure your computers to trust it’s root certificate, but once that’s done, your internal computers will have automated certificate management

But how do you configure Caddy to act as an ACME server, and how do you integrate this with a DNS server like Bind9 to support DNS challenges?

Useful links:
https://caddyserver.com/docs/caddyfile/directives/acme_server
https://caddyserver.com/docs/caddyfile/options
https://caddy.community/t/use-caddy-for-local-https-tls-between-front-end-reverse-proxy-and-lan-hosts/11650
https://bind9.readthedocs.io/en/latest/reference.html#dynamic-update-policies
https://smallstep.com/docs/tutorials/acme-protocol-acme-clients/#popular-acme-clients

Assumptions:
Now because this video is about configuring Caddy as an ACME server, I’m going to assume you already have Caddy installed or you at least know how to do that

If not, then I do have another video which shows you how to install Caddy as a container in Docker

I’m also going to assume you have a DNS server like Bind9 that supports Dynamic Updates or you at least know how to set this up

If not, then I do have another video that shows you how to do this on a Linux server

And I have another video which shows you how to do this in a container

Although if you have another DNS server that supports Dynamic DNS, feel free to use that instead

Overview:
Now before we get started with any configuration work I think it makes sense to go over the basics of ACME and explain why we’ll be using a DNS challenge

So first of all, what is ACME?

Well, ACME stands for Automatic Certificate Management Environment which is what Let’s Encrypt came up with to make provisioning TLS certificates sooo much easier

Basically, a web server for instance, requires a TLS certificate, and it uses an ACME client to ask the ACME server to provide it with one

The ACME server needs to validate that request because you can’t have everybody spinning up web servers claiming to belong to microsoft.com for instance

There are two direct methods to do that, but usually in an internal network you’ll have devices behind firewalls and they may not even be listening on the standard ports of 80 and 443 used by HTTP and HTTPS

So in this video, we’ll use a DNS challenge instead

For this, the ACME server will tell the web server it needs to see a text record on the domain’s DNS server which contains a specific value

To fully automate this, the DNS server will have to support Dynamic DNS and the web server in this case will need to be able make DNS changes

Assuming the ACME server sees the text record in DNS, it will then have its validation and it can then issue a certificate to the web server

It’s then left to the web server to load the certificate and it will then have to regularly repeat this process before the certificate expires

Dynamic Updates:
Because we’re using a DNS challenge, Bind9 needs updating to support this for ACME clients

We don’t want just any device being able to make changes so we’ll create a key or passphrase to restrict this ability

TIP: The developers of Bind9 removed IP address restriction because the source IP address can be spoofed

Now depending on your environment you could give all your devices the same key and allow them to make changes for any server, or you could lock this down so each device has its own key and can only update its own records

A “global” key if you will is much simpler to set up but it will require more work later on if a server gets compromised, because then every server will need to be updated

A “specific” key on the other hand requires a lot more work now, but it’s more secure and less work will be needed if a server is later compromised

For this example, we’ll create one key for all ACME clients to use, but different to the one used by DHCP servers

For Debian, the command we need is installed as part of the Bind installation

tsig-keygen -a hmac-sha256 acme.key | tee -a bind9/etc/bind/dns.keys.conf acme.key

I’m running Bind as a container though so I’ll need to run this within the container

docker exec bind9 tsig-keygen -a hmac-sha256 acme.key | tee -a bind9/etc/bind/dns.keys.conf acme.key
The output from the tsig-keygen command would normally go to the screen and look something like this
cat acme.key

key "acme.key" {
	algorithm hmac-sha256;
	secret "i7DjNuAZYanLNCYOW3HVIEQRXlNJx3dy3d8AU8Cenp0=";
};

But I want to store this in a file for Bind to read

For me, a file already exists so I’ve piped the output to that file using tee with the -a option to append

I also want to do some testing, so the output will also be sent to another file I’ve called acme.key

The reason I’ve done this is because we’ll later use the nsupdate command and as far as I’m aware, I can’t tell it which key to choose within a file

TIP: Although the documentation mentions using an FQDN for the name, it doesn’t seem to be necessary, although it would make it easier to identify what a key is for

We need to update Bind9 with an extra policy command for ACME clients

nano bind9/etc/bind/named.conf.local

Now it looks a bit like this

include "/etc/bind/dns.keys.conf";

zone "homelab.lan"  {
	type master;
	file "/var/lib/bind/db.homelab.lan";
        update-policy { 
		deny * name pvedemo1.homelab.lan;
		grant dhcp.key wildcard *.homelab.lan A DHCID;
        grant acme.key zonesub TXT;
	};
};

...

Now save and exit

I already have the file dns.keys.conf referenced in this file and it’s also mapped to the container

In which case, adding an extra key doesn’t require an update to the compose file either

I already have a policy to prevent updates for one specific record, another to let DHCP servers make changes and now I’m adding an extra line at the end for ACME clients

With this new policy, devices with the key can add and remove text records in the homelab.lan zone, plus sub zones

And we allow sub zones because of the way in DNS challenges work

For example, if a server called www.homelab.lan asks for a certificate, the ACME server will ask to see a text record called _acme-challenge.www.homelab.lan

To DNS that means a record called _acme-challenge in the www subdomain or sub zone of homelab.lan.

We’ll stop and start the container again to make sure these changes take effect

docker compose down bind9
docker compose up -d

Then check the container is running

docker ps -l

Before move on we should check DNS still works as a simple typo could break things

host testdns1.homelab.lan
host www.microsoft.com

NSUPDATE:
Now before we move on to configuring ACME, we should first test to see if Dynamic Updates work

Otherwise, if we have problems with Caddy, it’s harder to know where the problem is

And we can do this using the nsupdate command

I’ll switch to a user with privileges first

Then we’ll make sure the dnsutils package is installed

sudo apt update
sudo apt install dnsutils -y

Now I’ll switch back to our Docker user

Next, we’ll create an instruction file

nano acmetest.txt

server testdns1.homelab.lan
update delete _acme-challenge.test1.homelab.lan. TXT
update add _acme-challenge.test1.homelab.lan. 600 TXT "Test record"
show
send

Now save and exit

First we want to pick the correct DNS server

Then we’ll delete an existing TXT record, if it exists, and then add a new TXT record

We’ll then display the changes and apply them

Now we can test this

nsupdate -k acme.key -v acmetest.txt

The -v option incidentally is to make sure TCP is used, which is better than using UDP for large changes. But it’s easier to just to stick to using TCP

Assuming there are no errors, we’ll check we can find this record

dig -t TXT _acme-challenge.test1.homelab.lan.

This should return a status of NOERROR and we should see the new record

Now, my concern here is anything with this key can create any TXT record within the zone

If you’re really security conscious, you could lock this down so a server can only change it’s own ACME related TXT record

That would require a lot of extra work, and it’s beyond the scope of this video as it relates more to DNS security

Anyway, now that we know Bind9 is ready, we’ll tidy things up

nano acmetest.txt

server testdns1.homelab.lan
update delete _acme-challenge.test1.homelab.lan. TXT
show
send

Now save and exit

Then run the command to remove the TXT record

nsupdate -k acme.key -v acmetest.txt

We should also remove the temporary files

rm acmetest.txt
rm acme.key

New Root Certificate:
Now this next step isn’t necessary, but a problem I find is that each instance of Caddy has the same name on the root certificate

And if you’re running separate instances of Caddy on your network it becomes difficult to know which one is which in your browser

So now is as good a time as necessary to change that name

WARNING This will remove all existing certificates and keys that are stored in the Caddy container

If you do this, you’ll have to update every device to trust a new root CA

As long as they trust the old root certificate, any existing server certificate should still work until it expires

But new certificates that are created will be signed by a new root CA

And bear in mind that the root certificate will expire in 10 years anyway, so it helps to be prepared…just in case

The first thing we’ll do is to edit the Caddyfile

nano caddy/Caddyfile

Then we’ll paste this at the top

{
	pki {
		ca local {
			name "Lab Demo CA"
		}
	}
}

This is how we define global settings for Caddy, in this case we’re changing some PKI settings

NOTE: You need to use tabs and not spaces in the formatting

While you can configure multiple CAs on one server, and in an example the developers do that, I want to keep things simple so we’re renaming the local CA

The simplest way I can think of is to force a rebuild of the existing caddy-data volume

docker compose down caddy
docker volume ls
docker volume rm dockermgr_caddy_data
docker compose up -d

Each volume usually contains the Docker user’s name but I’ve opted to list all volumes so I can check what the actual name is before deleting anything

When Caddy starts back up, Docker will create a new data volume for it and Caddy will have to create new certificates and keys for the root and intermediate CAs as well as for servers

But we’ll check Caddy is actually still working

docker ps -l

We now need a copy of the new root certificate so we’ll copy that to the host

docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt caddy/lab-demo-root.crt

You should then import this into your browser for instance, and/or update OS trust stores with it

How you do that depends on the web browser and operating system

I tend to find that web browsers typically need a refresh for the changes to take effect; Ctrl-F5

On some occasions though I’ve had to restart the web browser

Caddy ACME Server:
Setting up an ACME server in Caddy is actually quite simple

We need to add a new web server entry to the Caddy file, but one that will act as an ACME server

nano caddy/Caddyfile

acme.homelab.lan {
	tls internal
	acme_server {
		challenges dns-01
		allow {
			domains *.homelab.lan
			ip_ranges 192.168.102.0/24
		}
	}
}

Now save and exit

I’m giving it a hostname of acme, but you can call it something else and my lab domain is called homelab.lan

We want this session to be secure, so we tell Caddy to provide a TLS session and to serve the certificate from its own internal CA

By default, Caddy will try to use an HTTP or TLS-APN challenge

These are both direct access methods, so in the example I’ve set the challenge type to DNS only

I’m restricting the ACME server to only provide certificates for this lab domain and to only allow access for clients in a specific IP range

TIP: If you want to add more subnets to the line, you can separate them out with a space

Granted, our DNS server will only allow updates to this same domain anyway, but when it comes to security, more layers can be better

Feel free to restart the container

docker compose restart caddy

Otherwise you can instruct caddy to reload its config file

docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile

Either way, it’s best to check Caddy is still working

docker ps -l

TIP: If you ever need shell access to the Caddy container, run this command

docker compose exec --user=root caddy sh

This can be useful if you want to troubleshoot issues within Caddy itself

Because we’ve created a new web server here, you should also update your DNS server as clients need to resolve this new FQDN

In my case, I’ll need to add an A record for acme.homelab.lan to Bind9

nano nsupdate.txt

server testdns1.homelab.lan
update delete acme.homelab.lan. A
update add acme.homelab.lan. 600 A 192.168.102.30
show
send

Now save and exit

TIP: I don’t add a PTR record as this is a container and the IP address already relates to the Docker host

nsupdate -k ddns.key -v nsupdate.txt

My ACME clients will then need to use this URL to request a certificate

https://acme.homelab.lan/acme/local/directory

And I can check this at least responds with a computer that trusts Caddy’s new root certificate

curl https://acme.homelab.lan/acme/local/directory

As long as this doesn’t return an error, you should be able configure ACME clients

Summary:
For me, Caddy now acts as a reverse proxy for my containers and it automates certificate management for computers that support ACME

Even some other Caddy instances can get their certificate from this Caddy server

In theory, I could extend this further and have Caddy provide manually applied certificates

In other words, there are some devices that can only be updated manually

Practically, these certificates would need a longer lifetime of maybe 1 year

And although Caddy did have a bug when I last checked, hopefully that’s now fixed and we’re no longer limited to 12 hour certificates

That would then leave me needing only one root certificate to access ALL of my internal devices

Sharing is caring!