Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/web/wrangler.hosted.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Hosted static app deployment for app.t3turbo.pro (fork equivalent of app.t3.codes).
// Deploy: npx wrangler deploy --config wrangler.hosted.jsonc
{
"name": "t3turbo-hosted-app",
"compatibility_date": "2026-08-01",
"assets": {
"directory": "./dist",
"not_found_handling": "single-page-application",
},
"routes": [
{
"pattern": "app.t3turbo.pro",
"custom_domain": true,
},
],
}
131 changes: 131 additions & 0 deletions docs/operations/host-your-own.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Host your own T3 Code

> For operators standing up a complete fork ("T3 Turbo"-style) from scratch. This is the ordering
> guide: it tells you what to build, in what order, and which steps break silently when skipped.
> Detail lives in the linked runbooks — [Self-host the T3 Connect relay](./self-host-relay.md),
> [Self-host the hosted web app](./self-host-hosted-app.md), and
> [T3 Connect](../internals/t3-connect.md).

## What you are assembling

Six pieces, with hard dependencies between them:

1. **The fork** — this repository, building desktop installers, the CLI, and web bundles.
2. **A Postgres database** — the relay's persistence (Supabase hosted or self-hosted).
3. **A Clerk application** — user auth for web, desktop, mobile, and CLI.
4. **The relay** — a Cloudflare Worker (control plane: links environments, mints credentials,
provisions managed tunnels).
5. **Client builds** — desktop/CLI/web with the public Connect values baked in at build time.
6. **The hosted web app** — the browser entry point (`app.<your-domain>`).

The dependency chain is strict: clients need the relay URL at build time, the relay needs the
database and Clerk at deploy time, and Clerk needs your final origins. Follow the order below and
each step's output feeds the next; skip ahead and you get the failure modes in the table at the
end — every one of which was hit for real while assembling the first T3 Turbo deployment.

## Step 1 — Fork, domains, Cloudflare account

