Scaling
Size a Pinwren server from measured load-test numbers, and scale out with workers and replicas.
One small server goes a long way. A widget visitor costs about two requests per page view (the config and the page's pins); a report adds one. Screenshots are the heaviest request.
Sizing guide
Measured with the k6 load test against the self-host compose stack (app + Postgres 17), with widget reads, report submissions (40 % bug reports, half with a screenshot), dashboard use and a single-IP flood running at once.
| Server | Settings | Comfortable load |
|---|---|---|
| 1–2 vCPU, 1 GB | PINWREN_WORKERS=2 | ~70 requests/s — thousands of daily active widget visitors |
| 4 vCPU, 2 GB | PINWREN_WORKERS=4 | ~130 requests/s with p95 under 100 ms |
The passing runs:
| Build | App limits | Offered load | p95 widget read / dashboard / submit | Errors |
|---|---|---|---|---|
| Hardened (2026-10-22) | 4 CPU, 4 workers | ~130 req/s | 46 ms / 76 ms / 83 ms | 0 |
| Before hardening (10-21) | 4 CPU, 4 workers | ~130 req/s | 38 ms / 65 ms / 70 ms | 0 |
| Before hardening (10-21) | 2 CPU, 2 workers | ~74 req/s | 20 ms / 41 ms / 50 ms | 0 |
The latest run (2026-10-22) is on the image with the pre-release security hardening — outbound checks, streamed body caps, per-project intake caps and the Postgres-backed sign-in limiter. All thresholds passed with zero errors; the few extra milliseconds are those checks. The 2-worker figure predates the hardening and was not re-measured. All runs are 2 minutes on Windows 11 with Docker Desktop.
The first build saturated at 64 req/s with multi-second p95s; three row-lock hot spots were fixed (one SQL statement per report, no page-row updates, throttled "last seen" writes) and the app was put on several cores — the details are in the load-test notes and ADR 0013. Run the test against your own hardware before you rely on these numbers.
Use more cores: PINWREN_WORKERS
One Node process uses one CPU core. The Docker image runs PINWREN_WORKERS processes (default 2) that share
port 3000 through node:cluster, restarting any that exit. Set it to roughly the number of cores you give the
container:
PINWREN_WORKERS=4PINWREN_WORKERS=1 runs a single process with no cluster.
Workers (and replicas) share everything that matters through Postgres, so it doesn't matter which one a request lands on:
- Live updates. Every event carries one global id, so a browser that reconnects to
/api/streamwithLast-Event-IDresumes on whichever worker it reaches. - The sign-in limiter. Sign-in, sign-up and password-reset attempts are counted in Postgres, so the allowance is shared rather than granted once per worker. The widget and API rate limits are counted there too.
- Settings. Instance settings such as the session lifetime are read from the database on each sign-in, not cached per process.
Database connections
Each worker process has its own connection pools: DATABASE_POOL_MAX (default 10) for requests and
PINWREN_JOBS_POOL_MAX (default 3) for the job queue. Make sure Postgres allows enough:
max_connections ≥ processes × (DATABASE_POOL_MAX + PINWREN_JOBS_POOL_MAX) + headroomWith the defaults and two workers that is 26 connections plus headroom — well inside Postgres's default of 100. Count every replica's workers if you run more than one container.
Separate web and jobs: PINWREN_ROLE
Background work — notification emails, webhook deliveries, issue sync, retention and clean-up — runs as jobs in a queue stored in Postgres (pg-boss). By default every process serves requests and works the queue; each job goes to exactly one of them, and scheduled jobs fire once.
PINWREN_ROLE | Serves HTTP | Runs jobs |
|---|---|---|
all (default) | yes | yes |
web | yes | no (still enqueues) |
worker | no | yes |
For a larger install, run several web replicas behind a load balancer and one worker, all pointing at the
same Postgres. Use S3 storage so every replica sees the same files, and the same
PINWREN_SECRET everywhere. Migrations are safe to run from all of them at once — they take an advisory lock.
A failing integration never slows ingest: a report is written in one statement and its notifications are queued after it.
Rate limits
The public widget endpoints are rate-limited per IP and per project in a fixed one-minute window, counted in
Postgres (no Redis): WIDGET_RATE_LIMIT_PER_MIN (default 10) reports per minute, and as many replies, counted separately.
Each project can override the limit in its settings (1–1000). Over the limit, the widget gets
429 RATE_LIMITED and shows the message. In the load test, the single-IP flood got exactly its 10 per minute
through and everything else was answered 429.
On top of the per-IP limit, each project has caps that hold however many addresses send:
WIDGET_PROJECT_REPORTS_PER_MIN (default 120 reports per minute, 429 PROJECT_RATE_LIMITED) and
WIDGET_PROJECT_MB_PER_HOUR (default 500 MB of stored files per hour, 429 PROJECT_STORAGE_LIMITED). Raise them
for a load test or a very busy project.
Behind a proxy, rate limits key on X-Forwarded-For only when PINWREN_TRUST_PROXY is set to the number of
proxies — see Client addresses.