Files
pnpm/pnpr/docs/cloudflare.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

290 lines
9.5 KiB
Markdown

---
id: cloudflare
title: Deploying on Cloudflare
---
You can run pnpr on Cloudflare, but not as a Worker. pnpr is an ordinary
long-running server, so it needs a
[Container](https://developers.cloudflare.com/containers/). The setup is one
container running the published pnpr image, plus a few lines of Worker that
pass every request through to it. When nobody has used the registry for a
while, the container goes to sleep, and the next request wakes it up. An idle
registry costs you nothing beyond the plan fee.
The container's disk is wiped whenever it stops, so everything that has to
survive lives somewhere else:
- Published packages go to an R2 bucket. pnpr talks to it through its
[S3 backend](storage.md), and R2 charges nothing for egress.
- Users and tokens go to a [Turso](https://turso.tech) database through the
[libsql auth backend](auth-backends.md). Cloudflare's own databases do not
speak a protocol pnpr can use, so Turso is the simplest option.
- The upstream cache and the publish journal stay on the container's disk.
Losing the cache only means fetching from npm again. Losing the journal in
the middle of a publish is the ephemeral-disk case described in
[Storage](storage.md#s3-publish-journal-and-lifecycle-caveats).
## Before you start
You need:
- A Cloudflare account on the Workers Paid plan. Containers are not available
on the free plan.
- [Wrangler](https://developers.cloudflare.com/workers/wrangler/) and Docker on
the machine you deploy from. Wrangler builds the image locally and pushes it
to Cloudflare's registry.
- An R2 bucket, and an R2 API token with Object Read and Write on it. Keep the
access key ID and the secret access key.
- A Turso database and a read-write token for it. Keep the `libsql://` URL.
## The project
The whole deployment is four files in one directory.
The Dockerfile takes the published image and adds your config:
```dockerfile title="Dockerfile"
FROM ghcr.io/pnpm/pnpr:<version>
COPY pnpr.yaml /pnpr/pnpr.yaml
CMD ["serve", "--listen", "0.0.0.0:7677", "--config", "/pnpr/pnpr.yaml", "--cache", "/pnpr/cache"]
```
The pnpr config points the hosted store at your bucket and auth at Turso. The
credentials arrive as environment variables, which the Worker below sets:
```yaml title="pnpr.yaml"
storage: /pnpr/storage
s3:
bucket: my-pnpr-packages
region: auto
endpoint: https://<account-id>.r2.cloudflarestorage.com
# Credentials come from AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
backend:
libsql:
url: ${PNPR_LIBSQL_URL}
authToken: ${PNPR_LIBSQL_TOKEN}
# Token lookups read a local replica of the Turso database. The replica
# lives on the container's ephemeral disk and is rebuilt on each start.
replicaPath: /pnpr/cache/auth-replica.db
secret: ${PNPR_SECRET}
auth:
htpasswd:
max_users: 3
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
```
The Wrangler config declares the container, the Durable Object that owns it,
and the public URL pnpr writes into tarball links:
```jsonc title="wrangler.jsonc"
{
"name": "pnpr",
"main": "src/index.ts",
"compatibility_date": "2026-09-01",
"workers_dev": true,
"vars": { "PNPR_PUBLIC_URL": "https://pnpr.<your-subdomain>.workers.dev" },
"containers": [
{
"name": "pnpr-container",
"image": "./Dockerfile",
"max_instances": 1,
"instance_type": "standard-1"
}
],
"durable_objects": {
"bindings": [{ "name": "PNPR", "class_name": "PnprContainer" }]
},
"exports": {
"PnprContainer": {
"type": "durable-object",
"storage": "sqlite",
"container": "pnpr-container"
}
}
}
```
The Worker forwards every request to the container and hands it the secrets.
One part of it deserves an explanation. When the container has been asleep,
Cloudflare can take 20 to 60 seconds to bring it back. The container library
waits only 20 seconds for the port by default and then fails the request with
a 500. The `fetch` override below waits longer, so the first request after a
sleep gets a slow answer instead of an error:
```ts title="src/index.ts"
import { Container, getContainer } from "@cloudflare/containers";
interface Env {
PNPR: DurableObjectNamespace<PnprContainer>;
AWS_ACCESS_KEY_ID: string;
AWS_SECRET_ACCESS_KEY: string;
PNPR_LIBSQL_URL: string;
PNPR_LIBSQL_TOKEN: string;
PNPR_SECRET: string;
PNPR_PUBLIC_URL: string;
}
export class PnprContainer extends Container<Env> {
defaultPort = 7677;
sleepAfter = "10m";
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.envVars = {
AWS_ACCESS_KEY_ID: env.AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY: env.AWS_SECRET_ACCESS_KEY,
PNPR_LIBSQL_URL: env.PNPR_LIBSQL_URL,
PNPR_LIBSQL_TOKEN: env.PNPR_LIBSQL_TOKEN,
PNPR_SECRET: env.PNPR_SECRET,
PNPR_PUBLIC_URL: env.PNPR_PUBLIC_URL,
};
}
// Wait longer than the library's default for a cold start, so the first
// request after a sleep gets an answer instead of a 500.
override async fetch(request: Request): Promise<Response> {
if (!this.ctx.container?.running) {
try {
await this.startAndWaitForPorts(7677, {
instanceGetTimeoutMS: 120_000,
portReadyTimeoutMS: 180_000,
abort: request.signal,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(`pnpr is starting, retry shortly (${message})\n`, { status: 503 });
}
}
return super.fetch(request);
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const headers = new Headers(request.headers);
headers.set("accept-encoding", "zstd, gzip");
const response = await getContainer(env.PNPR, "pnpr").fetch(
new Request(request, { headers }),
);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
encodeBody: "manual",
});
},
};
```
The default export also fixes compression. Cloudflare removes `zstd` from a
request's `Accept-Encoding` before the Worker sees it, so without help pnpr
would never send zstd. The Worker asks pnpr for `zstd, gzip`, which is what
pnpm sends anyway, and `encodeBody: "manual"` hands the coded body back
untouched. Cloudflare then passes it as-is to clients that accept zstd and
decodes it for those that do not. This only matters for a
[tar registry](configuration.md#tar-registries); a `.tgz` is served as stored
whatever the request asks for.
`PNPR_PUBLIC_URL` works because pnpr reads every flag from an environment
variable with the `PNPR_` prefix. This one is `--public-url`.
Finally, install the two packages the Worker needs:
```sh
pnpm add -D wrangler
pnpm add @cloudflare/containers
```
## Deploying
Store the credentials as Worker secrets. Each command asks for the value, so
nothing ends up in a file or in your shell history:
```sh
pnpm wrangler secret put AWS_ACCESS_KEY_ID
pnpm wrangler secret put AWS_SECRET_ACCESS_KEY
pnpm wrangler secret put PNPR_LIBSQL_URL
pnpm wrangler secret put PNPR_LIBSQL_TOKEN
openssl rand -hex 32 | pnpm wrangler secret put PNPR_SECRET
```
Now deploy:
```sh
pnpm wrangler deploy
```
The first deploy prints the Worker's URL. Put it into `PNPR_PUBLIC_URL` in
`wrangler.jsonc` and deploy again.
Make a first request to wake the container, then point pnpm at the registry's
npm base:
```sh
curl https://pnpr.<your-subdomain>.workers.dev/-/ping
pnpm config set registry https://pnpr.<your-subdomain>.workers.dev/npm/
```
When you change only the Worker, for example to set a variable, you can skip
the image build:
```sh
pnpm wrangler deploy --containers-rollout=none
```
## Things to know
**The first request after a sleep is slow.** Expect 20 to 60 seconds. pnpr
itself starts in well under a second; the wait is Cloudflare bringing the
container back. If that bothers you, raise `sleepAfter`. The container then
stays up longer, and you pay for its idle memory and disk.
**TLS and the hostname come from Cloudflare.** This setup runs behind
Cloudflare as its [reverse proxy](tls-and-reverse-proxy.md). To serve the
registry from your own hostname, add a custom domain to the Worker.
**Tars reach other clients uncompressed.** Because the Worker always asks for
zstd, a client that accepts only gzip gets a tar that Cloudflare has decoded
back to plain bytes. Only pnpm installs from a tar registry, and it accepts
zstd, so in practice this affects someone fetching a tar by hand. Metadata is
not affected: Cloudflare compresses JSON again for gzip clients.
**One container is enough for a team.** The Worker above always talks to the
one container named `pnpr`, so raising `max_instances` on its own changes
nothing. If you do want replicas, spread requests over several container
names, or use the `getRandom` helper from the same package. Replicas can share
the bucket and the database, which the
[S3 backend supports](storage.md#concurrent-writers), but each one has its own
upstream cache.
**Publishes go through the Worker**, so your plan's request body limit caps
the size of a package you can publish.
**Container logs are in the dashboard**, under the Worker's logs.
`wrangler tail` shows only the Worker's own events.
**There is no spending cap.** `max_instances` and `sleepAfter` bound what a
forgotten deployment can cost. A budget alert in the dashboard emails you when
projected spend crosses a threshold, but it does not stop anything.