TrayPage Docs

Self-hosting on Cloudflare

Set up TrayPage on your own Cloudflare account with the Deploy to Cloudflare button, Postgres, custom domains, and email.

Run TrayPage on your own Cloudflare account, with no servers to manage. See Self-hosting for a comparison of setups.

Browser / AI agent
  → Custom domains (one for the app, one for the viewer)
  → Worker (runs close to the database)
      ├─ Hyperdrive → Postgres (PlanetScale or Neon)
      ├─ R2 (page content)
      └─ Email Service (sign-in links and invitations)

Requirements

What you needWhy
Workers Paid plan ($5/month)The free plan's CPU limit (10 ms per request) is too low for sign-in and page rendering. This is required.
PostgresPlanetScale (billed through Cloudflare, from $5/month) or Neon (has a free tier)
A GitHub or GitLab accountThe button copies the repository into your account, and pushes to that copy redeploy the Worker
A domain managed in CloudflareUsed for the two custom domains (app and viewer) and as the email sender

As a rough cost, expect from about $5-10 per month for Workers Paid plus the database.

1. Create Postgres

PlanetScale

  1. In the Cloudflare dashboard, open Storage & databases → Postgres & MySQL (Hyperdrive) and choose Create PlanetScale database.
  2. Choose Postgres as the engine. Pick a region close to most of your users (for example aws-ap-northeast-1 for Japan). Write down the region; you need it in step 5.
  3. Wait for the database to finish creating.

