harmonia-cache-with-upstream-fallback¶
Modules
Self-host a signed Nix binary cache (harmonia) behind nginx so that one substituter URL transparently covers two things:
- this machine's own
/nix/store(everything it has built), served and ed25519-signed by harmonia; and - an on-disk-cached copy of an upstream substituter (
cache.nixos.orgby default), served by an nginxproxy_cache.
A request for a path harmonia doesn't have returns 404; nginx intercepts it
and re-issues against the cached upstream. The first peer to pull a path
warms the local disk cache; every subsequent peer -- and this builder
itself -- then hits local disk instead of the WAN.
Why¶
If you run a build box and a few machines that consume its output, the naive
setup gives everyone two substituters: your box, and cache.nixos.org. Every
machine still fetches stdenv/toolchain/etc. from the WAN independently.
Fold both behind a single nginx vhost and the upstream half gets cached to
disk once. Point consumers at just your cache (higher priority than
cache.nixos.org) and the LAN warms itself: one WAN fetch per path, ever.
The traps this module already solves¶
NAR serving through nginx has a handful of non-obvious footguns; they're all baked in here:
proxy_buffering off+proxy_request_buffering off+client_max_body_size 0. NARs are large and stream-served. Without these, nginx spools whole objects to a temp file before forwarding the first byte -- huge latency and disk churn.- Short 404 TTL (
proxy_cache_valid 404 1m). A path genuinely absent upstream must not be pinned as "missing" for long, but also must not be re-fetched on every retry. One minute is the compromise. - DNS re-resolution is opt-in, and needs both halves. A
resolverdirective on its own does not make nginx re-resolve a proxy target: an upstream written literally inproxy_passis resolved once, at config load, and pinned until the next reload. Only aproxy_passbuilt from an nginx variable is resolved per request — and that form in turn requires aresolver. This module therefore emits the two together or neither: setupstreamFallback.resolverand you getset $harmonia_upstream …+proxy_pass https://$harmonia_upstream+resolver … valid=30s; leave itnull(the default) and the endpoint is inlined literally with no resolver required. Pick the resolver to match the box — systemd-resolved's stub is127.0.0.53, a local unbound/dnsmasq is usually127.0.0.1— because a wrong value fails every fallback request. Left atnull, the fallback keeps working off the address resolved at reload time, which is fine until the upstream CDN rotates addresses. proxy_ssl_server_name on+proxy_ssl_name+Hostheader set to the upstream's own name, so the upstream CDN's TLS/routing is satisfied on the re-proxy. The re-proxy also runsproxy_ssl_verify onagainst the system CA bundle, since SNI alone does not authenticate the upstream cert.proxy_cache_lock on. Collapses a thundering herd -- when several peers ask for the same cold path at once, one fills the cache entry and the rest wait on it instead of each opening its own upstream fetch.- harmonia binds loopback only; nginx terminates TLS in front of it. The cache is never exposed unencrypted.
- nginx owns the cache dir.
proxy_cache_pathlives athttp{}scope (not in the server block), the directory is created via tmpfiles asnginx:nginx, and it's added to the hardened nginx unit'sReadWritePaths-- otherwise the unit's sandbox makes it unwritable.
Usage¶
Import default.nix as a NixOS module, then:
{
modules.services.harmonia = {
enable = true;
domain = "cache.example.com";
acmeHost = "cache.example.com"; # cert that fronts the vhost
signKeyFile = "/run/secrets/cache-priv-key";
upstreamFallback.enable = true; # the whole point; optional
};
}
Generate the signing key once, off-box:
Deliver the private half to signKeyFile out of band (agenix, sops-nix, a
systemd credential -- anything that keeps it out of the Nix store) and make it
readable by the harmonia service user. Publish the public half to every
consumer:
nix.settings = {
substituters = [ "https://cache.example.com" ];
trusted-public-keys = [ "cache.example.com-1:<contents of cache-pub-key.pem>" ];
};
Because priority defaults to 30 (vs. cache.nixos.org's 40), consumers
prefer your cache automatically -- you can even drop cache.nixos.org from
their substituter list entirely and let the fallback serve it.
Options¶
| Option | Default | Purpose |
|---|---|---|
enable |
false |
turn the cache on |
domain |
(required) | nginx vhost / public cache URL |
acmeHost |
(required) | ACME cert host fronting the vhost |
signKeyFile |
(required) | path to the ed25519 secret signing key |
port |
5000 |
loopback port harmonia listens on |
workers |
16 |
harmonia worker threads |
priority |
30 |
advertised substituter priority (lower wins) |
upstreamFallback.enable |
false |
turn on the disk-cached upstream fallback |
upstreamFallback.endpoint |
cache.nixos.org |
upstream substituter to fall back to |
upstreamFallback.cacheDir |
/var/cache/nix-cache |
nginx proxy_cache disk store |
upstreamFallback.maxCacheSize |
100g |
eviction ceiling for the disk cache |
upstreamFallback.resolver |
null |
DNS resolver enabling per-request re-resolution of the endpoint (see above) |
Caveats¶
- The fallback cache holds full NARs of everything anyone has ever pulled
through it. Size
cacheDir's filesystem andmaxCacheSizeaccordingly. proxy_intercept_errorstreats502/504from harmonia as fallback triggers too, so a flapping harmonia degrades to serving from upstream rather than erroring -- convenient, but it can mask a broken harmonia.- Requires the
nginxNixOS module with ACME configured foracmeHost.
Security notes¶
- Content trust rests on Nix signatures, not on this transport. Every NAR
the fallback caches is signature-verified against each consumer's
trusted-public-keysat install time, so a poisoned cache entry cannot install a forged store path. The upstream re-proxy now verifies the upstream TLS chain (proxy_ssl_verify on), which closes a cache-poisoning / disk-fill DoS vector but is not what protects store-path integrity. - If you point
upstreamFallback.endpointat a host that presents a self-signed or private-CA certificate,proxy_ssl_verify onwill reject it. Add that CA to the system trust store (security.pki.certificateFiles) or the fallback fetch will fail.
Source¶
modules/harmonia-cache-with-upstream-fallback/default.nix
# Harmonia binary cache with a transparent, disk-cached upstream fallback.
#
# One substituter URL covers BOTH this machine's own /nix/store (served and
# signed by harmonia) AND an on-disk-cached copy of an upstream substituter
# (cache.nixos.org by default). A 404 from harmonia falls through to an
# nginx proxy_cache'd upstream: the first peer to request a path warms the
# local disk cache, and every subsequent peer -- plus this builder itself --
# then hits local disk instead of the WAN.
#
# Import into a NixOS host, then set:
# modules.services.harmonia.enable = true;
# modules.services.harmonia.domain = "cache.example.com";
# modules.services.harmonia.acmeHost = "cache.example.com";
# modules.services.harmonia.signKeyFile = "/run/secrets/cache-priv-key";
# modules.services.harmonia.upstreamFallback.enable = true;
#
# Generate the signing key once with:
# nix-store --generate-binary-cache-key cache.example.com-1 \
# cache-priv-key.pem cache-pub-key.pem
# Keep the private half out of the store (agenix / sops / a systemd cred /
# any out-of-band path) and hand its path to signKeyFile. Publish the public
# half to consumers as trusted-public-keys.
{
config,
lib,
...
}:
with lib;
let
cfg = config.modules.services.harmonia;
fb = cfg.upstreamFallback;
in
{
options.modules.services.harmonia = {
enable = mkEnableOption "Harmonia: Rust-based Nix binary cache server";
domain = mkOption {
type = types.str;
example = "cache.example.com";
description = "Public domain name (nginx vhost) for the binary cache.";
};
acmeHost = mkOption {
type = types.str;
example = "cache.example.com";
description = ''
ACME host whose certificate fronts the cache. Usually equal to
`domain`; kept separate so you can share a wildcard/SAN cert.
'';
};
signKeyFile = mkOption {
type = types.path;
example = "/run/secrets/cache-priv-key";
description = ''
Path to the ed25519 secret signing key, generated with
`nix-store --generate-binary-cache-key`. Provide it out of band
(agenix, sops-nix, a systemd credential, ...) -- never in the store.
Must be readable by the harmonia service user.
'';
};
port = mkOption {
type = types.port;
default = 5000;
description = "Port harmonia listens on (loopback only; nginx fronts it).";
};
workers = mkOption {
type = types.int;
default = 16;
description = "Number of harmonia worker threads.";
};
priority = mkOption {
type = types.int;
default = 30;
description = ''
Substituter priority advertised in /nix-cache-info. Lower wins;
cache.nixos.org is 40, so 30 makes this cache preferred.
'';
};
upstreamFallback = {
enable = mkEnableOption ''
falling through to an nginx-cached upstream substituter on 404 from
harmonia. Consumers then see ONE URL covering both this machine's
local /nix/store AND cached upstream NARs: the first peer to fetch a
path warms the on-disk cache, subsequent peers (and this host) hit
local disk instead of the WAN
'';
endpoint = mkOption {
type = types.str;
default = "cache.nixos.org";
description = "Upstream substituter host to fall back to.";
};
cacheDir = mkOption {
type = types.str;
default = "/var/cache/nix-cache";
description = ''
Directory for the nginx proxy_cache disk store. Point this at a
filesystem with plenty of room -- it holds full NARs of everything
any peer has ever pulled through the fallback.
'';
};
maxCacheSize = mkOption {
type = types.str;
default = "100g";
description = "proxy_cache_path max_size (nginx evicts past this).";
};
resolver = mkOption {
type = types.nullOr types.str;
default = null;
example = "127.0.0.53";
description = ''
DNS resolver nginx should use to re-resolve `endpoint` at request
time, and the only way to get periodic re-resolution at all.
When null (the default) `endpoint` is written literally into
`proxy_pass`, which nginx resolves ONCE at config load and then pins
for the lifetime of the worker processes. That is fine for a stable
address and needs no resolver, but a CDN-backed upstream that rotates
addresses will eventually be proxied to a dead IP until the next
nginx reload.
When set, the proxy target is built from an nginx variable instead --
which is what actually forces per-request resolution -- and a
`resolver <value> valid=30s` directive is emitted. Point it at a
resolver that really answers on this box: systemd-resolved's stub is
`127.0.0.53`, a local unbound/dnsmasq is usually `127.0.0.1`, or use
your LAN DNS. A wrong value here makes every fallback request fail,
so it is opt-in rather than a guessed default.
'';
};
};
};
config = mkIf cfg.enable {
services.harmonia.cache = {
enable = true;
signKeyPaths = [ cfg.signKeyFile ];
settings = {
bind = "127.0.0.1:${toString cfg.port}";
workers = cfg.workers;
priority = cfg.priority;
};
};
# nginx (not the harmonia user) owns the proxy_cache directory, and the
# hardened nginx unit needs it whitelisted as writable.
systemd.tmpfiles.rules = mkIf fb.enable [
"d ${fb.cacheDir} 0755 nginx nginx -"
];
systemd.services.nginx.serviceConfig.ReadWritePaths = mkIf fb.enable [ fb.cacheDir ];
# proxy_cache_path must live at http{} scope, not inside a server block.
services.nginx.appendHttpConfig = mkIf fb.enable (
lib.mkAfter ''
proxy_cache_path ${fb.cacheDir} levels=1:2 keys_zone=harmonia_upstream:200m max_size=${fb.maxCacheSize} inactive=60d use_temp_path=off;
''
);
services.nginx.virtualHosts."${cfg.domain}" = {
forceSSL = true;
useACMEHost = cfg.acmeHost;
# A `resolver` alone does NOT make nginx re-resolve a proxy target: an
# upstream written literally in proxy_pass is resolved once, at config
# load. Only a proxy_pass built from a *variable* is resolved per request
# -- and that form in turn REQUIRES a resolver. So the two are emitted
# together or not at all.
extraConfig = mkIf (fb.enable && fb.resolver != null) ''
resolver ${fb.resolver} valid=30s ipv6=off;
set $harmonia_upstream ${fb.endpoint};
'';
locations."/" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
recommendedProxySettings = false;
extraConfig = ''
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_valid 200 365d;
expires max;
add_header Cache-Control "public, immutable" always;
# NARs are large and stream-served. Without these three, nginx
# spools whole objects to disk before forwarding a byte.
proxy_buffering off;
proxy_request_buffering off;
client_max_body_size 0;
${optionalString fb.enable ''
# A store path harmonia does not have returns 404 -- intercept it
# and re-issue against the cached upstream.
proxy_intercept_errors on;
error_page 404 502 504 = @upstream;
''}
'';
};
locations."@upstream" = mkIf fb.enable {
proxyPass = "https://${if fb.resolver == null then fb.endpoint else "$harmonia_upstream"}";
recommendedProxySettings = false;
extraConfig = ''
# Present the upstream's own name (SNI + Host) so its CDN/TLS is happy.
proxy_set_header Host ${fb.endpoint};
proxy_pass_request_headers off;
proxy_ssl_server_name on;
proxy_ssl_name ${fb.endpoint};
# Validate the upstream's TLS chain against the system CA bundle.
# proxy_ssl_server_name/proxy_ssl_name only set SNI -- they do NOT
# enable verification, which nginx leaves off by default. Without
# this, an on-path attacker could feed forged bytes into the on-disk
# proxy_cache. (Nix still signature-verifies every NAR on the
# consumer, so this is defense-in-depth, not the primary control.)
proxy_ssl_verify on;
proxy_ssl_verify_depth 3;
proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
proxy_cache harmonia_upstream;
proxy_cache_valid 200 302 365d;
# Cache genuine 404s only briefly, so absent paths don't hammer the
# upstream on every retry but also don't get pinned as missing.
proxy_cache_valid 404 1m;
# Collapse a thundering herd: one request fills the cache entry, the
# rest wait on it rather than each opening its own upstream fetch.
proxy_cache_lock on;
proxy_cache_lock_timeout 0s;
proxy_cache_lock_age 300s;
proxy_cache_use_stale updating error timeout;
add_header X-Cache-Status $upstream_cache_status always;
expires max;
proxy_buffering off;
proxy_request_buffering off;
'';
};
};
};
}