After I moved from Plex to Jellyfin, I reached it from outside over plain HTTP, so my password and everything I watched crossed the internet unencrypted. This post fixes that with a name under my own domain and Caddy in front of Jellyfin, using a free Let's Encrypt certificate.
My first design, a CNAME to my router's dynamic DNS name, worked in every browser and never got a certificate. An A record kept current from the server fixed it.
What it doesn't fix: Jellyfin is still reachable from the whole internet, and my domain points at my home. More on both at the end.
How it fits together
flowchart TD
subgraph net["On the internet"]
Phone["Phone or laptop<br/>away from home"] -- "1 · look up<br/>jellyfin.example.com" --> DNS["Route 53<br/>A record: home IP"]
end
subgraph home["Home network"]
Router["Router<br/>ports 80 and 443 forwarded"] --> Caddy
Caddy["Caddy on the server<br/>HTTPS"]
Jellyfin["Jellyfin<br/>:8096"]
Updater["dns-updater<br/>keeps the A record<br/>on the home IP"]
end
net -- "2 · https://, encrypted" --> home
Caddy -- "plain HTTP,<br/>inside the server" --> Jellyfin
Updater -. "every 5 minutes,<br/>if the IP changed" .-> DNS
Route 53 gives the phone my home IP, and the router forwards port 443 to Caddy, which passes the request to Jellyfin on the same machine. Who does what with the certificates is in How HTTPS certificates work, and who does what.
Why my own domain, rather than a certificate for the router's <name>.tplinkdns.com? Let's Encrypt
limits certificates per registered domain each week, and tplinkdns.com isn't on the
Public Suffix List, so every TP-Link owner shares one allowance.
Why a CNAME to the dynamic DNS name failed
The obvious move was one CNAME in Route 53: jellyfin.example.com is an alias for
<name>.tplinkdns.com, which the router keeps on my current IP. dig and browsers were happy. Caddy's log wasn't:
DNS problem: SERVFAIL looking up A for jellyfin.example.com - the domain's nameservers may be malfunctioning
Let's Debug, which checks a name the way Let's Encrypt does, agreed, while Google's and Cloudflare's resolvers answered correctly. Asked directly, TP-Link's nameservers break DNS rules three ways:
| Test | A correct nameserver | TP-Link's |
|---|---|---|
Query in mixed case (<NaMe>.TpLiNkDnS.CoM) |
Echoes the name in the same case | Answers in all lowercase |
| AAAA or NS query for a name that has an A record | "No records of that type" | NXDOMAIN, "no such name" |
| Query over TCP | Answers | Refuses the connection |
Public resolvers tolerate these. A CA's resolver is strict, because a forged DNS answer could get an attacker a certificate. I can't tell which check Let's Encrypt failed, but the fix was the same either way: take TP-Link out of the lookup.
The fix: an A record, kept current from the server
Now jellyfin.example.com and its www. name are A records holding the IP itself. Terraform
creates them with the current IP and then leaves the value alone:
resource "aws_route53_record" "jellyfin" {
zone_id = data.aws_route53_zone.site.zone_id
name = var.domain_name
type = "A"
ttl = 300
records = [var.home_ip]
# The dns-updater on the server keeps this current.
lifecycle {
ignore_changes = [records]
}
}
Home IPs change, so a small container, dns-updater, runs next to Jellyfin. Every five minutes it
gets the public IP from https://checkip.amazonaws.com and updates both records if they differ.
It gets AWS credentials through IAM Roles Anywhere, like my Raspberry Pi camera: a certificate from my own CA, traded for one-hour credentials, for a role that can only UPSERT those two A records. Terraform generates the CA and the certificate; the only manual step is copying it to the server.
Two Terraform gotchas:
Changing a CNAME to an A record needs a one-off
-replace. Withignore_changeson the value, Terraform plans an in-place change that Route 53 rejects:terraform apply -replace=aws_route53_record.jellyfin -replace=aws_route53_record.jellyfin_wwwDon't look the name up before the record exists. My router remembered the "doesn't exist" answer for up to 15 minutes. Check with
dig jellyfin.example.com @8.8.8.8instead.
Caddy in the Compose file
Caddy gets and renews HTTPS certificates on its own. It goes in Jellyfin's Compose file, so the existing systemd unit starts and stops both:
caddy:
image: caddy:2.11
container_name: caddy
network_mode: host
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- /home/me/.caddy/data:/data
- /home/me/.caddy/config:/config
environment:
- JELLYFIN_DOMAIN
depends_on:
- jellyfin
restart: unless-stopped
And the whole Caddyfile:
{$JELLYFIN_DOMAIN} {
reverse_proxy localhost:8096
}
www.{$JELLYFIN_DOMAIN} {
redir https://{$JELLYFIN_DOMAIN}{uri} permanent
}
- Naming the site is all the HTTPS setup there is. Caddy gets a certificate for it, renews it, and redirects plain HTTP to HTTPS.
network_mode: host, like Jellyfin, so Caddy listens on the server's ports 80 and 443 and reaches Jellyfin atlocalhost:8096./datamust persist. It holds the certificates; without it, every restart requests new ones.- Images are pinned (Jellyfin
12.2, Caddy2.11) to avoid surprise upgrades.
Jellyfin's JELLYFIN_PublishedServerUrl changes to https://jellyfin.example.com too.
Doing it in the right order
Let's Encrypt allows only a few failed validations an hour, so get everything else working first:
DNS. Apply the Terraform, and check that
dig +short jellyfin.example.com @8.8.8.8matchescurl ifconfig.mefrom home.The router. Forward TCP ports 80 and 443 to the server, and open them in the server's firewall if it has one.
Free the ports. Forwarding port 80 showed me Apache's "It works" page: Apache had been running on the server, unnoticed, and Caddy can't start while something holds the port:
sudo ss -tlnp '( sport = :80 or sport = :443 )' # should list nothing sudo systemctl disable --now apache2Caddy. Pull the change on the server and restart the unit:
mkdir -p ~/.caddy/data ~/.caddy/config sudo systemctl restart jellyfin-docker.service sudo docker logs -f caddy # wait for "certificate obtained successfully", twiceAfter repeated failures Caddy backs off;
sudo docker restart caddyretries now.
Then test from a phone on mobile data, with https:// and no port.
Telling Jellyfin about the proxy
Every request now reaches Jellyfin from Caddy on 127.0.0.1, so it treats every remote viewer as
local, which affects remote bitrate limits and failed-login records. Adding 127.0.0.1 to
Known proxies in Dashboard > Networking makes it use the real address Caddy passes on. Check the
page's HTTPS certificate fields are empty before saving: my password manager had filled them in.
Closing the old door
Once everyone I share with had switched to the new address, I removed the router's port 8096 rule. At home, Jellyfin still answers on 8096, and the https name works too.
What this doesn't fix
- Jellyfin is still on the internet. HTTPS stops eavesdropping, not someone trying the login page.
- My domain points at my home. Looking up the name gives my home IP.
- The server holds a key that can change those two DNS records, until I revoke its certificate.
Not exposing Jellyfin at all fixes the first two: a VPN such as Tailscale, with no ports open on the router.
| Caddy and a forwarded port | Tailscale | |
|---|---|---|
| Open ports on the router | 80 and 443 | None |
| Who can reach the login page | Anyone | Only my own devices |
| What a viewer needs | The Jellyfin app | Tailscale on every device, and an invite |
| Home IP in public DNS | Yes | No |
I chose Caddy so anyone I share with needs only the Jellyfin app and an address. If it were only me watching, Tailscale would be the better choice.