DOCS · DEPLOYMENT

Ship TillPulse to production.

Two pieces. The dashboard runs on Cloudflare Pages + Workers via @opennextjs/cloudflare. The ingest API runs as a Fastify Node.js process — Docker, Railway, Fly, your own VPS, anywhere with persistent compute.

Architecture on Cloudflare

Browser
  │
  ▼
Cloudflare Edge
  │
  ├─ Static assets ──→ CDN (HTML, CSS, JS, images)
  │
  └─ Dynamic ──→ Cloudflare Worker
                    ├─ Next.js RSC + API (via @opennextjs/cloudflare)
                    ├─ Neon Postgres   (HTTP fetch)
                    ├─ Upstash Redis   (HTTP REST)
                    ├─ ClickHouse      (HTTP)
                    ├─ Meilisearch     (HTTP)
                    ├─ Anthropic       (HTTP SSE)
                    └─ Resend          (HTTP)

The ingest API is not on Cloudflare. It needs persistent connections for high-throughput Redis Stream publishes and is best placed close to your users (in whichever region they're concentrated).

Prerequisites

1. Authenticate with Cloudflare

npx wrangler login

2. Create the Pages project

cd apps/web
npx wrangler pages project create <your-project> --production-branch main

3. Set secrets

npx wrangler pages secret put DATABASE_URL
npx wrangler pages secret put UPSTASH_REDIS_REST_URL
npx wrangler pages secret put UPSTASH_REDIS_REST_TOKEN
npx wrangler pages secret put JWT_PRIVATE_KEY
npx wrangler pages secret put JWT_PUBLIC_KEY
npx wrangler pages secret put TOTP_ENCRYPTION_KEY
npx wrangler pages secret put ENCRYPTION_KEY
npx wrangler pages secret put RESEND_API_KEY
npx wrangler pages secret put RESEND_FROM
npx wrangler pages secret put ANTHROPIC_API_KEY
npx wrangler pages secret put CLICKHOUSE_URL
npx wrangler pages secret put CLICKHOUSE_PASSWORD
npx wrangler pages secret put MEILI_URL
npx wrangler pages secret put MEILI_MASTER_KEY
npx wrangler pages secret put SLACK_SIGNING_SECRET
npx wrangler pages secret put SLACK_ACTION_SIGNING_SECRET

Non-secrets (e.g. APP_URL) go in wrangler.toml:

[vars]
APP_ENV     = "production"
APP_URL     = "https://app.tillpulse.io"
INGEST_HOST = "https://ingest.tillpulse.io"

4. Build & deploy

# From the repo root:
pnpm run deploy:cf

# Or just the web app:
cd apps/web && pnpm run deploy:cf

This runs turbo run build (compiles @tillpulse/db), then opennextjs-cloudflare to bundle Next.js for Workers, then wrangler pages deploy.

5. Custom domain

Cloudflare dashboard → Pages → tillpulse-web → Custom domains Set up a custom domain. Add app.tillpulse.io (or your chosen subdomain) and follow the DNS instructions.

6. Run database migrations

# From the repo root, with DATABASE_URL pointing at Neon
pnpm db:generate     # turbo proxies to drizzle-kit generate
pnpm db:migrate      # applies pending migrations

ClickHouse: run infra/clickhouse/init.sql against your ClickHouse cluster. The bottom of the file contains idempotent ALTER TABLE statements for upgrading existing deployments.

7. Deploy the ingest API

The ingest API is a stock Node.js / Fastify process. Anywhere with persistent compute and outbound HTTPS will work — Railway, Fly.io, Render, Heroku, AWS ECS, plain VPS.

# Build
pnpm --filter @tillpulse/ingest-api build

# Run
DATABASE_URL=... \
UPSTASH_REDIS_REST_URL=... \
UPSTASH_REDIS_REST_TOKEN=... \
INGEST_PORT=3001 \
INGEST_RATE_LIMIT_PER_DSN=10000 \
node apps/ingest-api/dist/index.js

Or use the docker-compose.yml at the repo root for a one-command local deploy of Postgres + Redis + ClickHouse + Meilisearch + ingest-api + event-processor.

8. Deploy the event processor

The event processor runs alongside the ingest API. Same env, plus:

PROCESSOR_REGION=af-south-1   # pin to your region's stream
ANTHROPIC_API_KEY=...         # for TillMind on new issues
GEO_DB_PATH=/path/to/GeoLite2-City.mmdb   # MaxMind, optional

9. CI/CD with GitHub Actions

name: Deploy
on: { push: { branches: [main] } }
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: pnpm }
      - run: pnpm install
      - run: pnpm --filter @tillpulse/db build
      - working-directory: apps/web
        run: pnpm run deploy:cf
        env:
          CLOUDFLARE_API_TOKEN: $\{\{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: $\{\{ secrets.CLOUDFLARE_ACCOUNT_ID }}

Troubleshooting

Function size exceeds limit

Cloudflare Workers cap script size at 1MB compressed. Drop unused deps from apps/web/package.json; check for accidental TCP-only packages (postgres, ioredis) that should be @neondatabase/serverless + @upstash/redis.

TOTP fails on the edge

Ensure compatibility_flags = ["nodejs_compat"] is in wrangler.toml. otplib needs crypto.createHmac.

Cookies not set

Make sure APP_URL matches the served origin. Cookies are SameSite=Lax; if you're proxying through a different domain (e.g. <your-project>.pages.dev served from your custom domain), the browser may drop them.