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
6 changes: 3 additions & 3 deletions .github/workflows/deploy-pr-preview.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Deploy PR Preview (Railway + Cloudflare)
name: Deploy PR Preview (Cloudflare via Alchemy)

on:
pull_request:
Expand Down Expand Up @@ -44,8 +44,8 @@ jobs:

- name: Deploy PR preview stack with Alchemy
if: ${{ github.event.action != 'closed' }}
run: bun run deploy:pr
run: bun run alchemy:deploy:pr

- name: Destroy PR preview stack with Alchemy
if: ${{ github.event.action == 'closed' }}
run: bun run destroy:pr
run: bun run alchemy:destroy:pr
7 changes: 2 additions & 5 deletions .github/workflows/deploy-prd.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
name: Deploy PRD (Railway + Cloudflare)
name: Deploy PRD (Cloudflare via Alchemy)

on:
push:
branches:
- main
workflow_dispatch:

concurrency:
Expand Down Expand Up @@ -38,4 +35,4 @@ jobs:
run: bun install --frozen-lockfile

- name: Deploy PRD stack with Alchemy
run: bun run deploy:prd
run: bun run alchemy:deploy:prd
6 changes: 3 additions & 3 deletions .github/workflows/deploy-stg.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
name: Deploy STG (Railway + Cloudflare)
name: Deploy STG (Cloudflare via Alchemy)

on:
push:
branches:
- develop
- main
workflow_dispatch:

concurrency:
Expand Down Expand Up @@ -38,4 +38,4 @@ jobs:
run: bun install --frozen-lockfile

- name: Deploy STG stack with Alchemy
run: bun run deploy:stg
run: bun run alchemy:deploy:stg
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ apps/api/.data/
.mcp.json
.dev.vars
apps/chat-agent/.dev.vars

# sst
.sst
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ Use `/Users/maki/Documents/superwall/app` as the reference implementation for Ef
End-user and platform documentation lives in `docs/`:
- `docs/sampling-throughput.md` — How Maple handles sampling-aware throughput metrics
- `docs/persistence.md` — Database persistence and migration operations
- `docs/railway-deploy.md` — Railway deployment with Alchemy
- `docs/sst-fork-workflow.md` — Running maple against a local SST fork, syncing with upstream, and opening PRs from fork branches

## Self-Observability (Trace Loop Prevention)

Expand Down
43 changes: 16 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,42 +74,36 @@ Services:
- Ingest: `http://localhost:3474`
- OTEL collector: `4317` (gRPC), `4318` (HTTP), `13133` (health/extensions)

## Railway + Cloudflare Deploy (Alchemy)
## Cloudflare Deploy (Alchemy)

Deployments are orchestrated from one Alchemy run in `apps/web/alchemy.run.ts` with stage-driven targets:
Deployments are per-app Alchemy runs pinned to Cloudflare Workers + D1:

- Provisions Railway project `maple`
- Uses Railway environments per stage: `prd`, `stg`, and `pr-<number>`
- Provisions stage-scoped services `api`, `ingest`, and `otel-collector`
- Configures service instance settings (`rootDirectory`, `dockerfilePath`, `watchPatterns`)
- Applies required Railway variables for API and ingest
- Uses production custom Railway domains (`api.maple.dev`, `ingest.maple.dev`) and Railway-generated domains in `stg` / `pr-*`
- Deploys `apps/web` to Cloudflare
- `apps/api/alchemy.run.ts` — D1 database `MAPLE_DB` + api Worker with all env bindings
- `apps/landing/alchemy.run.ts` — Astro build + Worker serving static assets
- `apps/web/alchemy.run.ts` — TanStack Start app via the `Vite()` resource

Railway orchestration module: `@maple/infra/railway` (workspace package at `packages/infra`).
Stage grammar is `prd` / `stg` / `pr-<number>`, resolved via `@maple/infra/cloudflare` (`parseMapleStage`, `resolveMapleDomains`, `resolveWorkerName`, `resolveD1Name`).

Run locally:

```bash
bun run deploy:prd
bun run deploy:stg
PR_NUMBER=123 bun run deploy:pr
bun run alchemy:deploy:prd
bun run alchemy:deploy:stg
PR_NUMBER=123 bun run alchemy:deploy:pr
```

All deploy scripts are full-stack only (Railway + Cloudflare). If `RAILWAY_API_TOKEN` is missing, deploy/destroy fails immediately.

Tear down:

```bash
bun run destroy:prd
bun run destroy:stg
PR_NUMBER=123 bun run destroy:pr
bun run alchemy:destroy:prd
bun run alchemy:destroy:stg
PR_NUMBER=123 bun run alchemy:destroy:pr
```

