manic-systems/ncro
{ "createdAt": "2026-05-11T11:39:10Z", "defaultBranch": "main", "description": "Lightweight HTTP proxy for optimizing Nix cache routes for fast access", "fullName": "manic-systems/ncro", "homepage": "", "language": "Rust", "name": "ncro", "pushedAt": "2026-06-05T20:09:24Z", "stargazersCount": 91, "topics": [ "nix", "nix-binary-cache", "nix-cache", "nix-proxy" ], "updatedAt": "2026-06-05T20:09:18Z", "url": "https://github.com/manic-systems/ncro"}Synopsis
Section titled “Synopsis”ncro (pronounced Necro) is a lightweight HTTP proxy, inspired by Squid and
several other projects in the same domain, optimized for Nix binary cache
routing. It routes narinfo requests to the fastest available upstream using EMA
latency tracking, persists routing decisions in SQLite and optionally gossips
routes to peer nodes over a mesh network. How cool is that!
[ncps] !: https://github.com/kalbasit/ncps
Unlike [ncps], ncro does not store NARs on disk. It streams NAR data directly from upstreams with zero local storage. The tradeoff is simple: repeated downloads of the same NAR always hit an upstream, but routing decisions (which upstream to use) are cached and reused. Though, this is desirable for what ncro aims to be. The optimization goal is extremely domain-specific.
Motivation
Section titled “Motivation”During a Nix build, binaries are downloaded from configured substituters, also known as binary caches. When multiple caches serve the same paths or you have multiple caches configured in your Nix setup, there is additional wait time and overhead to every build. ncro solves this by acting as an intelligent local proxy that measures upstream latency in real time and routes each request to the fastest responder. To keep ncro small and lightweight, routing metadata is persisted on disk; NAR content is streamed through with zero local storage. This keeps the proxy stateless on the data path and eliminates cache-invalidation complexity.
[architechture document] !: ./docs/architecture.md
For a deeper look at the system design, see the [architechture document].
How It Works
Section titled “How It Works”flowchart TD A[Nix client] --> B[ncro proxy :8080]
B --> C[/hash.narinfo request/] B --> D[/nar/*.nar request/]
C --> E[Parallel HEAD race] E --> F[Fastest upstream wins] F --> G[Result cached in SQLite TTL] E --> L{All caches unavailable?} L -- yes --> M[Optional fallback cache]
D --> H[Try upstreams in latency order] H --> I{404?} I -- yes --> J[Fallback to next upstream] J --> N{All caches failed?} N -- yes --> M I -- no --> K[Zero copy stream to client]
J --> H M --> A K --> AThe request flow follows two distinct paths depending on the request type:
Narinfo Lookups
Section titled “Narinfo Lookups”- Nix requests
/<hash>.narinfo - ncro checks the SQLite route cache; on a hit, it re-fetches from the cached upstream without probing others
- On a miss, it races HEAD requests to all configured upstreams in parallel
- The fastest upstream wins; the full narinfo body is fetched from that upstream and checked against any per-upstream filters
- If the upstream is rejected by filters, ncro tries the remaining candidates; otherwise the narinfo is returned to the client
- The winning route is persisted with a configurable TTL; subsequent requests for the same hash use the cached route directly
- If all normal candidates are unavailable, ncro may try the optional fallback cache. Fallback narinfos are returned directly and are not persisted as route winners.
NAR Streaming
Section titled “NAR Streaming”- Nix requests
/nar/<hash>.nar - ncro looks up the route for the corresponding narinfo hash; if no route is found (e.g. the narinfo was requested directly from an upstream), it tries upstreams in latency order
- The NAR body is streamed chunk-by-chunk from the selected upstream to the client with zero buffering on disk
- If the upstream returns 404, ncro falls through to the next upstream in latency order
- After all normal upstreams are exhausted with no success, ncro may optionally try the optional fallback cache before returning 404
Background probes (HEAD /nix-cache-info) run every 30 seconds to keep latency
measurements current and detect unhealthy upstreams. System design is covered
further in the [architechture document].
Runtime Endpoints
Section titled “Runtime Endpoints”GET /nix-cache-info: proxy capability advertisement used by NixGET /<hash>.narinfo: route lookup and upstream selectionGET /nar/<path>.nar: streamed NAR content from the chosen upstreamGET /metrics: Prometheus metricsGET /health: JSON health summary of configured upstreams
Routing Notes
Section titled “Routing Notes”- Route cache decisions are stored in SQLite and reused until their TTL expires
(or they are evicted by the LRU policy when
max_entriesis reached). - Latency is tracked using an Exponentially Weighted Moving Average (EMA) with a
configurable smoothing factor (
cache.latency_alpha, default 0.3). Higher alpha values react faster to changes; lower values filter out measurement noise. - Lower latency wins the race. When two upstreams are within 10% of each other,
the lower
priorityvalue acts as a tiebreaker. - Background probes (
HEAD /nix-cache-info) update latency estimates every 30 seconds even when no client traffic is flowing, ensuring warm routing data. - On a cache miss, ncro races all configured upstreams in parallel and returns the first successful response. Unhealthy upstreams (detected by consecutive probe failures) are excluded from the race until they recover.
- Per-upstream filters are applied after ncro fetches the full narinfo from a candidate winner. Rejected upstreams are not cached as winners and ncro keeps looking for another acceptable upstream.
fallback_cacheis a last-resort safety valve. It is disabled by default, defaults tohttps://cache.nixos.orgwhen enabled, and is intentionally not part of health probing, discovery, priority routing, filters, cooldown, or route persistence.
Quick Start
Section titled “Quick Start”# Run with defaults (upstreams: cache.nixos.org, listen: :8080)$ ncro
# Point at a config file$ ncro --config /etc/ncro/config.toml
# Tell Nix to use it. The trusted key must match the upstream narinfo signer.$ nix-shell -p hello \ --substituters http://localhost:8080 \ --extra-trusted-public-keys cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=[installation document] !: ./docs/install.md
Deployment instructions are in [installation document].
[!TIP] If you are testing locally, point only a single Nix client at ncro first. That makes it easier to see cache behavior and upstream selection in logs.
Configuration
Section titled “Configuration”Default config is embedded; create a TOML file to override any field.
[server]listen = ":8080"read_timeout = "30s"write_timeout = "30s"
[[upstreams]]url = "https://cache.nixos.org"priority = 10 # lower = preferred on latency ties (within 10%)public_key = "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
[[upstreams]]url = "https://nix-community.cachix.org"priority = 20public_key = "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
# S3-compatible cache (Garage, MinIO etc.)[[upstreams]]url = "s3://my-bucket?endpoint=minio.example.com&scheme=https"priority = 15
# Private HTTP cache requiring Basic Auth[[upstreams]]url = "https://cache.internal.example.com"priority = 5username = "ncro"password = "hunter2" # it says ******* on my screen it's secure!
# Last-resort fallback used only when all normal caches are unavailable.# Disabled by default and kept outside normal router features.[fallback_cache]enabled = falseurl = "https://cache.nixos.org"public_key = "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
[cache]db_path = "/var/lib/ncro/routes.db"max_entries = 100000 # LRU eviction above thisttl = "1h" # how long a routing decision is trustednegative_ttl = "10m" # cache misses for a short windowlatency_alpha = 0.3 # EMA smoothing factor (0 < alpha < 1)
[cache.mass_query]max_concurrent_races = 64 # total concurrent narinfo racesper_upstream_max_inflight = 8 # per-upstream narinfo head concurrencyin_memory_negative_ttl = "5s" # short-lived miss suppressionupstream_cooldown = "15s" # cooldown on transient upstream network errors
[logging]level = "info" # debug | info | warn | errorformat = "json" # json | text
[discovery]enabled = falseservice_name = "_nix-serve._tcp" # mDNS service type to browsedomain = "local" # mDNS domaindiscovery_time = "5s" # how long to listen per discovery cyclepriority = 20 # priority assigned to discovered upstreamsaddress_family = "any" # "any" | "ipv4" | "ipv6"
[mesh]enabled = falsebind_addr = "0.0.0.0:7946"peers = [] # list of {addr, public_key} peer entriesprivate_key = "" # path to ed25519 key file; empty = ephemeralgossip_interval = "30s"Environment Overrides
Section titled “Environment Overrides”| Variable | Config field |
|---|---|
NCRO_LISTEN | server.listen |
NCRO_DB_PATH | cache.db_path |
NCRO_LOG_LEVEL | logging.level |
Environment overrides are useful for containerized or Systemd deployments where you want a fixed config file but still need to tweak one or two settings.
Path Filters
Section titled “Path Filters”Upstreams can have allow/deny filters. Filters are evaluated after an upstream
wins the narinfo race, because the incoming request only contains the store hash
(/<hash>.narinfo) and the full StorePath is only available after fetching
the narinfo body.
[[upstreams]]url = "https://max.cachix.org"priority = 100
[[upstreams.filters]]action = "allow"field = "name"pattern = "zedless*"
[[upstreams.filters]]action = "deny"field = "name"pattern = "*-source"Supported actions:
allowdeny
Supported fields:
name: store path name after the hash, such aszedless-0.1.0store_path: full/nix/store/<hash>-<name>pathreference: entries from the narinfoReferencesfieldderiver: the narinfoDeriverfield
Patterns support * wildcards. A deny rule always rejects a matching narinfo.
If an upstream has at least one allow rule, at least one allow rule must match;
otherwise the upstream is rejected. If an upstream has no allow rules, it is
accepted unless a deny rule matches.
For a project-specific cache that should only serve zedless, prefer a high
priority plus an allow filter. The high priority keeps it behind general
caches for routing, while the filter prevents unrelated paths from being
accepted if the cache happens to respond first.
Fallback Cache
Section titled “Fallback Cache”fallback_cache is an optional last-resort cache for availability failures. It
is disabled by default. When enabled, it defaults to the nixpkgs binary cache:
[fallback_cache]enabled = trueurl = "https://cache.nixos.org"public_key = "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="The fallback cache accepts the same connection-related fields as an upstream:
url, public_key, username, password, and Nix-style s3:// URLs. It is
not a member of [[upstreams]], and ncro deliberately keeps it out of normal
router behavior:
- It is not health probed and does not appear in
/health. - It is not affected by upstream priority, discovery, cooldown, or filters.
- Successful fallback narinfo lookups are not stored in SQLite and do not become route-cache winners.
- It is used only after normal upstreams are unavailable for narinfo lookups, or after normal NAR streaming attempts fail.
Iif the router, filters, discovery, or health logic regresses, an enabled fallback cache can still provide a direct path to a known-good binary cache.
S3 Upstreams
Section titled “S3 Upstreams”ncro accepts Nix-style s3:// URLs in the url field and fetches narinfo/NAR
objects through the native AWS S3 SDK. Credentials are loaded through the
standard AWS provider chain: environment variables, shared config/credentials
files, profile=, or instance/task identity where available.
Supported query parameters:
| Parameter | Description |
|---|---|
endpoint | Custom S3-compatible host (MinIO, Garage, Backblaze, …). |
scheme | http or https. Only meaningful with endpoint. Default: https. |
region | AWS region. Default: us-east-1. |
profile | AWS credential profile name for the standard AWS config/credentials files. |
addressing-style | auto, path, or virtual. Default: auto; custom endpoints and dotted bucket names use path-style in auto. |
# S3-compatible store with a custom endpoint[[upstreams]]url = "s3://my-bucket?endpoint=minio.example.com&scheme=https"priority = 15
# AWS S3 bucket with explicit region and credential profile[[upstreams]]url = "s3://my-nix-cache?region=eu-west-1&profile=cache-readonly"priority = 20[!NOTE]
username/passwordare for HTTP Basic Auth upstreams only. S3 upstreams use AWS credentials, for exampleAWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEY, or a namedprofile=.
Upstream Authentication
Section titled “Upstream Authentication”Any upstream can carry username and password fields. ncro itself sends HTTP
Basic Auth on every request to that upstream: health probes, narinfo races, and
NAR streaming.
[[upstreams]]url = "https://cache.internal.example.com"priority = 5username = "ncro"password = "hunter2"password is optional. Omit it for token-only schemes where the token goes in
the username field.
NixOS Integration
Section titled “NixOS Integration”{lib, ...}: { services.ncro = { enable = true; settings = { upstreams = [ { url = "https://cache.nixos.org"; priority = 10; public_key = "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="; }
{ url = "https://nix-community.cachix.org"; priority = 20; public_key = "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="; } ]; }; };
# Point Nix at the proxy. By default the module appends every configured # upstream public_key, plus the fallback_cache public_key when fallback is # enabled, to nix.settings.trusted-public-keys; set # services.ncro.addUpstreamPublicKeys = false to manage those keys yourself. # NOTE: ncro needs to be the *only* substituter if you wish to benefit # from its capabilities fully. If there are other substituters in your # list, or if you don't mkForce this option, ncro will perform less # efficiently. nix.settings.substituters = lib.mkForce [ "http://localhost:8080" ];}Alternatively, if you’re not using NixOS, create a Systemd service similar to
this. You’ll also want to harden this, but for the sake of brevity I will not
cover that here. Make sure you have ncro in your PATH, and then write the
Systemd service:
[Unit]Description=Nix Cache Route Optimizer
[Service]ExecStart=ncro --config /etc/ncro/config.tomlDynamicUser=trueStateDirectory=ncroRestart=on-failure
[Install]WantedBy=multi-user.targetPlace it in /etc/systemd/system/ and enable the service with
systemctl enable. In the case you want to test out first, run the binary with
a sample configuration instead.
Discovery Mode
Section titled “Discovery Mode”When discovery.enabled = true, ncro browses the local network for mDNS
services matching service_name (default _nix-serve._tcp) and registers each
discovered instance as a dynamic upstream with priority.
Every routable address advertised by a discovered service is registered
separately. When address_family = "any" (default), both IPv4 and IPv6
addresses are added so the router’s race engine can try them in parallel. Set
address_family = "ipv4" or address_family = "ipv6" to restrict to one
family. This is generally useful when your binary cache server only listens on
one stack (e.g. nix-serve binds 0.0.0.0 by default and does not accept IPv6
connections.)
Discovered upstreams are removed when they have not been seen for three
discovery_time intervals.
[discovery]enabled = trueservice_name = "_nix-serve._tcp"domain = "local"discovery_time = "5s"priority = 20address_family = "ipv4" # restrict to IPv4-only cachesMesh Mode
Section titled “Mesh Mode”When mesh.enabled = true, ncro creates an ed25519 identity, binds a UDP socket
on bind_addr, and gossips recent route decisions to configured peers on
gossip_interval. Messages are signed with the node’s ed25519 private key and
serialized with msgpack. Received routes are merged into an in-memory store
using a lower-latency-wins / newer-timestamp-on-tie conflict resolution policy.
Each peer entry takes an address and an optional ed25519 public key. When a public key is provided, incoming gossip packets are verified against it; packets from unlisted senders or with invalid signatures are silently dropped.
If mesh.private_key is left empty, ncro generates an ephemeral identity on
startup. That is fine for testing, but persistent gossip requires a stable key
so peers can recognize the node across restarts.
[mesh]enabled = trueprivate_key = "/var/lib/ncro/node.key"
[[mesh.peers]]addr = "100.64.1.2:7946"public_key = "a1b2c3..." # hex-encoded ed25519 public key (32 bytes)
[[mesh.peers]]addr = "100.64.1.3:7946"public_key = "d4e5f6..."The node logs its public key on startup (mesh node identity log line). You can
share it with peers so they can add it to their config.
[!TIP] Keep mesh traffic on a private network. The gossip protocol is signed, but it is still meant for trusted peers. ncro’s mesh network feature was designed with Tailscale in mind.
Metrics
Section titled “Metrics”Prometheus metrics are available at /metrics.
| Metric | Type | Description |
|---|---|---|
ncro_narinfo_cache_hits_total | counter | Narinfo requests served from route cache |
ncro_narinfo_cache_misses_total | counter | Narinfo requests requiring upstream race |
ncro_narinfo_requests_total{status} | counter | Narinfo requests by status (200/error) |
ncro_nar_requests_total | counter | NAR streaming requests |
ncro_upstream_race_wins_total{upstream} | counter | Race wins per upstream |
ncro_upstream_latency_seconds{upstream} | histogram | Race latency per upstream |
ncro_route_entries | gauge | Current route entries in SQLite |
[!TIP] If you are tuning upstreams, watch
ncro_upstream_latency_secondsandncro_upstream_race_wins_totaltogether. The first shows raw response timing; the second shows which cache host is actually being chosen.
Operational Tips
Section titled “Operational Tips”- Use
priorityto break ties between similarly fast caches, not to override a clearly slower upstream. - Put
db_pathon persistent storage if you want routing decisions to survive restarts. - Use a small
ttlwhile testing and a larger one in production to reduce upstream probing. - Keep
cache.nixos.organd any private caches in the upstream list, with the most trusted cache first. - Enable
fallback_cacheonly when you want a last-resort cache that bypasses normal router features during upstream outages. - If you run behind a firewall or container network, make sure the listen port is reachable from your Nix clients.
Hacking
Section titled “Hacking”This project is built with NixOS in mind and naturally the primary means of
working on this project is using Nix for a reproducible developer environment.
Use nix develop to enter a development shell, or direnv allow to use the
provided .envrc if you use Direnv.
Building
Section titled “Building”# With Nix (recommended)$ nix build
# With Cargo directly$ cargo build --release
# Development shell$ nix develop$ cargo testLicense
Section titled “License”[provided here] !: https://interoperable-europe.ec.europa.eu/sites/default/files/custom-page/attachment/eupl_v1.2_en.pdf
This project is made available under European Union Public Licence (EUPL) version 1.2. See [LICENSE]!(LICENSE) for more details on the exact conditions. An online copy is [provided here].