Documentation

Configuration

peryx reads one TOML file, passed with --config <path>. A few operational settings double as flags or PERYX_* environment variables, which override the file. Precedence is defaults < TOML file < environment < flags.

Top level

SettingFlagEnvironmentTOML keyDefault
Bind host--hostPERYX_HOSThost127.0.0.1
Bind port--portPERYX_PORTport4433
Data directory--data-dirPERYX_DATA_DIRdata_dirperyx-data
Writer identity--writer-identityPERYX_WRITER_IDENTITYwriter_identity(none)
Offline mode--offlinePERYX_OFFLINEofflinefalse
Read replica mode--read-onlyPERYX_READ_ONLYread_onlyfalse
Config file--config / -c(n/a)(n/a)(none)
Cache freshness (seconds)(file/env only)PERYX_CACHE_TTL_SECScache_ttl_secs300
Page cache budget (bytes)(file/env only)PERYX_HOT_CACHE_BYTEShot_cache_bytes268435456
Upstream netrc file(file/env only)PERYX_NETRCnetrc(none)
Stale-on-error bound (s)(file/env only)PERYX_MAX_STALE_SECSmax_stale_secs300
Usage retention (days)(file/env only)PERYX_USAGE_RETENTION_DAYSusage_retention_days(unset)
Indexes(file only)(n/a)[[index]](see below)
Rate limits(file only)(n/a)[rate_limit](see below)
Background jobs(file only)(n/a)[jobs](see below)

Environment variables sit between the file and flags: a PERYX_* value overrides the TOML file, and a flag overrides the variable. Only scalar settings are environment-configurable. The [[index]] topology and [rate_limit] block stay file-only, since neither maps to a flat variable. An empty variable is treated as unset. The [log] block also reads variables (PERYX_LOG_LEVEL, PERYX_LOG_FORMAT, PERYX_LOG_SINK, PERYX_LOG_FILE); see [log].

cache_ttl_secs is both a fallback and a ceiling. When an upstream response carries a usable Cache-Control lifetime (s-maxage or max-age) that is shorter, that lifetime governs the page; a longer one is clamped to cache_ttl_secs. The fallback applies when the header is absent, no-cache/no-store, or zero.

The ceiling matters because Cache-Control is the upstream's opinion, not yours. An upstream — or any CDN in front of it — answering max-age=31536000 would otherwise pin a page in your cache for a year with no revalidation. Raise cache_ttl_secs if you want to trust a long upstream lifetime; lower it to revalidate sooner than the upstream asks.

Artifacts never expire; they are content-addressed by sha256, so a changed upstream file is a new entry on the page rather than a mutation.

max_stale_secs bounds the other direction. When the upstream is unreachable or answers 5xx, peryx keeps serving the last page it fetched rather than failing a build over a blip — but only for this long past the page's freshness window. Beyond it the upstream failure surfaces instead, because a cache that answers with whatever it last saw, forever, has stopped being a cache and become a fork. Set it to 0 to serve stale without limit, which is what mirroring a knowingly unreliable upstream asks for; offline = true below is the unconditional form.

usage_retention_days bounds the durable daily version and source usage aggregate: buckets older than this many days expire on the aggregator thread, off the request path. Leave it unset to keep every day. Tightening it only reclaims durable storage; a retained day's totals never change, and expiry never blocks request handling.

hot_cache_bytes is the memory budget for the transformed-page cache, where a warm request is a lookup, an expiry check, and a memcpy. It trades memory against warm-serve speed and nothing else: every entry is re-derivable from the cached raw page, so a smaller budget only lowers the hit rate, and 0 turns the cache off so each warm page pays its transform again. Lower it on a memory-tight host; raise it when a few projects with very large index pages (boto3 and numpy run to megabytes of JSON) carry the traffic. The PyPI driver is the only ecosystem that populates it today.

offline = true disables upstream network access for configured cached indexes. Whatever an ecosystem has cached serves from disk: PyPI project pages, PEP 658 metadata siblings, and wheels; OCI manifests and blobs. A cold cached-index miss returns 503; virtual-index routes still serve any hosted layer that can answer. Use peryx mirror sync before enabling offline mode on a machine that must run without network access.

