Homelab Ingress With Deterministic IPv6
Pete Keen
Essays<br>Stripe
Software<br>DNS
Finance<br>Books
Homelab Ingress With Deterministic IPv6
Last updated 2026-08-11, 11 min read
In my last post I talked in general about how my homelab is set up.<br>In this piece I'm going to explore one of the more interesting aspects of the stack in more depth: my web ingress.
The canonical homelab Docker-based ingress is Caddy, Traefik, or Nginx Proxy Manager sitting on a shared Docker bridge with every other web-facing container.<br>This is easy and understandable and also a security trap.
I wanted a harder network boundary between my stacks so I run Caddy with host networking and reach services with deterministic IPv6 addresses.<br>I trust Caddy significantly more than I trust whatever random container I spun up most recently.
Ingress in a Nutshell
Most server software targeting hobbyists and dilettantes has a web interface.<br>You spin up the container, you expose port 8080, you point your browser at zaphod.local:8080 and you're done.
Except, what about HTTPS? What about nice-looking names and subdomains?
Something has to deal with that essential complexity.<br>Should every random container handle it?<br>Do you really trust the latest slop app at the top of /r/selfhosted with your DNS credentials so it can generate a certificate?<br>I sure don't.
There are an uncountable number of solutions in this problem space.<br>In the past I've run nginx, Apache, Traefik, and probably more I've forgotten, but for now I've settled on Caddy.
Every stack gets its own Docker bridge network.<br>Services only join other stack networks when they truly need to.
So, how does Caddy get at containers? It runs in host network mode.<br>That means it can reach apps through the stacks' networks because the host inherently has routes into those bridges.<br>No shared bridge necessary.
Deterministic IPv6
The thing that makes this trick work is deterministic IPv6.<br>The lab as a whole has a shared /48 ULA prefix.<br>For each stack, we calculate two hashes: the host name and the stack name.<br>Then, we fill out this formula to determine the stack's /96 prefix:
::::
Each service in a stack gets a /128 address inside that /96, and to get the final 32 bits we hash the stack name and the service name together along with tags, then fill in the formula again:
:::
Here's a worked example:
ULA = IPAddr.new("2001:db8:1234::").mask(48)<br>hostname = "zaphod"<br>stack_name = "whoami"<br>container_name = "whoami"
# this is handwavey pseudocode<br>hostname_bits = SHA256(["host", hostname].join(":")).first_bits(16)<br>stack_bits = SHA256(["stack", stack_name].join(":")).first_bits(32)<br>container_bits = SHA256(["stack", stack_name, "container", container_name].join(":")).first_bits(32)
stack_prefix = ULA.to_i<br>stack_prefix |= hostname_bits 64<br>stack_prefix |= stack_bits 32<br>stack_addr = IPAddr.new(stack_prefix, Socket::AF_INET6).mask(96)<br>#=> IPAddr.new("2001:db8:1234:70ef:55a:c88c::")
container_addr_bits = stack_prefix<br>container_addr_bits |= container_bits<br>container_addr = IPAddr.new(container_addr_bits, Socket::AF_INET6)<br>#=> IPAddr.new("2001:db8:1234:70ef:55a:c88c:301d:58fe")<br>I need these addresses because Docker's internal DNS only works on bridges, not in host mode, so I can't use it from Caddy.
What does work is extra_hosts, a standard Docker Compose feature that injects hostname to address mappings directly into a container's /etc/hosts file.<br>I use this to statically map every deterministic IPv6 to a hostname like service_name.stack_name.docker.internal.<br>The config generator defaults to just using the IPv6 literal but the names are available for hand-written routes.
There are other ways to avoid a shared bridge.<br>I could publish ports on 127.0.0.1, for example, but then I'd have to figure out a fleet-wide port allocation scheme.<br>Been there, done that, IPv6 is cleaner and way more interesting.
How My Ingress Works
If you read the previous piece you might recall that I have my lab structured around the idea of static allocation.<br>What that means is that there's no scheduler determining where to place containers.<br>There are no leaders or Raft or consensus of any sort.<br>Every piece is determined at build time and then tested, linted, and pushed out to the various hosts in the lab.
Ingress is no different.<br>One of the hooks that runs during build examines every stack for a Compose extension named x-web.<br>The hook builds one or more WebRoute objects for each x-web, which then get passed to an ERB template.<br>The template spits out a config.json file for Caddy.<br>Every host has a different config based on what's actually running there.
WebRoute objects also play a starring role in DNS and TLS certificate setup.<br>Every WebRoute can contain one or more fully qualified domain names that get injected into my DNS config and applied with dnscontrol.<br>They also get normalized and dumped to a text file for my certificate script to consume, which runs lego daily and whenever I add a new domain name.<br>Caddy consumes the...