NGINX, Let’s Encrypt and NTS#
Why#
This server needs a TLS certificate for two distinct uses: the public telemetry web page (regular HTTPS) and NTS authentication (Chrony chapter), which reuses the same certificate. This chapter covers issuing the certificate via Let’s Encrypt, minimal nginx configuration for the public page, and, importantly, the mechanism for reloading the certificate on chrony’s side without breaking ongoing synchronization.
How#
Issuing the certificate#
apt install -y python3-venv libaugeas0
python3 -m venv /opt/certbot
/opt/certbot/bin/pip install --upgrade pip
/opt/certbot/bin/pip install certbot certbot-nginx
ln -sf /opt/certbot/bin/certbot /usr/bin/certbot
certbot --nginx \
-d time.example.org \
--key-type ecdsa --elliptic-curve secp256r1 \
--agree-tos --email admin@example.org --no-eff-emailMinimal vhost for the telemetry page#
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name time.example.org;
root /var/www/html;
index index.html;
if ($request_method !~ ^(GET|HEAD)$ ) { return 405; }
if ($http_user_agent ~* (wget|curl|scrapbot|zgrab)) { return 444; }
location / { return 444; }
location = /index.html { allow all; }
location = /chrony_stats.txt { allow all; access_log off; }
ssl_certificate /etc/letsencrypt/live/time.example.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/time.example.org/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305;
# ECDHE/KEX groups for TLS 1.3, post-quantum hybrid first (Post-quantum
# chapter); falls back to classic curves if the client doesn't support it.
ssl_ecdh_curve X25519MLKEM768:X25519:secp384r1:secp256r1;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
}| Header | Role |
|---|---|
Strict-Transport-Security | Forces HTTPS for every future visit, including subdomains |
X-Frame-Options | Prevents the page from being embedded in a third-party <iframe> (clickjacking) |
X-Content-Type-Options | Prevents the browser from guessing a MIME type different from the declared one |
X-XSS-Protection | Legacy browser XSS filter (redundant with CSP on modern browsers, harmless to keep) |
Referrer-Policy | Never sends the origin URL to outbound links |
Permissions-Policy | Explicitly disables sensitive browser APIs (geolocation, camera…) unneeded here |
Cross-Origin-*-Policy | Isolates the page from cross-origin interactions not explicitly allowed |
Content-Security-Policy | Restricts which origins can load scripts/styles/images, the broadest line of defense against content injection |
max-age=63072000 (2 years, in seconds) combined with includeSubDomains
and preload largely exceeds the eligibility requirements for browsers'
HSTS preload list (Chrome, Firefox), which in fact only requires one year
(max-age >= 31536000): two years remains the recommended common
practice, a margin beyond the minimum rather than a strict requirement.
The ssl_ecdh_curve directive puts X25519MLKEM768 first in the list: a
hybrid key exchange combining ML-KEM-768 (post-quantum, FIPS 203) and
X25519 (classical), natively available since OpenSSL 3.5. The combination
mechanism and its rationale are detailed in the Post-quantum
chapter, which covers the same hybridization on
the TLS and SSH side. ANSSI recommends this hybridization approach in its
technical note Transition post-quantique de TLS 1.3 (ANSSI-FT-115,
February 2026), which confirms this native implementation since OpenSSL
3.5.0.
Reloading the certificate into chrony, without breaking the PLL#
mkdir -p /etc/letsencrypt/renewal-hooks/post
cat > /etc/letsencrypt/renewal-hooks/post/copy.sh <<'EOF'
#!/bin/bash
set -e
SRC=/etc/letsencrypt/live/time.example.org
DST=/etc/chrony
cp "$SRC/fullchain.pem" "$DST/fullchain.pem"
cp "$SRC/privkey.pem" "$DST/privkey.pem"
chmod 640 "$DST/fullchain.pem" "$DST/privkey.pem"
chown root:_chrony "$DST/fullchain.pem" "$DST/privkey.pem"
# SIGHUP reloads certificates without restarting the daemon or disturbing
# the ongoing clock discipline loop.
if [ -f /run/chrony/chronyd.pid ]; then
kill -HUP "$(cat /run/chrony/chronyd.pid)"
elif systemctl is-active --quiet chrony; then
pkill -HUP -x chronyd
fi
EOF
chmod 755 /etc/letsencrypt/renewal-hooks/post/copy.sh
/etc/letsencrypt/renewal-hooks/post/copy.shPitfalls to avoid#
systemctl restart chronybreaks ongoing synchronization just to reload a certificate, never do this. A full daemon restart resets the clock discipline loop (PLL): the server starts its convergence over from scratch, potentially losing several minutes of optimal precision on every certificate renewal (every 60-90 days with Let’s Encrypt). Debian’schrony.servicedoesn’t even defineExecReload=,systemctl reload chronyfails outright with “Job type reload is not applicable”. The correct method is aSIGHUPsignal sent directly to the process, which reloads cryptographic files without disturbing synchronization.
Prefer ECDSA P-256 over RSA for this specific use case. Not for security reasons (both offer equivalent security at comparable key sizes) but for performance: an ECDSA certificate is around 700 bytes versus 2.5 KiB for an equivalent RSA-4096, and TLS handshake latency is significantly lower on an ARM platform. For NTS, where the key exchange (NTS-KE) needs to stay fast, this difference is far from negligible.
- OCSP stapling enabled by mistake. Since May 2025, Let’s Encrypt has
stopped issuing the OCSP URL in certificates,
ssl_stapling onis now just a silent warning in nginx logs, with no effect. Don’t waste time debugging a deprecated feature.
Verify#
nginx -t
systemctl reload nginx
# Check the issued key type
openssl x509 -in /etc/letsencrypt/live/time.example.org/cert.pem -noout -text \
| grep -A1 "Public Key Algorithm"
# Expected: "Public Key Algorithm: id-ecPublicKey"
# Effective TLS 1.3 negotiation
openssl s_client -connect time.example.org:443 -servername time.example.org </dev/null 2>/dev/null \
| grep -i protocol
# The post-quantum hybrid group is indeed offered and negotiated
openssl s_client -connect time.example.org:443 -groups X25519MLKEM768 </dev/null 2>/dev/null \
| grep "Negotiated TLS1.3 group"
# Expected: "Negotiated TLS1.3 group: X25519MLKEM768"
# Confirm chrony actually reloaded the certificate after the hook
journalctl -u chrony --since "5 minutes ago" | grep -i cert📚 Going further
TLS 1.3 is specified by RFC 8446 (2018). NTS builds on it to establish single-use keys (RFC 8915, see the Chrony chapter), which is why the web page’s TLS certificate and the one used by NTS-KE can be the same file: the distinction happens at the port level (443 for HTTPS, 4460 for NTS-KE), not at the certificate level.
- ANSSI details the post-quantum transition of the TLS 1.3 handshake in its technical note Transition post-quantique de TLS 1.3 (ANSSI-FT-115, February 2026): hybridization of the key exchange (ML-KEM combined with a classical curve), natively available in OpenSSL since version 3.5.0, and the state of signature hybridization (ML-DSA, SLH-DSA), implemented in OpenSSL 3.5.0 but not yet usable within the TLS 1.3 protocol itself.