read_only = true runs the process as a read replica. It rejects each HTTP mutation with 503 Service Unavailable, disables upstream cache fills, webhook delivery, and background maintenance, and reports the replica role through GET /+status. Use this mode only with a data directory populated by backup restore or an external replication system. Replica mode requires the copied metadata store and configuration to contain the same nonblank writer_identity; peryx stops startup unless both values match.

writer_identity enables the single-writer startup guard. A writer claims this value in the metadata store at startup; another configured identity cannot start against that store. Replica mode does not claim it, so a restored config snapshot may retain the writer's value while serving read-only. See High availability for promotion.

Upstream credential sources

An upstream password or token reads from one of three places: the value inlined in the config, a *_file sibling, or a *_env sibling. Set at most one per credential; naming two fails startup. The same three sources apply to both a legacy cached = URL index and each [[index.upstream]] source, and to both PyPI and OCI upstreams. A bearer token still takes precedence over username plus password, and both still precede any netrc match, so adding a _file or _env source changes where the secret comes from, never which credential wins.

[[index]]
name = "corp"
cached = "https://packages.corp.example/simple/"
username = "peryx"
password_env = "PERYX_CORP_PASSWORD"             # from the environment the process manager injects

[[index]]
name = "registry"
ecosystem = "oci"
cached = "https://registry.corp.example"
token_file = "/run/credentials/peryx.service/registry-token" # from a systemd credential

password_file/token_file fit secret files mounted read-only by the process manager: a systemd credential under $CREDENTIALS_DIRECTORY (LoadCredential= or SetCredential=), a Kubernetes Secret projected under /run/secrets, or a Docker secret. peryx trims surrounding whitespace, so a file that a kubectl create secret mount or an echo left with a trailing newline still resolves. password_env/token_env fit a value the manager passes as an environment variable, including systemd's %d-free Environment=/EnvironmentFile= and a Kubernetes secretKeyRef.

peryx resolves every source once, when it builds the cached index at startup, and holds the value for the process lifetime, so it never rereads the file or variable per artifact request. A missing, unreadable, empty, or oversized file and an unset, non-UTF-8, or empty environment variable each stop startup with an error that names only the file path or variable, never the secret. The one-mebibyte file ceiling rejects a path pointed at a log or device before it is read into memory. Config snapshots (peryx backup) keep the _file/_env reference, not the resolved secret.

To migrate an inlined credential, move the value into a file or environment variable and replace password/token with its _file/_env sibling; the inline keys keep working, so migrate one upstream at a time.

Upstream netrc credentials

Set netrc to opt into one shared file of Basic credentials for cached upstreams. peryx reads and parses the file once at startup. A configured bearer token wins first, followed by a configured username and password; netrc supplies credentials only when the cached index or [[index.upstream]] source has neither.

netrc = "/run/secrets/upstream.netrc"

[[index]]
name = "corp"
cached = "https://packages.example/simple/"

The traditional form matches pip and uv for a host on the scheme's default port:

machine packages.example
login __token__
password pypi-token

Use an origin or authority machine name when the same host serves more than one credential boundary. peryx searches an exact origin first, then host:port, a bare host on a default port, and default.

machine https://packages.example:8443
login release-reader
password release-secret

machine packages.example:9443
login staging-reader
password staging-secret

The resolved credential belongs to the configured upstream's exact scheme, host, and effective port. peryx does not send it to an artifact URL on another origin, and reqwest removes it when a redirect changes any part of that origin. A default entry can select credentials for an otherwise unmatched upstream, but those credentials remain bound to the selected upstream after lookup; redirects do not trigger another netrc search.

The selected path must name a regular file. On Unix, its owner must match the effective process user and group or other permission bits make startup fail; use chmod 600 /run/secrets/upstream.netrc. Parse and permission errors name the file without printing its contents. Peryx does not search ~/.netrc unless you select that path.

Upstream TLS

A cached PyPI index or OCI registry can extend the platform trust store with a private CA and authenticate with a client certificate. Configure the paths on the cached index:

[[index]]
name = "corp-python"
cached = "https://packages.example/simple/"
ca_file = "/run/secrets/corp-ca.pem"
client_cert_file = "/run/secrets/peryx-client.pem"
client_key_file = "/run/secrets/peryx-client-key.pem"

