Automate Let's Encrypt certificate generation with Caddy

Published on Jun 16, 2024

I needed to set up a small internal network with a few hosted services. I wanted these services to route through virtual hosts so I could have good semantic host names. Naturally I wanted all services over https with trusted certificates.

This felt like a good opportunity to explore using Caddy server and trying out its claim "By default, Caddy automatically obtains and renews TLS certificates for all your sites."

DNS Hosting and ACME challenge

This exercise is for an internal network, but it's still good practice to have FQDN. Read Why using .local as your domain name extension is a BAD idea for a multitude of reasons. For today's use case, having a domain name that I control is going to allow me to generate certificates with Let's Encrypt. If you want to try this out, you'll need a domain name to hand that you can configure.

Let's Encrypt supports both HTTP and DNS challenge types, that allow Let's Encrypt to validate that we have control of the domain we're requesting a certificate for. This is all part of the ACME standard. The HTTP challenge type involves dropping a file on a web server at the location http://<YOUR_DOMAIN>/.well-known/acme-challenge/<TOKEN>, which Let's Encrypt needs to be able to read. I'm working with an internal network with no public access, so the HTTP challenge is not going to work for me.

The DNS challenge, on the other hand, needs a TXT entry to be added to the DNS settings for the domain. This allows the domain to be validated without Let's Encrypt having access to the network.

The domain name I am using for this network is hosted on namecheap. I do like namecheap as a registrar and DNS hosting services, however when it comes to API access to updating TXT entries for a domain the API access is not fine grained. I can get just one API key and the key has a little too much control for my liking. As a first step I directed the nameservers to Cloudflare

kirk.ns.cloudflare.com
tess.ns.cloudflare.com

Cloudflare provides DNS hosting and API access as part of their free tier. Furthermore the API token access control configuration allowed me to generate an API token for 1 specific domain that could only update DNS and was locked to a single IP address.

Caddy build and configuration with Ansible

To customise the modules enabled with Caddy, we need to build a custom binary. For my use case, I need Caddy with the dns.providers.cloudflare module.

xcaddy handles this build process. I'm using Ansible to orchestrate this system set up, and with Ansible this build can be done as follows.

First set up the Cloudsmith apt repositories where the Caddy packages are located.

- name: Add Cloudsmith apt key
  apt_key:
    url: https://dl.cloudsmith.io/public/caddy/stable/gpg.key
    state: present
    id: 65760C51EDEA2017CEA2CA15155B6D79CA56EA34

- name: Add xcaddy apt repository
  apt_repository:
    repo: deb https://dl.cloudsmith.io/public/caddy/xcaddy/deb/debian any-version main
    state: present
    filename: xcaddy-stable

Then install caddy and xcaddy. xcaddy builds the binary and the caddy install brings in some other configuration files, in particular the systemd set up.

- name: Install caddy
  apt:
    update_cache: yes
    name: caddy
    state: present

- name: Install xcaddy
  apt:
    update_cache: yes
    name: xcaddy
    state: present

Then we can build our Caddy custom build.

- name: Build Caddy
  command: |
    xcaddy build --with github.com/caddy-dns/cloudflare --output /tmp/caddy
  environment:
    PATH: "/usr/local/go/bin:{ { ansible_env.PATH } }"
  args:
    creates: /tmp/caddy

Next we do the Caddy binary shuffle, and bring our custom binary into place following the Caddy build from source docs.

- name: Divert default caddy to make way for custom one
  command: dpkg-divert --divert /usr/bin/caddy.default --rename /usr/bin/caddy
  args:
    creates: /usr/bin/caddy.default

- name: Copy Caddy binary into place
  copy:
    src: /home/admin/tmp/caddy
    remote_src: true
    dest: /usr/bin/caddy.custom
    force: false
    mode: a+x

- name: Update service to custom caddy
  command: |
    update-alternatives --install /usr/bin/caddy caddy \
      /usr/bin/caddy.custom 50
  args:
    creates: /usr/bin/caddy

Finally bring in the templated Caddyfile and enable the service.

- name: Copy Caddyfile into place
  template:
    src: Caddyfile.j2
    dest: /etc/caddy/Caddyfile
    mode: 0644
  notify: reload Caddy

- name: Enable Caddy service
  service:
    name: caddy
    enabled: yes
    state: started

Caddyfile and ACME DNS challenge

The Caddyfile is where we can configure Caddy. This sets up the auto certificates with Cloudflare as DNS provider along with our virtual hosting.

{
  acme_dns cloudflare { { cloudflare_api_key } }
}

https://foo.test.com {
  reverse_proxy 127.0.0.1:8080
}

We need this Cloudflare API key secret injected in. See Handling secrets in your Ansible playbooks on a few ways you can do this.

With all this in place, when Caddy starts up, the certificate are auto generated and the virtual host https://foo.test.com has trusted certificates all set up. We can see the certificates generated in /var/lib/caddy/.local/share/caddy.

openssl x509 -noout -text -in \
  /var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/foo.test.com/foo.test.com.crt
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            04:27:48:6d:4e:cf:d4:9c:1a:14:14:a5:0d:45:b3:f8:b9:f0
        Signature Algorithm: ecdsa-with-SHA384
        Issuer: C = US, O = Let's Encrypt, CN = E5
        Validity
            Not Before: Jun 16 19:21:20 2024 GMT
            Not After : Sep 14 19:21:19 2024 GMT
        Subject: CN = foo.test.com

We can also get some visibility on the certificate generation process in the logs, with systemctl status caddy.service where we see Caddy initiating the certificate request process with Let's encrypt, then validating the DNS challenge before issuing a certificate.

waiting on internal rate limiter
done waiting on internal rate limiter
using ACME account
authorization finalized
validations succeeded; finalizing order
got renewal info
successfully downloaded available certificate chains
certificate obtained successfully
releasing lock

Auto certificate renewal

Let's Encrypt certificates are only valid for 90 days. It's deliberately short to enforce re-validation of the domain. With the process automated, re-validation shouldn't be a headache. I didn't get a chance to test the renewal today, but I expect Caddy to take care of the renewal of certificates when they approach their expiry date.

For cloud services, such as AWS ACM, we get this certificate renewal taken care of for us. This is one of the advantages of cloud services where don't need to be concerned about this kind of complexity.

However, it's good to see that Caddy helps make this straightforward outside of a cloud environment. Historically I've set up environments like certbot and nginx and although analogous to the Caddy set up, it does take a little more to set up and maintain.

So I've got myself a low maintenance set up for a collection of services on a local network. I can continue with spinning up those services and mapping them into the front door that Caddy provides. All the traffic to these services from are over https with trusted certificates, so I don't need to set up any other explicit certificate trusts, and fingers crossed if Caddy does it's thing, the certificates will automatically be renewed. I can choose to do TLS termination at Caddy, keeping many of my certificate concerns in one place.

Doing this has also helped me refresh my understanding of the certificate generation and renwal process. With leaning on cloud services recently, it's been years since I've used Let's Encrypt (and other similar services, like ZeroSSL) directly.