CI workflows:

- PRD: `.github/workflows/deploy-prd.yml` (`push` on `main` + `workflow_dispatch`)
- STG: `.github/workflows/deploy-stg.yml` (`push` on `develop` + `workflow_dispatch`)
- STG (default on push to `main`): `.github/workflows/deploy-stg.yml`
- PRD (manual only via `workflow_dispatch`): `.github/workflows/deploy-prd.yml`
- PR preview lifecycle: `.github/workflows/deploy-pr-preview.yml` (`pull_request` opened/synchronize/reopened/closed)

Secrets source model (CI):
Expand All @@ -119,16 +113,11 @@ Secrets source model (CI):
- `ALCHEMY_PASSWORD`
- `ALCHEMY_STATE_TOKEN`
- `CLOUDFLARE_API_TOKEN`
- `CLOUDFLARE_ACCOUNT_ID`
- `CLOUDFLARE_EMAIL`
- `RAILWAY_API_TOKEN`
- `RAILWAY_WORKSPACE_ID`
- `CLOUDFLARE_DEFAULT_ACCOUNT_ID`
- `TINYBIRD_HOST`
- `TINYBIRD_TOKEN`
- `RESEND_API_KEY`
- `RESEND_FROM_EMAIL`
- `MAPLE_DB_URL`
- `MAPLE_DB_AUTH_TOKEN`
- `MAPLE_INGEST_KEY_ENCRYPTION_KEY`
- `MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY`
- `MAPLE_AUTH_MODE`
Expand All @@ -141,7 +130,7 @@ Free/Starter note: when using a personal Doppler token, the workflow must also s

Runtime API URL behavior:

- Deploy-time web builds always use the Railway API domain from the same Alchemy run (`api.maple.dev` in `prd`, Railway-generated in `stg` / `pr-*`).
- Deploy-time web builds resolve `VITE_API_BASE_URL` from the Cloudflare api worker domain (`api.maple.dev` in `prd`, `api-staging.maple.dev` in `stg`, worker.dev URL for `pr-*`).
- Local `bun --filter=@maple/web dev` can still use root `.env` `VITE_API_BASE_URL` for local API routing.

## Environment
Expand Down
80 changes: 80 additions & 0 deletions alchemy.run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import alchemy from "alchemy"
import { CloudflareStateStore } from "alchemy/state"
import { parseMapleStage, resolveMapleDomains } from "@maple/infra/cloudflare"
import { createAlertingWorker } from "./apps/alerting/alchemy.run.ts"
import { createMapleApi } from "./apps/api/alchemy.run.ts"
import { createChatAgentWorker } from "./apps/chat-agent/alchemy.run.ts"
import { createLandingWorker } from "./apps/landing/alchemy.run.ts"
import { createMapleWeb } from "./apps/web/alchemy.run.ts"

const requireEnv = (key: string): string => {
const value = process.env[key]?.trim()
if (!value) {
throw new Error(`Missing required deployment env: ${key}`)
}
return value
}

const app = await alchemy("maple", {
password: requireEnv("ALCHEMY_PASSWORD"),
...(process.env.ALCHEMY_STATE_TOKEN
? { stateStore: (scope) => new CloudflareStateStore(scope) }
: {}),
})

const stage = parseMapleStage(app.stage)
const domains = resolveMapleDomains(stage)

const { worker: api, db: mapleDb } = await createMapleApi({ stage, domains })

const resolvedApiUrl = domains.api ? `https://${domains.api}` : api.url
if (!resolvedApiUrl) {
throw new Error(
"api worker deployed without a url — set `url: true` or provide a custom domain",
)
}

const chatAgent = await createChatAgentWorker({
stage,
domains,
mapleApiUrl: resolvedApiUrl,
})

const resolvedChatAgentUrl = domains.chat
? `https://${domains.chat}`
: chatAgent.url
if (!resolvedChatAgentUrl) {
throw new Error(
"chat-agent worker deployed without a url — set `url: true` or provide a custom domain",
)
}

// ingest is not currently deployed via alchemy; for non-custom-domain stages,
// fall back to a caller-supplied env var or localhost.
const resolvedIngestUrl = domains.ingest
? `https://${domains.ingest}`
: process.env.VITE_INGEST_URL?.trim() || "http://127.0.0.1:3474"

const web = await createMapleWeb({
stage,
domains,
apiUrl: resolvedApiUrl,
ingestUrl: resolvedIngestUrl,
chatAgentUrl: resolvedChatAgentUrl,
})

const landing = await createLandingWorker({ stage, domains })

const alerting = await createAlertingWorker({ stage, domains, mapleDb })