[[index]]
name = "corp-images"
ecosystem = "oci"
cached = "https://registry.example"
ca_file = "/run/secrets/corp-ca.pem"
client_cert_file = "/run/secrets/peryx-client.pem"
client_key_file = "/run/secrets/peryx-client-key.pem"

For an ordered route, put the same keys on each [[index.upstream]] source. Each source gets an independent trust store and identity; keys on the parent [[index]] are rejected because their scope would be ambiguous.

ca_file accepts one or more PEM certificates and adds them to the platform roots. It does not replace public trust. client_cert_file contains a PEM leaf certificate followed by any intermediate certificates. client_key_file contains its matching, unencrypted PEM private key. Configure the certificate and key together. Peryx parses the PEM and verifies the key match when it builds the client. During the TLS 1.3 handshake, peryx validates the upstream certificate and the upstream validates the client chain and RFC 5280 purpose.

The identity is bound to the configured origin: scheme, host, and effective port. An explicit artifact_url or an absolute artifact URL discovered in upstream metadata receives the configured CA but not the identity when it changes origin. With a client identity, peryx rejects cross-origin redirects instead of offering the certificate to the redirect target.

Peryx reads these files when it constructs the upstream client. To rotate them, replace each file atomically and restart peryx through the deployment supervisor; peryx does not poll the files. Existing clients keep their parsed material until the process replaces them. Restrict private keys to the peryx user (0400 or 0600) and their directory to 0700. In a container, mount the CA, certificate, and key as read-only secrets rather than copying them into the image.

Startup errors identify the index and whether the CA, certificate, or key was unreadable or invalid. They do not print the path or PEM contents. Configuration snapshots retain the paths so a restore can remount the same secrets; they never contain certificate or key bytes.

TLS

peryx serves plain HTTP by default, which is the right choice for a laptop: pip/uv accept any URL, and docker/podman trust a loopback registry (localhost, 127.0.0.0/8) over HTTP with no configuration. To serve over the network, where clients demand HTTPS, turn on TLS with one of two mutually exclusive tables. Neither is set by default, and an unconfigured server keeps the plain-HTTP path.

A [tls] table serves HTTPS from a certificate and key you provide:

[tls]
cert = "/etc/peryx/fullchain.pem"
key = "/etc/peryx/privkey.pem"

