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

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.