Skip to content

HTTPS

Set up HTTPS for a service, and keep it working without ongoing manual work.

When this applies

Use this when your own process serves HTTPS — a Node, Go, or nginx process on a machine you operate, reachable at a name that points to that machine.

Skip it when a platform serves HTTPS for you. Cloudflare (proxied), Vercel, Fly, Netlify, and tailscale serve all handle certificates themselves — they get them, renew them, and you never see one. There is nothing here for you to do.

If you are not sure which case you are in, the next section tells you.

Goals

  1. Browsers load your service over HTTPS and show no security warning.
  2. A replacement certificate is installed before the current one expires, and that happens automatically.
  3. You can check whether both of the above are actually true.

Goal 2 is the one that gets skipped. Certificates are valid for a fixed period — 90 days from Let's Encrypt — and a setup that never replaces them is indistinguishable from a working one until the day the current one runs out.

Termination: who decrypts the traffic

Terminating TLS means decrypting the traffic. Whatever does the decrypting is the terminator; it holds the certificate and private key, completes the handshake with the browser, and passes the decrypted request to the application.

Exactly one thing can terminate TLS for a given port. That is the first thing to establish, because it decides whether you have any work to do at all.

  • A platform terminates it — Cloudflare (proxied/orange-cloud), Vercel, Fly, Netlify, or tailscale serve on a *.ts.net name. It supplies and renews the certificate. You are done — build none of this.
  • Your own process terminates it — you supply the certificate and you are responsible for replacing it before it expires. Keep reading.
  • Both are configured — a bug, not a setup. One wins and serves the wrong certificate for the other's name. See Two things own 443.

Choosing a challenge: can the CA reach the name?

A certificate authority (CA) is the organization that issues certificates — Let's Encrypt is the usual free one. Browsers trust a certificate because they trust the CA that signed it. ACME is the protocol for requesting one automatically, and a challenge is how the CA checks you actually control the name before it signs anything.

How it runs that check depends on whether the name is reachable from the public internet.

The name resolves toChallengeWhy
A public IP the CA can reach on :80HTTP-01Simplest; CA fetches a token over HTTP
A private/internal IP (VPN, tailnet, LAN)DNS-01, forcedThe CA cannot reach the host at all

If the name is internal, HTTP-01 is not a preference you are declining — it cannot work. The CA has no route to the host. DNS-01 proves control by writing a TXT record instead, which the CA reads from public DNS, so the host never needs to be reachable.

DNS-01 needs an API token for the DNS provider, scoped to edit records on that one zone. Collect it with Collect Secrets rather than reinventing hidden input, and keep it in the ACME client's own home. The token never leaves that directory — not copied between machines, not baked into a service definition.

Pick the CA explicitly

acme.sh defaults to ZeroSSL, not Let's Encrypt. If you do not pass --server letsencrypt, you will silently get a different CA than you assumed, with different rate limits and account requirements.

Set it explicitly, every time. The same applies to key type — pass --keylength ec-256 if you want ECC rather than the default RSA.

Install the scheduler first, then issue through it

This is the part that gets skipped, and skipping it is invisible.

The failure looks like success: a valid certificate, a green padlock, no warnings — and nothing scheduled to renew it. It works for 90 days and then does not, with no signal in between. "Issued" and "automated" are different states that look identical from the outside.

So do it in this order:

  1. Install the scheduler, before there is anything to renew.
  2. Issue the certificate.
  3. Force one renewal of that certificate and watch it change, the service reload, and the next-renewal date advance.

Step 3 is what makes this trustworthy. It converts renewal from something you asserted into something you observed. If day one works, day sixty works, because they run the same renew → install → reload path — not two code paths where only one was ever exercised.

Scope the forced renewal to the one certificate:

sh
acme.sh --renew -d "$DOMAIN" --force     # this certificate only
acme.sh --cron --force                   # DON'T: every cert in the store

--cron --force looks like the more faithful rehearsal, since --cron is what the scheduler runs. It is a trap on any host with more than one certificate: it force-renews all of them, burning duplicate-certificate quota and firing other services' reload hooks. --cron is just "for each certificate that is due, renew it," so renewing one on purpose exercises everything that matters.

A renewal costs one of Let's Encrypt's five duplicate certificates per week. Spend one on the first run; it buys proof. Don't spend one on every re-run — scripts should force only when they actually issued something.

The scheduler is per machine, not per app

One scheduler renews every certificate in the ACME client's store. Install it once per host.

