Self-hosting Track
Gateweave Track · v0.34.0 · source on GitHub
Track runs on your own Cloudflare account as one Worker, with a D1 database for accounts and the list of workspaces, a Durable Object for each workspace, and an R2 bucket for attached files.
Use the Workers Paid plan. On the Free plan a request gets 10 ms of CPU, and checking a password takes far more: 85 ms in the one sign-in measured on a deployed Worker. Track has not been run on the Free plan, so how often a sign-in would fail there is not known. Email to any address needs the Paid plan too; Track works without email.
R2 has a free tier, but it is turned on once per account, in the dashboard under R2 Object
Storage, before anything can use it. To run without attachments instead, delete the
r2_buckets lines from wrangler.jsonc before deploying; uploads then answer that they are not
set up.
There are two ways to deploy: the button, which also sets up deploys on every push, or the command line.
Deploy with the button
Section titled “Deploy with the button”The button copies this repository to your GitHub or GitLab account and connects the copy to Workers Builds, so every push to its main branch deploys. On the form:
- Git account is the first field, the account the copy goes to. If nothing is connected yet, connect GitHub or GitLab there.
- Project name names the Worker. It answers on
<project name>.<your subdomain>.workers.dev. - D1 database and R2 bucket: choose Create new.
- BETTER_AUTH_SECRET signs sessions. Use a long random string, for example from
openssl rand -base64 32, and keep it secret. Changing it later signs everyone out. - Leave the build and deploy commands as they are. The deploy command applies the database migrations before the new code goes live.
When the build finishes, open the Worker’s address, create an account and then a workspace. Anyone who can reach the address can sign up and create workspaces of their own, and the first account is the owner of the server. A workspace is only visible to its members. To let in only people you choose, see Who may sign up.
Preview builds. The button turns them on: a push to any other branch builds a Preview with its
own URL. A Preview gets none of the Worker’s bindings or secrets, so its address only says that it
is not set up, and it cannot reach your data. To make Previews work, create another D1 database and
apply the migrations to it, name it, a bucket and the Durable Objects under previews in
wrangler.jsonc as Cloudflare’s
guide shows, and give them a secret
of their own with pnpm wrangler preview base-config secret put BETTER_AUTH_SECRET. If you don’t
need them, untick Enable Preview builds on the form, or turn them off later under Settings ›
Build › Branch control.
Dependabot. The copy brings this repository’s .github/dependabot.yml, so Dependabot opens a
pull request on it within minutes, and weekly after that. You don’t need them: a release brings
updates that were tested together, and what you merge can conflict with the next update. Delete the
file unless you want them, and close what it opened.
Deploy from the command line
Section titled “Deploy from the command line”Requirements: Node.js 24 (22+ works) and pnpm 12.
git clone https://github.com/gateweavehq/track.gitcd trackpnpm installpnpm wrangler loginpnpm run deploypnpm wrangler runs the Wrangler version Track pins, from the repository root. The Worker and
its database are named gateweave-track; to use other names, change name and database_name
in wrangler.jsonc before the first deploy.
The first deploy creates the D1 database, the R2 bucket and the Durable Object namespace, generates
BETTER_AUTH_SECRET (it is never shown), then applies the migrations. Every later
pnpm run deploy builds, applies new migrations, and only then deploys.
Where the database lives
Section titled “Where the database lives”Every read and write of an account or a session goes to one place, the D1 database’s primary, and D1 puts it close to whoever asked for the database unless told otherwise. That may be far from your people. Measured from a Worker with the primary in Eastern Europe, one read takes about 20 ms from Frankfurt, 110 ms from Virginia, 240 ms from Tokyo and 290 ms from Sydney (how).
To choose, make the database yourself before the first deploy, with a location hint (wnam, enam,
weur, eeur, apac or oc):
pnpm wrangler d1 create gateweave-track --location apacThen pick it instead of Create new on the button’s form, or put its id in wrangler.jsonc as
database_id.
Settings
Section titled “Settings”Everything except BETTER_AUTH_SECRET is optional. Set them in the Cloudflare dashboard
(Workers & Pages › your Worker › Settings › Variables and Secrets) or from the repository root:
pnpm wrangler secret put BETTER_AUTH_URLText variables and secrets both stay through a deploy, because wrangler.jsonc sets keep_vars. A
copy older than v0.28.1 lost its text variables on every deploy, so look through them after
updating. A site key missing there means the request forms were not open.
| Name | What it does |
|---|---|
BETTER_AUTH_SECRET |
Signs sessions. Required. |
BETTER_AUTH_URL |
The address people use, such as https://track.example.com. Turns on MCP. |
GITHUB_CLIENT_ID |
Turns on “Sign in with GitHub”, with GITHUB_CLIENT_SECRET. |
GITHUB_CLIENT_SECRET |
The client secret of the same GitHub OAuth app. |
OIDC_ISSUER |
Turns on an OpenID Connect provider, with the two below. See below. |
OIDC_CLIENT_ID |
This deployment’s client ID at the provider. |
OIDC_CLIENT_SECRET |
Its client secret there. |
OIDC_PROVIDER_NAME |
What the sign-in button calls it. Unset: “single sign-on”. |
GITHUB_APP_SLUG |
Turns on the GitHub App, with the three below. See below. |
GITHUB_APP_CLIENT_ID |
The GitHub App’s client ID. |
GITHUB_APP_CLIENT_SECRET |
The GitHub App’s client secret. |
GITHUB_APP_WEBHOOK_SECRET |
The secret that signs the GitHub App’s deliveries. |
EMAIL_FROM |
Turns on email, such as Gateweave Track <track@example.com>. |
PUSH_VAPID_KEY |
Turns on push notifications. A secret; see below. |
ATTACHMENT_QUOTA_MB |
Limits each workspace’s attached files, such as 1024. Unset or blank: none. |
LEGAL_NOTICE_URL |
The address of your legal notice. See below. |
PRIVACY_POLICY_URL |
The address of your privacy policy. |
TERMS_URL |
The address of your terms. |
SIGN_UP_REQUEST_URL |
Where to ask for an account, shown while sign-up is closed. See below. |
Your legal pages
Section titled “Your legal pages”Track comes with no legal notice, privacy policy or terms, and never links to Gateweave’s: whoever
runs a copy decides what applies to it. Set any of the three addresses above, and it is linked from
the sign-in pages, the workspace menu and the public request form. With TERMS_URL set, the sign-in
page also says that continuing means agreeing to them.
Who may sign up
Section titled “Who may sign up”A new server is open: anyone who can reach its address can make an account, and the first account owns the server. An upgrade changes nothing for a server that has accounts, which stays open with its oldest account as the owner. If the one who runs it wants only some people, the owner opens their account page, where a section called “This server” is shown to them alone, and chooses By invitation. Then these may make an account, and nobody else, whether with a password, GitHub or the sign-in provider:
- the addresses on the owner’s list, and everyone at a domain on it. The list takes an address, or a
domain with an
@in front such as@example.com, which covers that domain and not its subdomains, with a note of the owner’s own. It holds at most 5,000 entries. - anyone with a pending invitation to a workspace, since any workspace admin’s invitation lets the address it names in. That is how invitations always worked, so close sign-up only if your workspaces’ admins may invite.
- with a sign-in provider set up (see below), people who sign in through it, if the owner turns on “Trust” for it, since a provider keeps a list of its own. GitHub never counts.
People who already have an account are not affected, and the first account on a server that has
none is always let in. Every change on that page wants a sign-in from the last day. Refused people
are told that the server takes accounts by invitation, in the same words for every address. Set
SIGN_UP_REQUEST_URL to an https address, such as a form or a waitlist, and the sign-in page
links to it as “Ask for access” while sign-up is closed.
The list holds addresses of people who may not have an account yet, so it is personal data in
your D1 database (the sign_up_allow table), and an entry outlives any account made through it.
Remove an entry on that page if someone asks to be forgotten. While sign-up is closed, the owner’s
account stays: it cannot be deleted until sign-up is open again. When it is deleted while open, the
earliest remaining account takes over as owner. To hand the server to someone else on purpose,
change owner_id in the deployment table. pnpm dev:seed signs its sample people up through the
API, so it needs sign-up open.
Your own domain
Section titled “Your own domain”In the dashboard, open the Worker and add a custom domain under Settings › Domains & Routes. Or
add "routes": [{ "pattern": "track.example.com", "custom_domain": true }] to wrangler.jsonc.
Sign-in works on any address the Worker serves, as long as it is HTTPS: session cookies are marked
Secure, so a browser won’t keep them over plain HTTP. Turn on
Always Use HTTPS
for the domain so that nobody lands on the plain address.
Track’s pages refuse scripts they did not name, so a zone setting that adds scripts to a page does nothing here or gets in the way. Web Analytics’ automatic setup and Rocket Loader are two of them: leave those off for this hostname.
AI agents (MCP)
Section titled “AI agents (MCP)”Agents connect to /mcp and sign in through OAuth. Their tokens are bound to one address, so the
endpoint is only on once BETTER_AUTH_URL names it, for example https://track.example.com.
Agents then connect to https://track.example.com/mcp.
An agent that works on its own, such as one that triages new issues, gets a member of its own
instead: an admin creates it under Settings, Agents, and gives its MCP client the key shown there,
as Authorization: Bearer <key>. It needs nothing else to be set up.
Webhooks
Section titled “Webhooks”Webhooks need no setup either: each workspace’s Durable Object sends them itself, with no Queue
or other binding. Admins add them under Settings, Webhooks. They must use https, except when
the app itself runs on plain http, and cannot point at the app’s own host.
GitHub pull requests and commits
Section titled “GitHub pull requests and commits”Connecting repositories needs no app or secret on the server. An admin adds a connection under
Settings, GitHub, and adds a webhook on GitHub with the payload URL and secret shown there:
content type application/json, and the Pull requests and Pushes events. GitHub must be able to
reach your address, so a local http://localhost cannot receive deliveries.
Instead, admins can install a GitHub App on the repositories they choose, once you register one
for your deployment. Create it under GitHub’s Settings, Developer settings, GitHub Apps, with your
address in place of https://track.example.com:
- Callback URL:
https://track.example.com/api/github/app/callback. Leave “Expire user authorization tokens” on, and “Request user authorization (OAuth) during installation” off: it would replace the setup URL. Track sends the admin to authorize the app itself, and connects an installation only once GitHub lists it among the installations they can reach, on an account they control: an organization they are an owner of, or their own user account. - Setup URL:
https://track.example.com/api/github/app/setup, with “Redirect on update” on, so that choosing other repositories comes back to Track too. - Webhook: active, with the URL
https://track.example.com/api/github/appand a secret you make, such as one fromopenssl rand -hex 32. - Permissions: Pull requests and Contents, read-only, and Metadata, read-only, on repositories; and Members, read-only, on organizations, which lets Track ask GitHub whether the person connecting an organization’s installation is its owner.
- Events: Pull request and Push.
- Where it can be installed: any account, unless only your own organization uses it.
GitHub will not install an app until it has a private key, so generate one on the app’s page. Track never signs as the app and does not read the key, so you can delete the downloaded file.
Then set GITHUB_APP_SLUG to the app’s name in its URL (github.com/apps/<slug>),
GITHUB_APP_CLIENT_ID to its client ID, and the client secret and webhook secret as secrets:
pnpm wrangler secret put GITHUB_APP_CLIENT_SECRET and
pnpm wrangler secret put GITHUB_APP_WEBHOOK_SECRET. Settings, GitHub then offers “Install GitHub
App” next to adding a webhook. Only an owner or admin of the workspace who is also an owner of
the GitHub organization the app is installed on (or the GitHub user, for an installation on a
personal account) can connect it; someone who can only read some of its repositories cannot. An
installation belongs to one workspace at a time; disconnecting it in Settings, GitHub frees it
for another workspace at once. An app registered before Members was in its permissions has to
ask its installations to accept the new permission, under the app’s Permissions & events.
Request forms
Section titled “Request forms”Each team can have a public request form, whose link an admin shares so that people without an account file requests into the team’s intake. Forms take requests only once Turnstile keeps bots out:
- In the Cloudflare dashboard, add a Turnstile widget for your hostname (Managed mode).
- Set
TURNSTILE_SITE_KEYto its site key andTURNSTILE_SECRET_KEYto its secret key, the second as a secret:pnpm wrangler secret put TURNSTILE_SECRET_KEY.
Without both, admins can still turn a form on, but its link says the form is not open. Each client network can file 10 requests an hour and 30 a day across all forms.
Intake email
Section titled “Intake email”Each team can also have an intake email address, which files the email it gets as requests in the team’s intake. Teams usually forward their support address to it. To turn addresses on:
- Pick a domain or subdomain that receives no other mail, such as
requests.example.com, in a zone on Cloudflare, and enable Email Routing on it. - Add a catch-all rule that sends every address to the Worker (
gateweave-track, or your Worker’s name). - Set
INTAKE_EMAIL_DOMAINto that domain.
Admins then turn on each team’s address next to its form. Each sender can file 10 requests an hour and 30 a day, and each address takes at most 60 an hour and 300 a day. Attachments go into the bucket for attached files, where there is one.
Sign in with GitHub
Section titled “Sign in with GitHub”Create a GitHub OAuth app whose redirect URI, which
GitHub has also called the callback URL, is https://track.example.com/api/auth/callback/github
(your address), then set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET.
Sign in through an OpenID Connect provider
Section titled “Sign in through an OpenID Connect provider”Any OpenID Connect provider can sign people in: Gateweave ID, Pocket ID, authentik, Keycloak, Okta and others. It adds a button to the sign-in page, and passwords keep working.
- Register Track with the provider as a confidential client that uses the authorization code
flow and
client_secret_basic, with the scopesopenid,profileandemail:- Redirect URI:
https://track.example.com/api/auth/callback/oidc(your address) - Back-channel logout URI, if the provider has them:
https://track.example.com/api/auth/backchannel-logout/oidc
- Redirect URI:
- Set
OIDC_ISSUERto the provider’s issuer, the address its/.well-known/openid-configurationis under, thenOIDC_CLIENT_IDandOIDC_CLIENT_SECRET.OIDC_PROVIDER_NAMEnames the button, as in “Continue with Acme”.
Someone new gets an account (unless sign-up is closed and the owner has not turned on “Trust” for the provider, see Who may sign up), named as the provider names them, and can change that name in Track without the provider changing it back. Someone whose address already has one links to it only when the provider says the address is verified and they have verified it in Track too, so anyone who could sign up at the provider with that address cannot take over the account. Without email nobody can verify an address in Track, so existing accounts stay with their passwords.
If the provider is down, its button says so, and everything else keeps working. People already signed in stay signed in, because Track asks the provider nothing except when someone uses that button or the provider signs someone out. With the back-channel logout URI registered, signing out at the provider ends the Track sessions it started. Signing out of Track leaves the provider’s session alone.
A member’s picture shows when it comes from the provider’s own host or from GitHub. From anywhere else, the page’s policy stops it and their initials show instead.
If Track and the provider are both Workers, give each a
Custom Domain.
A Worker cannot reach another on its own zone through a route, and two on workers.dev cannot reach
each other at all: Cloudflare answers with error 1042, and the button says it could not reach the
provider. If they have to stay on workers.dev, add global_fetch_strictly_public to
compatibility_flags in each one’s wrangler.jsonc.
Without email, admins share invitation links themselves and nobody can reset a forgotten
password for themselves. As you hold the database, you can do it for them, from the repository
root. Find their id in this list. Don’t paste an address someone sent you into a command, as
it could run as part of it:
pnpm wrangler d1 execute DB --remote --command "SELECT id, email FROM user ORDER BY email"Then make a link for that id. It works once, for an hour, and using it signs them out
everywhere. Whoever opens it can choose the password, so send it only to them:
PERSON=the-id-from-that-listTOKEN=$(openssl rand -hex 32)pnpm wrangler d1 execute DB --remote --command \ "INSERT INTO verification (id, identifier, value, expires_at) VALUES ('$TOKEN', 'reset-password:$TOKEN', '$PERSON', (unixepoch() + 3600) * 1000)"echo "https://your-server/reset-password?token=$TOKEN"To turn email on:
- Onboard a domain to Cloudflare Email Sending:
pnpm wrangler email sending enable example.com. Sending to any address needs the Workers Paid plan. - Set
EMAIL_FROMto a sender on that domain, such asGateweave Track <track@example.com>.
Track then sends password resets, address verification, invitations and, to people who want
them, emails of inbox news they leave unread. With email on, joining a workspace requires a
verified address. Serve Track over HTTPS (a workers.dev or custom domain is) so that those
emails carry a one-click unsubscribe link: Email Service refuses one over HTTP.
Push notifications
Section titled “Push notifications”People can turn on push in Settings, Notifications, on each phone or computer they use Track on. What they get by email then also reaches that device as it happens. It needs one secret, the key your server signs its pushes with. Make it and set it in one go, so it is never shown:
node apps/web/scripts/push-key.mjs | pnpm wrangler secret put PUSH_VAPID_KEYKeep the key: a new one makes everyone turn push on again on each device. Messages go through the push service of each person’s browser (Google’s for Chrome, Mozilla’s for Firefox, Apple’s for Safari, Microsoft’s for Edge), encrypted so that only that browser can read them. Mention those services in your privacy policy. On iPhone and iPad, push works once Track is added to the Home Screen.
Backups
Section titled “Backups”D1 has 30 days of Time Travel, restored in place. For copies you keep yourself, a backup Worker in
the same account can read every workspace through the Worker’s Backup entrypoint, which has no
route; see Backups in architecture.md. Gateweave Backup binds to it
with a service binding:
"services": [{ "binding": "TRACK", "service": "gateweave-track", "entrypoint": "Backup" }]Your D1 database is backed up by binding it directly.
When someone deletes their account
Section titled “When someone deletes their account”People delete their own account on /settings/account, and their name, address and picture go
from every workspace, with no setting to turn on. If you publish a privacy notice, say what stays:
what they wrote stays with the team under “Deleted member”, and files they attached stay with the
workspace.
The architecture notes have the full list, including
workspaces someone left before v0.29, which you would have to clear by hand if they ask, and
what Cloudflare’s point-in-time recovery keeps.
An account that is the only owner of a workspace is refused until someone else owns it or it is deleted. If you restore D1 or a workspace to a time before a deletion, the person comes back.
The public demo
Section titled “The public demo”Gateweave’s own demo (a private sandbox per visitor, deleted after a day) is a separate
deployment, the demo environment in wrangler.jsonc. You do not need it: it is off unless the
TRACK_DEMO variable is "1", which only that environment sets. See
the architecture notes.
Updating
Section titled “Updating”Command line: git pull, pnpm install, pnpm run deploy.
From a version without attachments, the next deploy creates the R2 bucket, so R2 must be on in the account first (see the top of this page).
Button: your copy does not follow this repository on its own, and shares no history with it,
so git pull refuses. This takes the latest release into your copy instead, as one commit on top
of it. A copy can skip releases, however old it is:
git remote add upstream https://github.com/gateweavehq/track.git # oncegit fetch upstreamgit show upstream/main:scripts/update-copy.mjs | node --input-type=modulegit pushThe script pushes nothing, and git reset --hard HEAD~1 undoes it until you do. The push is what
deploys: Workers Builds builds it and applies the D1 migrations, each workspace’s Durable Object
applies its own the next time it wakes, and open tabs reload if the protocol changed.
If GitHub refuses the push with push declined due to email privacy restrictions, the commit
carries the address your GitHub settings keep private. Use the noreply address those settings
show you, and make the commit again:
git config user.email "<your noreply address>"git commit --amend --reset-author --no-editgit pushWhat your copy changed stays: the Worker’s, database’s and bucket’s names in wrangler.jsonc, the
name in package.json, and having no workflows. If something else you changed conflicts with the
release, the script names the files for you to fix before committing. To take a release other than
the latest, end the third line with node --input-type=module - --to v1.2.3.
Track is in early development. Read CHANGELOG.md before updating, as some releases change how things work.
Checking a deployment
Section titled “Checking a deployment”The smoke tests check a live copy from outside, as a visitor’s browser finds it: the sign-in page
loads and works offline, accounts are looked up, and the sync gateway and /mcp turn away anyone
without a session or a token. They sign in to nothing and change nothing, so they are safe to run
against your production after each update. They need a checkout and a browser:
pnpm --filter @gateweave/track-web exec playwright install chromium # onceTRACK_SMOKE_URL=https://track.example.com pnpm test:smoke