A Rust/Tokio/Axum npm registry proxy inspired by Verdaccio core behavior.
Rustaccio prioritizes npm client compatibility for common Verdaccio workflows (install, publish, dist-tags, auth, and uplink proxying). It does not guarantee byte-for-byte parity with Verdaccio internals or edge-case behavior; known differences and current limits are documented in Verdaccio Differences and Limits.
- Copy the example config:
cp config.example.yml config.yml- Start the server with that config:
cargo run -- --config ./config.yml- Point npm to Rustaccio:
npm config set registry http://127.0.0.1:4873/Standalone with defaults (no config file):
cargo runStandalone with explicit config file:
cargo run -- --config ./config.ymlStandalone help:
cargo run -- --helpRelease binary:
cargo build --release
./target/release/rustaccio --config ./config.ymlMaximum-optimization distribution build:
cargo build --profile dist
./target/dist/rustaccio --config ./config.ymlContainer (local build + run):
docker build -t rustaccio:local .
# Lower memory pressure on constrained builders (slower compile):
docker build --build-arg CARGO_BUILD_JOBS=1 -t rustaccio:local .
# Build image with managed-platform backends enabled at compile-time:
docker build \
--build-arg CARGO_FEATURES="s3,redis,postgres,otel" \
-t rustaccio:saas .
docker run --rm -p 4873:4873 \
-v "$(pwd)/.rustaccio-data:/var/lib/rustaccio/data" \
-v "$(pwd)/config.yml:/etc/rustaccio/config.yml:ro" \
-e RUSTACCIO_CONFIG=/etc/rustaccio/config.yml \
rustaccio:localThe Docker image compiles rustaccio with --features s3 by default.
Override compile-time features with --build-arg CARGO_FEATURES=... when you need optional backends:
redisfor distributed rate limiting (RUSTACCIO_RATE_LIMIT_BACKEND=redis)postgresfor persistent quota backend (RUSTACCIO_QUOTA_BACKEND=postgres)otelfor OTLP tracing export (RUSTACCIO_OTEL_ENABLED=true)
If a backend is configured at runtime without its compile-time feature, startup fails fast.
The image does not include config.example.yml; mount your own config and set RUSTACCIO_CONFIG.
--config loads the given YAML file and fails fast if the file cannot be read or parsed.
RUSTACCIO_CONFIG and RUSTACCIO_CONFIG_BASE64 remain available as environment-variable alternatives.
Unified merge precedence is:
defaults < RUSTACCIO_CONFIG or RUSTACCIO_CONFIG_BASE64 < --config file < environment variables.
main delegates to library runtime (rustaccio::runtime::run_from_env()), so standalone and embedded usage share the same config/runtime path.
The full RUSTACCIO_* environment variable list is generated into .env.example via cargo run --bin sync_examples.
Environment variables:
RUSTACCIO_BIND(default127.0.0.1:4873)PORT(optional platform-assigned port; when set, rustaccio binds to0.0.0.0:$PORTand this takes precedence overRUSTACCIO_BIND)RUSTACCIO_DATA_DIR(default.rustaccio-data)RUSTACCIO_CONFIG(optional Verdaccio-style YAML; loadspackagesACL rules +uplinks)RUSTACCIO_CONFIG_BASE64(optional base64-encoded Verdaccio-style YAML; mutually exclusive withRUSTACCIO_CONFIG)RUSTACCIO_UPSTREAM(optional, eghttps://registry.npmjs.org)RUSTACCIO_WEB_LOGIN(defaultfalse; enables/-/v1/login*endpoints)RUSTACCIO_WEB_ENABLE(defaulttrue)RUSTACCIO_WEB_TITLE(defaultRustaccio; used by built-in web UI title)RUSTACCIO_PUBLISH_CHECK_OWNERS(defaultfalse; enforces owner-only package mutations)RUSTACCIO_PASSWORD_MIN(default3)RUSTACCIO_LOGIN_SESSION_TTL_SECONDS(default120)RUSTACCIO_AUTH_TOKEN_TTL_SECS(default2592000/ 30 days;0keeps local bearer auth tokens non-expiring)RUSTACCIO_MAX_BODY_SIZE(default50mb, acceptskb|mb|gbsuffixes)RUSTACCIO_AUDIT_ENABLED(defaulttrue)RUSTACCIO_URL_PREFIX(default/)RUSTACCIO_TRUST_PROXY(defaultfalse)RUSTACCIO_KEEP_ALIVE_TIMEOUT(seconds, optional; applied as HTTP/1 keep-alive/header-read timeout)RUSTACCIO_REQUEST_TIMEOUT_SECS(default30, clamps1..=300)RUSTACCIO_LOG_LEVEL(defaultinfo)RUSTACCIO_LOG_FORMAT(pretty,compact, orjson, defaultpretty)RUSTACCIO_VERBOSE_DEP_LOGS(defaultfalse; settrue/1to keep noisy dependency targets at your chosenRUST_LOGlevel)RUST_LOG(optional full tracing filter; overrides defaultrustaccio=<level>,tower_http=info)RUSTACCIO_TOKIO_WORKER_THREADS(defaultmin(max(available_parallelism, 2), 8))RUSTACCIO_TOKIO_MAX_BLOCKING_THREADS(default64)RUSTACCIO_TOKIO_THREAD_STACK_SIZE(bytes, default1048576)RUSTACCIO_STARTUP_CONNECTIVITY_CHECK(defaultfalse; when enabled, runs non-fatal startup TCP reachability checks forhttps://registry.npmjs.organd the configured tarball S3 endpoint, logging IPv4 and IPv6 results separately)RUSTACCIO_UPSTREAM_CONNECT_TIMEOUT_SECS(default3)RUSTACCIO_UPSTREAM_TIMEOUT_SECS(default20)RUSTACCIO_UPSTREAM_POOL_IDLE_TIMEOUT_SECS(default30)RUSTACCIO_UPSTREAM_POOL_MAX_IDLE_PER_HOST(default4)RUSTACCIO_UPSTREAM_TCP_KEEPALIVE_SECS(default30)RUSTACCIO_AUTH_BACKEND(localorhttp, defaultlocal)RUSTACCIO_AUTH_HTTP_BASE_URL(required forhttpauth backend)RUSTACCIO_AUTH_HTTP_ADDUSER_ENDPOINT(default/adduser)RUSTACCIO_AUTH_HTTP_LOGIN_ENDPOINT(default/authenticate)RUSTACCIO_AUTH_HTTP_CHANGE_PASSWORD_ENDPOINT(default/change-password)RUSTACCIO_AUTH_HTTP_REQUEST_AUTH_ENDPOINT(optional token->identity hook for custom auth middleware parity)RUSTACCIO_AUTH_HTTP_ALLOW_ACCESS_ENDPOINT(optional ACL override hook endpoint)RUSTACCIO_AUTH_HTTP_ALLOW_PUBLISH_ENDPOINT(optional ACL override hook endpoint)RUSTACCIO_AUTH_HTTP_ALLOW_UNPUBLISH_ENDPOINT(optional ACL override hook endpoint)RUSTACCIO_AUTH_EXTERNAL_MODE(defaultfalse; disables local user/token/web-login endpoints)RUSTACCIO_AUTH_HTTP_TIMEOUT_MS(default5000)RUSTACCIO_RUNTIME_PROFILE(local,s3, ormanaged; primary mode selector when set, otherwise inferred fromRUSTACCIO_MANAGED_MODEand tarball backend)RUSTACCIO_STRICT_REVISION_CHECK(optionaltrue|false; defaulttruein managed mode, otherwisefalse)RUSTACCIO_PACKAGE_DISCOVERY_MODE(single-nodeormulti-node; default is mode-aware:multi-nodefor managed/S3 runtime,single-nodeotherwise)RUSTACCIO_PACKAGE_CACHE_MAX_ENTRIES(default5000; bounded in-memory package cache cap)RUSTACCIO_PACKAGE_CACHE_TTL_SECS(default0insingle-node,120inmulti-node;0keeps strict sidecar revalidation)RUSTACCIO_PACKAGE_CACHE_PRUNE_INTERVAL_SECS(default30; periodic package-cache pruning)RUSTACCIO_PACKAGE_NEGATIVE_CACHE_TTL_SECS(default30insingle-node,5inmulti-node)RUSTACCIO_PACKAGE_DISCOVERY_REFRESH_SECS(default0insingle-node,15inmulti-node; periodic shared-backend package-name refresh)RUSTACCIO_TARBALL_BACKEND(localors3, defaultlocal)RUSTACCIO_S3_BUCKET(required fors3backend)RUSTACCIO_S3_REGION(defaultus-east-1)RUSTACCIO_S3_ENDPOINT(optional, eg MinIO/LocalStack endpoint)RUSTACCIO_S3_ACCESS_KEY_ID/RUSTACCIO_S3_SECRET_ACCESS_KEY(optional static credentials)RUSTACCIO_S3_PREFIX(optional key prefix)RUSTACCIO_S3_FORCE_PATH_STYLE(defaulttrue)RUSTACCIO_S3_CA_BUNDLE(optional PEM bundle path for S3 TLS trust; falls back to common system bundle paths when present)RUSTACCIO_METADATA_BACKEND(sidecarortransactional; defaultsidecar,transactionalreserved/not yet available)RUSTACCIO_PACKAGE_METADATA_AUTHORITY(sidecar, defaultsidecar)- Any non-empty value other than
sidecaris rejected at startup.
- Any non-empty value other than
RUSTACCIO_STATE_COORDINATION_BACKEND(none,redis, ors3, defaultnone)RUSTACCIO_STATE_COORDINATION_REDIS_URL(required forredisstate coordination backend)RUSTACCIO_STATE_COORDINATION_LOCK_KEY(defaultrustaccio:state:lock)RUSTACCIO_STATE_COORDINATION_LEASE_MS(default5000)RUSTACCIO_STATE_COORDINATION_ACQUIRE_TIMEOUT_MS(default15000)RUSTACCIO_STATE_COORDINATION_POLL_INTERVAL_MS(default100)RUSTACCIO_STATE_COORDINATION_FAIL_OPEN(defaultfalse)RUSTACCIO_STATE_COORDINATION_S3_BUCKET(required fors3state coordination backend)RUSTACCIO_STATE_COORDINATION_S3_REGION(defaultus-east-1)RUSTACCIO_STATE_COORDINATION_S3_ENDPOINT(optional, eg MinIO/LocalStack endpoint)RUSTACCIO_STATE_COORDINATION_S3_ACCESS_KEY_ID/RUSTACCIO_STATE_COORDINATION_S3_SECRET_ACCESS_KEY(optional static credentials)RUSTACCIO_STATE_COORDINATION_S3_PREFIX(defaultrustaccio/state-locks/)RUSTACCIO_STATE_COORDINATION_S3_FORCE_PATH_STYLE(defaultfalse)- When
RUSTACCIO_STATE_COORDINATION_S3_*fields are unset, Rustaccio falls back to matchingRUSTACCIO_S3_*tarball backend settings. RUSTACCIO_RATE_LIMIT_MEMORY_MAX_KEYS(default10000; in-memory rate-limit key bound)RUSTACCIO_QUOTA_MEMORY_MAX_KEYS(default50000; in-memory quota key bound)RUSTACCIO_QUOTA_MEMORY_RETENTION_DAYS(default2; in-memory quota day retention window)RUSTACCIO_POLICY_HTTP_CACHE_MAX_ENTRIES(default10000; bounded policy decision cache)RUSTACCIO_POLICY_HTTP_CACHE_PRUNE_INTERVAL_MS(default30000; periodic policy-cache pruning)RUSTACCIO_EVENT_SINK(noneorhttp, defaultnone)RUSTACCIO_EVENT_HTTP_BASE_URL(required whenRUSTACCIO_EVENT_SINK=http)RUSTACCIO_EVENT_HTTP_ENDPOINT(default/events/registry)RUSTACCIO_EVENT_HTTP_TIMEOUT_MS(default2000)
Build features:
s3feature enables native S3 tarball backend support (disabled by default for a leaner production binary).- Enable with
--features s3when you need S3 tarball storage.
- Rust CI is in
.github/workflows/ci.ymland runsfmt,check,clippy -D warnings, tests (all-featuresandno-default-features), and docs with-D warnings. - Container publish is in
.github/workflows/docker-publish.ymland pushes multi-arch images toghcr.io/<owner>/<repo>on version tags (vX.Y.Z). - Keep a Changelog format changelog lives in
CHANGELOG.md.
cargo test
cargo test --features s3Run real S3-backend integration tests against local MinIO:
just minio-up
just test-s3-it
just minio-downRun Redis/Postgres governance integration tests:
just governance-up
just test-governance-it
just governance-downDefaults:
- MinIO API:
http://127.0.0.1:9002 - MinIO console:
http://127.0.0.1:9003 - Access key / secret:
minioadmin/minioadmin - Test bucket:
rustaccio-it
Override integration test connection settings with:
RUSTACCIO_S3_IT_ENDPOINTRUSTACCIO_S3_IT_REGIONRUSTACCIO_S3_IT_BUCKETRUSTACCIO_S3_IT_ACCESS_KEYRUSTACCIO_S3_IT_SECRET_KEY
Governance integration test connection settings:
RUSTACCIO_REDIS_IT_URL(defaultredis://127.0.0.1:56379/)RUSTACCIO_POSTGRES_IT_URL(defaultpostgres://postgres:postgres@127.0.0.1:55432/rustaccio)
just # default: check + test
just check
just test
just build # fast local release profile
just dist # fully optimized distribution profile
just serve
just serve ./config.yml
just minio-up
just minio-down
just test-s3-it
just governance-up
just governance-down
just test-governance-itInstall and enable local pre-commit hooks:
brew install lefthook
lefthook installRun hooks manually:
lefthook run pre-commitConfigured pre-commit checks:
cargo fmt --all -- --checkcargo check --workspace --all-targets --lockedcargo clippy --workspace --all-targets --all-features -- -D warningscargo test --workspace --all-targets --locked --quiet
Quality gate:
cargo clippy --workspace --all-targets --all-features -- -D warningsIf you want your own binary entrypoint but keep rustaccio runtime/config behavior:
use rustaccio::{config::Config, runtime};
use std::{error::Error, path::PathBuf};
fn parse_config_arg() -> Option<PathBuf> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--config" || arg == "-c" {
return args.next().map(PathBuf::from);
}
if let Some(value) = arg.strip_prefix("--config=") {
return Some(PathBuf::from(value));
}
}
None
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let cfg = if let Some(path) = parse_config_arg() {
Config::from_env_with_config_file(path)
.map_err(|msg| std::io::Error::new(std::io::ErrorKind::InvalidInput, msg))?
} else {
Config::from_env()
};
runtime::run_standalone(cfg).await?;
Ok(())
}use async_trait::async_trait;
use rustaccio::{auth::AuthHook, error::RegistryError, models::AuthIdentity};
#[derive(Default)]
struct CompanyAuthHook;
#[async_trait]
impl AuthHook for CompanyAuthHook {
async fn authenticate_request(
&self,
token: &str,
_method: &str,
_path: &str,
) -> Result<Option<AuthIdentity>, RegistryError> {
if token == "internal-token" {
return Ok(Some(AuthIdentity {
username: Some("ci-bot".to_string()),
groups: vec!["publishers".to_string()],
}));
}
Ok(None)
}
async fn allow_publish(
&self,
identity: Option<AuthIdentity>,
_package_name: &str,
) -> Result<Option<bool>, RegistryError> {
let can_publish = identity
.as_ref()
.map(|id| id.groups.iter().any(|g| g == "publishers"))
.unwrap_or(false);
Ok(Some(can_publish))
}
}use axum::{Router, routing::get};
use rustaccio::{app::build_router, runtime};
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut cfg = rustaccio::config::Config::from_env();
// Router::nest("/registry", ...) strips the prefix before dispatch.
// Keep url_prefix as "/" in this mode.
cfg.url_prefix = "/".to_string();
let state = runtime::build_state(&cfg, Some(Arc::new(CompanyAuthHook::default()))).await?;
let registry_router = build_router(state);
let app = Router::new()
.route("/healthz", get(|| async { "ok" }))
.nest("/registry", registry_router);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}If you instead run rustaccio at the root path (not nested) behind a reverse proxy prefix, set RUSTACCIO_URL_PREFIX (for example /registry) so generated URLs include that prefix.
Key APIs:
rustaccio::auth::AuthHookrustaccio::storage::Store::open_with_optionsrustaccio::runtime::{build_state, run, run_from_env}
/-/ping/-/whoami/-/user/*(add user/login/logout)/-/v1/search/-/alland/-/all/since(deprecated response)/-/admin/reindex,/-/admin/storage-health,/-/admin/policy-cache/invalidate, and/-/admin/package-cache/invalidate(admin/ops endpoints)/-/_view/starredByUser/-/package/:package/dist-tags(+:tag)/-/npm/v1/user/-/npm/v1/bootstrap(npm/pnpm/yarn/bun onboarding snippets)/-/npm/v1/tokens(+ token delete)/-/npm/v1/security/advisories/bulk/-/npm/v1/security/audits/quick/-/npm/v1/security/audits/-/metrics(optional, whenRUSTACCIO_METRICS_BACKEND=prometheus)/-/v1/login,/-/v1/login_cli/:sessionId,/-/v1/done/:sessionId- Built-in web UI routes:
/,/-/web,/-/web/login,/-/web/settings,/-/web/detail/:package, and static assets under/-/web/static/* - Package/tarball/publish routes (including scoped packages):
GET|HEAD /:package/:version?GET|HEAD /:package/-/:filenamePUT /:packagePUT /:package/-rev/:revisionDELETE /:package/-rev/:revisionDELETE /:package/-/:filename/-rev/:revision- legacy dist-tag
PUT /:package/:tag
The integration suite in tests/parity.rs currently validates:
- user creation/login/conflict/mismatch/logout/whoami
- package publish/get/tarball (including scoped and encoded scoped names)
- package version and dist-tag lookups (
/:package/:versionOrTag) ?write=truepackage reads for unpublish-style flows- dist-tags add/remove/read + invalid body handling
- owner/star update flows
- deprecate + undeprecate package versions via metadata updates
- unpublish-version flow via
PUT /:package/-rev/:revisionmetadata mutation publish.check_ownersparity for write routes (GET ?write=true, publish/unpublish, dist-tags)- external HTTP auth plugin backend (
add user,authenticate,change password) - HTTP request-auth hook contract (
token + method + path -> identity/groups) - pluggable tarball backend (
localfilesystem ors3) tarball backend startup reindexingto discover versions from existing backend tarballs before serving requests- npm token APIs (list/create/delete + validation errors)
- profile APIs (get + password change validation)
- security audit endpoints (uplink proxy + local fallback response shape)
- search v1 with pagination semantics
- login session APIs (
/-/v1/login,/-/v1/login_cli/:sessionId,/-/v1/done/:sessionId) flags.webLoginparity behavior (login routes disabled unless enabled)- deprecated search endpoint (
/-/all) - uplink behavior for package metadata, dist-tags, search, and tarballs
- package ACL parity subset (
access/publish/unpublish) with pattern matching and proxy uplink selection url_prefixpath handling +max_body_sizerequest enforcement- built-in web UI serving, SPA fallback behavior, and
web.enableroute gating
Rustaccio targets Verdaccio-compatible npm client behavior for core flows, but it is not a byte-for-byte Verdaccio clone. Current known differences/limits:
- ACL matching is a parity subset: rule matching supports common wildcard patterns, but not full Verdaccio/micromatch pattern semantics.
- Package routes only consult an explicitly configured package-rule
proxyuplink; they do not implicitly fall back todefaultor every configured uplink. - Authorization parsing currently accepts
Bearer <token>only. :revisionroute segments are accepted for Verdaccio-compatible route shapes, but revision values are not currently used for optimistic-concurrency checks./-/npm/v1/usercurrently does not support 2FA updates (tfapayload returns503).- Search (
/-/v1/search) currently usestext,size, andfrom; score tuning params are ignored, andtotalreflects returned page size. - YAML
listencan be configured as a list for config compatibility, but the server currently binds a single effective socket address. server.keepAliveTimeoutis currently mapped to an HTTP/1 header-read timeout for keep-alive connections (not a byte-for-byte Node.js socket timeout implementation).- Built-in web UI is a lightweight Verdaccio-style SPA shell and static assets, not the full upstream Verdaccio frontend/runtime surface.
- Rustaccio-specific admin endpoints are exposed at
/-/admin/reindex,/-/admin/storage-health,/-/admin/policy-cache/invalidate, and/-/admin/package-cache/invalidate.
src/api.rs: HTTP routing + Verdaccio-compatible endpoint behaviorsrc/acl.rs: package rule matching + access/publish/unpublish permission checkssrc/config.rs: env + Verdaccio-style YAML parsing (packages,uplinks)src/storage.rs: local state, persistence, auth/token/package operations + backend integrationsrc/policy.rs: policy engine abstraction (external policy backend -> auth hook/plugin decisions -> ACL fallback)src/governance.rs: opt-in governance controls (rate limiting,quota,metrics) via trait-based guards/backendssrc/auth_plugin.rs: HTTP auth backend plugin clientsrc/tarball_backend.rs: tarball backend abstraction (local,s3)src/upstream.rs: npm uplink proxy client for package/search/tarballsrc/app.rs: app state + router constructionsrc/web_ui.rs: built-in Verdaccio-style web UI shell/assets and SPA route handling
auth:
backend: http
external: false
http:
baseUrl: http://auth.local:9000
addUserEndpoint: /adduser
loginEndpoint: /authenticate
changePasswordEndpoint: /change-password
requestAuthEndpoint: /request-auth
allowAccessEndpoint: /allow-access
allowPublishEndpoint: /allow-publish
allowUnpublishEndpoint: /allow-unpublish
timeoutMs: 5000
store:
backend: s3
s3:
bucket: npm-cache
region: us-east-1
endpoint: http://127.0.0.1:9002
accessKeyId: minio
secretAccessKey: miniopass
prefix: tarballs/
forcePathStyle: trueThe HTTP auth backend is called by core user endpoints and keeps the same external npm/Verdaccio API contract.
Versioned contract reference: docs/contracts/auth-request-v1.md
GET /-/npm/v1/bootstrap returns registry and .npmrc snippets for npm/pnpm/yarn/bun.
Use ?scope=<name> (for example ?scope=acme) to include scope-specific registry lines.
Versioned contract reference: docs/contracts/npm-bootstrap-v1.md
POST {baseUrl}{addUserEndpoint}with{ "username", "password" }POST {baseUrl}{loginEndpoint}with{ "username", "password" }POST {baseUrl}{changePasswordEndpoint}with{ "username", "old_password", "new_password" }POST {baseUrl}{requestAuthEndpoint}with{ "token", "method", "path", "request_id" }and response including:authenticated(true|false, optional)- user identity:
usernameoruserorname(optional) - groups:
groups/rolesarray orgroupscalar (optional)
- Optional ACL override callbacks:
POST {baseUrl}{allowAccessEndpoint}POST {baseUrl}{allowPublishEndpoint}POST {baseUrl}{allowUnpublishEndpoint}- request body includes
{ "package", "username", "groups", "identity", "request_id" }, response supports{ "allowed": true|false }or raw boolean
Request ID propagation:
- Rustaccio sends
x-request-idto request-auth and allow-* plugin callbacks when available.
Behavior:
2xxmeans success.- Non-
2xxpropagates status anderror/messagefrom plugin JSON body when present.
Policy decisions can be sourced from a dedicated HTTP backend and will run before auth-hook/plugin/ACL fallback.
Versioned contract reference: docs/contracts/policy-decision-v1.md
Environment variables:
RUSTACCIO_POLICY_BACKEND=local|http(defaultlocal)RUSTACCIO_POLICY_HTTP_BASE_URL(required when backend=http)RUSTACCIO_POLICY_HTTP_DECISION_ENDPOINT(default/authorize)RUSTACCIO_POLICY_HTTP_TIMEOUT_MS(default3000)RUSTACCIO_POLICY_HTTP_CACHE_TTL_MS(default5000, set0to disable cache)RUSTACCIO_POLICY_HTTP_FAIL_OPEN(defaultfalse)
Decision request payload includes:
action(access|publish|unpublish)packagemethodpathrequest_id- identity context:
username,groups,identity - tenant context:
tenant,org_id,project_id(from request headers when present)
Request ID propagation:
- Rustaccio sends
x-request-idto external policy requests when available.
Decision response:
{ "allowed": true|false }or raw JSON boolean401/403is treated as an explicit deny- Other non-
2xx:- with
RUSTACCIO_POLICY_HTTP_FAIL_OPEN=true: fall back to local policy chain - with
RUSTACCIO_POLICY_HTTP_FAIL_OPEN=false: request fails with502
- with
Cache control:
POST /-/admin/policy-cache/invalidateclears in-memory external policy decision cache for the running instance.RUSTACCIO_POLICY_HTTP_CACHE_MAX_ENTRIESbounds in-memory policy decisions (default10000).RUSTACCIO_POLICY_HTTP_CACHE_PRUNE_INTERVAL_MScontrols periodic expired-entry pruning (default30000).
Error responses include machine-readable code in addition to error.
Versioned taxonomy: docs/contracts/error-taxonomy-v1.md.
Rustaccio can emit structured registry/admin mutation events to a configured sink.
Versioned schema: docs/contracts/registry-events-v1.md.
Environment variables:
RUSTACCIO_EVENT_SINK=none|http(defaultnone)RUSTACCIO_EVENT_HTTP_BASE_URL(required forhttpsink)RUSTACCIO_EVENT_HTTP_ENDPOINT(default/events/registry)RUSTACCIO_EVENT_HTTP_TIMEOUT_MS(default2000)
Notes:
- Event emission is best-effort; npm request success does not depend on sink availability.
x-request-idis forwarded to the HTTP sink when present.
Rustaccio defaults to simple mode. Governance controls are disabled unless explicitly enabled via env.
Rate limiting:
RUSTACCIO_RATE_LIMIT_BACKEND=none|memory|redis(defaultnone)RUSTACCIO_RATE_LIMIT_REQUESTS_PER_WINDOW(default0, disabled)RUSTACCIO_RATE_LIMIT_WINDOW_SECS(default60)RUSTACCIO_RATE_LIMIT_REDIS_URL(required when backend=redis)RUSTACCIO_RATE_LIMIT_FAIL_OPEN(defaulttrue)RUSTACCIO_RATE_LIMIT_MEMORY_MAX_KEYS(default10000; bounds in-memory limiter key cardinality)
Quota enforcement:
RUSTACCIO_QUOTA_BACKEND=none|memory|postgres(defaultnone)RUSTACCIO_QUOTA_REQUESTS_PER_DAY(default0, disabled)RUSTACCIO_QUOTA_DOWNLOADS_PER_DAY(default0, disabled)RUSTACCIO_QUOTA_PUBLISHES_PER_DAY(default0, disabled)RUSTACCIO_QUOTA_POSTGRES_URL(required when backend=postgres)RUSTACCIO_QUOTA_FAIL_OPEN(defaulttrue)RUSTACCIO_QUOTA_MEMORY_MAX_KEYS(default50000; bounds in-memory quota cardinality)RUSTACCIO_QUOTA_MEMORY_RETENTION_DAYS(default2; prunes old in-memory quota day buckets)
Postgres quota migrations:
- Rustaccio applies quota schema migrations automatically on startup when
RUSTACCIO_QUOTA_BACKEND=postgres. - Migration files live under
migrations/(current:migrations/0001_quota_usage_table.sql).
State write coordination (opt-in):
RUSTACCIO_STATE_COORDINATION_BACKEND=none|redis|s3(defaultnone)RUSTACCIO_STATE_COORDINATION_REDIS_URL(required when backend=redis)RUSTACCIO_STATE_COORDINATION_LOCK_KEY(defaultrustaccio:state:lock)RUSTACCIO_STATE_COORDINATION_LEASE_MS(default5000)RUSTACCIO_STATE_COORDINATION_ACQUIRE_TIMEOUT_MS(default15000)RUSTACCIO_STATE_COORDINATION_POLL_INTERVAL_MS(default100)RUSTACCIO_STATE_COORDINATION_FAIL_OPEN(defaultfalse)RUSTACCIO_STATE_COORDINATION_S3_BUCKET(required when backend=s3)RUSTACCIO_STATE_COORDINATION_S3_REGION(defaultus-east-1)RUSTACCIO_STATE_COORDINATION_S3_ENDPOINT(optional)RUSTACCIO_STATE_COORDINATION_S3_ACCESS_KEY_ID,RUSTACCIO_STATE_COORDINATION_S3_SECRET_ACCESS_KEY(optional)RUSTACCIO_STATE_COORDINATION_S3_PREFIX(defaultrustaccio/state-locks/)RUSTACCIO_STATE_COORDINATION_S3_FORCE_PATH_STYLE(defaultfalse)- If unset,
RUSTACCIO_STATE_COORDINATION_S3_*values fall back to matchingRUSTACCIO_S3_*values.
Semantics:
- Coordinates write sections with scoped lease locks (
statescope for auth/session persistence andpackage:<name>scope for package mutations). - Prevents overlapping multi-instance write sections when all instances use the same coordination backend.
- This is a write-coordination primitive, not full multi-writer state conflict resolution.
Metrics endpoint:
RUSTACCIO_METRICS_BACKEND=none|prometheus(defaultnone)RUSTACCIO_METRICS_PATH(default/-/metrics)RUSTACCIO_METRICS_REQUIRE_ADMIN(defaulttrue)
Build features for external backends:
cargo build --features redisfor Redis rate limitercargo build --features postgresfor Postgres quota backendcargo build --features otelfor OTLP span export
OpenTelemetry (opt-in):
RUSTACCIO_OTEL_ENABLED=false|true(defaultfalse)RUSTACCIO_OTEL_EXPORTER_OTLP_ENDPOINT(for examplehttp://otel-collector:4318/v1/traces)RUSTACCIO_OTEL_SERVICE_NAME(defaultrustaccio)
Admin endpoints are controlled by environment variables:
RUSTACCIO_MANAGED_MODE=false|true(defaultfalse)RUSTACCIO_ADMIN_ALLOW_ANY_AUTHENTICATED(defaulttrue)RUSTACCIO_ADMIN_USERS(comma/space-separated usernames)RUSTACCIO_ADMIN_GROUPS(comma/space-separated groups/roles)
Behavior:
- If
RUSTACCIO_ADMIN_ALLOW_ANY_AUTHENTICATED=true, any authenticated identity can call/-/admin/*. - If
RUSTACCIO_ADMIN_ALLOW_ANY_AUTHENTICATED=false, only identities whose username is inRUSTACCIO_ADMIN_USERSor whose group/role is inRUSTACCIO_ADMIN_GROUPSare allowed. - Unauthenticated requests receive
401; authenticated non-admin requests receive403. - If
RUSTACCIO_MANAGED_MODE=true, startup enforces stricter guardrails:RUSTACCIO_ADMIN_ALLOW_ANY_AUTHENTICATED=false- at least one explicit admin principal in
RUSTACCIO_ADMIN_USERSorRUSTACCIO_ADMIN_GROUPS auth.plugin.externalMode=true(external identity provider mode)RUSTACCIO_AUTH_BACKEND=httpRUSTACCIO_AUTH_HTTP_REQUEST_AUTH_ENDPOINT=<path>
Recommended managed-mode posture:
- Set
RUSTACCIO_MANAGED_MODE=true. - Set
RUSTACCIO_ADMIN_ALLOW_ANY_AUTHENTICATED=false. - Define a dedicated admin group from your control-plane identity provider, and set it in
RUSTACCIO_ADMIN_GROUPS.
Rustaccio now runs sidecar-authoritative package metadata in all modes.
Mode-specific env presets are included at repo root: .env.local.example, .env.s3.example, .env.managed.example.
| Mode | Tarball Backend | Metadata Authority | Governance Backends | Typical Use |
|---|---|---|---|---|
| Simple local | local |
package sidecars (package.json) |
none/memory | single-node, low ops |
| Shared object store | s3 |
package sidecars (package.json) |
none/memory | multi-node with shared blob storage |
| Managed governance | local or s3 |
package sidecars (package.json) |
Redis/Postgres/Prometheus/OTel | managed platform with limits/observability |
Simple local mode defaults:
RUSTACCIO_RUNTIME_PROFILE=localRUSTACCIO_TARBALL_BACKEND=localRUSTACCIO_PACKAGE_METADATA_AUTHORITY=sidecarRUSTACCIO_RATE_LIMIT_BACKEND=noneRUSTACCIO_QUOTA_BACKEND=noneRUSTACCIO_POLICY_BACKEND=localRUSTACCIO_MANAGED_MODE=falseRUSTACCIO_STATE_COORDINATION_BACKEND=noneRUSTACCIO_PACKAGE_DISCOVERY_MODE=single-nodeRUSTACCIO_PACKAGE_DISCOVERY_REFRESH_SECS=0
Shared object store mode defaults:
RUSTACCIO_RUNTIME_PROFILE=s3RUSTACCIO_TARBALL_BACKEND=s3RUSTACCIO_S3_BUCKET=<tarball-bucket>RUSTACCIO_MANAGED_MODE=falseRUSTACCIO_RATE_LIMIT_BACKEND=none|memoryRUSTACCIO_QUOTA_BACKEND=none|memoryRUSTACCIO_STATE_COORDINATION_BACKEND=none|s3RUSTACCIO_PACKAGE_DISCOVERY_MODE=multi-nodeRUSTACCIO_PACKAGE_DISCOVERY_REFRESH_SECS=15
Managed hardening mode:
RUSTACCIO_RUNTIME_PROFILE=managedRUSTACCIO_RUNTIME_PROFILE=managedimplies managed guardrails even whenRUSTACCIO_MANAGED_MODEis unset (RUSTACCIO_MANAGED_MODE=trueremains a compatibility alias)- Managed guardrails enforce:
RUSTACCIO_ADMIN_ALLOW_ANY_AUTHENTICATED=false- explicit admin principals (
RUSTACCIO_ADMIN_USERSorRUSTACCIO_ADMIN_GROUPS) auth.plugin.externalMode=true(env:RUSTACCIO_AUTH_EXTERNAL_MODE=true)RUSTACCIO_AUTH_BACKEND=httpRUSTACCIO_AUTH_HTTP_REQUEST_AUTH_ENDPOINT=<path>
- Managed profile additionally requires:
RUSTACCIO_RATE_LIMIT_BACKEND=redisRUSTACCIO_QUOTA_BACKEND=postgresRUSTACCIO_STATE_COORDINATION_BACKEND=redis|s3
Package discovery and cache behavior:
- Rustaccio keeps an in-memory package record cache and a package-name index to avoid storage-backend round-trips on every request.
RUSTACCIO_PACKAGE_DISCOVERY_MODE=single-nodefavors local cache hits and on-demand backend probes (no periodic list refresh by default).RUSTACCIO_PACKAGE_DISCOVERY_MODE=multi-nodeenables periodic backend list refresh (RUSTACCIO_PACKAGE_DISCOVERY_REFRESH_SECS) to detect package adds/removals from other nodes.- Invalid
RUSTACCIO_PACKAGE_DISCOVERY_MODEvalues fail startup (accepted:single-node,multi-nodeand their aliases). - Package cache growth is bounded by
RUSTACCIO_PACKAGE_CACHE_MAX_ENTRIES, TTL-controlled byRUSTACCIO_PACKAGE_CACHE_TTL_SECS, and pruned periodically byRUSTACCIO_PACKAGE_CACHE_PRUNE_INTERVAL_SECS. - Missing-package probes are negative-cached with
RUSTACCIO_PACKAGE_NEGATIVE_CACHE_TTL_SECSto suppress repeated misses. - For strict multi-node write safety, still configure
RUSTACCIO_STATE_COORDINATION_BACKEND=redis|s3; package discovery refresh is not a write lock. - External event-driven cache hook:
POST /-/admin/package-cache/invalidatewith{ "package": "<name>" }evicts a package from in-memory cache so subsequent reads reload from authoritative storage.
docker build \
--build-arg CARGO_FEATURES="s3,redis,postgres,otel" \
-t rustaccio:saas .Required profile and mode:
RUSTACCIO_RUNTIME_PROFILE=managedRUSTACCIO_MANAGED_MODE=true
Required for Redis rate limiter:
RUSTACCIO_RATE_LIMIT_BACKEND=redisRUSTACCIO_RATE_LIMIT_REDIS_URL=redis://redis:6379/
Required for Postgres quotas:
RUSTACCIO_QUOTA_BACKEND=postgresRUSTACCIO_QUOTA_POSTGRES_URL=postgres://postgres:postgres@postgres:5432/rustaccio
Recommended managed security baseline:
RUSTACCIO_AUTH_EXTERNAL_MODE=trueRUSTACCIO_ADMIN_ALLOW_ANY_AUTHENTICATED=falseRUSTACCIO_ADMIN_GROUPS=<control-plane-admin-group>RUSTACCIO_PACKAGE_METADATA_AUTHORITY=sidecarRUSTACCIO_STATE_COORDINATION_BACKEND=redisRUSTACCIO_STATE_COORDINATION_REDIS_URL=redis://redis:6379/
Alternative coordination backend (if you prefer object-storage-native locking):
RUSTACCIO_STATE_COORDINATION_BACKEND=s3RUSTACCIO_STATE_COORDINATION_S3_BUCKET=<lock-bucket>RUSTACCIO_STATE_COORDINATION_S3_PREFIX=rustaccio/state-locks/
Example container run:
docker run --rm -p 4873:4873 \
-v "$(pwd)/.rustaccio-data:/var/lib/rustaccio/data" \
-v "$(pwd)/config.yml:/etc/rustaccio/config.yml:ro" \
-e RUSTACCIO_CONFIG=/etc/rustaccio/config.yml \
-e RUSTACCIO_RUNTIME_PROFILE=managed \
-e RUSTACCIO_MANAGED_MODE=true \
-e RUSTACCIO_AUTH_EXTERNAL_MODE=true \
-e RUSTACCIO_ADMIN_ALLOW_ANY_AUTHENTICATED=false \
-e RUSTACCIO_ADMIN_GROUPS=platform-admins \
-e RUSTACCIO_PACKAGE_METADATA_AUTHORITY=sidecar \
-e RUSTACCIO_RATE_LIMIT_BACKEND=redis \
-e RUSTACCIO_RATE_LIMIT_REDIS_URL=redis://redis:6379/ \
-e RUSTACCIO_STATE_COORDINATION_BACKEND=redis \
-e RUSTACCIO_STATE_COORDINATION_REDIS_URL=redis://redis:6379/ \
-e RUSTACCIO_QUOTA_BACKEND=postgres \
-e RUSTACCIO_QUOTA_POSTGRES_URL=postgres://postgres:postgres@postgres:5432/rustaccio \
-e RUSTACCIO_METRICS_BACKEND=prometheus \
rustaccio:saasNotes:
RUSTACCIO_RATE_LIMIT_FAIL_OPEN=true|falsecontrols availability vs strictness on Redis failures.RUSTACCIO_QUOTA_FAIL_OPEN=true|falsecontrols availability vs strictness on Postgres failures.- Postgres migrations for quotas run automatically at startup.
The local persisted state file (<data_dir>/state.json) stores only auth/session state:
usersauth_tokens(local bearer login tokens; expired entries are pruned on startup, lookup, and background maintenance)npm_tokens(persistent npm tokens; remain until explicitly deleted)login_sessions(short-lived web-login handoff state; expired entries are pruned on startup, poll, and background maintenance)
packages is intentionally persisted as an empty map. Package metadata authority is sidecar-only.
Package runtime state (PackageRecord) contains:
manifest(full package manifest JSON)upstream_tarballs(filename -> original upstream URL cache)updated_atcached_from_uplink
Package metadata is stored in backend sidecars:
- Local:
<data_dir>/tarballs/<package-with-slashes-replaced>/package.json - S3:
<prefix><package>/package.json(Verdaccio-compatible layout)
Rustaccio writes sidecars after manifest mutations (publish, metadata-only update, dist-tag/owner/star changes, tarball removals).
At startup and reindex:
- Rustaccio loads tarball references from backend listing.
- It loads sidecars when available.
- It merges legacy Verdaccio package index hints (
verdaccio-s3-db.json) when present. - It rebuilds missing manifest structures from tarball filenames and sidecar metadata.
Known failure windows:
- Tarball written, sidecar write fails:
- blob may exist without updated manifest reference.
- Sidecar updated, tarball delete fails:
- manifest may stop referencing a blob that still exists (or vice versa, depending on operation ordering).
- Sidecar authority multi-writer races:
- if coordination backend is
none, concurrent writers can still race. - with
redis/s3coordination enabled, rustaccio serializes package mutations bypackage:<name>scope, which removes overlapping write sections but is still not a full transactional metadata system.
- Backend outages (Redis/Postgres/S3 lock backend):
- behavior depends on
*_FAIL_OPENsettings (allowvs reject with backend-unavailable errors).
Operational diagnostics:
GET /-/admin/storage-healthreports drift signals:tarballsWithoutSidecarsidecarsWithoutTarballstarballsMissingFromManifestmanifestAttachmentsMissingBlobstaleStatePackages
Use admin endpoint:
curl -X POST \
-H "Authorization: Bearer <admin-token>" \
http://<host>:4873/-/admin/reindexResponse includes:
changedpackagesBeforepackagesAftersidecarsSynced
This rebuilds package metadata from backend tarballs/sidecars and can repair many drift cases.
users,auth_tokens,npm_tokens, andlogin_sessionsare local auth/session state.- If local
state.jsonis lost and no backup exists, those records are not recoverable from tarball blobs.
- Back up local
state.jsonfor auth/session records. - Back up all tarball objects and package sidecars.
- For governance:
- back up Postgres quota tables
- persist Redis if you require durable counters across restarts (optional by policy)
- HTTP integrations (
upstream, auth plugin, external policy backend, event sink) use process-scopedreqwestclients with bounded idle pools; they do not accumulate per-request entries in Rustaccio maps. - The Postgres quota backend keeps one background connection task for the lifetime of the process when enabled.
- Redis/S3 state coordination create renewal tasks only while a write lease is held and release them when the lease ends.
- OTLP tracing may keep exporter workers while the process is running when
RUSTACCIO_OTEL_ENABLED=true. - These outbound clients are expected to stop with the server/runtime shutdown path; they are not an independent container keepalive mechanism.
What scales today:
- Object-store tarballs (
s3) and sidecars. - Horizontal read/write nodes with shared object storage.
- Distributed rate limiting (Redis) and quota accounting (Postgres).
Current bottlenecks/limits:
- Metadata writes are still not transactional across tarball + sidecar artifacts.
- Sidecar conflict resolution remains optimistic at application level.
Recommended evolution for high-scale managed deployments:
- Move package/user/token metadata to a transactional DB-backed metadata store.
- Keep object storage for immutable tarballs/blobs.
- Add distributed compare-and-swap/evented invalidation for metadata cache coherence.
Licensed under either of:
- MIT (
LICENSE-MIT) - Apache-2.0 (
LICENSE-APACHE)
When embedding, implement AuthHook:
authenticate_request(token, method, path)for token-to-identity mappingallow_access(identity, package)optional override for read permissionallow_publish(identity, package)optional override for publish permissionallow_unpublish(identity, package)optional override for unpublish permission- optional:
add_user,authenticate,change_passwordfor user/profile/token flows
Returned identity (AuthIdentity) is used directly by package ACL rules (access, publish, unpublish) via username and groups.
If allow_* returns Some(true|false), that decision overrides ACL; None falls back to ACL rules.
