Run your own registry

On this page 15

Everything the public registry runs on is in this repo. Fork it, point pantry registry at your own box, and you have a registry for your own packages — no part of the deployment depends on the maintainers' infrastructure.

Two shapes, one process:

  • Public — anyone can resolve and download; publishing needs a token. This is what registry.pantry.dev runs, and it's the default.
  • Private — nothing is served without a credential. Metadata, tarballs, binaries, search and the web UI all require an API token or a logged-in session. One environment variable away from the above.

For the API surface and storage guarantees, see the registry contract.

Quick start

One command, from your workstation, against a fresh Linux box you can SSH into:

pantry registry setup \
  --host registry.example.com \
  --repo https://github.com/you/your-fork \
  --provider hetzner --bucket my-registry --region fsn1 \
  --access-key-id  --secret-access-key 

That installs Bun, clones your fork, writes the service environment, installs and starts the systemd unit, configures object storage, generates a registry token, and then proves the result from the outside: an anonymous read must come back 401, an authenticated one must not. It finishes by printing your token and the exact commands to publish, install, and add a teammate.

Every flag also reads from the environment (PANTRY_REGISTRY_HOST, S3_BUCKET, S3_ACCESS_KEY_ID, …), which is usually how you'd drive it from CI. --user, --key, --service and --env-file cover boxes that don't match the defaults (root, your SSH agent, pantry-registry, /opt/pantry-registry/registry.env).

It is idempotent. Re-run it to upgrade the checkout, change storage, or move between public and private (--public). Your token is kept unless you pass --rotate-token.

You still need two things it deliberately doesn't do:

  • TLS. Put Caddy, nginx or rpx in front of the port. It prints a two-line Caddyfile that does it.
  • A private bucket. Keep object storage private; the registry proxies …/binaries/… itself, so the bucket never needs to be public.

Prefer to do it by hand, or deploying somewhere systemd isn't? See Manual setup — the registry is a Bun process reading an environment file, and nothing about it requires this command.

What "private" means

REGISTRY_VISIBILITY=private closes reads. The gate runs before routing, as an allowlist of what stays open, so a route added to the registry later is private by default rather than by someone remembering to gate it.

PathPublic registryPrivate registry
GET /healthopenopen
GET /api/registry-infoopenopen
/login, /signup, /auth/login, /auth/logout, /auth/meopenopen
GET /packages/…, /commits/…, /zig/…, /php/…opencredential required
GET /binaries/… (system packages, apps)opencredential required
GET /search, the web UI, the dashboardopencredential required
POST /publish, /admin/…tokentoken

A credential is any of:

  • a user API token (ptry_…) with read or publish permission — publish implies read, so one CI token can both upload a version and ask which versions exist;
  • the shared registry token (PANTRY_REGISTRY_TOKEN), which is also the admin credential;
  • a logged-in browser session, so the web UI works for signed-in humans.

Anything else gets 401 with a WWW-Authenticate header and a hint naming the command that fixes it. A browser navigating to a gated page is sent to /login instead — a JSON error is not a useful thing to show a person.

Signups are closed by default on a private registry — a private registry anyone can sign up to isn't private. Members are provisioned by an operator (see below), or you can re-open self-serve signup and restrict it to your own email domains:

REGISTRY_ALLOW_SIGNUP=true
REGISTRY_SIGNUP_DOMAINS=yourco.com,yourco.dev

Clients discover all of this without a credential:

curl https://registry.example.com/api/registry-info
# {"visibility":"private","requiresAuth":true,"signupsEnabled":false,…}

Members and tokens

Open signup is off, so onboarding goes through pantry registry. It authenticates with the registry token you already stored for this registry (pantry token set --registry …), or an admin session — a plain publish token can't mint access.

export PANTRY_REGISTRY_URL=https://registry.example.com

pantry registry member add dev@yourco.com --name Dev --password 'a-long-password'

Pass --admin to make them an operator. Re-running with a new password resets it.

Members can then log in at https://registry.example.com/login and manage their own tokens from /account. For machines — CI, a build box, a container image — issue the token directly and hand over only what it needs:

pantry registry token issue dev@yourco.com --name ci --expires-in-days 90

Tokens are read-only unless you pass --publish. The raw value is shown once; only its SHA-256 is stored. Revoke it by the id printed alongside it:

pantry registry token revoke dev@yourco.com --id ptry_abcwxyz

Revocation takes effect immediately — the next download with that token is 401.

All of these are ordinary HTTPS calls to /admin/users, /admin/tokens and /admin/tokens/revoke if you'd rather drive them from your own tooling.

Rotate the shared registry token with pantry registry rotate-token, which updates the server first and only touches CI secrets once the live registry accepts the new value — a failed server update can't leave CI holding a token the server rejects:

pantry registry rotate-token --host registry.example.com --repos "you/app,you/lib"

Installing from your registry

Two things point a machine at your registry: where it is, and the credential.

export PANTRY_REGISTRY_URL=https://registry.example.com
echo 'ptry_…' | pantry token set --registry https://registry.example.com
pantry install

PANTRY_REGISTRY_URL redirects registry traffic to your host; the stored credential is attached as Authorization: Bearer … on every request that goes there. Tokens are only ever sent to a registry you have named — either the origin matches PANTRY_REGISTRY_URL, or ~/.pantry/credentials holds an entry scoped to exactly that origin. A stray PANTRY_TOKEN in your environment is never broadcast to npm, GitHub, or the object-storage host a download redirects to. (curl drops Authorization across a cross-host redirect, which is exactly what a presigned download URL is.)

The credentials file makes several registries coexist, which is what you want when your own packages are private but everything else comes from the public registry:

# ~/.pantry/credentials — written by `pantry token set`, mode 0600
PANTRY_TOKEN=ptry_public_default

