Homelab Ingress with Deterministic IPv6

zrail1 pts0 comments

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...

stack ipv6 ingress caddy deterministic host

Related Articles