Skip to main content

Setting up Let's Encrypt for Halon

Let's Encrypt offers a convenient way to enable SSL certificates for Halon as well as the web administration, ensuring automatic certificate renewal. Setting up Let's Encrypt for Halon is pretty straightforward, and you can start by following the official Certbot instructions to set up the basic requirements needed.

smtpd with TLS

To enable TLS to be offered on incoming and outgoing connections, we incorporate the SSL private key and certificate chain into the the smtpd process configuration. You can set both in smtpd.yaml, which is useful for quick testing because the startup configuration is done at elevated privilege, so the /etc/letsencrypt/live/ directory is available.

However the better way is to refer to the private key in the startup, and the certificate chain file in the running configuration. The private key file will remain fixed when Certbot renewal runs, whereas the certificate will change. We can then use a "soft" Halon config reload on renewal, rather than a full service restart. We will need to ensure that the certificate is readable by user halon after the privilege drop.

/src/config/smtpd.yaml
pki:
private:
- id: mx.example.com
privatekey:
path: /etc/letsencrypt/live/mx.example.com/privkey.pem
/src/config/smtpd-app.yaml
pki:
private:
- certificate:
path: /etc/letsencrypt/live/mx.example.com/fullchain.pem
id: mx.example.com

halon-web

To enable Let's Encrypt for the web component, ensure you have halon-web installed on your server. Then, set up the SSL certificate by adding it to the pki directive for the listener:

/src/config/web.yaml
- pki:
certificate:
path: /etc/letsencrypt/live/mx.example.com/fullchain.pem
privatekey:
path: /etc/letsencrypt/live/mx.example.com/privkey.pem

Note that halon-web reads both certificate and private key on startup at elevated privilege, there is no separate "startup" and "running" configuration, so a service restart is required on renewal.

halon-api

The halon-api service is configured similarly to halon-web.

Requesting certificates

We need to ensure that the cert path exists and is readable by users other than root (such as the halon user after privilege drop):

# Enable Halon user to read certs and private keys
# See https://eff-certbot.readthedocs.io/en/stable/using.html#where-are-my-certificates
# This needs to be done before the first cert is issued
mkdir -p -m 755 /etc/letsencrypt/{live,archive}
certbot certonly --standalone -d $(hostname) -m [email protected] --agree-tos --reuse-key

Note: you can replace $(hostname) with other domains that are mapped to this host. Use your own valid email address with -m.

tip

Ensure you are using smtpd v6.10 or newer - see changelog item "Set the supplementary groups (from the user) when privdropping"

Use a valid email address. Note the --reuse_key flag writes a persistent setting into your site's .conf file, to ensure Certbot does not change the private key on renewals.

Next, we enable a renewal hook for Certbot to ensure that services are restarted during certificate renewal, and the new certificates are loaded into the configuration. If your web server exclusively uses port 443 for HTTPS, you can opt for the standalone authentication method. This method is suitable when no other web server or service occupies port 80.

However, if halon-web uses port 80 or you wish to minimize downtime, you can use the webroot authentication method instead. For halon-web the webroot path is /opt/halon/web/node_modules/@halon/web-frontend/dist.

Finally, with HAProxy, we can use routing rules to handle renewal.

With HAProxy

HAProxy is useful to manage ingress/egress of SMTP traffic - see article. It can also proxy HTTP and HTTPS traffic for engagement tracking. This is helpful when you wish to have a single listener for multiple secure tracking domains, such as in multi-tenant setups.

Traffic can be routed based on the request path, so that Let's Encrypt domain authentication requests are passed on to Certbot:

haproxy.cfg
# Frontend settings for engagement-tracking over HTTP and HTTPS.
# Certificates are loaded from a directory using .crt / .crt.key symlinks managed by the renewal hook
frontend submission-tracking-frontend
bind *:80
bind *:443 ssl crt /etc/haproxy/certs/ strict-sni
option httpclose
option forwardfor
option dontlog-normal # only log errors, making logs quieter

# Let the letsencrypt backend handle requests to the acme-challenge url
acl letsencrypt-req path_beg /.well-known/acme-challenge/
use_backend letsencrypt if letsencrypt-req

default_backend submission-tracking-backend