[https://registry.example.com]
PANTRY_TOKEN=ptry_private_read_only

In CI, the token is a secret and the URL is a plain variable:

- uses: pantry-pm/pantry/packages/action@main
- run: pantry install
  env:
    PANTRY_REGISTRY_URL: https://registry.example.com
    PANTRY_REGISTRY_TOKEN: ${{ secrets.PANTRY_TOKEN }}

pantry token sync --repo you/app --registry https://registry.example.com copies a stored credential into a repository's Actions secrets, so you don't paste tokens into the GitHub UI.

If a token is missing or wrong, the CLI says so during install rather than reporting a generic network failure:

HTTP 401 from https://registry.example.com/binaries/… — this registry requires authentication.
Store a token with: pantry token set --registry <registry-url>

Publishing to it

echo 'ptry_…' | pantry token set --registry https://registry.example.com
pantry publish --registry https://registry.example.com

The credential is scoped to that registry, so publishing to a different one still uses its own token. For CI:

pantry token sync --repo you/app --registry https://registry.example.com

Opening part of a private registry

Sometimes one package should be readable by anyone — a public SDK next to your internal packages, or an install script you curl | bash:

REGISTRY_PUBLIC_PATHS=/,/packages/@acme/sdk

Each entry is a path prefix; a bare / means only the root path, not everything. For policy more interesting than a prefix list — per-team access, an IP allowlist, SSO — write a plugin.

Extending it

A fork can edit packages/registry/src/server.ts, but then every upstream pull is a merge conflict. Plugins are the seam that avoids that: your code lives in your own file or package, and the registry loads it at boot.

REGISTRY_PLUGINS=./plugins/access.ts,@acme/pantry-audit

A plugin can add routes, decide access (including overruling the built-in check), and observe every access decision for an audit log. See Extending the registry for the API and worked examples.

Manual setup

The script above does all of this; this is what it does, for when you'd rather do it yourself or you're deploying somewhere else entirely.

1. Provision the box

curl -fsSL https://bun.sh/install | bash

install -d /opt/pantry-registry
git clone https://github.com/<you>/<your-fork> /opt/pantry-registry/repo
cd /opt/pantry-registry/repo && bun install

Create the environment file the service reads. Keep it 0600 — it holds your storage credentials and registry token:

install -m 600 /dev/null /opt/pantry-registry/registry.env
cat > /opt/pantry-registry/registry.env <<'EOF'
PORT=3000
BASE_URL=https://registry.example.com
REGISTRY_VISIBILITY=private
EOF

Then a systemd unit. Note it loads the environment from that file rather than carrying Environment= lines — pantry registry writes to the file, and mixing the two means whichever systemd applies last silently wins:

# /etc/systemd/system/pantry-registry.service
[Unit]
Description=Pantry Registry
After=network.target

[Service]
Type=simple
WorkingDirectory=/opt/pantry-registry/repo/packages/registry
EnvironmentFile=/opt/pantry-registry/registry.env
ExecStart=/root/.bun/bin/bun run src/server.ts
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
systemctl daemon-reload && systemctl enable --now pantry-registry
curl -fsS http://localhost:3000/health

Point your proxy at localhost:3000 and confirm https://registry.example.com/health answers.

2. Configure storage

From your workstation:

pantry registry storage \
  --host registry.example.com \
  --provider hetzner --bucket my-registry --region fsn1 \
  --access-key-id  --secret-access-key 

STORAGE_PROVIDER accepts hetzner, backblaze and aws; the endpoint is derived from the provider and region unless you set S3_ENDPOINT yourself (for MinIO or any other S3-compatible service, set it explicitly). On a non-AWS provider the registry keeps its metadata, auth and analytics as JSON objects in the bucket, so nothing needs DynamoDB — see object storage.

This writes to the environment file, restarts the service and waits for /health. It is idempotent — re-run it to change providers.

3. Set a registry token

pantry registry rotate-token --host registry.example.com

That prints the token, writes it to the box, restarts the service and verifies the registry accepts it before reporting success. Add --repos "you/app,you/lib" to push it to the repositories that publish from CI once the server has accepted it.

Deploying updates

.github/workflows/deploy-registry.yml SSHes into the box, fast-forwards the checkout and restarts the service. To use it on a fork, set these repository variables and one secret:

SettingKindValue
REGISTRY_HOSTvariableYour registry hostname
REGISTRY_PATHvariableCheckout path (default /opt/pantry-registry/repo)
REGISTRY_SERVICEvariablesystemd unit (default pantry-registry)
REGISTRY_SSH_KEYsecretPrivate key for the SSH user on the box

Or deploy however you already deploy — the workflow is a convenience, not a requirement. The registry is a Bun process reading an environment file.

Configuration reference

Everything below lives in the registry's environment file.

VariableDefaultMeaning
REGISTRY_VISIBILITYpublicprivate closes every read behind a credential
REGISTRY_PUBLIC_PATHSComma-separated path prefixes that stay public on a private registry
REGISTRY_ALLOW_SIGNUPfollows visibilitySelf-serve account creation
REGISTRY_SIGNUP_DOMAINSComma-separated email domains allowed to sign up
REGISTRY_PLUGINSComma-separated plugin module specifiers
PANTRY_REGISTRY_TOKENShared publish/admin token
PORT3000Port the Bun process listens on
BASE_URLhttp://localhost:$PORTPublic URL, used in generated links
STORAGE_PROVIDERawsaws, hetzner, backblaze, or any S3-compatible endpoint
S3_BUCKET / S3_REGION / S3_ENDPOINTObject storage location
S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEYObject storage credentials
METADATA_BACKENDderivedobject (default off AWS), dynamodb, or file

On the client side: PANTRY_REGISTRY_URL names the registry to talk to, and pantry token set --registry <url> stores the credential for it.

Command reference

CommandWhat it does
pantry registry setupProvision a box and start the service (private by default)
pantry registry storagePoint the registry at an S3-compatible bucket
pantry registry rotate-tokenReplace the shared registry token, then update CI
pantry registry member addCreate an account
pantry registry token issueMint a read-only (or --publish) token for a member
pantry registry token revokeRevoke one
pantry registry infoWhat a registry says about itself

setup, storage and rotate-token drive the box over SSH; the rest are HTTPS calls to the registry's admin API.

Verifying end to end

A green /health only proves the process is up. Confirm a real round trip, and — on a private registry — that anonymous access is actually refused:

curl -s -o /dev/null -w '%{http_code}\n' https://registry.example.com/packages/any   # 401
pantry publish --registry https://registry.example.com                               # from a test package
curl -fsS -H "Authorization: Bearer $PANTRY_REGISTRY_TOKEN" \
  https://registry.example.com/api/packages/<name>

Restoring metadata without the matching tarballs produces versions that resolve but cannot be downloaded, so back up the bucket and the metadata snapshot together — see the registry contract for the storage model.