Files
pnpm/pnpr/docs/storage.md
Zoltan Kochan 7fb2bfab4a docs(pnpr): correct configuration keys, defaults, and commands (#17245)
Part of https://github.com/pnpm/tasks/issues/191.

Wrong claims:
- cli: drop the nonexistent `add-user --password` flag and document the
  terminal/stdin password read; `oci-gc --min-age` and `cache-gc --max-age`
  are the flags (the `-secs` forms are aliases); `--from` defaults to the
  `storage` key, not an internal field; `info` and `http` log the same
  access records; only global and server options have PNPR_* variables.
- configuration: `packumentTtl` defaults to 5m; the minimal config now
  sets `auth.htpasswd.file`, and the auth section says accounts live in
  memory without it or a backend; grouped hosted registries default their
  org to `<ecosystem>~<name>`; the handshake is served for any surface;
  the pipeline surface also satisfies "something must be served"; add
  `package.update_maintainers` to the audit list.
- tls-and-reverse-proxy: remove the invalid `publicUrl:` YAML key and the
  nonexistent `rate_limiting` setting.
- backup-and-restore: `pnpr verify` hashes by default (`--check-hashes`
  does not exist, `--skip-hashes` skips only the hash
  comparison); `migrate-storage
  --verify` compares bytes.
- webhooks: audit events do not cover publishes or token creation; the
  example uses a real action and the `actor` field.
- cli: the `RUST_LOG` example keeps `pnpr::audit=info`.
- install-acceleration: the `--public-url` host is exempt from the
  private-address refusal.
- shared-side-effects-cache: `pnpm pipeline` publishes workspace-task
  artifacts.

Missing facts: `routes.allowedPrivateNetworks`, the upstream `search` key
and canonical `maxAge`, built-in `npm.jsr.io` route, relative path
resolution, `log` defaults and `type`, `${VAR:-default}` substitution and
the full secret-field list, `oci` defaults, `artifacts.quota` bounds,
`migrate-storage` destination rules, `--osv false`, `--packument-ttl`
default, S3 `forcePathStyle`/`allowHttp` defaults, grouped org layout,
OIDC key table, container image section, `backend:` ignoring the htpasswd
and token files.

Duplicates and links: health probe bodies, reload procedure, and the
resolver allowlist now link to their one home; broken `#secret` anchor
fixed; links into the pnpm docs use absolute pnpm.io URLs.

Stale history: drop "pnpr v1" and the per-paragraph "Since alpha.N" /
"Since pnpm 12.x" prefixes, and the contributor-only extensibility text.

Also corrects the `as_filter_directive` doc comment, which claimed `http`
quiets the rest of the crate.
2026-10-23 00:11:00 +02:00

209 lines
9.2 KiB
Markdown

---
id: storage
title: Storage backends (S3 / R2)
---
pnpr keeps two kinds of data:
- **Hosted**: the source of truth. It holds packages published to this server plus
anything served in static mode. Lives under `storage`. Packages awaiting
approval through [staged publishing](endpoints.md#staged-publishing-endpoints)
are held here too, under a reserved `.staged/` namespace.
- **Cache**: the disposable mirror of upstream registries plus the resolver
cache, lockfile-verdict cache, local shared-artifact store, and S3 upload
staging scratch. Lives under `cache` (defaults to
`<storage>/.pnpr-cache`).
## Layout
The hosted store has the same layout on disk and in a bucket:
```text
<storage>/
layout.json marks the layout version
default/packages/<name>/ registries without an org
orgs/<org>/packages/<name>/ one namespace per org
<namespace>/packages/<name>/document.json
<namespace>/packages/<name>/blobs/<file>
```
A hosted registry declared under an
[ecosystem group](ecosystems.md#reusing-a-name-across-ecosystems) without an
`org` uses the org `<ecosystem>~<name>`, stored as `orgs/<ecosystem>!7e<name>/`,
for example `orgs/npm!7einternal/`.
Every package name, org and file name is one path segment. Lowercase
letters, digits, `.`, `_` and `-` are kept, and every other byte is written
as `!` and two hex digits, so `@scope/pkg` is stored as `!40scope!2fpkg`.
pnpr refuses to start on a store written by a newer pnpr, or on one in the
layout pnpr used before `layout.json` existed. Convert such a store with
[`pnpr upgrade-storage`](cli.md#upgrade-storage). A cache in another layout is
emptied on startup and refilled on demand.
## Backends
By default both are local directories. Adding an `s3:` block moves the
**hosted** store and the enabled **shared-artifact** store into an S3-compatible
object store, so they can be shared by several stateless pnpr replicas. The
ordinary upstream and resolver cache stays on local disk.
Shared artifacts use a reserved `.pnpr-artifacts/v0/` namespace under the
configured prefix; hosted package objects keep their normal namespace. Without
S3, artifacts live under `<cache>/shared-artifacts/v0`. They are rebuildable
cache data rather than authoritative packages, but publication slots are
immutable while they exist.
Because any S3-compatible endpoint works, this also covers **Cloudflare R2**,
**MinIO**, **Backblaze B2**, **Wasabi**, and so on. Point `endpoint` at the right
host.
```yaml title="pnpr.yaml"
storage: ./storage
# cache: ./cache # local proxy cache + resolver cache + S3 upload staging
s3:
bucket: my-pnpr-packages
region: auto
# Omit `endpoint` for AWS S3. For R2 use the account endpoint:
endpoint: https://<account-id>.r2.cloudflarestorage.com
# Optional key prefix, so one bucket can hold more than the hosted store:
prefix: packages
# Credentials. Omit these to fall back to the standard
# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY environment variables.
accessKeyId: ${PNPR_S3_ACCESS_KEY_ID}
secretAccessKey: ${PNPR_S3_SECRET_ACCESS_KEY}
```
## Options
| Key | Required | Description |
| --- | --- | --- |
| `bucket` | yes | Bucket the hosted packages and enabled shared artifacts are stored in. |
| `region` | no | AWS S3 needs a real region (e.g. `us-east-1`); Cloudflare R2 uses `auto`. |
| `endpoint` | no | Custom endpoint for S3-compatible providers. Omit for AWS S3; for R2 it's `https://<account-id>.r2.cloudflarestorage.com`; for MinIO it's e.g. `http://127.0.0.1:9000`. |
| `prefix` | no | Key prefix every object is stored under. |
| `accessKeyId` | no | Access key. Falls back to `AWS_ACCESS_KEY_ID` when unset. |
| `secretAccessKey` | no | Secret key. Falls back to `AWS_SECRET_ACCESS_KEY` when unset. |
| `forcePathStyle` | no | Boolean. `true` uses path-style addressing (`endpoint/bucket/key`), and `false` uses virtual-hosted addressing (`bucket.endpoint/key`). When omitted, `AWS_VIRTUAL_HOSTED_STYLE_REQUEST=true` selects virtual-hosted addressing, and otherwise pnpr uses path-style addressing, which AWS, R2, and MinIO accept. |
| `allowHttp` | no | Boolean. Allow plain-HTTP endpoints, needed for a local MinIO over `http://`. Defaults to `false` (HTTPS only). |
## Concurrent writers {#concurrent-writers}
Several stateless pnpr replicas can share one bucket. Because a replica's
in-process package lock only serializes that replica, writes to the hosted
store are made safe across replicas at the object-store level rather than by
locking:
- **Packument updates**: publish, partial unpublish, and dist-tag writes read
the packument together with its object-store version and write back
conditionally, so an update computed from a stale copy is rejected instead of
overwriting a newer one. Dist-tag writes retry on conflict, since their
mutation can be replayed against the fresh packument. A write that
keeps losing surfaces as an HTTP `409` rather than silently discarding
another writer's update.
- **Tarballs**: finalizing a tarball is compare-and-swap. A byte-identical
object is tolerated, but an object with different bytes left by a concurrent
publisher of the same `name@version` is never overwritten; the losing publish
gets a `409` before it writes any packument.
- **Shared artifacts**: envelopes and blobs up to 8 MiB use create-only
writes, and the quota counter uses conditional updates across replicas. A
larger blob is uploaded in parts and committed only once its bytes match its
integrity, so a concurrent upload of it can only store the same bytes. A
publication whose compatibility set overlaps an existing artifact for the
same input key gets a `409`; a byte-identical retry succeeds. After an
ambiguous object-store write failure, pnpr reclaims blobs referenced by no
stored envelope and rebuilds quota once active publications drain. An upload
that fails and cannot be aborted leaves incomplete parts that pnpr cannot
list, so give the bucket a lifecycle rule that aborts incomplete multipart
uploads.
This applies to the S3 backend. The local filesystem backend keeps its
single-process behavior, since the shared-store race is specific to replicas
sharing one object store.
## A complete Cloudflare R2 example
```yaml title="pnpr.yaml"
storage: ./storage
s3:
bucket: my-pnpr-packages
region: auto
endpoint: https://abc123def456.r2.cloudflarestorage.com
auth:
htpasswd:
file: ./htpasswd
registries:
local:
type: hosted
packages:
'@mycompany/*':
publish: $authenticated
npmjs:
type: upstream
url: https://registry.npmjs.org/
public: true
main:
type: router
sources: [local, npmjs]
defaultRegistry: main
artifacts:
enabled: true
```
```sh
export AWS_ACCESS_KEY_ID="<r2-access-key-id>"
export AWS_SECRET_ACCESS_KEY="<r2-secret-access-key>"
pnpr -c ./pnpr.yaml --listen 0.0.0.0:7677 --public-url https://registry.example.com
```
`--public-url` rewrites the `dist.tarball` URLs in served packuments, so clients
fetch tarballs back through this server rather than the upstream.
## A local MinIO over plain HTTP
MinIO over `http://` needs `allowHttp`:
```yaml title="pnpr.yaml"
s3:
bucket: pnpr
region: us-east-1
endpoint: http://127.0.0.1:9000
forcePathStyle: true
allowHttp: true
accessKeyId: minioadmin
secretAccessKey: minioadmin
```
## S3 publish journal and lifecycle caveats
When publishing packages to an S3 backend, pnpr coordinates uploads through a local write-ahead commit journal:
1. **Local staging**: Incoming blobs are staged under `<cache>/pnpr-hosted-staging/`.
2. **Journal seal**: pnpr writes the transaction under `<cache>/.pnpr-journal/<txn>/` and seals it before applying object-store writes.
3. **Apply**: pnpr uploads the blobs and conditionally updates the packument.
4. **Journal cleanup**: The local journal entry is removed once the object store confirms the write.
### Multi-replica considerations
The write-ahead journal is kept on the local disk of the replica handling the publish request. Keep these operational details in mind:
- **Startup recovery**: If a replica terminates unexpectedly mid-publish, it automatically recovers pending journal entries in its `<cache>/.pnpr-journal/` on next startup, applying sealed transactions and rolling back unsealed ones.
- **Ephemeral disks**: In containerized multi-replica environments where instances use ephemeral local storage, destroying a failed instance before it restarts means its local journal cannot be replayed. Any partially uploaded blobs in S3 remain as unreferenced objects.
- **Multipart uploads**: If a large upload is aborted or interrupted, S3 retains incomplete multipart upload parts until explicitly aborted or cleaned up by policy.
### Operational recommendations
- **Abort incomplete multipart uploads**: Configure a lifecycle rule on your S3 bucket to abort incomplete multipart uploads after 1 to 7 days.
- **Enable bucket versioning**: Enable S3 bucket versioning to protect against accidental object deletion or overwrite.
- **Run periodic integrity audits**: Use `pnpr verify` to audit your hosted packages against stored S3 blobs, and `pnpr verify --prune-orphans` to detect and remove orphaned blobs associated with verified npm packages.
- **Backup procedures**: Follow the guidelines in [Backup and restore](backup-and-restore.md) for snapshotting and restoring S3 package data and revision history.