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.
9.5 KiB
id, title
| id | title |
|---|---|
| cloudflare | 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. 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, and R2 charges nothing for egress.
- Users and tokens go to a Turso database through the libsql auth backend. 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.
Before you start
You need:
- A Cloudflare account on the Workers Paid plan. Containers are not available on the free plan.
- 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:
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:
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:
{
"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:
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; 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:
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:
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:
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:
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:
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. 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, 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.