Packr Registry β Deployment Guide
Fly.io and Vercel are retired β Phases 1 and 2 below are historical notes only. Both the registry and the dashboard run on forge-control behind Caddy (registry on app-3 since 2026-09-06; Fly decommissioned 2026-09-09, the dashboard left Vercel earlier). For current topology, deploys and secrets, start at
09-operations-guide.mdanddeployment-architecture.md. (blueforge.manifest.jsonstill says"vercel": "packr-site"β a stale declaration nothing enforces.)
Architecture
Packr has two deployable components, both on forge-control app-3
(116.203.97.105) behind the host Caddy, deployed by docker compose from
the forge-control worker (see 09-operations-guide.md):
public DNS (verified 2026-09-24)
packr.blueforge.studio β A 116.203.97.105 (app-3)
api.packr.blueforge.studio β A 116.203.97.105 (app-3)
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββ
β Host Caddy (app-3) β TLS + routing β
βββββββββββββββββββββββββ¬βββββββββββββββββββββββ
βββββββββββββββββ΄βββββββββββββββββ
βΌ βΌ
βββββββββββββββββββββ ββββββββββββββββββββββ
β Dashboard Site β β Registry Server β
β (Next.js 16) ββββββββββ>β (Go binary) β
β :3555 (loopback) β INTERNAL β :4873 (loopback) β
β packr.blueforgeβ¦ β API_SECRET api.packr.blueβ¦ β
βββββββββββββββββββββ βββββββββββ¬βββββββββββ
βΌ
ββββββββββββββββββββββββββββββββββββββ
β postgres-shared (app_packr_β¦) β
β fleet MinIO (app-packr-β¦) β
ββββββββββββββββββββββββββββββββββββββ
Secrets: forge vault (project packr-registry) β rendered to
/opt/packr-registry/registry.secrets.env / site.secrets.env on app-3.
Phase 1 (retired): Vercel + Supabase
The dashboard originally deployed to Vercel while the Go registry ran
locally or on any VPS (Supabase for DB/blobs). Both halves moved to
forge-control β the registry to app-3 on 2026-09-06, the site behind
Caddy on the fleet β so those Vercel steps and env tables no longer describe
anything deployable. Current procedures:
09-operations-guide.md; migration log:
HANDOVER-registry-migration-to-forge-control.md.
Phase 2 (retired): Fly.io for Registry Server
The registry once ran as a Fly.io app (packr-registry) with fly secrets set for configuration. Fly was decommissioned 2026-09-09 after both halves
moved to forge-control (registry on app-3, Postgres + fleet MinIO). The
old fly.toml is kept in the repo for history only. Secrets now live in the
forge vault (project packr-registry) rendered to the app-3 env files β see
09-operations-guide.md Β§ "Secrets & env files";
migration log: HANDOVER-registry-migration-to-forge-control.md.
The INTERNAL_API_SECRET pairing lesson from that era still holds: the
secret must exist and match on both registry and site, or every login fails
closed with a confusing error.
Email Configuration
Password reset emails are sent via a configurable email provider.
| Variable | Default | Description |
|---|---|---|
EMAIL_PROVIDER | log | Email provider: resend or log |
EMAIL_API_KEY | (empty) | API key for the chosen provider (required for resend) |
EMAIL_FROM | noreply@packr.blueforge.studio | Sender address shown in emails |
Providers
log (default) β Logs email content to stdout instead of sending. Safe for development and staging.
resend β Sends transactional email via Resend. Requires an API key and a verified sender domain.
# Development β emails logged to stdout (default)
EMAIL_PROVIDER=log
# Production β send via Resend
EMAIL_PROVIDER=resend
EMAIL_API_KEY=re_your_resend_api_key
EMAIL_FROM=noreply@yourdomain.com
The DASHBOARD_ORIGIN variable is used to construct the reset link in the email (e.g., https://packr.blueforge.studio/login/reset?token=...). Ensure it is set correctly in production.
(Deploy/custom-domain/cost instructions for this phase were retired with Fly β see the Phase 2 note above.)
Phase 3: Kubernetes (When Needed)
Move to K8s when you need:
-
2 replicas with auto-scaling
- Zero-downtime canary deployments
- Enterprise customers requiring "runs in our cluster"
- Traffic exceeding a single-host deployment
Recommended Providers
| Provider | Min Cost | Best For |
|---|---|---|
| DigitalOcean K8s | $24/mo | First K8s, simple |
| Hetzner + k3s | $15/mo | Cheapest, EU-based |
| GKE Autopilot | Pay-per-pod | Scale-to-zero |
| AWS EKS | $72/mo + nodes | Enterprise/AWS |
Helm Chart (Future)
When the time comes, a Helm chart would include:
- Deployment (Go server, 2+ replicas)
- Service + Ingress (TLS termination)
- ConfigMap (non-secret env vars)
- Secret (JWT_SECRET, DB passwords)
- HPA (auto-scale on CPU/request count)
- PodDisruptionBudget (HA guarantees)
# Future usage:
helm install packr ./charts/packr \
--set supabase.projectId=your-id \
--set supabase.dbPassword=your-pass \
--set image.tag=v1.0.0
DNS Setup
| Record | Type | Value |
|---|---|---|
packr.blueforge.studio | A | 116.203.97.105 β forge-control app-3, site behind Caddy |
api.packr.blueforge.studio | A | 116.203.97.105 β forge-control app-3, registry behind Caddy |
(Both verified 2026-09-24. The former Vercel/Fly CNAMEs died with those providers.)
Environment Variable Reference
Registry Server (Go)
| Variable | Required | Description |
|---|---|---|
PORT | No (4873) | HTTP port |
STORAGE_DRIVER | No (sqlite) | sqlite, postgres, supabase |
SUPABASE_PROJECT_ID | If supabase | Supabase project ID |
SUPABASE_DB_PASSWORD | If supabase | Database password |
BLOB_DRIVER | No (file) | file, supabase, backblaze, s3 |
S3_ACCESS_KEY | If S3 | S3 access key |
S3_SECRET_KEY | If S3 | S3 secret key |
JWT_SECRET | Yes | JWT signing key |
SESSION_SECRET | Yes | Session cookie key |
ALLOW_REGISTRATION | No (false) | Allow public self-registration via /-/v1/login |
INTERNAL_API_SECRET | Yes (dashboard) | Shared secret for admin session creation |
DASHBOARD_ORIGIN | No | Allowed CORS origin for admin API |
MAX_TARBALL_SIZE | No (5242880) | Max tarball upload in bytes (default 5 MB) |
DEFAULT_PACKAGE_VISIBILITY | No (public) | Visibility given to a package on first publish: public or private. See the warning below before changing. |
RATE_LIMIT_LOGIN | No (10) | Login rate limit/min |
RATE_LIMIT_PUBLISH | No (30) | Publish rate limit/min |
LOG_LEVEL | No (info) | debug, info, warn, error |
Dashboard Site (Next.js)
| Variable | Required | Description |
|---|---|---|
PACKR_REGISTRY_URL | Yes | Registry server URL |
NEXT_PUBLIC_SITE_URL | Yes | This site's public URL |
SESSION_SECRET | Yes | Session cookie key |
INTERNAL_API_SECRET | Yes | Must match registry's INTERNAL_API_SECRET |
GITHUB_OAUTH_CLIENT_ID | For OAuth | GitHub App client ID |
GITHUB_OAUTH_CLIENT_SECRET | For OAuth | GitHub App secret |
STRIPE_SECRET_KEY | For billing | Stripe secret key |
STRIPE_WEBHOOK_SECRET | For billing | Stripe webhook secret |
STRIPE_SOLO_PRICE_ID | For billing | Solo plan price ID |
STRIPE_TEAM_PRICE_ID | For billing | Team plan price ID |
DATABASE_URL | Optional | Direct Postgres (Drizzle) |
OAuth Configuration
Packr supports OAuth login via GitHub, Google, GitLab, and any OIDC-compliant provider. Set the env vars for the providers you want to enable. See docs/06-oauth.md for full details.
Auth Mode
| Variable | Default | Description |
|---|---|---|
PACKR_AUTH_MODE | hybrid | standalone (password only), forge-auth (SSO only), hybrid (both) |
All OAuth providers share one redirect base env var:
OAUTH_REDIRECT_BASE=https://packr.example.com # public dashboard URL
Each provider's callback URL is built as {OAUTH_REDIRECT_BASE}/api/auth/callback/{provider} β configure this on the provider's OAuth app settings.
GitHub OAuth
docker run -p 4873:4873 \
-e OAUTH_GITHUB_CLIENT_ID=Ov23liABCDEFGH123456 \
-e OAUTH_GITHUB_CLIENT_SECRET=abc123def456... \
-e OAUTH_REDIRECT_BASE=https://packr.example.com \
-e JWT_SECRET=$(openssl rand -hex 32) \
packr-registry
Callback URL to configure in GitHub: https://packr.example.com/api/auth/callback/github
Google OAuth
docker run -p 4873:4873 \
-e OAUTH_GOOGLE_CLIENT_ID=123456789-abc.apps.googleusercontent.com \
-e OAUTH_GOOGLE_CLIENT_SECRET=GOCSPX-abc123... \
-e OAUTH_REDIRECT_BASE=https://packr.example.com \
-e JWT_SECRET=$(openssl rand -hex 32) \
packr-registry
GitLab OAuth
docker run -p 4873:4873 \
-e OAUTH_GITLAB_CLIENT_ID=abc123def456... \
-e OAUTH_GITLAB_CLIENT_SECRET=gloas-abc123... \
-e OAUTH_GITLAB_BASE_URL=https://gitlab.com \
-e OAUTH_REDIRECT_BASE=https://packr.example.com \
-e JWT_SECRET=$(openssl rand -hex 32) \
packr-registry
Generic OIDC (Keycloak, Okta, Azure AD, Authentik)
docker run -p 4873:4873 \
-e OAUTH_OIDC_CLIENT_ID=packr \
-e OAUTH_OIDC_CLIENT_SECRET=super-secret \
-e OAUTH_OIDC_ISSUER_URL=https://auth.example.com/realms/myrealm \
-e OAUTH_REDIRECT_BASE=https://packr.example.com \
-e JWT_SECRET=$(openssl rand -hex 32) \
packr-registry
Forge-auth SSO
docker run -p 4873:4873 \
-e PACKR_AUTH_MODE=forge-auth \
-e FORGE_AUTH_URL=https://auth.blueforge.studio \
-e FORGE_AUTH_CLIENT_ID=packr-registry \
-e FORGE_AUTH_CLIENT_SECRET=forge-client-secret \
-e OAUTH_REDIRECT_BASE=https://packr.example.com \
-e JWT_SECRET=$(openssl rand -hex 32) \
packr-registry
Multiple Providers + Password Login
docker run -p 4873:4873 \
-e PACKR_AUTH_MODE=hybrid \
-e OAUTH_GITHUB_CLIENT_ID=... \
-e OAUTH_GITHUB_CLIENT_SECRET=... \
-e OAUTH_GOOGLE_CLIENT_ID=... \
-e OAUTH_GOOGLE_CLIENT_SECRET=... \
-e OAUTH_REDIRECT_BASE=https://packr.example.com \
-e JWT_SECRET=$(openssl rand -hex 32) \
packr-registry
Credential Store (CLI)
| Variable | Default | Description |
|---|---|---|
PACKR_CREDENTIAL_STORE | file | Where CLI stores tokens: file, keychain, secret-service |
This variable is used by the CLI, not the registry server.
Security Configuration
Generating Secrets
All secrets should be cryptographically random. Use openssl rand -hex 32 for each:
openssl rand -hex 32 # once per secret: JWT_SECRET, SESSION_SECRET, INTERNAL_API_SECRET
Store them in the forge vault (project packr-registry); deploys render
them to /opt/packr-registry/registry.secrets.env (worker path) and
/opt/packr-registry/registry.env (manual path) on app-3. The site's copy of
INTERNAL_API_SECRET lives in /opt/packr-registry/site.secrets.env and must
be the same value as the registry's. Rotation procedure (update both env
files, then recreate the container β docker restart silently keeps the
old env):
09-operations-guide.md Β§ "Secrets & env files".
Key Security Variables
| Variable | Where | Notes |
|---|---|---|
INTERNAL_API_SECRET | Registry + Site | Must match on both sides. Used to authenticate dashboard β registry admin calls via X-Internal-Secret header. |
ALLOW_REGISTRATION | Registry | Defaults to false. Set to true only if you want open sign-up. |
DASHBOARD_ORIGIN | Registry | CORS origin allowed to call admin endpoints. Defaults to https://packr.blueforge.studio. Set to your dashboard URL. |
MAX_TARBALL_SIZE | Registry | Defaults to 5 MB (5242880 bytes). Increase only if you publish large packages. |
DEFAULT_PACKAGE_VISIBILITY | Registry | public (default) or private. Applies on first publish only. Read the warning under Package visibility first β enabling it without working install credentials breaks consumers. |
Password Requirements
All user passwords must meet these requirements:
- Minimum 10 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one digit
- At least one special character
Post-Deployment Checklist
After deploying with new secrets, complete these steps:
- Verify
INTERNAL_API_SECRETis the same value in the forge vault (projectpackr-registry),/opt/packr-registry/registry.secrets.env(andregistry.env), and the site's/opt/packr-registry/site.secrets.env - Revoke any existing tokens that were issued before the security hardening (JWT algorithm and hash changes invalidate old tokens)
- Confirm
ALLOW_REGISTRATION=falseis set (or intentionally omitted β it defaults to false) unless open sign-up is desired - Set
DASHBOARD_ORIGINto your actual dashboard URL if it differs from the default - Test dashboard login end-to-end after deployment
- Rotate
JWT_SECRETandSESSION_SECRETif they were previously auto-generated (random at startup means they change on every restart)
Package visibility
Package reads are anonymous by default. This is deliberate: visibility was
designed as opt-in privacy so that adding the feature broke no existing
install (see docs/superpowers/plans/2026-08-22-package-visibility.md). The
packages.visibility column defaults to public and the migration backfilled
every existing row the same way.
The consequence is worth stating plainly, because the default is easy to leave in place without realising its reach: unless you have opted individual packages in, every package in your registry β metadata and tarballs β is readable without a token:
curl -s -o /dev/null -w '%{http_code}\n' \
https://your-registry/@scope%2fpackage # 200, no credentials
For a registry deployed as a private replacement for Artifactory or GitHub Packages, that is often not what is wanted once the migration settles β the default optimises for not breaking installs, not for confidentiality. Audit what you are actually exposing with:
SELECT visibility, count(*) FROM packages GROUP BY visibility;
Making packages private
Do this in order. The trap is that because reads have always been anonymous, consumers may never have needed a working token β so their credentials can be long expired without anyone noticing, and making packages private is the moment that surfaces.
-
Verify install credentials actually authenticate.
GET /-/whoamiaccepts stored tokens as well as JWTs and reports which path accepted it:curl -s -H "Authorization: Bearer $PACKR_TOKEN" https://your-registry/-/whoami # {"auth":"token","name":"ci-publish","permissions":["read","publish"], ...}"auth":"token"means it resolves against the tokens table β the path installs and publishes use. A401naming the signing key means the token predates the lastJWT_SECRETrotation; re-issue it. -
Stop the exposure growing. Set
DEFAULT_PACKAGE_VISIBILITY=privateso newly published packages start private. Existing packages are unaffected. -
Migrate existing packages in batches, watching for install failures between batches:
packr-cli visibility npm/@scope/package private
Existing packages are never changed implicitly, and a republish never re-opens a package you made private β visibility is applied on first publish only.