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 need | Why |
|---|---|
| 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. |
| Postgres | PlanetScale (billed through Cloudflare, from $5/month) or Neon (has a free tier) |
| A GitHub or GitLab account | The button copies the repository into your account, and pushes to that copy redeploy the Worker |
| A domain managed in Cloudflare | Used 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
- In the Cloudflare dashboard, open Storage & databases → Postgres & MySQL (Hyperdrive) and choose Create PlanetScale database.
- Choose Postgres as the engine. Pick a region close to most of your users (for example
aws-ap-northeast-1for Japan). Write down the region; you need it in step 5. - Wait for the database to finish creating.
Neon
- Create a project at Neon and note its region (for example
aws-ap-southeast-1). - 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
-
Click the Deploy to Cloudflare button in the TrayPage repository README.
-
Fill in the form:
Field Value Git account / repository name Where the copy goes. You change settings in this repository from now on Worker name traypageby default. It becomes the workers.dev URLHYPERDRIVEThe 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 outVIEWER_TICKET_SECRETThe output of a separate openssl rand -hex 32. Do not reuse theBETTER_AUTH_SECRETvalueBuild command / Deploy command Leave the defaults -
When the deploy finishes, check that
https://<worker-name>.<account-subdomain>.workers.dev/healthreturns{"ok":true,...}. -
Database migrations are applied automatically by the app on the first access. Open any URL other than
/healthonce (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_devtofalse. If the workers.dev URL remains, it can be used to bypass the separation of the viewer host. - Put
varsinwrangler.jsonc, not in the dashboard. Values changed in the dashboard revert to the contents ofwrangler.jsoncon 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 region | Value for region |
|---|---|
PlanetScale aws-ap-northeast-1 (Tokyo) | aws:ap-northeast-1 |
PlanetScale aws-us-east-1 | aws:us-east-1 |
PlanetScale gcp-us-central1 | gcp:us-central1 |
Neon aws-ap-southeast-1 (Singapore) | aws:ap-southeast-1 |
Neon aws-us-east-2 | aws:us-east-2 |
Neon azure-eastus2 | azure: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-placement6. Set up email (Cloudflare Email Service)
Sign-in uses an emailed link. Email is sent through Cloudflare Email Service and needs no API key.
-
In the dashboard, open Email Service → Domains and register the domain you will send from (SPF and DKIM DNS records are added).
-
Add the sender to
varsinwrangler.jsoncand push."EMAIL_FROM": "TrayPage <noreply@traypage.example.com>" -
From
/loginon 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) tosend_emailinwrangler.jsoncstops the Worker from sending from any address other thanEMAIL_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
| Setting | How |
|---|---|
| Google sign-in | Add 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 license | The secret TRAYPAGE_LICENSE_KEY (License and Enterprise features) |
| PDF export | The BROWSER binding (Browser Run). Included in the template. "Print / save as PDF" works without it |
| Real-time notifications | REVIEW_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 pushTroubleshooting
| Symptom | What to check |
|---|---|
| Slow responses (about a second per operation) | Placement in step 5, and the cf-placement header |
Exceeded CPU time limit / error 1102 | Whether you are on Workers Paid |
/health returns 200 but everything else returns 500 | The Hyperdrive configuration (connection string and password). Read the logs with npx wrangler tail |
| Sign-in links do not arrive | Step 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 domain | VIEWER_HOST and routes (step 4) |