Running a Secure Server with TLS
Putting Frontier and Radio behind https, from certificate to cipher suite.
A runtime designed in the early nineties can serve traffic on the public internet in 2026, but not on
its own terms. Browsers now refuse plain http for anything meaningful, certificate lifetimes
have collapsed to weeks, and the cipher suites the original code knew about are actively rejected.
This is the whole path, in order: what TLS actually is, the two architectures available to you, how to obtain and renew certificates without touching the runtime, the headers that matter, and how to verify the result rather than hope.
What TLS is, briefly and correctly
TLS — Transport Layer Security — is the standard that superseded SSL. It does three things: it proves the server is who the certificate says it is, it negotiates keys over an untrusted channel, and it then encrypts and authenticates every byte in both directions. Every one of those three matters independently, and confusing them is the source of most misconfiguration.
Macrobyte's TLS layer brought this to Frontier and Radio in two directions: as a server, letting the
runtime answer https, and as a client, letting scripts fetch secure pages and call remote
services. Release 0.3.1 was a bug-fix pass over the secure server, and the last before 1.0.
Terminology that trips people
A certificate proves identity. A private key is the secret that makes the certificate yours and must never leave the host. A chain is the intermediates between your certificate and a root the client already trusts — omit it and roughly half your clients fail with an error your browser will not show you.
Two architectures, and how to choose
Native termination
The runtime holds the certificate and speaks TLS itself. Fewer moving parts, one process to monitor, and the script can see the negotiated connection directly. The cost is that your TLS stack ages with your runtime: when a protocol version is deprecated, you wait for a release or you are stuck.
Reverse-proxy termination
A modern proxy terminates TLS and forwards plain HTTP to the runtime over the loopback interface. The runtime never sees a certificate, never needs to know what a cipher is, and never has to be restarted for a renewal. Protocol upgrades become a proxy upgrade.
| Native termination | Reverse proxy | |
|---|---|---|
| Moving parts | One | Two |
| Protocol upgrades | Tied to the runtime | Independent |
| Certificate renewal | Usually needs a restart | Hot reload, no downtime |
| Private key exposure | In the runtime's process | Isolated in the proxy |
| Client IP visibility | Direct | Needs a forwarded header, and trust for it |
| Low port binding | Needs forwarding on OS X | Handled by the proxy |
| Good for | Internal tools, client-side TLS | Anything public |
The unprivileged-port problem
On OS X the runtime runs as an ordinary user process and cannot bind to ports below 1024. This is why Eric Soroos's port-forwarding tool existed, and the constraint has not gone away — it has simply become good practice. Let the runtime listen high and have the kernel or the proxy map 80 and 443 onto it. Nothing that parses untrusted bytes should be running as root.
# Map the privileged ports onto the high ports the runtime owns.
# 80 -> 8080 and 443 -> 8443, applied at boot, no root in the runtime.
echo "
rdr pass inet proto tcp from any to any port 80 -> 127.0.0.1 port 8080
rdr pass inet proto tcp from any to any port 443 -> 127.0.0.1 port 8443
" | sudo pfctl -ef -
# Verify the rules are loaded and the listener is unprivileged.
sudo pfctl -s nat
lsof -nP -iTCP:8443 -sTCP:LISTEN
Certificates that renew themselves
Ninety-day certificates were the end of manual renewal and the best operational change of the last decade — not because ninety days is special, but because it forced automation. An expiry is now a monitoring failure rather than a calendar failure.
Two validation methods are worth knowing. The HTTP challenge serves a token from a well-known path, which means your renewal depends on the web path being reachable. The DNS challenge publishes a TXT record instead; it is slower but works for hosts that are not publicly reachable and is the only way to obtain a wildcard.
# Renewal runs twice daily; it exits quietly unless work is due.
0 3,15 * * * /usr/local/bin/acme-client renew \
--config /etc/acme/meridian.conf \
--deploy-hook "/usr/local/bin/reload-proxy" \
>> /var/log/acme.log 2>&1
# reload-proxy: signal, never restart. Connections in flight survive.
#!/bin/sh
set -eu
proxy -t # validate config before touching anything
kill -HUP "$(cat /var/run/proxy.pid)"
logger -t acme "certificate deployed and proxy reloaded"
Monitor the expiry separately
Automation fails silently more often than it fails loudly. Check the certificate the way a client sees it — over the network, from outside the host — and alert well before the deadline.
on checkCertificate (host, warnDays = 21) {
local (info, daysLeft);
try {
info = tls.peerCertificate (host, 443)}
else {
ops.alert ("TLS check failed for " + host + ": " + tryError);
return (false)};
daysLeft = (info.notAfter - clock.now ()) / (60 * 60 * 24);
if daysLeft < warnDays {
ops.alert ("Certificate for " + host + " expires in " +
string (math.round (daysLeft)) + " days")};
if not info.chainComplete {
ops.alert ("Incomplete chain served for " + host)};
return (daysLeft)}
Configuration that is actually current
Defaults age badly. The shape below is what we deploy in 2026; the point is not the exact list but that it is written down, version-controlled and reviewed on a schedule.
- TLS 1.2 and 1.3 only. 1.0 and 1.1 are deprecated and 1.3 should be preferred.
- Forward secrecy everywhere. ECDHE key exchange, so a future key compromise cannot decrypt recorded past traffic.
- AEAD ciphers only. AES-GCM and ChaCha20-Poly1305.
- OCSP stapling on. Faster handshakes and no revocation lookup from the client.
- Serve the full chain. The most common real-world failure, and invisible in most desktop browsers because they cache intermediates.
- Redirect
httppermanently — 301, not 302, so it is cached.
listen 443 ssl http2;
server_name www.scriptmeridian.org;
ssl_certificate /etc/tls/meridian/fullchain.pem; # chain included
ssl_certificate_key /etc/tls/meridian/privkey.pem; # never leaves this host
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off; # let TLS 1.3 choose
ssl_stapling on;
ssl_stapling_verify on;
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:" always;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $remote_addr;
}
Before you enable HSTS
Strict-Transport-Security with a two-year max-age is a promise browsers will hold you
to, and it cannot be withdrawn quickly. Deploy it with a short max-age first, confirm every
subdomain genuinely serves https, and only then raise it.
The application still has to cooperate
Terminating TLS at the proxy means the runtime sees plain HTTP, which is fine until the application generates an absolute URL and gets the scheme wrong. Two rules prevent nearly all of it: trust the forwarded scheme header (and only from the proxy's address), and emit relative URLs wherever you can.
on requestScheme (adrRequest) {
«Only trust the header when the peer is our own proxy.
local (peer = adrRequest^.client.address);
if peer == "127.0.0.1" {
if defined (adrRequest^.headers.["x-forwarded-proto"]) {
return (adrRequest^.headers.["x-forwarded-proto"])}};
if adrRequest^.secure {
return ("https")};
return ("http")}
Cookies need the same care: set Secure so they are never sent in clear,
HttpOnly so scripts cannot read them, and an explicit SameSite so a browser is
not left guessing.
Verify, then keep verifying
A deployment is not finished when the padlock appears. The padlock only proves the handshake completed in one browser that may have had the intermediate cached.
- Fetch the chain from a machine with no cache and confirm it is complete.
- Confirm 1.0 and 1.1 are refused, not merely deprioritised.
- Confirm plain
httpreturns a 301 to the canonicalhttpsaddress. - Confirm the certificate covers every hostname actually in use, including the bare domain.
- Force a renewal in a staging environment and watch the hook fire without dropping connections.
- Put the expiry on a dashboard a human looks at weekly.
# Full chain, as served. "Verify return code: 0 (ok)" is the line that matters.
openssl s_client -connect www.scriptmeridian.org:443 \
-servername www.scriptmeridian.org -showcerts </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
# Old protocols must be refused outright.
openssl s_client -tls1_1 -connect www.scriptmeridian.org:443 </dev/null \
&& echo "FAIL: TLS 1.1 accepted" || echo "OK: TLS 1.1 refused"
# The redirect should be permanent.
curl -sI http://www.scriptmeridian.org/ | head -n 1
What we actually run for clients
Proxy termination, certificates from an automated issuer with a deploy hook that reloads rather than restarts, monitoring on both uptime and expiry, and a quarterly drill in which someone deliberately breaks renewal in staging to confirm the alert arrives. That last one finds more real problems than the other four combined, and it is the part almost nobody does.
If that sounds like work you would rather not own, it is exactly what the secure hosting service covers.
Take this away
- Prefer proxy termination for public services; native for internal ones
- Never let the runtime bind a privileged port — forward instead
- Automate renewal with a reload hook, then monitor expiry independently
- Serve the full chain; an incomplete chain fails invisibly
- Trust a forwarded scheme header only from the proxy's own address