Neon

  1. Create a project at Neon and note its region (for example aws-ap-southeast-1).
  2. Copy the connection string (postgresql://...). Hyperdrive provides its own connection pooling, so use the direct connection string, not the pooled one (the host name containing -pooler).

2. Create Hyperdrive and connect the database

Creating the database does not create a Hyperdrive configuration. The button asks you to pick an existing one, so create it first.

  • PlanetScale: on the same screen, choose Create Hyperdrive Connection and select the database from step 1.

  • Neon: go to Storage & databases → Hyperdrive → Create configuration and enter the connection string from step 1. With the CLI:

    npx wrangler hyperdrive create traypage --connection-string="postgresql://USER:PASSWORD@HOST/DB?sslmode=require"

3. Deploy with the button

  1. Click the Deploy to Cloudflare button in the TrayPage repository README.

  2. Fill in the form:

    FieldValue
    Git account / repository nameWhere the copy goes. You change settings in this repository from now on
    Worker nametraypage by default. It becomes the workers.dev URL
    HYPERDRIVEThe Hyperdrive configuration from step 2
    PAGE_BUCKETThe R2 bucket for page content. It is created automatically
    BETTER_AUTH_SECRETThe output of openssl rand -hex 32. Changing it later signs everyone out
    VIEWER_TICKET_SECRETThe output of a separate openssl rand -hex 32. Do not reuse the BETTER_AUTH_SECRET value
    Build command / Deploy commandLeave the defaults
  3. When the deploy finishes, check that https://<worker-name>.<account-subdomain>.workers.dev/health returns {"ok":true,...}.

  4. Database migrations are applied automatically by the app on the first access. Open any URL other than /health once (for example /api/organizations, which returns 401).

At this point page content is served from the same host as the app, and email cannot be sent. Finish steps 4 to 6 before you start using it.

4. Set up two custom domains

Edit wrangler.jsonc in your copy of the repository with your own domains.

"workers_dev": false,
"routes": [
  { "pattern": "traypage.example.com", "custom_domain": true },
  { "pattern": "view.traypage.example.com", "custom_domain": true }
],
"vars": {
  "ENVIRONMENT": "production",
  "TRAYPAGE_EDITION": "self_hosted",
  "APP_ORIGIN": "https://traypage.example.com",
  "VIEWER_ORIGIN": "https://view.traypage.example.com",
  "VIEWER_HOST": "view.traypage.example.com"
},
  • Both host names must belong to a zone in the same account. DNS records and certificates are created automatically.
  • Set workers_dev to false. If the workers.dev URL remains, it can be used to bypass the separation of the viewer host.
  • Put vars in wrangler.jsonc, not in the dashboard. Values changed in the dashboard revert to the contents of wrangler.jsonc on the next deploy (secrets are kept).

After you push, Workers Builds redeploys. Check that https://traypage.example.com/health returns 200 and https://view.traypage.example.com/ returns 404 (the viewer host only serves shared page content).

5. Match placement to the database region

If you do not set it, the Worker runs near the user and makes a round trip to the database for every query, which is slow (in testing, a Vancouver edge with a Tokyo database took 0.6 to 1.6 seconds per operation). Set the database region in wrangler.jsonc.

"placement": { "mode": "targeted", "region": "aws:ap-northeast-1" },
Database regionValue for region
PlanetScale aws-ap-northeast-1 (Tokyo)aws:ap-northeast-1
PlanetScale aws-us-east-1aws:us-east-1
PlanetScale gcp-us-central1gcp:us-central1
Neon aws-ap-southeast-1 (Singapore)aws:ap-southeast-1
Neon aws-us-east-2aws:us-east-2
Neon azure-eastus2azure:eastus2

The format is provider:region. After redeploying, if the cf-placement response header looks like remote-NRT (for Tokyo), the Worker is running near the database.

curl -sI https://traypage.example.com/api/organizations | grep -i cf-placement

6. Set up email (Cloudflare Email Service)

Sign-in uses an emailed link. Email is sent through Cloudflare Email Service and needs no API key.

  1. In the dashboard, open Email Service → Domains and register the domain you will send from (SPF and DKIM DNS records are added).

  2. Add the sender to vars in wrangler.jsonc and push.

    "EMAIL_FROM": "TrayPage <noreply@traypage.example.com>"
  3. From /login on the app domain, check that a sign-in link reaches your email address.

Note: Email Service is in public beta. Sign-in links do not arrive until the sender domain is registered. Adding allowed_sender_addresses (an allowlist of sender addresses) to send_email in wrangler.jsonc stops the Worker from sending from any address other than EMAIL_FROM.

If RESEND_API_KEY, which earlier versions documented, is set, Resend is still used in preference to Email Service (delete the secret to switch to Email Service).

Optional settings

SettingHow
Google sign-inAdd the secrets GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET, and add "NEXT_PUBLIC_GOOGLE_AUTH": "1" to vars. The redirect URI in the Google Cloud Console is https://<app-domain>/api/auth/callback/google
Enterprise licenseThe secret TRAYPAGE_LICENSE_KEY (License and Enterprise features)
PDF exportThe BROWSER binding (Browser Run). Included in the template. "Print / save as PDF" works without it
Real-time notificationsREVIEW_SESSIONS (Durable Objects). Included in the template. Without it, the app falls back to periodic checks

Add secrets in the dashboard under Workers & Pages → (your Worker) → Settings → Variables and Secrets, or with npx wrangler secret put <NAME>. Every setting is listed in the Configuration reference.

Updating

Merge upstream changes into your copy of the repository and push; Workers Builds redeploys. Database migrations are applied automatically on the first access to the new version.

git remote add upstream <URL of the TrayPage repository>
git fetch upstream && git merge upstream/main   # if wrangler.jsonc conflicts, keep your own values
git push

Troubleshooting

SymptomWhat to check
Slow responses (about a second per operation)Placement in step 5, and the cf-placement header
Exceeded CPU time limit / error 1102Whether you are on Workers Paid
/health returns 200 but everything else returns 500The Hyperdrive configuration (connection string and password). Read the logs with npx wrangler tail
Sign-in links do not arriveStep 6 (sender domain registration and EMAIL_FROM), and the note in step 6 (a leftover RESEND_API_KEY)
The app shows up on the viewer domainVIEWER_HOST and routes (step 4)