- Fork the repository. Decide your product name and (optionally) apply branding.
- Add your DNS zone(s) to a Cloudflare account and finish nameserver activation. You need an API
zone (`relay.<zone>`) and a tunnel zone for per-environment endpoints; they may be the same
zone. If you plan multiple TLDs, decide **now** which one actually hosts things — deploy
nothing to the others or you will chase ghosts later.
- Create the Cloudflare API token with the exact permission groups listed in
[Prepare Cloudflare](./self-host-relay.md#prepare-cloudflare).

## Step 2 — Database

- Hosted Supabase: follow [Prepare Supabase](./self-host-relay.md#prepare-supabase).
- Self-hosted (Docker Supabase or plain Postgres): the origin must be **publicly reachable** for
Hyperdrive, with TLS enabled. Restrict the exposed Postgres port to
[Cloudflare's IP ranges](https://www.cloudflare.com/ips/) in your provider's cloud firewall —
and remember a hypervisor-level firewall (Hetzner Cloud Firewall, AWS SG) is invisible from
inside the VM when debugging reachability.
- **Apply the migrations before the first deploy** —
[Apply database migrations](./self-host-relay.md#apply-database-migrations). The deploy
workflow does not do this for external databases. An unmigrated database is the single most
confusing failure in the stack: the relay deploys green, auth works, and then every real
request 500s with `replay_persistence_failed`.

## Step 3 — Clerk

Follow [Prepare Clerk](./self-host-relay.md#prepare-clerk) plus the redirect and native-auth
detail in [T3 Connect](../internals/t3-connect.md). Every sub-step here is load-bearing:

1. Create the application; enable your sign-in methods.
2. **Create the JWT template `t3-relay` with claims `{ "aud": "t3-code-relay" }`.** Without it,
sign-in works everywhere but T3 Connect stays locked: token minting 404s and clients report
"Could not obtain the T3 Connect session token."
3. Create the public (PKCE) OAuth application for the CLI with both redirect URIs.
4. Enable the **Native API** and add `t3code://app/` (and `t3code-dev://app/` for dev builds) to
the mobile SSO redirect allowlist.
5. Set **`allowed_origins`** through the Backend API — there is no dashboard UI for it, and the
PATCH replaces the whole array, so send every origin at once: the desktop scheme(s), your
hosted app origin, and any LAN origin you open the web client from:

```sh
curl -X PATCH https://api.clerk.com/v1/instance \
-H "Authorization: Bearer $CLERK_SECRET_KEY" -H "Content-Type: application/json" \
-d '{"allowed_origins":["t3code://app","t3code-dev://app","https://app.example.com"]}'
```

Skipping this is invisible in a browser at `localhost` and fatal in the packaged desktop app:
Clerk never finishes loading, so the sign-in entry point simply does not render. Do **not**
work around origin rejections by stripping the `Origin` header in the desktop shell — Clerk's
API needs the CORS preflight to succeed, and header surgery kills every request instead.

## Step 4 — GitHub configuration and relay deploy

- Set the repository variables and secrets from the
[Activation checklist](./self-host-relay.md#activation-checklist).
- Run the deploy per [First deploy and later deploys](./self-host-relay.md#first-deploy-and-later-deploys).
- **Verify before moving on:** `curl https://relay.<zone>/v1/environments` must return
`401 {"code":"auth_invalid"}`. A `500` here means database (reachability or migrations) — fix
it now, nothing downstream works until this is a 401.

## Step 5 — Client builds

- Put the four public values in the repository-root `.env` or `.env.local`
(`T3CODE_CLERK_PUBLISHABLE_KEY`, `T3CODE_CLERK_JWT_TEMPLATE`,
`T3CODE_CLERK_CLI_OAUTH_CLIENT_ID`, `T3CODE_RELAY_URL`). A successful relay deploy writes
`T3CODE_RELAY_URL` for you.
- Build installers (`vp run dist:desktop:win:x64` etc.). All Connect values are **baked at build
time** — a build made before the `.env` existed is permanently Connect-free, and no runtime
configuration will revive it. When in doubt whether an installed build has the values, grep the
packaged client bundle for your relay hostname.
- CI releases inject the same values automatically once Step 4's variables exist.

## Step 6 — Hosted web app

Follow [Self-host the hosted web app](./self-host-hosted-app.md). Deploy it to the hostname you
chose in Step 1, and confirm that hostname carries **nothing else** — no leftover tunnels, no
Cloudflare Access in front. Then add that origin to Clerk `allowed_origins` (Step 3.5) if you
have not already.

## Step 7 — Verify the whole chain

In order, each proving one layer:

1. Relay: `curl https://relay.<zone>/v1/environments` → `401 auth_invalid`.
2. Desktop app: sidebar shows **Sign in to T3 Connect**; sign-in completes.
3. Settings → Connections: **T3 Connect** toggle is enabled after sign-in; flip it →
"T3 Connect linked".
4. Tunnel: the environment's endpoint (visible in the relay's `environment_links` row or the
client UI) serves `/.well-known/t3/environment` with your environment's descriptor.
5. Hosted app: sign in at `https://app.<zone>`, your environment appears, connect — **no pairing
token is involved anywhere in this flow**.

## Failure-mode index

Symptoms observed while standing up the first fork deployment, and what each actually means:

| Symptom | Actual cause |
| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Sign-in works, Connect toggle stays disabled, Clerk returns 404 on `.../tokens/t3-relay` | JWT template `t3-relay` missing (Step 3.2) |
| Desktop app shows no sign-in entry at all; web at `127.0.0.1` works | Desktop scheme absent from Clerk `allowed_origins` (Step 3.5), or the build predates the `.env` (Step 5) |
| CORS preflight failures on every Clerk request from `t3code://app` | Origin not allowed (Step 3.5). Never fix by stripping the `Origin` header |
| Relay 500s: `replay_persistence_failed`, environments list fails | Migrations never applied, or Hyperdrive cannot reach the database (Step 2) |
| Enabling Connect fails with "Could not check relay client availability" | Same as above — the relay's database layer is down |
| Hosted origin gates on `/pair` | The deployed bundle was built without hosted flags, or the hostname serves something older (Step 6) |
| "Invalid pairing token" in a browser | Pairing tokens are one-time and expire in minutes; the Connect flow never needs one. Generate fresh, or sign in to T3 Connect instead |
| OAuth completes in the browser but never returns to the Windows app | Installer built before `t3code://` protocol registration existed on Windows — rebuild with a current `scripts/build-desktop-artifact.ts` |
79 changes: 79 additions & 0 deletions docs/operations/self-host-hosted-app.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Self-host the hosted web app on Cloudflare

> For operators running a fork. The upstream hosted app (`app.t3.codes`) deploys to Vercel via
> `apps/web/vercel.ts`; this runbook is the Cloudflare Workers equivalent for forks that keep
> their infrastructure on Cloudflare.

The hosted app is a static build of `apps/web` with hosted-mode flags baked in at build time.
Hosted mode makes the client show the multi-environment T3 Connect experience (sign in, pick a
linked environment, connect through its managed tunnel) instead of treating its own origin as a
single environment and demanding pairing.

## Prerequisites

- A deployed relay and Clerk application ([Self-host the T3 Connect relay](./self-host-relay.md)).
- Repository-root `.env` or `.env.local` with the Connect public values
(`T3CODE_CLERK_PUBLISHABLE_KEY`, `T3CODE_CLERK_JWT_TEMPLATE`,
`T3CODE_CLERK_CLI_OAUTH_CLIENT_ID`, `T3CODE_RELAY_URL`).
- A Cloudflare API token that can upload Workers scripts and manage the target zone's DNS.

## Build

From the repository root:

```sh
VITE_HOSTED_APP_CHANNEL=latest \
VITE_HOSTED_APP_URL=https://app.example.com \
pnpm --filter @t3tools/web run build
node scripts/apply-web-brand-assets.ts --channel latest
```

`VITE_HOSTED_APP_CHANNEL` switches the bundle into hosted mode; `VITE_HOSTED_APP_URL` must be the
final origin so origin detection and CLI OAuth callbacks resolve correctly. Both are baked at
build time — a rebuild is required to change them.

## Deploy

`apps/web/wrangler.hosted.jsonc` deploys `dist/` as an assets-only Worker with SPA routing and a
custom domain:

```sh
cd apps/web
npx wrangler deploy --config wrangler.hosted.jsonc
```

If the token cannot manage zone-level Worker routes, the asset upload still succeeds; attach the
custom domain through the account-scoped API instead:

```sh
curl -X PUT "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/workers/domains" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"zone_id":"<zone-id>","hostname":"app.example.com","service":"t3turbo-hosted-app","environment":"production"}'
```

An existing DNS record on the hostname blocks custom-domain attachment — delete it first.

## Clerk origin

Add the hosted origin to the Clerk instance's `allowed_origins` (Backend API only, no dashboard
UI — preserve existing entries, the PATCH replaces the whole array):

```sh
curl -X PATCH https://api.clerk.com/v1/instance \
-H "Authorization: Bearer $CLERK_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"allowed_origins":["t3code://app","https://app.example.com"]}'
```

## Pitfalls

- **Do not put Cloudflare Access in front of the hosted app.** The app authenticates with Clerk;
an Access wall breaks OAuth redirects and the CLI's out-of-band connect flow.
- A bundle built **without** the hosted flags served on this hostname falls back to
single-environment mode and gates on `/pair` — if users report a pairing prompt on the hosted
origin, the deployed bundle is wrong, not their tokens.
- Connecting from the hosted app to a linked environment never uses pairing tokens; pairing
tokens are one-time, short-lived, and only for direct (LAN/tailnet) backends.
- This deploy is manual. Rebuild and redeploy after web changes you want reflected on the hosted
origin, or wire the two commands above into the release workflow.
48 changes: 42 additions & 6 deletions docs/operations/self-host-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

This runbook deploys the existing T3 Connect relay into infrastructure owned by the fork operator
and explains how release builds consume it. T3 Connect remains optional: a repository without both
Cloudflare credentials produces release artifacts with Connect disabled.
Cloudflare credentials produces release artifacts with Connect disabled. For the browser-facing
companion deployment, see [Self-host the hosted web app](./self-host-hosted-app.md).

## Architecture

Expand Down Expand Up @@ -124,10 +125,10 @@ and [DNS records](https://developers.cloudflare.com/api/resources/dns/subresourc
See [Supabase's connection guide](https://supabase.com/docs/guides/database/connecting-to-postgres)
for the current dashboard location and pooler formats.

4. Before the first relay deploy, open **SQL Editor** in Supabase and run every
`infra/relay/migrations/postgres/*/migration.sql` file in directory-name order. Run each file as
a separate query, stop on the first error, and apply future migration files before deploying
relay code that depends on them.
4. Before the first relay deploy, apply the relay migrations — see
[Apply database migrations](#apply-database-migrations). The Supabase **SQL Editor** can run
each `migration.sql` in directory-name order by hand, but prefer the bookkeeping procedure so
later deploys can tell which migrations are already applied.

`DATABASE_*` takes precedence over the upstream managed-PlanetScale deployment. The legacy
`PLANETSCALE_ORGANIZATION`, `PLANETSCALE_API_TOKEN_ID`, and `PLANETSCALE_API_TOKEN` settings do not
Expand Down Expand Up @@ -227,12 +228,47 @@ gh secret set CLERK_SECRET_KEY --repo OWNER/REPO
gh secret set DATABASE_PASSWORD --repo OWNER/REPO
```

## Apply database migrations

The deploy workflow does **not** apply migrations to an external `DATABASE_*` database. Only the
upstream managed-PlanetScale path has a migration runner; the external-database path in
`infra/relay/src/db.ts` provisions the Hyperdrive connection and nothing else. A relay pointed at
an unmigrated database returns HTTP 500 for every request that touches persistence — clients
surface it as `environment_link_failed (replay_persistence_failed)` when linking an environment.

Apply the migrations in `infra/relay/migrations/postgres/` by hand, in timestamp order, with the
same bookkeeping the upstream runner uses so a future runner can take over without re-applying:

```sh
psql "$DATABASE_URL" -c 'CREATE TABLE IF NOT EXISTS "relay_migrations" (
id TEXT PRIMARY KEY, name TEXT NOT NULL, applied_at TIMESTAMPTZ NOT NULL DEFAULT now());'

seq=1
for d in infra/relay/migrations/postgres/*/; do
name="$(basename "$d")/migration.sql"
id=$(printf '%05d' "$seq"); seq=$((seq + 1))
{ echo 'BEGIN;'; cat "$d/migration.sql"; echo ';';
echo "INSERT INTO relay_migrations (id, name) VALUES ('$id', '$name');";
echo 'COMMIT;'; } | psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f -
done
```

Each migration runs in one transaction together with its bookkeeping insert, so partial
application is impossible. Re-running is safe once you guard on the `relay_migrations` row
(`SELECT count(*) FROM relay_migrations WHERE name = '<name>'`). Verify with `\dt public.*`:
the relay expects the `relay_*` tables, including `relay_dpop_proofs` and
`relay_environment_links`.

Repeat this section whenever a release adds a new folder under
`infra/relay/migrations/postgres/`.

## First deploy and later deploys

The workflow [`deploy-relay.yml`](../../.github/workflows/deploy-relay.yml) supports both pushes
to `main` and manual dispatch:

1. Apply the relay SQL migrations and add all **Required** variables and secrets above.
1. Apply the relay SQL migrations (see [Apply database migrations](#apply-database-migrations))
and add all **Required** variables and secrets above.
2. Open **Actions > Deploy T3 Connect relay > Run workflow** and select `main`.
3. Confirm **Detect Cloudflare configuration** reports enabled.
4. Confirm **Deploy production relay stage** completes and reports an HTTPS `relay_url`.
Expand Down
Loading