Documentation

Deployment

Container, fleet and environment configuration.

Source: api.packr.blueforge.studio/-/docs/deployment

Packr Registry — Deployment Guide

Partly stale — read deployment-architecture.md first.

The dashboard is not on Vercel. It moved to forge-control's app-3 behind Caddy, so the Vercel steps and the environment table in Phase 1 describe a deployment that no longer exists. blueforge.manifest.json still 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 omit INTERNAL_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:

VariableValueRequired
PACKR_REGISTRY_URLhttp://your-registry:4873Yes
NEXT_PUBLIC_SITE_URLhttps://packr.blueforge.studioYes
NEXT_PUBLIC_REGISTRY_URLhttp://your-registry:4873Yes
SESSION_SECRETopenssl rand -hex 32Yes
INTERNAL_API_SECRETopenssl rand -hex 32 (must match registry)Yes
GITHUB_OAUTH_CLIENT_IDFrom GitHub OAuth AppFor GitHub login
GITHUB_OAUTH_CLIENT_SECRETFrom GitHub OAuth AppFor GitHub login
STRIPE_SECRET_KEYFrom Stripe DashboardFor billing
STRIPE_WEBHOOK_SECRETFrom Stripe webhook configFor billing
STRIPE_SOLO_PRICE_IDFrom stripe-setup.shFor billing
STRIPE_TEAM_PRICE_IDFrom stripe-setup.shFor billing
DATABASE_URLSupabase Postgres connection stringFor Drizzle direct queries
VAULT7_API_URLAI server URLOptional
VAULT7_API_KEYAI API keyOptional

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.

VariableDefaultDescription
EMAIL_PROVIDERlogEmail provider: resend or log
EMAIL_API_KEY(empty)API key for the chosen provider (required for resend)
EMAIL_FROMnoreply@packr.blueforge.studioSender 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

ResourceFly.io Cost
1x shared-cpu-1x (256MB)$1.94/mo
2x shared-cpu-1x (HA)$3.88/mo
Bandwidth (10GB/mo)Free
Custom domain + SSLFree

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
ProviderMin CostBest For
DigitalOcean K8s$24/moFirst K8s, simple
Hetzner + k3s$15/moCheapest, EU-based
GKE AutopilotPay-per-podScale-to-zero
AWS EKS$72/mo + nodesEnterprise/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

RecordTypeValue
packr.blueforge.studioCNAMEcname.vercel-dns.com (Vercel)
api.packr.blueforge.studioCNAMEpackr-registry.fly.dev (Fly.io)

Environment Variable Reference

Registry Server (Go)

VariableRequiredDescription
PORTNo (4873)HTTP port
STORAGE_DRIVERNo (sqlite)sqlite, postgres, supabase
SUPABASE_PROJECT_IDIf supabaseSupabase project ID
SUPABASE_DB_PASSWORDIf supabaseDatabase password
BLOB_DRIVERNo (file)file, supabase, backblaze, s3
S3_ACCESS_KEYIf S3S3 access key
S3_SECRET_KEYIf S3S3 secret key
JWT_SECRETYesJWT signing key
SESSION_SECRETYesSession cookie key
ALLOW_REGISTRATIONNo (false)Allow public self-registration via /-/v1/login
INTERNAL_API_SECRETYes (dashboard)Shared secret for admin session creation
DASHBOARD_ORIGINNoAllowed CORS origin for admin API
MAX_TARBALL_SIZENo (5242880)Max tarball upload in bytes (default 5 MB)
DEFAULT_PACKAGE_VISIBILITYNo (public)Visibility given to a package on first publish: public or private. See the warning below before changing.
RATE_LIMIT_LOGINNo (10)Login rate limit/min
RATE_LIMIT_PUBLISHNo (30)Publish rate limit/min
LOG_LEVELNo (info)debug, info, warn, error

Dashboard Site (Next.js)

VariableRequiredDescription
PACKR_REGISTRY_URLYesRegistry server URL
NEXT_PUBLIC_SITE_URLYesThis site's public URL
SESSION_SECRETYesSession cookie key
INTERNAL_API_SECRETYesMust match registry's INTERNAL_API_SECRET
GITHUB_OAUTH_CLIENT_IDFor OAuthGitHub App client ID
GITHUB_OAUTH_CLIENT_SECRETFor OAuthGitHub App secret
STRIPE_SECRET_KEYFor billingStripe secret key
STRIPE_WEBHOOK_SECRETFor billingStripe webhook secret
STRIPE_SOLO_PRICE_IDFor billingSolo plan price ID
STRIPE_TEAM_PRICE_IDFor billingTeam plan price ID
DATABASE_URLOptionalDirect 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

VariableDefaultDescription
PACKR_AUTH_MODEhybridstandalone (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)

VariableDefaultDescription
PACKR_CREDENTIAL_STOREfileWhere 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

VariableWhereNotes
INTERNAL_API_SECRETRegistry + SiteMust match on both sides. Used to authenticate dashboard → registry admin calls via X-Internal-Secret header.
ALLOW_REGISTRATIONRegistryDefaults to false. Set to true only if you want open sign-up.
DASHBOARD_ORIGINRegistryCORS origin allowed to call admin endpoints. Defaults to https://packr.blueforge.studio. Set to your dashboard URL.
MAX_TARBALL_SIZERegistryDefaults to 5 MB (5242880 bytes). Increase only if you publish large packages.
DEFAULT_PACKAGE_VISIBILITYRegistrypublic (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_SECRET is 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=false is set (or intentionally omitted — it defaults to false) unless open sign-up is desired
  • Set DASHBOARD_ORIGIN to your actual dashboard URL if it differs from the default
  • Test dashboard login end-to-end after deployment
  • Rotate JWT_SECRET and SESSION_SECRET if 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.

  1. Verify install credentials actually authenticate. GET /-/whoami accepts 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. A 401 naming the signing key means the token predates the last JWT_SECRET rotation; re-issue it.

  2. Stop the exposure growing. Set DEFAULT_PACKAGE_VISIBILITY=private so newly published packages start private. Existing packages are unaffected.

  3. 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.