david alfonso

Certbot webroot auth with Ansible

Certbot is the recommended software to generate Let's Encrypt certificates and prove you control the domain. It does so by implementing the ACME protocol.

The webroot authenticator plugin allows to renew certificates without stopping the web server. It works by writing a challenge file to the domain's document root path that will be requested by the Let's Encrypt validation service. This file will live in a hidden folder named .well-known/acme-challenge/ and will be accessed through the standard HTTP port.

Apache configuration

Let's start from the end by making sure that Apache will serve files in the expected path:

<VirtualHost *:80>
  # ...

  # Avoid redirecting to HTTPS if it's an acme challenge request
  RewriteEngine On
  RewriteCond %{REQUEST_URI} !^/\.well\-known/acme\-challenge/
  RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L]
</VirtualHost>

We now face a chicken-egg problem where we need a web server in the authentication process but Apache will fail to start if a referenced file does not exist.

A solution is to add a conditional clause to the HTTPS virtual host so that it's only active when the certificate exists:

<IfFile /etc/letsencrypt/live/your-domain.com/fullchain.pem>
  <VirtualHost *:443>
    # ...
    SSLCertificateFile /etc/letsencrypt/live/your-domain.com/fullchain.pem
    # ...
  </VirtualHost>
</IfFile>

This allows to start Apache serving only on the HTTP port, but we'll need to reload the configuration after the certificate is generated.

Ansible orchestration

Let's assume we're managing multiple domains and have these variables defined:

apache_cert_path: /etc/letsencrypt/live
apache_sites:
  - hostname: domain1.com
    # other info for domain1
  - hostname: domain2.com
    # other info for domain2

We need to first install and start Apache with the appropriate configuration. We could use the geerlingguy apache role for that:

- name: Use geerlingguy apache role
  ansible.builtin.include_role:
    name: geerlingguy.apache
  vars:
    # our config...

If we're using a firewall like UFW, we have to open the ports, but note that it won't restart and apply the new config until the end of the play:

- name: UFW - Allow HTTP/HTTPS connections
  community.general.ufw:
    rule: allow
    name: Apache Full
  notify: Restart ufw

Now, before setting up certbot, let's check if there are any certificates missing and, if that's the case, force a UFW restart (reload is not supported) to make sure the HTTP port will be opened for the authentication process.

- name: Certbot - Check if certificates exist
  ansible.builtin.stat:
    path: "{{ apache_cert_path }}/{{ item.hostname }}/fullchain.pem"
  loop: "{{ apache_sites }}"
  register: certbot_certs_stat

- name: Certbot - Force UFW restart
  ansible.builtin.service:
    name: ufw
    state: restarted
  when: certbot_certs_stat.results | rejectattr('stat', 'defined') != []

Finally, using geerlingguy certbot role, create the certificates and reload the Apache config, if necessary.

- name: Certbot - Include certbot role
  ansible.builtin.include_role:
    name: geerlingguy.certbot
  vars:
    certbot_create_method: webroot
    certbot_create_if_missing: true
    # other settings...

- name: Certbot - Force Apache config reload after creating new certificates
  ansible.builtin.service:
    name: apache2
    state: reloaded
  when: certbot_certs_stat.results | rejectattr('stat', 'defined') != []