# Backend settings: assume halon-submission-tracking service is running on this host
backend submission-tracking-backend
server halon 127.0.0.1:8086 check

backend letsencrypt
server file_server 127.0.0.1:8085 # forward the acme challenge to the certbot listener

Renewal hook

Certbot looks for scripts to run at renewal time, known as renewal hooks. The following renewal hook script supports all the above use-cases.

99-letsencrypt-hook.sh
#!/usr/bin/env bash
# -------------------------------------------------------------------
# Certbot deploy hook for HAProxy + Halon (multi-domain aware)
#
# - Specific renewal activity logging
# - Updates HAProxy certificate symlinks for all renewed domains
# - Reloads HAProxy and soft-reloads Halon config
# - Restarts Halon web and API services when active
# -------------------------------------------------------------------

set -euo pipefail # Exit on error, undefined var, or pipe failure

# Note: for Production usage, you may want to configure logfile rotation as well.
LOGFILE="/var/log/certbot-deploy-hook.log"
exec >> "$LOGFILE" 2>&1
echo "[$(date)] Deploy hook started for $RENEWED_DOMAINS"

# Config
HAPROXY_CERT_DIR="/etc/haproxy/certs"
LIVE_DIR="$RENEWED_LINEAGE"

# Build symbolic links for HAProxy using the split-file convention: .crt for the certificate chain, .crt.key for the private key
# This relies on HAProxy's default directory loading behaviour (ssl-load-extra-del-ext is not set)
# See: https://www.haproxy.com/documentation/haproxy-configuration-manual/latest/#3.1-ssl-load-extra-del-ext
# $RENEWED_DOMAINS is a space-separated list of domains
mkdir -p "$HAPROXY_CERT_DIR"
for DOMAIN in $RENEWED_DOMAINS; do
ln -sf "$LIVE_DIR/fullchain.pem" "$HAPROXY_CERT_DIR/$DOMAIN.crt"
ln -sf "$LIVE_DIR/privkey.pem" "$HAPROXY_CERT_DIR/$DOMAIN.crt.key"
echo "[$(date)] HAProxy symlinks updated for $DOMAIN"
done

# Services that can be reloaded
for svc in haproxy; do
if systemctl is-active --quiet "$svc"; then
systemctl reload "$svc"
echo "[$(date)] $svc reloaded"
fi
done

# Halon soft-reload config (leaving suspensions + policy + delivery intact)
if systemctl is-active --quiet "halon"; then
halonctl config reload
echo "[$(date)] halon soft-reloaded"
fi

# Services that need to be restarted
for svc in halon-web halon-api; do
if systemctl is-active --quiet "$svc"; then
systemctl restart "$svc"
echo "[$(date)] $svc restarted"
fi
done

echo "[$(date)] Deploy hook finished successfully for $RENEWED_DOMAINS"

Copy into /etc/letsencrypt/renewal-hooks/deploy/, and ensure it is executable.

Testing via HAProxy

The first time you request certificates, we need to specify the ACME challenge port number and deploy hook script.

sudo certbot certonly --standalone -d $(hostname) --http-01-port 8085 --preferred-challenges http \
--deploy-hook /etc/letsencrypt/renewal-hooks/deploy/99-letsencrypt-hook.sh \
--non-interactive -m [email protected] --agree-tos --reuse-key

Note: you can replace $(hostname) with other domains that are mapped to this host. Use your own valid email address with -m.

You can verify the automatic renewal process for the certificate(s) (i.e. a dry run) using the following command. Note dry runs do not run the renewal hook.

sudo certbot renew --dry-run

Logging renewal activity

Renewal hook activity is logged into the script's LOGFILE path. A successful renewal will look like this:

[Tue May 12 09:22:38 PM UTC 2026] Deploy hook started for example.com
[Tue May 12 09:22:38 PM UTC 2026] HAProxy symlinks updated for example.com
[Tue May 12 09:22:38 PM UTC 2026] haproxy reloaded
OK
[Tue May 12 09:22:38 PM UTC 2026] halon soft-reloaded
[Tue May 12 09:22:41 PM UTC 2026] halon-web restarted
[Tue May 12 09:22:42 PM UTC 2026] Deploy hook finished successfully for example.com