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-email

Minimal 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;
}
HeaderRole
Strict-Transport-SecurityForces HTTPS for every future visit, including subdomains
X-Frame-OptionsPrevents the page from being embedded in a third-party <iframe> (clickjacking)
X-Content-Type-OptionsPrevents the browser from guessing a MIME type different from the declared one
X-XSS-ProtectionLegacy browser XSS filter (redundant with CSP on modern browsers, harmless to keep)
Referrer-PolicyNever sends the origin URL to outbound links
Permissions-PolicyExplicitly disables sensitive browser APIs (geolocation, camera…) unneeded here
Cross-Origin-*-PolicyIsolates the page from cross-origin interactions not explicitly allowed
Content-Security-PolicyRestricts 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.sh

Pitfalls to avoid#

systemctl restart chrony breaks 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’s chrony.service doesn’t even define ExecReload=, systemctl reload chrony fails outright with “Job type reload is not applicable”. The correct method is a SIGHUP signal 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 on is 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.