If each application installs its own, a second app on that host means two schedulers over one store, racing each other. Scheduler installation belongs to the host's setup, not to any one app's provisioning.

Some installs give you no scheduler at all

The upstream acme.sh --install creates a cron entry. Package-manager installs frequently do not — a Homebrew install, for instance, gives you the binary and nothing else, so a host can have a perfectly configured client and no trigger whatsoever.

Never assume installing the client scheduled anything. Check:

sh
crontab -l | grep -i acme        # cron
launchctl list | grep -i acme    # launchd (macOS)

Empty output means the certificate expires on schedule and nothing prevents it.

Renewed is not installed until something reloads

Servers read certificate and key files once, at startup. A renewed certificate on disk changes nothing for a process that is already running — it keeps serving the old one until it restarts, and then serves the new one whether or not anyone intended that.

So the reload belongs inside the install step, not as a follow-up:

sh
--reloadcmd 'launchctl kickstart -k gui/501/com.example.server'   # macOS
--reloadcmd 'systemctl reload nginx'                              # systemd

Without it, renewal silently accomplishes nothing, and the failure surfaces 90 days later as an expired certificate that was renewed on time.

Two things own 443

Only one process can hold the port. When a second one is configured to, the result is not an error at setup time — it is a name that fails to serve.

The common shape: a host runs an app terminating its own TLS and a platform proxy configured for the same port. The proxy wins, and it can only present a certificate for the name it manages — so a request for any other hostname gets tlsv1 alert internal error during the handshake. The certificate is fine. The name is broken.

tailscale serve --https=443 is the case worth calling out, because it is easy to enable for a *.ts.net name and then forget when the same host later starts serving its own name:

sh
tailscale serve status              # want: "No serve config"
tailscale serve --https=443 off     # release the port; no sudo required

Turning it off removes HTTPS for the *.ts.net name entirely — that name has no other listener. Decide which name matters and let one thing own the port.

Verify from a peer, not from the host

A host cannot verify its own reachability. When a port redirect (pf, iptables, NAT) sits in front of the service, locally-originated traffic takes a different path than a remote client's, and the local path is usually the one that works.

The result is a false negative that is very expensive: the check passes on the host, before and after the fix, identically — so a real outage reads as "not reproducible."

Run this from a different machine:

sh
echo | openssl s_client -connect <host-ip>:443 -servername <hostname> 2>/dev/null \
  | openssl x509 -noout -subject -dates

Then load the URL in a browser on a third device. Confirm the certificate names the expected issuer and the expected hostname.

A modern browser shows no padlock for a healthy site — Chrome replaced it in version 117. Absence of a warning is the signal, not presence of an icon.

Confirm the traffic is really encrypted

A page served over plain HTTP also shows no warning in many browsers, so "no warning" alone does not distinguish encrypted from not.

The authoritative check is the peer-side openssl s_client above: it completes a real handshake and prints the certificate it was served.

A host-side look at connections is useful corroboration, not proof:

sh
netstat -an -p tcp | grep ESTABLISHED | grep '\.<tls-port>'

A remote address established on the TLS port shows that peer reached the TLS listener rather than a plaintext one. Be precise about what it does not show: ESTABLISHED is TCP state, reached before the TLS handshake begins, so a connection that fails certificate validation can appear here briefly. A session that persists and carries traffic is good evidence; a single snapshot is a hint. Confirm with the peer-side handshake.

The script

https.sh does all of the above in the correct order: ensures the client, forces the CA and key type, installs the scheduler, issues, installs with a reload command, and forces one renewal to prove the path.

sh
./https.sh \
  --domain='*.example.com' \
  --cert=/path/to/fullchain.pem \
  --key=/path/to/key.pem \
  --reload-cmd='launchctl kickstart -k gui/501/com.example.server'

It is idempotent — safe to re-run, and a no-op when everything is already correct. It targets macOS/launchd; on other platforms it prints what to do and exits rather than guessing.

Checklist

Do not call it done until every line is true:

  • [ ] Exactly one thing terminates TLS on 443
  • [ ] CA and key type set explicitly, not defaulted
  • [ ] A scheduler exists and is verified present (crontab -l / launchctl list)
  • [ ] The install step carries a reload command
  • [ ] One forced renewal has been observed end-to-end
  • [ ] Verified from a different machine, not from the host

See also

  • Collect Secrets: Hidden-input collection for the DNS API token the DNS-01 challenge needs.
  • Server Startup: Binding the listener that terminates TLS, including dual-stack binding so an IPv4 redirect reaches an IPv6 socket.