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
- DNS. The browser's resolver asks Route 53 and gets my home IP. Nothing is encrypted or verified yet.
- 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.
- 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.
- 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.