While putting my Jellyfin server behind HTTPS, I kept mixing up who does what. Does Route 53 serve the certificate? Why does Let's Encrypt care about my router's DNS? Why can't I use the free AWS certificates I already have?

This post is the map I wanted, using my setup as the example: a home server with Caddy in front of Jellyfin. The roles are the same for any self-hosted site. New to certificates? Start with What is an SSL certificate?

The short version

  • A certificate is a signed statement: "this public key belongs to jellyfin.example.com", signed by a certificate authority (CA) the browser already trusts.
  • The private key stays on the machine that serves HTTPS. Whoever holds it can serve the name. That one fact decides most of what follows.
  • DNS is never part of the encryption. It only turns a name into an address. But the CA uses DNS to check you control the name, so DNS can stop you getting a certificate.
  • Certificates work in both directions. A server certificate proves the server to visitors. A client certificate proves a machine to a service; here, my server to AWS.

The cast

Who What it does Holds a private key?
Browser or app Looks up the name, connects, checks the certificate No
Route 53 Answers "what's the address of jellyfin.example.com?" No
Home router Forwards ports 80 and 443 to the server, still encrypted No
Caddy Ends HTTPS, passes requests to Jellyfin, gets and renews the certificate Yes, the site's
Jellyfin The app itself, plain HTTP on the same machine No
Let's Encrypt The CA: checks you control the name, then signs Yes, its own signing keys
AWS Certificate Manager (ACM) AWS's CA, for AWS services such as CloudFront Yes, for its certificates
My private CA Signs the server's client certificate for AWS Yes, the CA key
IAM Roles Anywhere Trades a client certificate for temporary AWS credentials No

The router only moves packets. It never sees inside HTTPS.

Visiting the site

sequenceDiagram
    participant B as Browser
    participant R as Resolver
    participant D as Route 53
    participant C as Caddy (via router)
    participant J as Jellyfin
    B->>R: address of jellyfin.example.com?
    R->>D: address of jellyfin.example.com?
    D-->>R: A <home IP>
    R-->>B: <home IP>
    B->>C: TLS hello, for jellyfin.example.com
    C-->>B: certificate + proof it has the key
    Note over B: checks signature chain,<br/>name and dates
    B->>C: encrypted request
    C->>J: plain HTTP on localhost
    J-->>C: response
    C-->>B: encrypted response
  1. DNS. The browser's resolver asks Route 53 and gets my home IP. Nothing is encrypted or verified yet.
  2. Handshake. The browser connects on port 443 and names the site it wants. The router forwards it to Caddy, which sends its certificate and proves it holds the private key.
  3. Check. The browser confirms the certificate is for that name, hasn't expired, and was signed by a CA it trusts. Every major browser and operating system trusts Let's Encrypt.
  4. Encrypted from here on. Caddy decrypts and passes the request to Jellyfin, but that hop never leaves the machine.

If Caddy has no certificate for the name, step 2 fails and Chrome shows ERR_SSL_PROTOCOL_ERROR.

Getting the certificate

Caddy speaks ACME, the protocol Let's Encrypt uses to issue certificates automatically. Before it signs, Let's Encrypt needs proof that the requester controls the name. Caddy's default is HTTP-01:

sequenceDiagram
    participant C as Caddy
    participant L as Let's Encrypt
    participant D as DNS
    C->>L: I want a cert for jellyfin.example.com
    L-->>C: serve this token on port 80
    L->>D: address of jellyfin.example.com?
    D-->>L: <home IP>
    L->>C: GET /.well-known/acme-challenge/<token>
    C-->>L: token
    L-->>C: signed certificate

The key step is the third: the CA looks the name up itself, from several places at once, rather than trusting an address from the requester. So the name has to resolve for Let's Encrypt, not just for you. Its resolver is also strict, because a CA fooled by a forged DNS answer issues certificates to attackers. My first setup, a CNAME to my router's dynamic DNS name, worked in every browser and still failed there; the Jellyfin post has the details.

The other common method, DNS-01, has Caddy put a TXT record in the domain's DNS instead. It works for servers the internet can't reach, but Caddy then needs credentials for the DNS provider and a build with its plugin.

Either way, Caddy renews well before the 90-day certificate expires.

Why not a free AWS certificate

I already had free ACM certificates for my other sites. They're free because the private key never leaves AWS: only AWS services such as CloudFront can use it, and Caddy needs the key on my server. ACM's exportable certificates do include the key, but they're paid, per name, and each renewal has to be exported and installed by hand.

CloudFront in front of the server would work with a free certificate, but every stream would then pass through AWS, which is free only up to a monthly allowance, and the hop from CloudFront to my house would still need its own encryption.

Let's Encrypt on Caddy Free ACM on CloudFront Exportable ACM on Caddy
Private key lives On my server Inside AWS On my server
Cost Free Free, plus CloudFront traffic Paid, per name
Renewal Automatic Automatic By hand

The other direction: a certificate for the server

The server also has to call AWS, to keep the DNS record on my home IP. Instead of an access key, it uses IAM Roles Anywhere, as my Raspberry Pi camera does:

sequenceDiagram
    participant U as dns-updater
    participant H as Signing helper
    participant A as Roles Anywhere
    participant R as Route 53
    U->>H: credentials, please
    H->>A: request signed with the server's key,<br/>plus its certificate
    Note over A: checks it was signed<br/>by my CA (trust anchor)
    A-->>H: credentials for one hour
    H-->>U: credentials
    U->>R: update the A record

Here the roles reverse. My server holds the certificate and key, my own CA signed it, and AWS checks it against that CA, which I registered as a "trust anchor". The role can only update two DNS records. The server's key can still get credentials until I revoke the certificate, but the credentials in use expire within the hour; the Pi article covers that trade-off.

All the certificates in one place

Certificate Proves Issued by Trusted by Private key on Lifetime
jellyfin.example.com and www. This is the real Jellyfin site Let's Encrypt Every browser The server, in Caddy's data 90 days, renewed by Caddy
The server's client certificate This machine is my server My private CA AWS, through the trust anchor The server Two years, renewed with Terraform
My private CA Anything it signs is mine Itself Only my AWS account My Terraform state Ten years

What this doesn't fix

  • HTTPS hides what's sent, not who's talking. The name and IP address are visible along the way.
  • A certificate proves control of a name, nothing more. It says nothing about who runs the server or whether its software is safe.
  • My private CA's key is in my Terraform state. The bucket is private and encrypted, but anyone who can read it can sign certificates my AWS account trusts.