Packr Registry — Deployment Guide
Partly stale — read
deployment-architecture.mdfirst.The dashboard is not on Vercel. It moved to forge-control's
app-3behind Caddy, so the Vercel steps and the environment table in Phase 1 describe a deployment that no longer exists.blueforge.manifest.jsonstill says"vercel": "packr-site"for the same reason.The Fly.io sections for the registry are accurate. Phase 2's secret list is the authoritative one — note that
fly.toml's own quick-start comment used to omitINTERNAL_API_SECRET, which is how a registry that refuses every login becomes deployable from a copy-paste.
Architecture
Packr has two deployable components:
┌─────────────────────┐ ┌─────────────────────┐
│ Dashboard Site │ │ Registry Server │
│ (Next.js 16) │────>│ (Go binary) │
│ │ │ │
│ Vercel / CDN │ │ Fly.io / VPS │
│ packr.blueforge.studio │ │ api.packr.blueforge.studio │
└─────────────────────┘ └─────────────────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ Supabase │ │ Supabase │
│ (Postgres DB) │ │ (S3 Storage) │
└─────────────────────┘ └─────────────────────┘
Phase 1: Vercel + Supabase (Current)
Deploy the dashboard site to Vercel. The Go registry runs locally or on any VPS for now.
Dashboard Site → Vercel
1. Connect to Vercel
cd apps/site
npx vercel link
Or connect via Vercel Dashboard → New Project → Import from GitHub.
Settings:
- Root Directory:
apps/site - Framework: Next.js
- Build Command:
npm run build - Output Directory:
.next
2. Environment Variables
Set these in Vercel Dashboard → Settings → Environment Variables:
| Variable | Value | Required |
|---|---|---|
PACKR_REGISTRY_URL | http://your-registry:4873 | Yes |
NEXT_PUBLIC_SITE_URL | https://packr.blueforge.studio | Yes |
NEXT_PUBLIC_REGISTRY_URL | http://your-registry:4873 | Yes |
SESSION_SECRET | openssl rand -hex 32 | Yes |
INTERNAL_API_SECRET | openssl rand -hex 32 (must match registry) | Yes |
GITHUB_OAUTH_CLIENT_ID | From GitHub OAuth App | For GitHub login |
GITHUB_OAUTH_CLIENT_SECRET | From GitHub OAuth App | For GitHub login |
STRIPE_SECRET_KEY | From Stripe Dashboard | For billing |
STRIPE_WEBHOOK_SECRET | From Stripe webhook config | For billing |
STRIPE_SOLO_PRICE_ID | From stripe-setup.sh | For billing |
STRIPE_TEAM_PRICE_ID | From stripe-setup.sh | For billing |
DATABASE_URL | Supabase Postgres connection string | For Drizzle direct queries |
VAULT7_API_URL | AI server URL | Optional |
VAULT7_API_KEY | AI API key | Optional |
3. Deploy
npx vercel --prod
# Or push to main — auto-deploys if GitHub integration is connected
Registry Server → Local / VPS (Temporary)
Until Fly.io is set up, run the Go server on any machine:
# With Supabase
STORAGE_DRIVER=supabase \
SUPABASE_PROJECT_ID=your-id \
SUPABASE_DB_PASSWORD=your-pass \
BLOB_DRIVER=supabase \
S3_ACCESS_KEY=your-key \
S3_SECRET_KEY=your-secret \
JWT_SECRET=$(openssl rand -hex 32) \
SESSION_SECRET=$(openssl rand -hex 32) \
./bin/packr
Or via Docker:
docker run -p 4873:4873 \
-e STORAGE_DRIVER=supabase \
-e SUPABASE_PROJECT_ID=your-id \
-e SUPABASE_DB_PASSWORD=your-pass \
-e BLOB_DRIVER=supabase \
-e S3_ACCESS_KEY=your-key \
-e S3_SECRET_KEY=your-secret \
-e JWT_SECRET=your-secret \
-e SESSION_SECRET=your-secret \
packr-registry
Phase 2: Fly.io for Registry Server
When you're ready for a production registry endpoint:
1. Create fly.toml
# fly.toml
app = "packr-registry"
primary_region = "iad"
[build]
dockerfile = "Dockerfile"
[http_service]
internal_port = 4873
force_https = true
[[http_service.checks]]
path = "/health"
interval = 10000
timeout = 2000
[env]
PORT = "4873"
STORAGE_DRIVER = "supabase"
BLOB_DRIVER = "supabase"
LOG_LEVEL = "info"
2. Set Secrets
fly secrets set \
SUPABASE_PROJECT_ID=your-id \
SUPABASE_DB_PASSWORD=your-pass \
S3_ACCESS_KEY=your-key \
S3_SECRET_KEY=your-secret \
JWT_SECRET=$(openssl rand -hex 32) \
SESSION_SECRET=$(openssl rand -hex 32) \
INTERNAL_API_SECRET=$(openssl rand -hex 32) \
EMAIL_PROVIDER=resend \
EMAIL_API_KEY=re_your_resend_api_key \
EMAIL_FROM=noreply@yourdomain.com
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.
3. Deploy
fly launch # First time
fly deploy # Subsequent deploys
fly scale count 2 # Add a second instance for HA
4. Custom Domain
fly certs add api.packr.blueforge.studio
# Add CNAME: api.packr.blueforge.studio → packr-registry.fly.dev
Cost Estimate
| Resource | Fly.io Cost |
|---|---|
| 1x shared-cpu-1x (256MB) | $1.94/mo |
| 2x shared-cpu-1x (HA) | $3.88/mo |
| Bandwidth (10GB/mo) | Free |
| Custom domain + SSL | Free |
Total: ~$4/mo for a production registry with HA.
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 Fly.io limits
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 | CNAME | cname.vercel-dns.com (Vercel) |
api.packr.blueforge.studio | CNAME | packr-registry.fly.dev (Fly.io) |
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:
# Registry (Go) — set on Fly.io
fly secrets set \
JWT_SECRET=$(openssl rand -hex 32) \
SESSION_SECRET=$(openssl rand -hex 32) \
INTERNAL_API_SECRET=$(openssl rand -hex 32)
# Dashboard (Next.js) — set on Vercel
# INTERNAL_API_SECRET must be the SAME value as the registry's
vercel env add INTERNAL_API_SECRET production
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 set on both Vercel (dashboard) and Fly.io (registry) with the same value - 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.