Real HTTPS for a .lan Network

When I started running services at home, every one of them lived at a URL like 10.0.1.147:8222 and greeted me with a browser warning the colour of a stop sign. You click through it, you tell yourself it’s fine because it’s your own network, and then you do it forty more times that week. It works, but it’s grim, and it trains you to ignore exactly the warning you’d want to notice if it ever meant something.

What I wanted was the boring, professional thing: type vaultwarden.lan, get a green padlock, no warning, no port number. That turns out to need three small services working together — one for names, one for certificates, one for routing. None of them is complicated on its own. The nice part is how cleanly they hand off to each other.

The three jobs

A real HTTPS request to an internal service needs three questions answered. Where does jellyfin.lan live? That’s DNS — Pi-hole. Can I trust the certificate it’s serving? That’s a certificate authority — step-ca. Who actually answers on 443 and forwards to the app? That’s a reverse proxy — Traefik.

Here’s the whole handshake for a single request:

flowchart LR
    client["💻 Browser\nwants jellyfin.lan"]

    subgraph lab["🪶 corvidae cluster"]
        direction TB
        pihole["🌐 Pi-hole\nDNS for .lan"]
        stepca["🔒 step-ca\nInternal CA"]
        traefik["🔀 Traefik\nReverse Proxy :443"]
        jellyfin["🎞️ Jellyfin\nHTTP backend"]
    end

    client -->|"1. where is jellyfin.lan?"| pihole
    pihole -->|"2. → 10.0.1.203 (Traefik)"| client
    client -->|"3. HTTPS request"| traefik
    stepca -->|"issues cert via ACME"| traefik
    stepca -.->|"root CA trusted on device"| client
    traefik -->|"4. plain HTTP, same box"| jellyfin

The solid arrows are the request path; the dotted one is the bit that makes the padlock green instead of red. Walk through it once and the rest of the post is just detail.

Pi-hole answers “where”

Pi-hole was already on the network as a DNS server and ad blocker — it’s the resolver every device points at. Because it’s the resolver, it’s also the natural place to invent local names. I give it one job for this setup: every .lan hostname resolves to Traefik’s address.

Almost every record is identical — jellyfin.lan, forgejo.lan, vaultwarden.lan, all pointing at 10.0.1.203, which is Traefik. The service itself might be a container on a completely different machine; the DNS record doesn’t care, because Traefik will sort that out. There are only a handful of exceptions, for things that aren’t behind the proxy:

DomainPoints atWhy
jellyfin.lan (and friends)10.0.1.203Traefik routes it
corvus.lan10.0.1.200Proxmox UI, not proxied
step-ca.lan10.0.1.202The CA itself, reached directly

So the pattern for adding any new service is: point its name at Traefik, then teach Traefik what to do with it. DNS stays dumb on purpose.

step-ca answers “can I trust this”

This is the piece people skip, and it’s the one that actually matters. A certificate is only trustworthy because something you already trust vouches for it. On the public web that’s a commercial CA baked into your operating system. On a .lan network there is no such authority — so I run my own.

step-ca is a small certificate authority you host yourself. It has a root certificate — mine identifies itself as Homelab Root CA — and anything that root signs is trusted by any device that trusts the root. So the setup is two halves:

  1. step-ca signs certificates for .lan services (Traefik asks for them, more on that below).
  2. I install the root certificate once on each device — Mac keychain, iPhone, the Linux containers, the lot.

That second step is the whole game, and it’s where the footguns live. On Linux it’s three lines — fetch the root and refresh the trust store:

curl -k https://10.0.1.202/roots.pem \
  -o /usr/local/share/ca-certificates/homelab-root.crt
update-ca-certificates

(The -k is fine here and only here — you’re bootstrapping trust, so there’s nothing to verify against yet. After this, never again.)

iOS is the one that catches everyone. Installing the certificate profile is not enough — there’s a second, separate switch buried in Settings → General → About → Certificate Trust Settings that has to be toggled on. Miss it and Safari keeps showing warnings with no hint why. I lost a genuinely embarrassing amount of time to that toggle.

Once a device trusts the root, every .lan certificate just works, today and for every service I add later. That’s the payoff for running your own CA: you do the trust dance once per device, not once per service.

Traefik answers “who’s actually there”

Traefik is the reverse proxy sitting on 443 for the entire network. Every .lan name resolves to it, so it’s the thing that has to know jellyfin.lan means that container on that port and vaultwarden.lan means a different box entirely. It terminates TLS at the edge and talks plain HTTP to the backends behind it, which is why those backends never need certificates of their own.

The elegant bit is how it gets those certificates. step-ca speaks ACME — the same protocol Let’s Encrypt uses to hand out certificates for the public web. Traefik already knows how to be an ACME client. So I point Traefik at step-ca as a certificate resolver and the two of them sort it out automatically: Traefik requests a cert for a hostname, step-ca issues it, Traefik renews it before it expires. I never touch a certificate file.

Adding a service is therefore two small declarations. A router that says “this hostname, with TLS from step-ca”:

http:
  routers:
    jellyfin:
      rule: "Host(`jellyfin.lan`)"
      entryPoints: [websecure]
      service: jellyfin
      tls:
        certResolver: step-ca

and a service that says where the actual app lives:

  services:
    jellyfin:
      loadBalancer:
        servers:
          - url: "http://10.0.1.204:8096"

That certResolver: step-ca line is the entire integration. Traefik sees a hostname it doesn’t have a cert for, asks step-ca over ACME, and serves the result — which the browser trusts, because step-ca’s root is already in the device’s trust store. The three services close the loop without me in the middle.

Adding a service, end to end

Once it’s all wired up, standing up a new service with proper HTTPS is genuinely a two-step chore:

  1. Pi-hole: add a DNS record, newthing.lan → 10.0.1.203.
  2. Traefik: add the router + service block above, with the backend’s real address.

That’s it. No certificate to generate, no warning to click through, no port to remember. The name resolves to Traefik, Traefik already knows how to get a cert from step-ca, and every device already trusts step-ca. The first service is the hard one because you’re building all three pieces at once; every service after that rides on the same rails.

It’s a small thing, a green padlock on an internal dashboard. But it’s the difference between a network that’s quietly correct and one that’s trained you to ignore its own alarms — and it’s a faithful miniature of how the real web’s certificate trust actually works, which is the kind of thing a homelab is for. I’ll dig into how step-ca itself is set up in a later post.

Discussion