chore(pnpr): add Dockerfile, compose, and deployment guide

Adds container tooling for self-hosting pnpr:

- pnpr/Dockerfile: multi-stage build (context = monorepo root, since
  pnpr depends on pacquet-* workspace crates) producing a slim runtime
  image with a /-/ping HEALTHCHECK.
- pnpr/docker/entrypoint.sh: maps PNPR_* env vars to CLI flags so a PaaS
  can configure the server without editing files.
- pnpr/docker/config.yaml: production config that proxies npm and
  persists state under PNPR_STORAGE via ${VAR:-default} substitution.
- pnpr/docker-compose.yml: local/PaaS compose with a /data volume.
- pnpr/DEPLOY.md: deployment guide including a step-by-step Coolify walkthrough.
This commit is contained in:
Zoltan Kochan
2026-06-04 17:11:33 +02:00
parent 9f002da43f
commit 82da8431c2
7 changed files with 253 additions and 0 deletions

96
pnpr/DEPLOY.md Normal file
View File

@@ -0,0 +1,96 @@
# Deploying pnpr
`pnpr` is a single long-running HTTP server backed by a filesystem
store, so it deploys like any stateful web service: one instance, one
persistent volume, a domain with TLS in front.
> **Licensing.** `pnpr` is source-available under the
> [PolyForm Shield License](../LICENSE.md), **not** open source. You may
> run, modify, and self-host it for any purpose **except** providing a
> product that competes with `pnpr`. Self-hosting a private registry for
> your own team or CI is fine; offering it as a commercial hosted
> registry needs a commercial license from
> [Zoltan Kochan](https://kochan.io).
## Container image
The repo ships a multi-stage [`Dockerfile`](./Dockerfile). Its build
context is the **monorepo root**, because pnpr depends on `pacquet-*`
crates from the shared Cargo workspace. Build it from the repo root:
```bash
docker build -f pnpr/Dockerfile -t pnpr .
```
Run it with a persistent volume for state:
```bash
docker run -d --name pnpr \
-p 4873:4873 \
-v pnpr-data:/data \
-e PNPR_PUBLIC_URL=https://registry.example.com \
pnpr
```
Then point pnpm at it:
```bash
pnpm config set registry http://localhost:4873/
```
## Configuration
The image is configured through environment variables (translated into
CLI flags by [`docker/entrypoint.sh`](./docker/entrypoint.sh)):
| Variable | Default | Purpose |
| --- | --- | --- |
| `PNPR_PUBLIC_URL` | `http://<listen>` | URL clients use to reach the server. **Set this in production** — pnpr rewrites `dist.tarball` URLs in served packuments to it. Without it, clients get links pointing at the container's bind address. |
| `PNPR_LISTEN` | `0.0.0.0:4873` | Address the server binds to inside the container. |
| `PNPR_STORAGE` | `/data` | Directory for the package cache, `htpasswd`, and `tokens.db`. Mount a volume here. |
| `PNPR_CONFIG` | `/etc/pnpr/config.yaml` | Path to the YAML config. The baked-in [`docker/config.yaml`](./docker/config.yaml) proxies all of npm and persists state under `PNPR_STORAGE`. |
| `PNPR_PACKUMENT_TTL_SECS` | (config value) | Seconds before a cached packument is refetched. |
| `PNPR_LOG_LEVEL` / `PNPR_LOG_FORMAT` | `info` / `json` | Log verbosity and shape. `RUST_LOG` overrides the level. |
To customize package routing, auth, or uplinks beyond what the env vars
cover, mount your own YAML over `/etc/pnpr/config.yaml` (or point
`PNPR_CONFIG` elsewhere). It follows verdaccio's `config.yaml` shape;
`${VAR:-default}` placeholders are substituted from the environment.
## Coolify
[Coolify](https://coolify.io) is a self-hosted PaaS. pnpr fits its
Dockerfile/Compose deployment model directly.
1. **New Resource → Application** in your Coolify project, sourced from
this Git repository.
2. **Build pack: Dockerfile.** Set:
- **Base Directory:** `/` (the repo root — the build context needs the
whole Cargo workspace).
- **Dockerfile Location:** `pnpr/Dockerfile`.
3. **Port:** expose `4873` so Coolify's Traefik proxy routes to it.
4. **Domain:** assign one (e.g. `registry.example.com`). Coolify
provisions HTTPS automatically.
5. **Environment variables:** set `PNPR_PUBLIC_URL` to that same HTTPS
domain. Add `PNPR_LOG_LEVEL` / `PNPR_LOG_FORMAT` if you want to tune
logging.
6. **Persistent storage:** add a volume mounted at `/data`. This is the
one step that bites people — without it, every redeploy wipes your
published packages, accounts, and cache.
7. Deploy. The image's `HEALTHCHECK` hits `/-/ping`, which Coolify
surfaces as the container's health status.
Prefer Compose? Point Coolify at [`docker-compose.yml`](./docker-compose.yml)
instead — it already wires up the port, the `/data` volume, and the env
vars; just set `PNPR_PUBLIC_URL`.
## Operational notes
- **Single instance.** pnpr is a stateful single process over a
filesystem store. Don't scale it to multiple replicas behind a load
balancer expecting them to share state.
- **Back up `/data`.** It holds published packages, the htpasswd user
file, and the SQLite token database.
- **Accounts.** `npm adduser --registry <url>` registers a user (written
to `htpasswd`). Set `auth.htpasswd.max_users: -1` in the config once
your accounts exist to close registration.

51
pnpr/Dockerfile Normal file
View File

@@ -0,0 +1,51 @@
# Build context must be the monorepo root, since pnpr depends on
# pacquet-* crates from the shared Cargo workspace:
#
# docker build -f pnpr/Dockerfile -t pnpr .
#
# The companion pnpr/docker-compose.yml sets the context for you.
# The pinned toolchain matches rust-toolchain.toml at the repo root.
FROM rust:1.95.0-slim-bookworm AS builder
# cmake + perl are needed by aws-lc-sys/ring (pulled in via reqwest's
# rustls backend); build-essential provides the C compiler that
# rusqlite's bundled SQLite and the *-sys crates compile against.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
cmake \
perl \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY . .
RUN cargo build --release --locked --bin pnpr
FROM debian:bookworm-slim AS runtime
# ca-certificates lets pnpr's reqwest client validate TLS to upstream
# registries (registry.npmjs.org and any configured uplinks).
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/pnpr /usr/local/bin/pnpr
COPY pnpr/docker/config.yaml /etc/pnpr/config.yaml
COPY pnpr/docker/entrypoint.sh /usr/local/bin/pnpr-entrypoint
RUN chmod +x /usr/local/bin/pnpr-entrypoint
# Storage, auth state (htpasswd, tokens.db), and the packument/tarball
# cache all live under /data. Mount it as a volume so they survive
# redeploys.
ENV PNPR_STORAGE=/data \
PNPR_LISTEN=0.0.0.0:4873 \
PNPR_CONFIG=/etc/pnpr/config.yaml
VOLUME /data
EXPOSE 4873
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -fsS http://127.0.0.1:4873/-/ping || exit 1
ENTRYPOINT ["/usr/local/bin/pnpr-entrypoint"]

View File

@@ -0,0 +1,7 @@
# BuildKit reads this when building with -f pnpr/Dockerfile. Keeps the
# repo-root build context small so `COPY . .` doesn't ship build output
# or installed dependencies into the image.
target/
**/node_modules/
.git/
*.log

View File

@@ -4,6 +4,13 @@ A pnpm-compatible npm registry server, written in Rust.
Lives in the [pnpm monorepo](https://github.com/pnpm/pnpm) under `registry/`.
## Deployment
See [`DEPLOY.md`](../../DEPLOY.md) for running pnpr in a container, the
`PNPR_*` configuration variables, and a step-by-step Coolify guide. The
repo ships a [`Dockerfile`](../../Dockerfile) and
[`docker-compose.yml`](../../docker-compose.yml).
## License
Source-available under the [PolyForm Shield License 1.0.0](../../LICENSE.md) —

28
pnpr/docker-compose.yml Normal file
View File

@@ -0,0 +1,28 @@
# Build context is the monorepo root because pnpr depends on pacquet-*
# crates from the shared Cargo workspace. Run from the pnpr/ directory:
#
# docker compose up --build
#
# Or point a PaaS (e.g. Coolify) at this file. See DEPLOY.md.
services:
pnpr:
build:
context: ..
dockerfile: pnpr/Dockerfile
image: pnpr:local
restart: unless-stopped
ports:
- "4873:4873"
environment:
# The URL clients use to reach this server. Set it to your public
# domain in production so proxied tarball URLs are rewritten
# correctly.
PNPR_PUBLIC_URL: ${PNPR_PUBLIC_URL:-http://localhost:4873}
PNPR_LOG_LEVEL: info
PNPR_LOG_FORMAT: json
volumes:
- pnpr-data:/data
volumes:
pnpr-data:

41
pnpr/docker/config.yaml Normal file
View File

@@ -0,0 +1,41 @@
# Production-oriented pnpr config for container deployments.
#
# Unlike the bundled default config (which is tuned for
# @pnpm/registry-mock's test packages), this one proxies the whole npm
# namespace through registry.npmjs.org and persists all state under
# ${PNPR_STORAGE}. `${VAR:-default}` placeholders are substituted from
# the environment at startup, so a PaaS can configure it without editing
# the file.
storage: ${PNPR_STORAGE:-/data}
auth:
htpasswd:
file: ${PNPR_STORAGE:-/data}/htpasswd
# Cap open registration. Omit for unlimited; set to -1 to disable
# `npm adduser` entirely once your accounts exist.
#max_users: -1
tokens:
file: ${PNPR_STORAGE:-/data}/tokens.db
uplinks:
npmjs:
url: https://registry.npmjs.org/
# Evaluated in order; first match wins. Publishing requires auth;
# everything not published locally is proxied from npmjs and cached.
packages:
'@*/*':
access: $all
publish: $authenticated
proxy: npmjs
'**':
access: $all
publish: $authenticated
proxy: npmjs
log:
type: stdout
format: ${PNPR_LOG_FORMAT:-json}
level: ${PNPR_LOG_LEVEL:-info}

23
pnpr/docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,23 @@
#!/bin/sh
# Translate PNPR_* environment variables into pnpr CLI flags so the
# server can be configured entirely through a PaaS's env-var UI.
set -eu
set -- --listen "${PNPR_LISTEN:-0.0.0.0:4873}" --storage "${PNPR_STORAGE:-/data}"
if [ -n "${PNPR_CONFIG:-}" ]; then
set -- "$@" --config "$PNPR_CONFIG"
fi
# Required behind a reverse proxy: pnpr rewrites dist.tarball URLs in
# served packuments to this URL. Without it, clients receive links
# pointing at the container's bind address.
if [ -n "${PNPR_PUBLIC_URL:-}" ]; then
set -- "$@" --public-url "$PNPR_PUBLIC_URL"
fi
if [ -n "${PNPR_PACKUMENT_TTL_SECS:-}" ]; then
set -- "$@" --packument-ttl-secs "$PNPR_PACKUMENT_TTL_SECS"
fi
exec pnpr "$@"