Add PostgreSQL and MySQL adapters to the shared metadata SQL layer. Translate placeholders, JSON queries, and upserts at the adapter boundary, and check per-dialect schemas against the common table contract, including integer timestamps. Writes use serializable transactions with retries for conflicts; reads use repeatable-read snapshots. Schema initialization uses database advisory locks and resumes interrupted MySQL DDL. Repeated fragment reads use one parameterized query per batch. Failed or panicked operations cannot return an open transaction to the connection pool. Extract the existing sqlx pool configuration into pnpr-sql so auth and metadata share a pool and connection limit. Resolve both from database.url. libSQL auth and metadata use separate local replica files for their independent sync schedules. Local SQLite retains the existing local account and token settings. Use binary MySQL keys to preserve identity and reject keys over 512 bytes without truncation. Keep document payloads outside this key limit. Add PostgreSQL and MySQL CI services and extend the shared conformance tests with rollback, case sensitivity, and key-limit coverage. Update the registry mock's generated config while keeping historical benchmark binaries on their own configuration format. Closes zkochan/pnpm-tasks#38.
15 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.
- Package metadata, users, and tokens go to a Turso database, through libSQL metadata and 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 stays on the container's disk. Losing it only means fetching from npm again. A container that stops in the middle of a publish leaves blobs no package references, as 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.
database:
url: ${PNPR_LIBSQL_URL}
authToken: ${PNPR_LIBSQL_TOKEN}
# Package views are read from a local replica, rebuilt on each start.
replicaPath: /pnpr/cache/metadata-replica.db
syncInterval: 10s
secret: ${PNPR_SECRET}
# The Worker below passes the client's address in X-Real-IP.
trustedProxies: [10.0.0.0/8]
trustedProxyHeader: x-real-ip
auth:
users:
maxUsers: 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> {
try {
if (!this.ctx.container?.running) {
await this.startAndWaitForPorts(7677, {
instanceGetTimeoutMS: 120_000,
portReadyTimeoutMS: 180_000,
abort: request.signal,
});
}
return await super.fetch(request);
} catch (error) {
return unavailable(error);
}
}
}
function unavailable(error: unknown): Response {
console.error("Registry request failed", error);
return new Response("Registry temporarily unavailable, retry shortly\n", {
status: 503,
headers: { "Retry-After": "5" },
});
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
try {
decodeURIComponent(new URL(request.url).pathname);
} catch (error) {
if (!(error instanceof URIError)) throw error;
return new Response("Malformed percent-encoded path\n", { status: 400 });
}
const headers = new Headers(request.headers);
headers.set("accept-encoding", "zstd, gzip");
headers.delete("x-forwarded-for");
headers.set("x-real-ip", request.headers.get("cf-connecting-ip") ?? "");
try {
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",
});
} catch (error) {
return unavailable(error);
}
},
};
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.
It also passes on the client's address. pnpr sees every request come from
the container network, so without help a token limited to an address range
would check the wrong address, and login rate limiting would count all clients
as one. Cloudflare sets CF-Connecting-IP and does not let clients send it.
The Worker copies it into X-Real-IP, and the
trustedProxies setting in
pnpr.yaml makes pnpr read it.
PNPR_PUBLIC_URL works because pnpr reads every flag from an environment
variable with the PNPR_ prefix. This one is --public-url.
Malformed percent-encoded paths receive HTTP 400 before the request reaches
the container.
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.
The Worker caches nothing. If you add a cache, store only GET
responses without private, no-store, or no-cache in Cache-Control.
A login answers with a new token and is marked only no-cache. With
CORS on, pnpr's answer depends on the request's
Origin, so keep requests with an Origin header out of a cache that does
not key on it. A cache also outlives
rule changes.
Metadata at one URL has several representations, selected by Accept. Keep
full JSON and application/vnd.npm.install-v1+json in separate cache entries.
Bypass caching when Accept offers application/vnd.pnpr.install-v2+json,
including when it also offers install-v1. Before storing metadata, check that
its response Content-Type matches the entry's representation. Vary: Accept
on the response does not partition a cache that builds its own keys.
Bypass both cache reads and writes for requests with a query string, including
?write=true, or request Cache-Control: no-cache or no-store. Such reads
must reach the registry. Bypass conditional requests too, so the registry can
evaluate their validators. Put representation selectors in the cache key's path
or query, never its fragment. When replacing a cache that mixed representations,
use a new key namespace or purge its entries.
A revalidating metadata cache example
checks freshness and access with the registry on every read. It reuses stored
metadata bodies after a 304, forwards client validators unchanged, and leaves
tarballs uncached. This avoids regional purges and missed mutation routes. Each
read still needs an origin round trip. When replacing a caching Worker, remove
its old metadata and tarball lookup paths and purge any separate CDN cache rules.
Publishes go through the Worker, so your plan's request body limit caps
the size of a package you can publish. It also caps each OCI blob upload
request. A layer larger than that limit fails with HTTP 413 when the client
sends it in one PATCH or PUT, even if it fits oci.maxBlobBytes.
For clients that honor the proposed OCI-Chunk-Max-Length header, configure
the upload hint below your plan's request body limit:
oci:
chunkMaxBytes: 50000000
This hint cannot force a client to split requests. Docker and Podman uploads
may still send a whole layer in one request. For those clients, host pnpr
outside Cloudflare Containers behind your own
TLS reverse proxy, with a request body limit that
accommodates the largest layer. The Container in this guide is
only reachable through its Worker.
Adding a custom domain to that Worker does not bypass its limit. Raising
oci.maxBlobBytes alone cannot raise Cloudflare's limit.
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.
Non-browser clients blocked with error 1010
Cloudflare's Browser Integrity Check
can reject the default Python urllib User-Agent with HTTP 403 and error
1010 before the request reaches your Worker. Changing headers inside the
Worker cannot fix this.
On a custom domain in your Cloudflare zone, create a WAF custom rule with a Skip action for Browser Integrity Check. Limit it to the registry hostname and paths, for example:
http.host eq "registry.example.com" and
(starts_with(http.request.uri.path, "/npm/") or http.request.uri.path eq "/npm")
Include other API prefixes your clients use. Skip only Browser Integrity Check, keeping authentication, rate limits, and other WAF protections active. Check Security Events if requests still fail, and adjust the specific blocking rule for those paths.
On workers.dev, Browser Integrity Check cannot be disabled.
Use a custom domain for clients that need their default User-Agent to work.
Scripts using the trial hostname can send a descriptive User-Agent:
import urllib.request
request = urllib.request.Request(
"https://pnpr.<your-subdomain>.workers.dev/npm/example-package",
headers={"User-Agent": "registry-check/1.0"},
)
with urllib.request.urlopen(request) as response:
print(response.status)
Verify your deployment with both the script and a malformed path:
curl --path-as-is -i 'https://registry.example.com/npm/%E0%A4%A'
The malformed path should return HTTP 400. On a custom domain with the
exception, repeat the Python request without the headers argument to verify
that the default User-Agent also reaches the registry.