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. With ignore_changes on the value, Terraform plans an in-place change that Route 53 rejects:

    terraform apply -replace=aws_route53_record.jellyfin -replace=aws_route53_record.jellyfin_www
    
  • Don'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.8 instead.

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 at localhost:8096.
  • /data must persist. It holds the certificates; without it, every restart requests new ones.
  • Images are pinned (Jellyfin 12.2, Caddy 2.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:

  1. DNS. Apply the Terraform, and check that dig +short jellyfin.example.com @8.8.8.8 matches curl ifconfig.me from home.

  2. The router. Forward TCP ports 80 and 443 to the server, and open them in the server's firewall if it has one.

  3. 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 apache2
    
  4. Caddy. 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", twice
    

    After repeated failures Caddy backs off; sudo docker restart caddy retries 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.