# 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.md`](09-operations-guide.md) and
> [`deployment-architecture.md`](deployment-architecture.md).
> (`blueforge.manifest.json` still 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`](09-operations-guide.md); migration log:
[`HANDOVER-registry-migration-to-forge-control.md`](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`](09-operations-guide.md) § "Secrets & env files";
migration log: [`HANDOVER-registry-migration-to-forge-control.md`](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](https://resend.com). Requires an API key and a verified sender domain.

```bash
# 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)

```bash
# 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`](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**:

```bash
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

```bash
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

```bash
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

```bash
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)

```bash
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

```bash
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

```bash
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:

```bash
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`](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](#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 the **same value** in the forge vault (project `packr-registry`), `/opt/packr-registry/registry.secrets.env` (and `registry.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=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:

```bash
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:

```sql
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:

   ```bash
   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:

   ```bash
   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.