console.log({
stage: app.stage,
apiUrl: resolvedApiUrl,
chatAgentUrl: resolvedChatAgentUrl,
ingestUrl: resolvedIngestUrl,
webUrl: domains.web ? `https://${domains.web}` : web.url,
landingUrl: domains.landing ? `https://${domains.landing}` : landing.url,
alertingWorker: alerting.name,
})

await app.finalize()
28 changes: 0 additions & 28 deletions apps/alerting/Dockerfile

This file was deleted.

85 changes: 85 additions & 0 deletions apps/alerting/alchemy.run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import path from "node:path"
import alchemy from "alchemy"
import { Worker, type D1Database } from "alchemy/cloudflare"
import type {
MapleDomains,
MapleStage,
} from "@maple/infra/cloudflare"
import { resolveWorkerName } from "@maple/infra/cloudflare"

const requireEnv = (key: string): string => {
const value = process.env[key]?.trim()
if (!value) {
throw new Error(`Missing required deployment env: ${key}`)
}
return value
}

const optionalPlain = (
key: string,
fallback?: string,
): Record<string, string> => {
const value = process.env[key]?.trim() || fallback
return value ? { [key]: value } : {}
}

const optionalSecret = (
key: string,
): Record<string, ReturnType<typeof alchemy.secret>> => {
const value = process.env[key]?.trim()
return value ? { [key]: alchemy.secret(value) } : {}
}

export interface CreateAlertingWorkerOptions {
stage: MapleStage
domains: MapleDomains
mapleDb: D1Database
}

export const createAlertingWorker = async ({
stage,
mapleDb,
}: CreateAlertingWorkerOptions) => {
const worker = await Worker("alerting", {
name: resolveWorkerName("alerting", stage),
cwd: import.meta.dirname,
entrypoint: path.join(import.meta.dirname, "src", "worker.ts"),
compatibility: "node",
compatibilityDate: "2026-04-08",
adopt: true,
crons: ["* * * * *", "*/15 * * * *"],
bindings: {
MAPLE_DB: mapleDb,
TINYBIRD_HOST: requireEnv("TINYBIRD_HOST"),
TINYBIRD_TOKEN: alchemy.secret(requireEnv("TINYBIRD_TOKEN")),
MAPLE_AUTH_MODE: process.env.MAPLE_AUTH_MODE?.trim() || "self_hosted",
MAPLE_DEFAULT_ORG_ID:
process.env.MAPLE_DEFAULT_ORG_ID?.trim() || "default",
MAPLE_INGEST_KEY_ENCRYPTION_KEY: alchemy.secret(
requireEnv("MAPLE_INGEST_KEY_ENCRYPTION_KEY"),
),
MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: alchemy.secret(
requireEnv("MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY"),
),
MAPLE_INGEST_PUBLIC_URL:
process.env.MAPLE_INGEST_PUBLIC_URL?.trim() ||
"https://ingest.maple.dev",
MAPLE_APP_BASE_URL:
process.env.MAPLE_APP_BASE_URL?.trim() || "https://app.maple.dev",
RESEND_FROM_EMAIL:
process.env.RESEND_FROM_EMAIL?.trim() ||
"Maple <notifications@maple.dev>",
...optionalSecret("MAPLE_ROOT_PASSWORD"),
...optionalSecret("CLERK_SECRET_KEY"),
...optionalPlain("CLERK_PUBLISHABLE_KEY"),
...optionalSecret("CLERK_JWT_KEY"),
...optionalPlain("MAPLE_ORG_ID_OVERRIDE"),
...optionalSecret("AUTUMN_SECRET_KEY"),
...optionalSecret("SD_INTERNAL_TOKEN"),
...optionalSecret("INTERNAL_SERVICE_TOKEN"),
...optionalSecret("RESEND_API_KEY"),
},
})

return worker
}
10 changes: 5 additions & 5 deletions apps/alerting/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,22 @@
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --env-file ../../.env.local --watch src/index.ts",
"start": "bun run --env-file ../../.env.local src/index.ts",
"dev": "wrangler dev --test-scheduled",
"deploy": "wrangler deploy",
"test": "echo 'No tests yet'",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@effect/platform-bun": "catalog:effect",
"@maple/api": "workspace:*",
"@maple/db": "workspace:*",
"@maple/domain": "workspace:*",
"effect": "catalog:effect"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260405.0",
"@effect/language-service": "catalog:effect",
"@types/bun": "^1.3.11",
"@types/node": "catalog:tooling",
"typescript": "catalog:tooling"
"typescript": "catalog:tooling",
"wrangler": "^4.41.0"
}
}
Loading
Loading