nix-binary-cache-proxy¶
Modules
A pure-nginx caching proxy in front of cache.nixos.org (or any Nix binary
cache). No nix-serve, no extra daemon — just nginx's own proxy_cache_path.
What it solves¶
When you have more than one or two machines pulling from the public binary
cache, they all fetch the same NARs over the WAN, over and over. Put this proxy
on one always-on box, point every machine's substituters at it, and:
- Bandwidth: each derivation crosses the WAN once, then serves from local disk at LAN speed.
- Availability: when the upstream cache is slow or unreachable, builds keep
working — cached entries are served stale (
proxy_cache_use_stale updating) instead of failing.
It's a transparent pass-through: NARs are cached and re-served verbatim, so clients still verify them against the upstream's signing key. This proxy does not re-sign anything.
Usage¶
imports = [ ./nix-binary-cache-proxy ];
modules.services.nix-cache = {
enable = true;
domain = "cache.example.com";
acmeHost = "cache.example.com"; # a security.acme cert you configure
# maxCacheSize = "800g"; # defaults to 50g
};
On every client:
nix.settings.substituters = [ "https://cache.example.com" ];
nix.settings.trusted-public-keys = [ "cache.nixos.org-1:6NCHdD…" ];
Keep the upstream's public key — the proxy hands NARs through unchanged.
Options¶
| Option | Default | Meaning |
|---|---|---|
domain |
— | Virtual host the proxy is served under. |
acmeHost |
— | security.acme cert name for TLS. |
upstreamEndpoint |
cache.nixos.org |
Upstream cache host to proxy. |
verifyUpstreamTls |
true |
Validate the upstream's TLS cert on the WAN fetch. |
cacheDir |
/var/cache/nix-cache-proxy |
On-disk cache location. |
maxCacheSize |
50g |
LRU eviction ceiling for the cache. |
cacheLockTimeout |
60s |
How long a cold-miss herd waits for the request that is filling the entry (see below). |
resolver |
127.0.0.1 |
DNS resolver for re-resolving the upstream (see below). |
websiteDir |
null |
Optional static site at /; 404s fall through to the proxy. |
extraHeaders |
"" |
Extra nginx directives injected into each location. |
The three non-obvious tricks¶
1. Static-first, proxy-fallback routing — and pinning /nix-cache-info¶
If websiteDir is set, / serves that directory from disk and only a 404
falls through (error_page 404 = @fallback) to the upstream proxy. But
/nix-cache-info is pinned to the proxy in all cases (= /nix-cache-info),
so Nix clients always get a valid cache-info response even if the static
directory happens to contain a same-named file. Get this wrong and clients
silently treat the store as unusable.
2. directio + AIO tuning so mirroring doesn't thrash the page cache¶
Nix NARs are large and a warm cache holds thousands of them. Streaming GBs
through nginx with default settings evicts everything else the kernel had hot.
directio 8192 makes any response over 8 KiB bypass the page cache on the way
out; aio on plus output_buffers 12 512k keep those large files streaming
without stalling a worker. The result is that a full mirror pass doesn't
demolish the box's page cache.
3. Upstream in an nginx variable, not a literal proxy_pass¶
The upstream host lives in set $upstream_endpoint … and proxy_pass uses the
variable. This forces nginx to re-resolve the host through resolver on every
request. If you instead write the hostname literally in proxy_pass, nginx
resolves it once at startup and freezes that IP until the next config
reload — which breaks the moment a CDN-backed cache like cache.nixos.org
rotates addresses. The cost: you must provide a working resolver (a local
stub resolver, or your LAN DNS). With the variable form and no resolver, nginx
fails the request.
A proxy_cache_lock group is also set, and the timeout inside it is the part
that decides whether the lock does anything at all:
proxy_cache_lock on— only one request at a time populates a given cache entry.proxy_cache_lock_timeout(optioncacheLockTimeout, default60s) — how long the other concurrent requests wait for that one. They are released as soon as the entry lands in the cache. At0sthey do not wait at all: they are passed straight to the upstream and their responses are not cached, so on a cold miss the lock collapses nothing.60scovers a typical NAR fill; if the fill is still running when it expires, the waiters fall through to the upstream uncached (i.e.0sbehaviour, delayed). SetcacheLockTimeout = "0s"if you would rather never wait.proxy_cache_lock_age 300s— lets a second request take over populating the entry if the first is still running after five minutes.proxy_cache_use_stale updating— serves the old entry to everyone else while a refresh is in flight, so a herd asking for an already-cached-but-stale NAR collapses into one upstream fetch regardless of the lock timeout.
Caveats¶
- The
nginxuser needs write access tocacheDir; the module adds a tmpfiles rule and aReadWritePathsentry for it. - TLS is required (
forceSSL). Provision theacmeHostcertificate separately. - This caches; it does not sign. Clients trust the upstream key, so the proxy can never serve a NAR the upstream wouldn't have.
Security notes¶
- Upstream TLS is verified on the WAN fetch by default
(
verifyUpstreamTls = true): the proxy validates the upstream cache's certificate against the system CA bundle (/etc/ssl/certs/ca-certificates.crt), with SNI and the certificate name checked againstupstreamEndpoint. This is defense-in-depth, not the integrity mechanism: end-to-end integrity comes from the Nix client checking every narinfo/NAR against the upstream signing key (trusted-public-keys), so even without it an on-path attacker between the proxy and the CDN could only observe which store paths are requested (metadata) or cause denial of builds — not inject code clients will accept. SetverifyUpstreamTls = falseonly for an upstream whose certificate the system bundle can't validate (e.g. a self-signed internal cache), accepting that metadata/DoS exposure.
Source¶
modules/nix-binary-cache-proxy/default.nix
# nix-binary-cache-proxy
#
# A pure-nginx caching proxy in front of cache.nixos.org (or any binary cache).
# No nix-serve, no separate daemon — just nginx's own `proxy_cache_path`.
#
# Point every machine's `nix.settings.substituters` at this host and:
# - the same NAR is fetched from the WAN once, then served from local disk;
# - builds keep working while the upstream cache is unreachable
# (served stale via `proxy_cache_use_stale`).
#
# Drop-in usage:
# imports = [ ./nix-binary-cache-proxy ];
# modules.services.nix-cache = {
# enable = true;
# domain = "cache.example.com";
# acmeHost = "cache.example.com"; # a services.nginx / security.acme cert
# };
#
# Then on clients:
# nix.settings.substituters = [ "https://cache.example.com" ];
# nix.settings.trusted-public-keys = [ "cache.nixos.org-1:6NCHdD..." ];
# (Keep the upstream's public key — this proxy passes NARs through verbatim,
# it does not re-sign them.)
{ config, lib, ... }:
with lib;
let
cfg = config.modules.services.nix-cache;
in
{
options.modules.services.nix-cache = {
enable = mkEnableOption "pure-nginx Nix binary cache proxy";
domain = mkOption {
type = types.str;
example = "cache.example.com";
description = "Virtual host name the proxy is served under.";
};
acmeHost = mkOption {
type = types.str;
example = "cache.example.com";
description = ''
`security.acme` certificate name to use for TLS (`useACMEHost`).
Configure the cert itself via `security.acme.certs.<name>` elsewhere.
'';
};
upstreamEndpoint = mkOption {
type = types.str;
default = "cache.nixos.org";
description = "Upstream binary cache host to proxy and cache.";
};
verifyUpstreamTls = mkOption {
type = types.bool;
default = true;
description = ''
Verify the upstream cache's TLS certificate against the system CA
bundle when filling the cache. Defense-in-depth on top of the Nix
clients' signature checks. Disable only for an upstream with a
certificate the system bundle can't validate (e.g. a self-signed
internal cache).
'';
};
cacheDir = mkOption {
type = types.str;
default = "/var/cache/nix-cache-proxy";
description = "Directory nginx stores the cached NARs / narinfo in.";
};
maxCacheSize = mkOption {
type = types.str;
default = "50g";
example = "800g";
description = ''
Upper bound on the on-disk cache (`proxy_cache_path max_size`).
nginx evicts least-recently-used entries above this.
'';
};
cacheLockTimeout = mkOption {
type = types.str;
default = "60s";
example = "0s";
description = ''
`proxy_cache_lock_timeout`: how long a request may wait for another
request that is already filling the same cache entry.
This is what makes `proxy_cache_lock on` actually collapse a cold-miss
herd. Waiters are released as soon as the entry lands in the cache, so
for a typical NAR the wait is short; if the fill is still running when
the timeout expires, the waiters are passed straight through to the
upstream and their responses are NOT cached — i.e. the herd is no
longer collapsed.
Set this to `"0s"` to opt out of waiting entirely (every concurrent
cold-miss request goes straight to the upstream, uncached) — lower
worst-case latency, no request collapsing.
'';
};
resolver = mkOption {
type = types.str;
default = "127.0.0.1";
description = ''
DNS resolver nginx uses to re-resolve the upstream host at request
time. Must be a working resolver reachable from the box (e.g. a local
stub resolver, or your LAN DNS). Required because the upstream is held
in an nginx variable (see notes) rather than baked in at startup.
'';
};
websiteDir = mkOption {
type = types.nullOr types.path;
default = null;
example = literalExpression "./landing";
description = ''
Optional static directory served at `/`. Any 404 there falls through
to the upstream cache proxy. When null, `/` proxies straight to the
upstream. `/nix-cache-info` is always pinned to the proxy regardless.
'';
};
extraHeaders = mkOption {
type = types.lines;
default = "";
description = ''
Extra nginx directives (e.g. security `add_header` lines) injected
into every served location. Kept as an option so a standalone deploy
can bolt on its own header policy without patching the module.
'';
};
};
config = mkIf cfg.enable {
systemd.tmpfiles.rules = [
"d ${cfg.cacheDir} 0755 nginx nginx -"
];
systemd.services.nginx.serviceConfig.ReadWritePaths = [ cfg.cacheDir ];
services.nginx = {
enable = lib.mkDefault true;
virtualHosts."${cfg.domain}" = {
forceSSL = true;
useACMEHost = cfg.acmeHost;
# The upstream is placed in a *variable* so nginx re-resolves it via
# `resolver` on every request. If you write the host literally in
# `proxy_pass`, nginx resolves it once at startup and freezes that IP
# until the next config reload — which breaks when a CDN-backed cache
# rotates addresses.
extraConfig = ''
resolver ${cfg.resolver} valid=30s ipv6=off;
set $upstream_endpoint ${cfg.upstreamEndpoint};
'';
locations =
let
cached = {
proxyPass = "https://$upstream_endpoint";
recommendedProxySettings = false;
extraConfig = ''
${optionalString cfg.verifyUpstreamTls ''
proxy_ssl_verify on;
proxy_ssl_verify_depth 5;
proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
proxy_ssl_server_name on;
proxy_ssl_name $upstream_endpoint;
''}
proxy_set_header Host $upstream_endpoint;
add_header Cache-Control $nix_cache_header always;
proxy_cache nix_cache_proxy;
proxy_cache_valid 200 302 60d;
proxy_pass_request_headers off;
expires max;
# Collapse a thundering herd: only one request populates a
# given key at a time; the rest wait (or serve stale) instead
# of all stampeding the upstream for the same NAR. The wait is
# bounded by cacheLockTimeout — at 0s there is no waiting at
# all and the lock collapses nothing on a cold miss.
proxy_cache_lock on;
proxy_cache_lock_timeout ${cfg.cacheLockTimeout};
proxy_cache_lock_age 300s;
proxy_cache_use_stale updating;
${cfg.extraHeaders}
'';
};
in
(optionalAttrs (cfg.websiteDir != null) {
# Static-first: serve the site from disk, and only on a 404 fall
# through to the upstream proxy.
"/" = {
root = cfg.websiteDir;
extraConfig = ''
expires max;
add_header Cache-Control $nix_cache_header always;
error_page 404 = @fallback;
${cfg.extraHeaders}
'';
};
"@fallback" = cached;
})
// optionalAttrs (cfg.websiteDir == null) {
"/" = cached;
}
// {
# Pin the cache-info endpoint to the proxy so Nix clients always
# get a valid response even if the static dir shadows the name.
"= /nix-cache-info" = cached;
};
};
appendHttpConfig = lib.mkAfter ''
proxy_cache_path ${cfg.cacheDir} levels=1:2 keys_zone=nix_cache_proxy:200m max_size=${cfg.maxCacheSize} inactive=30d use_temp_path=off;
# Whole-cache mirroring pushes GBs of large NARs through nginx.
# directio makes files >8 KiB bypass the page cache on the way out so
# they don't evict everything else the box has hot; AIO + wide output
# buffers keep those large files streaming instead of stalling.
open_file_cache max=512;
aio on;
directio 8192;
output_buffers 12 512k;
# Only cache-hittable statuses advertise as publicly cacheable.
map $status $nix_cache_header {
200 "public";
302 "public";
default "no-cache";
}
'';
};
};
}