An [acme] table obtains and renews a certificate from an ACME provider (Let's Encrypt), so a publicly reachable deployment serves trusted HTTPS with no manual certificate handling and no client-side insecure flag:

[acme]
domains = ["registry.example.com"]
contact = "admin@example.com"
cache-dir = "/var/lib/peryx/acme"  # where issued certificates are cached; default "acme-cache"
staging = false                    # true uses Let's Encrypt staging while testing
TableKeyMeaningDefault
[tls]certPEM certificate chain(required)
[tls]keyPEM private key(required)
[acme]domainsDomains to request a certificate for; reachable on port 443(required)
[acme]contactContact email the ACME account registers(required)
[acme]cache-dirWhere certificates and the account key are cachedacme-cache
[acme]stagingUse the provider's staging environmentfalse

For an [acme] deployment the domain's DNS must point at the server and port 443 must be reachable, since the ACME handshake happens there. Behind a load balancer or reverse proxy that already terminates TLS, leave both tables unset and let the proxy hold the certificate.

[[index]]

Each [[index]] table declares one index. name is required; exactly one of cached, hosted, or layers selects the role. peryx rejects unknown keys.

KeyRoleMeaningDefault
nameallIdentifier other indexes reference in layers(required)
routeallURL prefix the index is served undersame as name
ecosystemallPackaging format: pypi or ocipypi
cachedcachedUpstream URL to cache (a Simple index, or a /v2/ registry for OCI)
usernamecachedBasic-auth username for the upstream(none)
passwordcachedBasic-auth password for the upstream(none)
password_filecachedPath to read password from instead of inlining it(none)
password_envcachedEnvironment variable to read password from instead of inlining it(none)
tokencachedBearer token; takes precedence over username/password(none)
token_filecachedPath to read token from instead of inlining it(none)
token_envcachedEnvironment variable to read token from instead of inlining it(none)
ca_filecachedPEM CA bundle added to platform trust for this upstream(none)
client_cert_filecachedPEM client certificate chain; requires client_key_file(none)
client_key_filecachedMatching unencrypted PEM client key; requires client_cert_file(none)
upstream_concurrencycachedCap on concurrent upstream fetches; 0 is unlimited and the default0
offlinecachedServe this cached index from disk onlyfalse
prefetchcachedPackage and artifact selection for peryx mirror(see below)
hostedhostedtrue marks this index as a hosted store (implied by upload_token)false
upload_tokenhostedSugar for one token granted write and delete over every project(none)
upload_token_filehostedPath to read upload_token from instead of inlining it(none)
volatilehostedAllow delete and overwritetrue
anonymous_readallWhether a credential-less request may read this index[auth] default
access_tokenallNamed credentials the index accepts, with scoped grantsnone
layersvirtualOrdered index names to compose; first match per filename wins
uploadvirtualHosted layer that receives uploadsfirst hosted layer
policyallNested index policy tableempty
settingsallNested table of the index ecosystem's own settingsempty
webhookallSigned delivery targets for upload and index-change eventsnone

A route is a raw URL path prefix. It must be one or more non-empty path segments separated by /; each segment may contain only ASCII letters, digits, -, ., _, and ~. Startup rejects routes with a leading or trailing /, empty segments, percent encoding, traversal segments, control characters, spaces, and routes whose first segment is reserved for Peryx endpoints such as browse, stats, +stats, +status, api-docs, metrics, and pkg.

Declaring any [[index]] replaces the default topology, which ships a trio per ecosystem: a cached upstream, a hosted store, and a virtual index that layers the two.

[[index]]
name = "pypi"
cached = "https://pypi.org/simple/"

[[index]]
name = "hosted"
hosted = true

[[index]]
name = "root/pypi"
layers = ["hosted", "pypi"]
upload = "hosted"

[[index]]
name = "dockerhub"
ecosystem = "oci"
cached = "https://registry-1.docker.io"

[[index]]
name = "images"
ecosystem = "oci"
hosted = true

[[index]]
name = "root/oci"
ecosystem = "oci"
layers = ["images", "dockerhub"]
upload = "images"

Startup rejects duplicate names, duplicate routes, invalid routes, layers entries that name no index, layers that mix ecosystems, an upload target that is not a hosted index, and an empty upload_token = "". An empty token is a configuration error rather than a valid value: authorization compares upload_token against the Basic-auth password on each request, so a blank string would admit any request that presents an empty password and turn off write and delete authentication for the index. The empty value almost always comes from a config template whose environment variable never expanded, so peryx refuses it at load time instead of booting into that state. To configure no token, omit the key rather than setting it to "".

[index.policy]

Policy rules apply to the index that owns the table. A cached-index policy filters that cache; a hosted policy filters direct uploads and hosted-route reads; a virtual policy filters the merged index clients use. Project names are compared after PEP 503 normalization.

[[index]]
name = "root/pypi"
layers = ["hosted", "pypi"]
upload = "hosted"

[index.policy]
fallback_mode = "private-first"
allow_projects = ["flask", "requests"]
block_projects = ["bad-package"]
protected_names = ["acme-secrets", "acme-*"]
allow_versions = ">=1,<3"
allow_package_types = ["wheel"]
block_package_types = ["sdist"]
allow_wheel_pythons = ["py3", "cp313"]
block_wheel_platforms = ["win_amd64"]
max_file_size_bytes = 104857600
max_project_size_bytes = 1073741824
min_release_age_secs = 604800
required_attestations = ["https://docs.pypi.org/attestations/publish/v1"]
attestation_mode = "enforce"
KeyMeaning
fallback_modePyPI virtual source policy: fallback, private-first, or no-fallback
allow_projectsOnly these normalized projects may be served, mirrored, or uploaded
block_projectsThese normalized projects are denied
protected_namesReserved names that never fall back upstream; exact or prefix-* namespace
allow_versionsPEP 440 specifier set accepted for parsed distribution filenames
allow_package_typesAccepted parsed file types: wheel, sdist
block_package_typesDenied parsed file types: wheel, sdist
allow_wheel_pythonsAccepted wheel Python tags, matched against each dot-compressed tag segment
block_wheel_pythonsDenied wheel Python tags
allow_wheel_platformsAccepted wheel platform tags, matched against each dot-compressed tag segment
block_wheel_platformsDenied wheel platform tags
max_file_size_bytesMaximum file size from the Simple API size field or from an uploaded file
max_project_size_bytesMaximum sum of retained file sizes for one project detail page
min_release_age_secsHide an upstream file until this many seconds past its upload-time
required_attestationsIn-toto predicate types an upload must carry a PEP 740 attestation for
attestation_modeenforce rejects a missing attestation; audit records it but publishes

min_release_age_secs quarantines fresh upstream releases: a file whose Simple API upload-time is younger than the delay is hidden from the served page, giving operators a window to catch a malicious or mistaken upload before it reaches clients. A common baseline is a seven-day delay (604800). A file with no upload-time is hidden while the delay is set, since its age cannot be established. The clock is the serving clock, so the file appears once enough time passes. This is PyPI-specific and applies only to a PyPI index.

required_attestations makes a hosted upload carry a PEP 740 attestation for every listed in-toto predicate type. peryx evaluates the rule at the upload boundary, after the distribution's structure and each attestation's subject binding are validated and before the file and its provenance publish, so an upload missing a required predicate type publishes neither object. The check reads only the predicate types the bound attestations already declared: it performs no signature, certificate, or transparency-log verification, and asserts no publisher identity. A file uploaded with no attestations satisfies no requirement. attestation_mode chooses the outcome of an unmet requirement: enforce (the default) returns a 403 that names the missing predicate types without echoing bundle content, while audit records the same required-attestation-audit policy decision but lets the upload publish, so an operator can measure coverage before turning enforcement on. Both modes persist the decision. The rule is PyPI-specific and applies only to a PyPI index; it runs after the structural, size, and tag rules, so a file rejected on one of those reports that denial rather than the attestation requirement.

File and project size rules require declared sizes. A file without size is denied by max_file_size_bytes; a project page with any retained file lacking size is denied by max_project_size_bytes. Active policies use the buffered Simple-page path so file lists and PEP 691 versions are filtered together before peryx serves bytes.

protected_names reserves private names against dependency confusion. peryx refuses a reserved name on the upstream mirror path only: a hosted member still serves it and accepts uploads for it, but a request the local members cannot answer fails instead of reaching the public index. That closes the gap a missing, renamed, or deleted local package would otherwise open. An entry is an exact name or a prefix-* namespace rule, both normalized like the incoming name before the comparison.

fallback_mode controls how a PyPI virtual index chooses project candidates from its immediate hosted and cached members:

  • fallback is the compatibility default. It merges every member and keeps the first record for a duplicate filename, so hosted and upstream files with different names remain visible together.
  • private-first serves only hosted candidates when both source classes contain the normalized project. It uses the cached candidates only when the hosted members contain no files, and records a structured policy_decision security event for each collision.
  • no-fallback does not query an immediate cached member. A project with no hosted candidates returns a structured 403 policy denial instead of an empty or upstream page.

Protected names take precedence over all three modes: hosted members can serve a protected name, but cached members do not query it upstream. The comparison uses the same PEP 503 normalization as project routing, so acme-pkg/acme_pkg/acme.pkg select one policy decision. A nested virtual member uses its own mode; configure that member too when its boundary must enforce the same rule.

Leaving fallback_mode unset preserves existing filename-level merging. An unknown value or use on an OCI index is a startup error. The setting governs candidates returned by this server, not indexes a client adds to its own config; pip's --extra-index-url, uv source overrides, and nested virtual indexes with their own mode remain separate trust boundaries. Use protected_names for private names that must stay blocked even while absent or renamed.

allow_projects, block_projects, protected_names, max_file_size_bytes, and max_project_size_bytes are ecosystem-neutral and apply to an OCI index too, matching on image name and blob size: a blocked image is hidden on reads and refused on push, and a layer or manifest over the size limit is refused. The rest of the keys above cover fallback selection, version specifiers, package types, and wheel tags. These are Python-specific (PEP 440 versions, wheel/sdist types, wheel tags) and have no OCI counterpart, so they are implemented in the PyPI ecosystem crate and apply only to a PyPI index. Each ecosystem contributes its own matchers to the same neutral [index.policy] engine through a rule trait.

[index.settings]

Settings the index's ecosystem defines for itself. The keys belong to the ecosystem, not to this layer: peryx carries the table to the ecosystem of the index that owns it and compiles it there, so a key that ecosystem does not know is a startup error.

PyPI defines no settings, so [index.settings] on a PyPI index fails to start. OCI defines library_prefix on a cached index, which decides how that index spells a repository name when it asks its upstream for it. Its values, and what each one rewrites, are in OCI index settings.

[[index.access_token]]

Each [[index.access_token]] table adds one named credential the index accepts, with a grant scoped to some projects and actions. Put these under the hosted index that stores the writes. The access model covers the grammar; the keys are:

[[index]]
name = "hosted"
hosted = true

[[index.access_token]]
name = "ci"
secret = "ci-secret"
projects = ["team-*"]
actions = ["write", "delete"]
expires_at = "2027-01-01T00:00:00Z"
KeyMeaningDefault
nameSubject the token authenticates as; unique per index, not upload_token(required)
secretPassword a client presents as its Basic password(required)
secret_filePath to read secret from instead of inlining it(none)
projectsProject globs the token may act on; * matches any run of characters["*"]
actionsAny of read, write, delete; at least one(required)
expires_atRFC 3339 time after which it stopsnever

A token needs exactly one of secret and secret_file. Write and delete are enforced now; a read grant records a read policy that the forthcoming read challenge will enforce.

[index.prefetch]

Cached indexes can declare the default selection for peryx mirror plan, peryx mirror sync, and peryx mirror verify. CLI flags add package selectors and override booleans or mode for one run.

[[index]]
name = "pypi"
cached = "https://pypi.org/simple/"

[index.prefetch]
mode = "selected"
packages = ["requests>=2,<3"]
requirements = ["requirements.txt"]
include_wheels = true
include_sdists = true
python_tags = ["py3", "cp312"]
abi_tags = ["none", "abi3"]
platform_tags = ["any", "manylinux_2_28_x86_64"]
max_file_size_bytes = 524288000
metadata_only = false
KeyValuesDefault
modeselected, all, metadata-onlyselected
packagespackage selectors such as flask>=3[]
requirementsrequirements or constraints files[]
include_wheelsbooleantrue
include_sdistsbooleantrue
python_tagswheel Python tags[]
abi_tagswheel ABI tags[]
platform_tagswheel platform tags[]
max_file_size_bytespositive integer(none)
metadata_onlybooleanfalse

mode = "all" reads the upstream root Simple project list and then visits matching projects. Artifact filters apply after a project page is fetched. mode = "metadata-only" implies metadata_only = true.

The wheel/sdist and wheel-tag keys above are the PyPI selection set and seed peryx mirror for a PyPI index. For an OCI index, packages is the image list peryx mirror pulls (image references such as library/alpine:3.19), the same way it seeds a PyPI index's project list; --image <ref> on the command line adds one-off references on top. The PyPI wheel/sdist/wheel-tag keys do not apply to an OCI index.

[rate_limit]

Rate limits are local to one peryx process and disabled by default. When enabled = true, they use fixed windows and bounded in-memory buckets; restarting the process clears the buckets. max_clients caps the number of client/class buckets kept in memory. Set a class requests or window_secs to 0 to disable that class limit.

peryx hashes a named principal with a process-random seed and stores neither the credential nor the principal name. Invalid credentials and routes without an authentication driver use the socket peer IP. Without socket metadata, peryx uses 127.0.0.1 and ignores forwarding headers.

Set trusted_proxies to the reverse-proxy networks from which peryx accepts forwarding headers. For a matching socket peer, peryx scans X-Forwarded-For from the nearest hop and selects the first address outside the trusted networks. If the header is absent, peryx accepts one X-Real-IP value. The same peer check gates X-Forwarded-Host and X-Forwarded-Proto for public API links and OCI token realms, whether or not enabled is true. Peryx uses the socket peer when the trusted client-address suffix is malformed or the chain contains trusted addresses throughout. It treats IPv4-mapped IPv6 addresses as their IPv4 equivalents. Leave the list empty for direct deployments. Exclude client networks and intermediaries that accept caller-supplied forwarding headers.

Clients can change a Basic username or bearer value without changing buckets when both values resolve to the same principal. peryx groups rotated invalid Authorization values under the peer IP.

SettingMeaningDefault
enabledInstall the HTTP request limiterfalse
max_clientsMaximum client/class buckets kept in memory8192
trusted_proxiesIPv4 and IPv6 networks allowed to set forwarding headers[]

Each route class is a sub-table with requests and window_secs:

TableRoute classDefault
[rate_limit.listing]Project listing and detail pages600 / 60s
[rate_limit.metadata]PEP 658/714 .metadata siblings1200 / 60s
[rate_limit.artifact]Artifact downloads and archive inspection300 / 60s
[rate_limit.upload]Upload, yank, restore, and delete requests60 / 60s
[rate_limit.admin]Status, stats, metrics, and discovery endpoints120 / 60s

A request's class follows its method and its path. POST, PUT, PATCH, and DELETE count against upload. GET, HEAD, and OPTIONS are reads, classed by the path they hit: a manifest or artifact HEAD shares the artifact budget, a project listing the listing budget, a .metadata sibling the metadata budget, and a status or discovery call the admin budget. That split matters for OCI, where a client sends a HEAD on every manifest and blob before it pulls; charging those reads against the strict upload budget would let a routine pull drain it and start drawing 429 rejections.

Example:

[rate_limit]
enabled = true
max_clients = 4096
trusted_proxies = ["127.0.0.1/32", "10.42.0.0/16"]

[rate_limit.listing]
requests = 300
window_secs = 60

[[index]]
name = "pypi"
cached = "https://pypi.org/simple/"
upstream_concurrency = 4

[jobs]

The [jobs] table controls the node-local background work peryx runs on a timer: reclaiming expired process resources and revalidating stale cached pages. Each ecosystem is swept on its own, so independent repositories run together while one repository never sweeps itself twice at once.

[jobs]
mode = "none"
KeyMeaningDefault
modelocal runs maintenance on this node; none runs nothinglocal

mode = "none" starts no scheduler, timer, or worker, which suits a node fronted by an external maintenance runner or one that should only serve. A read replica runs no maintenance regardless of this setting.

Schedules

By default a node runs cache maintenance once a minute. Replace that with an explicit [[jobs.schedule]] array to choose which jobs run and how often. Each entry names one registered job and a positive interval in seconds; peryx rejects a non-positive interval at startup, naming the schedule's index (jobs schedule [0]).

[[jobs.schedule]]
job = "cache_maintenance"
interval_secs = 300
KeyMeaningDefault
jobThe registered job to run; cache_maintenance(required)
interval_secsSeconds between runs, must be positive(required)

cache_maintenance reclaims expired process resources and revalidates stale cached pages, fanning out one run per installed ecosystem so independent repositories sweep together while one repository never sweeps itself twice at once.

One bounded timer drives every schedule, so a large set costs no per-tick scan. When a tick arrives while the same job's previous run is still going, peryx skips it rather than queueing it, and counts the skip in the job metrics. Pick an interval longer than a sweep takes, and stagger it clear of your peak request hours so maintenance and traffic do not contend for upstream bandwidth.

The timer keeps no durable state. On restart it sets each schedule's next run one full interval after startup and drops the occurrences missed while the process was down rather than replaying them as a backlog.

[blob]

The [blob] table selects where blobs live: the local filesystem (the default) or an S3-compatible object store. Metadata always stays in the local redb store; only the content-addressed blobs move. Omit the table to keep blobs under data_dir/blobs.

[blob]
backend = "s3"
endpoint = "https://s3.us-east-1.amazonaws.com"
bucket = "peryx-blobs"
region = "us-east-1"
prefix = "prod"
path_style = false
timeout_secs = 30
max_retries = 3
multipart_threshold_bytes = 16777216
part_size_bytes = 16777216
upload_concurrency = 4
KeyMeaningDefault
backendfilesystem or s3filesystem
endpointBase URL of the S3-compatible service (http or https)(required)
bucketBucket that holds the blobs(required)
regionSigning region(required)
prefixKey prefix inside the bucket; blobs land at <prefix>/sha256/...(none)
path_styletrue for path-style addressing (MinIO); false virtual-hostedfalse
timeout_secsPer-request timeout, in seconds30
max_retriesRetries for a transient transport or 5xx/429 response3
multipart_threshold_bytesObjects at or below this size upload in one PUT16777216
part_size_bytesMultipart part size above the threshold16777216
upload_concurrencyParts uploaded at once during a multipart upload4

Blobs are immutable and keyed by their sha256, so a write stages to data_dir/blob-staging, hashes as it streams, then uploads under <prefix>/sha256/<digest> — one PUT below multipart_threshold_bytes, bounded concurrent parts above it. Reads stream ranged GETs, and every fetch is verified against its digest.

Credentials

The [blob] table never holds a secret. S3 credentials resolve at startup from the standard environment variables AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and the optional AWS_SESSION_TOKEN. Startup fails fast if the S3 backend is selected and no access key or secret is present. The bucket policy must allow s3:GetObject, s3:PutObject, and s3:DeleteObject on <prefix>/*, plus s3:ListBucket for the startup health check.

Backup and failure recovery

Because the object write commits the blob before its metadata row is written, a crash between the two leaves an orphan object — harmless and overwritten byte-for-byte by any later write of the same content. peryx backup snapshots the [blob] selection (never the credentials) so a restore points at the same bucket; the objects themselves are not copied into the archive. Bucket-level versioning or replication, if you need it, is configured on the object store, not here.

[auth]

The [auth] table holds the access settings every index shares: the signing key of peryx's token realm, the lifetime of a minted token, and the default each index's anonymous_read takes. All keys are optional.

[auth]
signing_key_file = "/run/secrets/peryx-signing-key"
token_ttl_secs = 300
default_anonymous_read = false
oidc_audience = "https://packages.example/_/oidc"
KeyMeaningDefault
signing_keySecret peryx signs its own tokens with(none)
signing_key_filePath to read signing_key from instead of inlining it(none)
token_ttl_secsLifetime of a minted token, in seconds; must be positive300
default_anonymous_readWhat an index's anonymous_read defaults to when the index omits ittrue
oidc_audienceAudience external CI identity tokens must carryperyx

Set at most one of signing_key and signing_key_file. peryx reads the key at startup and uses it to mint OCI and trusted-publishing tokens whose maximum lifetime is token_ttl_secs. default_anonymous_read = false sets the anonymous-read default once instead of adding a flag to each index. Each [[auth.trusted_publisher]] requires id, issuer, repository, subject, and a non-empty projects list; its claims table is optional. The repository is a configured writable PyPI index name. See publish from CI identities for the provider contract and examples.

[[index.webhook]]

Put webhook tables under the index that should emit them. A target on a virtual index receives events for requests made through the virtual-index route; the payload also names the hosted layer that stored the change.

[[index]]
name = "root/pypi"
layers = ["hosted", "pypi"]
upload = "hosted"

[[index.webhook]]
name = "ci"
url = "https://ci.example/hooks/peryx"
secret_env = "PERYX_WEBHOOK_SECRET"
events = ["upload", "delete", "restore"]
KeyMeaningDefault
nameStable target name used in delivery logs
urlHTTP or HTTPS endpoint that receives JSON payloads; credentials, query, and fragment are rejected
secretLiteral HMAC signing secret
secret_envEnvironment variable that contains the HMAC signing secret
eventsEvent names to send; omit or leave empty for all supported event namesall

Use one of secret or secret_env. Supported event names are upload, yank, unyank, delete, restore, promote, project-status, and management. Peryx emits upload, yank, unyank, delete, and restore from the write endpoints in this release; the other names reserve the contract for management surfaces that use this runtime.

Peryx stores pending deliveries in the metadata database and sends them outside the request path. A failed delivery retries up to five attempts with capped backoff of 5, 15, 45, and 135 seconds. The delivery log stores the payload, target name, attempt count, next retry time, response status, and last error. It does not store webhook secrets.

[log]

KeyValuesDefault
levela tracing directive: error ... trace, per-module filtersinfo
formatpretty, jsonpretty
sinkstdout, file, journald, syslogstdout
filepath, required when sink = "file"(none)

The flags --log-level, --log-format, --log-sink, --log-file, -v, and -vv override these, as do the PERYX_LOG_LEVEL, PERYX_LOG_FORMAT, PERYX_LOG_SINK, and PERYX_LOG_FILE variables (below the flags in precedence).

On this page