pypi-cache-proxy¶
Modules
A local, outage-resilient caching proxy for PyPI. Point a client's index URL at it to cut WAN traffic and to keep installing even when PyPI is unreachable.
The problem¶
A CI fleet or a room full of dev machines re-downloads the same wheels from
PyPI over and over. That wastes bandwidth, and worse: when pypi.org has an
outage (or your uplink flaps), every pip install fails, even for packages you
already fetched a hundred times.
A single caching layer usually solves the bandwidth problem but not the outage problem — most caches happily return an error or expire an entry the moment the upstream is down.
The design: two layers¶
pip → nginx (disk cache, TLS) → proxpi (Flask/gunicorn, localhost) → PyPI
└ outage resilience └ index proxy + its own package cache
- proxpi — EpicWink/proxpi, a small
Flask app run under gunicorn on
127.0.0.1. It proxies the PyPI index and keeps its own package cache. - nginx — fronts proxpi with a much larger disk-backed
proxy_cache, terminates TLS, and is where the outage resilience actually lives.
Either layer alone is insufficient — the value is in what nginx adds on top.
The two traps this encodes¶
Both of these are the reason the module exists; without them a naive proxy cache does not survive a PyPI outage.
1. proxy_cache_use_stale — this is the outage resilience¶
Only nginx runs this. It tells nginx: if the upstream errors, times out, or
returns a 5xx — serve the stale cached copy anyway. Combined with
proxy_cache_background_update on, clients keep getting previously-seen
packages while a refresh is attempted in the background. This is what turns
"cache" into "keep working when PyPI is down."
2. proxy_ignore_headers Cache-Control — required, not optional¶
PyPI index pages are served with Cache-Control: no-cache. If nginx honors
that header it will refuse to cache the index at all, silently defeating the
whole setup — you'd get bandwidth savings on nothing and zero outage
resilience. Ignoring it lets your own proxy_cache_valid rules govern
freshness instead.
Two other things worth knowing¶
- proxpi is built inline. It is not in nixpkgs, so
default.nixbuilds it withbuildPythonPackage.pythonRelaxDeps = [ "lxml" ]strips proxpi's exact lxml pin so the nixpkgslxmlsatisfies it without a source rebuild. - The proxpi cache is deliberately smaller than the nginx cache (defaults: 5 GiB inner, 10 GiB outer). The big, durable, outage-serving cache is the nginx one.
Usage¶
Import default.nix as a NixOS module and enable it:
{
imports = [ ./modules/pypi-cache-proxy ];
modules.services.pypi-cache = {
enable = true;
domain = "pypi.example.com"; # required
acmeHost = "example.com"; # optional: reuse an existing ACME cert for TLS
};
}
Then point clients at it:
Check curl -I responses for the X-Cache-Status header (HIT / MISS /
STALE / EXPIRED) to confirm caching is working — STALE during a PyPI
outage is the whole point.
Options¶
| Option | Default | Purpose |
|---|---|---|
enable |
false |
Turn the service on. |
domain |
null (required) |
Virtual host nginx serves the cache on. |
acmeHost |
null |
Existing ACME cert host to reuse for TLS. Null ⇒ no forced SSL (bring your own). |
port |
5000 |
Localhost port for the inner proxpi/gunicorn. |
user / group |
pypi-cache |
System user/group the proxy runs as. |
uid / gid |
null |
Optional fixed ids (null ⇒ auto-allocated). |
dataDir |
/var/lib/pypi-cache |
Inner proxpi package cache directory. |
proxpiCacheSize |
5 GiB |
Inner proxpi cache size, in bytes. |
nginxCacheDir |
/var/cache/nginx/pypi |
Outer nginx disk cache directory. |
nginxCacheSize |
10g |
Max size of the nginx disk cache. |
nginxCacheTime |
30d |
Valid/inactive window for the nginx cache. |
Caveats¶
- nginx is enabled with
mkDefault. If you already manage nginx elsewhere, the vhost merges in; make sure nothing else claims the samedomain. dataDirshould live on persistent storage with enough room forproxpiCacheSize. On impermanence-style setups, point it at your persisted path.- The proxpi service is heavily sandboxed (
ProtectSystem = "strict", empty capability set, syscall filter). It only gets write access todataDir; if you relocate the cache, that path is what's whitelisted. - Pin/refresh the
proxpiversion+sha256indefault.nixwhen you want a newer release.
Source¶
modules/pypi-cache-proxy/default.nix
# pypi-cache-proxy — a two-layer caching proxy for PyPI.
#
# proxpi (Flask under gunicorn on localhost) is the inner proxy; nginx wraps
# it with a larger disk-backed cache. The outage resilience lives entirely in
# the nginx layer — see the README for why. This module is self-contained:
# drop it into your modules list and set `enable = true` plus a `domain`.
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.modules.services.pypi-cache;
# proxpi is not in nixpkgs, so it is built inline here. `pythonRelaxDeps`
# strips proxpi's exact lxml pin so nixpkgs' lxml satisfies it without a
# source rebuild of lxml.
proxpiEnv = pkgs.python3.withPackages (
ps: with ps; [
ps.flask
ps.requests
ps.lxml
ps.beautifulsoup4
ps.gunicorn
(ps.buildPythonPackage rec {
pname = "proxpi";
version = "1.2.0";
pyproject = true;
src = pkgs.fetchPypi {
inherit pname version;
sha256 = "sha256-7Z4NdBJrQK9cd4ZDTLSYfjsw4YNnio9hvUptamUzrjY=";
};
build-system = with ps; [
setuptools
setuptools-scm
];
nativeBuildInputs = [ ps.pythonRelaxDepsHook ];
pythonRelaxDeps = [ "lxml" ];
propagatedBuildInputs = with ps; [
flask
requests
lxml
beautifulsoup4
];
doCheck = false;
})
]
);
in
{
options.modules.services.pypi-cache = {
enable = mkEnableOption "PyPI cache proxy service";
user = mkOption {
type = types.str;
default = "pypi-cache";
description = "System user the proxpi service runs as.";
};
group = mkOption {
type = types.str;
default = "pypi-cache";
description = "System group the proxpi service runs as.";
};
uid = mkOption {
type = types.nullOr types.int;
default = null;
description = "Optional fixed UID for the service user (null = auto-allocate).";
};
gid = mkOption {
type = types.nullOr types.int;
default = null;
description = "Optional fixed GID for the service group (null = auto-allocate).";
};
dataDir = mkOption {
type = types.str;
default = "/var/lib/pypi-cache";
description = "Directory where the inner proxpi proxy stores cached packages.";
};
domain = mkOption {
type = types.nullOr types.str;
default = null;
example = "pypi.example.com";
description = "Virtual host name nginx serves the cache on. Required when enabled.";
};
acmeHost = mkOption {
type = types.nullOr types.str;
default = null;
example = "example.com";
description = ''
Existing ACME certificate host to reuse for TLS (`useACMEHost`). Leave
null to disable forced SSL / bring your own TLS wiring.
'';
};
port = mkOption {
type = types.port;
default = 5000;
description = "Localhost port the inner proxpi/gunicorn process listens on.";
};
proxpiCacheSize = mkOption {
type = types.int;
default = 5368709120; # 5 GiB
description = "Inner proxpi package cache size, in bytes.";
};
nginxCacheDir = mkOption {
type = types.str;
default = "/var/cache/nginx/pypi";
description = "Directory for the outer nginx disk cache.";
};
nginxCacheSize = mkOption {
type = types.str;
default = "10g";
description = "Maximum size of the nginx disk cache (nginx size syntax).";
};
nginxCacheTime = mkOption {
type = types.str;
default = "30d";
description = "How long items stay valid / inactive in the nginx cache.";
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = cfg.domain != null;
message = "modules.services.pypi-cache: domain must be set when enabled";
}
];
users = {
users.${cfg.user} = {
uid = mkIf (cfg.uid != null) cfg.uid;
isSystemUser = true;
group = cfg.group;
};
groups.${cfg.group} = {
gid = mkIf (cfg.gid != null) cfg.gid;
};
};
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0700 ${cfg.user} ${cfg.group} - -"
"d ${cfg.nginxCacheDir} 0750 nginx nginx - -"
];
systemd.services.pypi-cache = {
description = "PyPI caching proxy";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
PROXPI_CACHE_DIR = cfg.dataDir;
PROXPI_CACHE_SIZE = toString cfg.proxpiCacheSize;
PROXPI_INDEX_TTL = "1800";
PROXPI_CONNECT_TIMEOUT = "5";
PROXPI_READ_TIMEOUT = "10";
};
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = cfg.dataDir;
ExecStart = "${proxpiEnv}/bin/gunicorn -b 127.0.0.1:${toString cfg.port} -w 4 proxpi.server:app";
Restart = "always";
RestartSec = "10s";
# Hardening. proxpi only needs to read/write its own cache dir.
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.dataDir ];
NoNewPrivileges = true;
PrivateDevices = true;
ProtectHostname = true;
ProtectClock = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
RemoveIPC = true;
PrivateMounts = true;
MountAPIVFS = true;
SystemCallFilter = [
"@system-service"
"~@privileged"
"~@resources"
"~@mount"
];
SystemCallArchitectures = "native";
CapabilityBoundingSet = "";
AmbientCapabilities = "";
MemoryDenyWriteExecute = true;
LockPersonality = true;
UMask = "0077";
KeyringMode = "private";
ProtectProc = "invisible";
ProcSubset = "pid";
};
};
services.nginx = {
enable = lib.mkDefault true;
appendHttpConfig = lib.mkAfter ''
proxy_cache_path ${cfg.nginxCacheDir}
levels=1:2
keys_zone=pypicache:100m
max_size=${cfg.nginxCacheSize}
inactive=${cfg.nginxCacheTime}
use_temp_path=off;
'';
virtualHosts.${cfg.domain} = {
forceSSL = mkDefault (cfg.acmeHost != null);
useACMEHost = mkIf (cfg.acmeHost != null) (mkDefault cfg.acmeHost);
locations."/" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}/";
proxyWebsockets = true;
extraConfig = ''
proxy_cache pypicache;
proxy_cache_valid 200 301 302 ${cfg.nginxCacheTime};
proxy_cache_valid 404 1m;
proxy_cache_valid any 10m;
# Outage resilience: keep serving previously-seen packages when
# proxpi and/or PyPI are unreachable or erroring.
proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504 updating;
proxy_cache_revalidate on;
proxy_cache_lock on;
proxy_cache_lock_timeout 5m;
proxy_cache_background_update on;
# PyPI index pages send `Cache-Control: no-cache`, which would
# otherwise defeat the cache entirely. Ignore it and let the
# proxy_cache_valid rules above govern freshness.
proxy_ignore_headers Cache-Control Expires Set-Cookie;
proxy_cache_key $scheme$host$request_uri;
add_header X-Cache-Status $upstream_cache_status always;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
'';
};
};
};
systemd.services.nginx.serviceConfig.ReadWritePaths = [ cfg.nginxCacheDir ];
};
}