Steam LAN Cache: lancache + Unbound on OPNsense
Why Cache Steam Downloads at All?
Modern games are huge. A single AAA title easily weighs in at 100+ GB, and updates regularly push tens of gigabytes. Now multiply that by several gaming PCs in the house, the occasional LAN party, and my habit of reinstalling games I deleted three months ago - and suddenly the same bytes are crossing my internet uplink over and over again.
The fix is a LAN cache: a transparent proxy that sits between the Steam clients and Valve's CDN. The first download fills the cache at internet speed; every subsequent download of the same content is served from the local network at wire speed. Steam even supports this officially - the client checks whether lancache.steamcontent.com resolves locally and, if it does, routes its CDN traffic through that host automatically. No client-side configuration needed.
One important mental model up front: lancache doesn't cache "games", it caches depot chunks. Steam splits every game into content-addressed chunks, and the cache stores exactly those. That means updates are covered automatically - a patched game just requests a few new chunks while everything unchanged is still a cache hit.
lancache-dns vs. Unbound Overrides
The lancache project ships its own DNS container, lancachenet/lancache-dns, which rewrites the CDN hostnames for you. It works, but it means putting yet another DNS server into the resolution path - and my network already has a perfectly good resolver: Unbound on OPNsense.
My firewall is an OPNsense HA pair (CARP), with clients pointing at the virtual IP 10.1.0.1. Instead of introducing lancache-dns, I added a single host override in Unbound:
Services → Unbound DNS → Overrides → Host Overrides
Host: lancache
Domain: steamcontent.com
Type: A
IP address: 10.1.0.111
Two details matter here:
- The override has to exist on both firewall nodes. With CARP, either node can be the active one, and Unbound config is per-node. An override that only lives on one firewall works fine - until the next failover, at which point Steam silently bypasses the cache and pulls from the public CDN again. I keep everything as GUI-visible host overrides on both nodes (no hidden include files), so the config survives audits and my future self can actually see it.
- AAAA must return NODATA, not a public address. Unbound's transparent local zone handles this correctly: the A override answers with the cache IP, and the AAAA query gets an empty answer. If clients received a real IPv6 CDN address, they would happily use it and skip the cache.
For me this approach wins clearly: one less container, one less DNS hop, and the override lives in the same HA-replicated firewall config as everything else. The lancache-dns container makes more sense if you want to cache many services (Epic, Blizzard, Riot, Windows Update, ...) and let the project's maintained domain lists do the work. I only cache Steam, so a single override is all it takes.
The Cache Host
The cache itself is a small Debian VM on Proxmox (4 cores, 8 GB RAM) running the lancachenet/monolithic container:
services:
lancache:
image: lancachenet/monolithic:latest
container_name: lancache
restart: unless-stopped
ports:
- '80:80'
- '443:443'
environment:
CACHE_DISK_SIZE: 4000g
CACHE_INDEX_SIZE: 1000m
UPSTREAM_DNS: 1.1.1.1 9.9.9.9
volumes:
- /data/cache:/data/cache
- /data/logs:/data/logs
Two non-obvious lessons baked into that file:
UPSTREAM_DNSmust bypass Unbound. The container needs to resolve the real Steam CDN addresses to fill the cache. If it asked my Unbound, it would get its own IP back forlancache.steamcontent.com- a perfect DNS loop.CACHE_INDEX_SIZEscales withCACHE_DISK_SIZE. nginx keeps the cache key index in RAM, and the rule of thumb is roughly 250 MB of index per 1 TB of cache. When I grew the cache from 2 TB to 4 TB I had to bump the index from 500 MB to 1000 MB along with it - on an 8 GB VM, forgetting this is the difference between a working cache and an OOM-killed one.
Storage: NFS from TrueNAS
The VM itself is tiny; the actual cache lives on my TrueNAS box. I created a dedicated dataset with recordsize=1M - a good match for lancache's large slice files - and an 8 TB refquota so a growing cache can never eat the pool. The dataset is NFS-exported to exactly one client, the cache VM, and mounted via fstab:
10.1.0.254:/mnt/data02/lancache /data nfs4 defaults,_netdev 0 0
This split has a pleasant side effect: the cache VM is completely disposable. Recreating the container with docker compose up -d - or even rebuilding the whole VM - leaves the cached chunks untouched. I verified this the boring way: recreate the container, request a known chunk, watch the very first response come back as a HIT.
Prefill: Warm Cache Before Anyone Asks
A cache only helps once it's warm, and the worst time to warm it is when four people are waiting to play. Enter SteamPrefill, a CLI tool that speaks the Steam depot protocol and downloads your library through the cache without installing anything.
After a one-time interactive login, a systemd timer runs it every night at 04:00:
[Unit]
Description=Prefill Steam library into lancache
[Service]
Type=oneshot
WorkingDirectory=/opt/steamprefill
ExecStart=/opt/steamprefill/SteamPrefill prefill --all
The --all flag is the key detail: instead of maintaining a hand-picked app list with select-apps, prefill simply walks everything the account owns. Buy a game in the evening, and by the next morning its chunks are already sitting in the cache - new purchases prefill themselves, zero maintenance.
Two operational footguns worth writing down:
- Never start a prefill through an interactive SSH pipe. If the SSH session dies, SteamPrefill blocks writing to the dead pipe and hangs silently at some percentage. Always start it detached (
systemctl start --no-block steamprefill.service) and watch progress viajournalctl. - The prefill host's DNS must go through the firewall. systemd-resolved cheerfully learned an upstream resolver from DHCP and asked it directly - straight past my override, filling the internet instead of the cache. Pinning resolved to
10.1.0.1withDomains=~.fixed that.
I also added a traffic shaper on OPNsense limiting cache-fill traffic to 150 Mbit/s, so a nightly prefill of a freshly bought 120 GB game never saturates the uplink. Cache hits stay on the LAN and are unlimited.
Does It Actually Help?
Checking is trivial - the access log tells the truth:
docker exec lancache tail -f /data/logs/access.log
# [steam] 10.1.0.x ... "GET /depot/<id>/chunk/<sha> HTTP/1.1" 200 HIT
Some numbers from my setup:
| Metric | Value |
|---|---|
| Prefillable library | 68 apps, ~1.3 TiB |
| Cache dataset in use | ~1.5 TB |
| Cold download (internet) | limited by uplink, shaped to 150 Mbit/s |
| Warm download (cache hit) | line rate - the client's disk becomes the bottleneck |
The practical difference: a warm 100 GB install lands in minutes instead of hours, a second PC downloading the same update costs the uplink nothing, and Steam's "download in progress" bar at a LAN party stops being a social event.
Conclusion
For a Steam-only setup, I'd skip lancache-dns and use your resolver's native overrides every time - it's one DNS record, fully visible in the firewall GUI, replicated across the HA pair. Combine that with NFS-backed storage so the cache outlives the VM, size the nginx index alongside the disk, and let a nightly prefill --all keep the cache warm without ever thinking about it again.
Is 4 TB of cache for one household overkill? Probably. But the first time a friend's laptop pulls a full game at 10 gigabit while the internet uplink stays idle, it feels entirely reasonable.