Skip to content
GitHubGet started

Deploy to Cloudflare

Self-hosting

Put DecTog on your own zone, run the first deploy and the release routine, verify it and roll back when needed.

Production is the production environment in apps/worker/wrangler.jsonc: one Worker bound to two routes on your zone, one D1 database, two cron triggers and a static-assets binding that serves the dashboard. The same Worker answers the API, the admin API, the link redirects, the well-known files and the dashboard host. No domain yet? One click on workers.dev deploys the same Worker on your workers.dev subdomain.

Deploy to Cloudflare

The button runs DecTog on your Cloudflare account without a zone: Cloudflare clones the server repository into your GitHub account, creates a D1 database, builds the dashboard, deploys the Worker to https://<worker name>.<your subdomain>.workers.dev and connects the copy to Workers Builds, which redeploys it on every push to main. The deployment is the wrangler.jsonc at the root of the repository (the hosted service and npm run setup use apps/worker/wrangler.jsonc), and the root package.json holds its build and deploy commands: the build installs both apps and exports the dashboard, the deploy applies the pending migrations and then deploys.

  1. Click the button and sign in to Cloudflare. Choose the repository name, the Worker name (dectog unless you change it) and the database name.

  2. Fill in the secrets: JWT_SECRET and IP_SALT, each from openssl rand -hex 32. IP_SALT salts every stored IP hash, so never change it afterwards; the Worker refuses to run without it.

  3. Fill in the variables with the Worker’s own URL. Your workers.dev subdomain is on Workers & Pages > Overview; with the Worker named dectog the URL is https://dectog.<subdomain>.workers.dev.

    Variable Value
    PUBLIC_BASE_URL, WEB_URL, SITE_URL https://dectog.<subdomain>.workers.dev
    APP_HOST, ROOT_DOMAIN dectog.<subdomain>.workers.dev

    A wrong value is corrected later under the Worker’s Settings > Variables and Secrets; the Worker reads them on every request.

  4. Deploy. The first build takes a few minutes (two npm ci runs and the Next.js export). https://dectog.<subdomain>.workers.dev/readyz then answers 200 with no missing migrations.

  5. Create your account at https://dectog.<subdomain>.workers.dev/login?tab=signup: the bootstrap route is closed (ENVIRONMENT=production, no BOOTSTRAP_TOKEN) and signups are open. Then add OPEN_SIGNUPS = 0 to the Worker’s variables so nobody else can sign up, and continue with Apps.

What is different from a deployment on your own domain:

  • Links are path mode, https://dectog.<subdomain>.workers.dev/r/<username>/<slug>, because tenant subdomains need a zone with a wildcard record. The SDK is configured with baseUrl and no linkRootDomain; the AASA and Asset Links files are served from that same host, so Universal Links and App Links work on it.
  • The one host serves the public site, the dashboard and the API; the export’s links between them are relative.
  • There is no BOOTSTRAP_TOKEN and no demo seed.
  • The R2 exports bucket and customers’ custom link domains are not bound. Add them to your copy’s wrangler.jsonc as apps/worker/wrangler.jsonc describes.

To move to your own domain later, edit the root wrangler.jsonc of your copy: add the two routes from the production environment of apps/worker/wrangler.jsonc with your zone, set LINK_URL_MODE to subdomain, ROOT_DOMAIN to the apex, APP_HOST to app.<domain> and the three URLs accordingly, add the proxied A records for the apex and *, and push. Workers Builds redeploys; the database, the secrets and your account stay.

  • A zone on Cloudflare (the reference configuration uses dectog.com). Replace it with your own domain everywhere below.
  • DNS, both proxied (orange cloud): a record for the apex, and a wildcard record * pointing at the apex. Worker routes only see traffic that passes through Cloudflare’s proxy, so an unproxied record never reaches the Worker. Tenant links live on <username>.<ROOT_DOMAIN>, the public site on the apex (SITE_URL) and the dashboard on APP_HOST (app.<ROOT_DOMAIN>), and the two records cover all three.
  • Two routes with zone_name set: <root>/* and *.<root>/*. A more specific route in the same zone (for example a separate docs site on docs.<root>/*) takes precedence over the wildcard without any change here.
  • Usernames app, www, api, links, dashboard, admin, mail, static, cdn, docs, status, help and support, and every name starting with t- (the prefix of test environments), are reserved (apps/worker/src/bootstrap.ts), so no signup can claim those hosts. Any other subdomain you use for something else can be claimed by a signup unless you add it to that list or close signups with OPEN_SIGNUPS="0".
  • Node 22.12 or newer and a wrangler login session on the account that owns the zone (npx wrangler whoami).

Edit env.production in apps/worker/wrangler.jsonc. The file as shipped carries the hosted service’s domain and D1 id; replace every value marked here.

apps/worker/wrangler.jsonc (env.production)
"production": {
"routes": [
{ "pattern": "example.com/*", "zone_name": "example.com" },
{ "pattern": "*.example.com/*", "zone_name": "example.com" }
],
"vars": {
"ENVIRONMENT": "production",
"ROOT_DOMAIN": "example.com",
"LINK_URL_MODE": "subdomain",
"PUBLIC_BASE_URL": "https://app.example.com",
"WEB_URL": "https://app.example.com",
"SITE_URL": "https://example.com",
"APP_HOST": "app.example.com",
"UNKNOWN_HOST_REDIRECT": "https://example.com/",
"RETENTION_DAYS": "400",
"ATTRIBUTION_WINDOW_HOURS": "24"
},
"triggers": { "crons": ["17 3 * * *", "7 * * * *"] },
"d1_databases": [
{ "binding": "DB", "database_name": "dectog", "database_id": "<id printed by wrangler d1 create>", "migrations_dir": "migrations" }
],
"assets": { "directory": "../web/out", "binding": "ASSETS", "run_worker_first": true, "html_handling": "auto-trailing-slash" }
}

run_worker_first matters: the Worker decides every request before the asset server does, so <tenant>.<root>/login is a link slug, not the dashboard’s login page. Every variable is described on the Configuration reference. Add "OPEN_SIGNUPS": "0" to vars if only you should have an account, and "EMAIL_FROM" with a sender you verified in Resend if you set RESEND_API_KEY.

From apps/worker, logged in with npx wrangler login:

npm run setup -- --domain example.com --email you@example.com --username acme

It checks the login, finds or creates the D1 database, writes wrangler.self-host.jsonc for your domain (your deployment’s config, kept out of git), applies every migration, builds the dashboard, deploys the Worker, sets JWT_SECRET, IP_SALT and BOOTSTRAP_TOKEN only if they are missing, creates your owner account, prints the dashboard URL, the SDK key and the account key once, and deletes BOOTSTRAP_TOKEN. Add --dry-run to print every step without running any.

It is safe to run again: a finished step is skipped, and existing secrets are never rotated, because a new IP_SALT would break every stored hash and a new JWT_SECRET every stored key copy and session. DNS is the one manual step: in the Cloudflare dashboard for the zone, add an A record for the apex and one for *, both proxied (or let the Terraform module create them). When https://app.<domain>/readyz does not answer yet, the script says which records to add and stops; run it again afterwards.

Two options add to the config. Pass them on every run, because the config is rewritten each time: --exports-bucket <name> binds the R2 bucket for CSV exports too large to stream (with --exports-jurisdiction eu, fedramp or us when the bucket is kept in one), and --custom-domain-target <host> turns on customers’ custom link domains (Custom domains).

For later releases, use --config wrangler.self-host.jsonc instead of --env production in the commands below. What it costs to run is on What it costs to run.

deploy/terraform in the server repository is a Terraform module for the resources the Worker runs on: the zone (optional), the proxied apex and wildcard DNS records, the D1 database (kept in a jurisdiction, eu, fedramp or us, when you set one), and optionally the exports bucket and the custom-domain fallback origin. Its next_step output is the npm run setup command for what it created, with the flags that bind it; setup finds the database by name and does the rest.

Secrets stay out of it on purpose. Terraform keeps every value it manages in its state in plain text, so JWT_SECRET, IP_SALT and BOOTSTRAP_TOKEN are generated by setup and handed to the Worker without being written anywhere. The module’s README lists the API token permissions and every variable, and terraform test checks the plan against a mocked provider, with no account needed.

  1. Create the database and copy its id into env.production.d1_databases[0].database_id.

    cd apps/worker
    npx wrangler whoami
    npx wrangler d1 create dectog # add --location <weur|eeur|apac|oc|wnam|enam> to pin a region
  2. Set the secrets. JWT_SECRET and IP_SALT are mandatory; BOOTSTRAP_TOKEN is needed once.

    npx wrangler secret put JWT_SECRET --env production # openssl rand -hex 32
    npx wrangler secret put IP_SALT --env production # openssl rand -hex 32
    npx wrangler secret put BOOTSTRAP_TOKEN --env production # any random string; deleted after step 6
    # optional
    npx wrangler secret put RESEND_API_KEY --env production
    npx wrangler secret put GOOGLE_CLIENT_ID --env production
    npx wrangler secret put GOOGLE_CLIENT_SECRET --env production

    Without IP_SALT a production Worker throws (IP_SALT is not set, naming the wrangler secret put command) on the first request that hashes an IP: every link tap, install, app open and beacon. The Google redirect URI to register is <WEB_URL>/api/auth/google/callback.

  3. Apply the migrations to the remote database.

    npx wrangler d1 migrations apply DB --remote --env production
  4. Build the dashboard’s static export. The production assets binding reads apps/web/out.

    (cd ../web && npm ci && npm run build:static) # NEXT_OUTPUT=export next build
  5. Deploy.

    npx wrangler deploy --env production

    Wrangler names the Worker dectog-worker-production and attaches the two routes, the crons and the assets.

  6. Create the owner account. The body form is the only one production accepts.

    curl -s -X POST https://app.example.com/api/admin/bootstrap \
    -H "x-bootstrap-token: $BOOTSTRAP_TOKEN" \
    -H 'content-type: application/json' \
    -d '{"email":"you@example.com","password":"<at least 12 characters>","username":"acme"}'
    # {"ok":true,"email":"you@example.com","username":"acme","api_key":"dtg_…","sdk_key":"dtg_pub_…","demo":false}

    The account key is shown only here: DecTog keeps its hash and prefix, and Regenerate in Settings replaces it. The SDK key is listed again in Settings (its copy was encrypted with the JWT_SECRET you set in step 2). Status codes and validation messages are on the Local quick start. Then remove the token; with ENVIRONMENT=production and no token, every further bootstrap call is 403.

    npx wrangler secret delete BOOTSTRAP_TOKEN --env production
  7. Sign in at https://app.example.com/login, register your app under Apps (bundle id, Team ID, package, SHA-256 fingerprints, store URLs) and use the Verify buttons, which fetch https://<username>.example.com/.well-known/apple-app-site-association and assetlinks.json as a device would. See Apps and Platform setup.

Run these in order from apps/worker:

  1. git pull, then npm ci here and in ../web.
  2. npm test and npm run typecheck (worker), npm run typecheck (web). Optional, recommended.
  3. npx wrangler d1 migrations apply DB --remote --env production. Applying a migration while the old Worker is still live is safe when it only adds nullable or defaulted columns and indexes; the old Worker ignores columns it does not know. The command prints which files it applied; npx wrangler d1 migrations list DB --remote --env production shows what is pending.
  4. (cd ../web && npm run build:static) so the deploy ships the current dashboard. Skipping this deploys the Worker with whatever apps/web/out already holds.
  5. npx wrangler deploy --env production.
  6. The smoke checks below.
Command Expected
curl -s https://app.example.com/healthz {"ok":true,"service":"dectog","version":"v1","host":"app.example.com"}
curl -s https://app.example.com/readyz 200 {"ok":true,"database":true,"migrations":{…,"missing":[]}}. A 503 names the migrations still to apply: run npx wrangler d1 migrations apply DB --remote --env production.
curl -s https://app.example.com/api/v1/links 401 {"error":{"code":"missing_api_key","message":"Missing API key",…}}
curl -sI https://example.com/ 200, text/html (the landing page from the static export)
curl -sI https://app.example.com/login 200, text/html (the dashboard’s login page)
curl -sI https://app.example.com/ 302 to /login
curl -sI https://no-such-user.example.com/ 302 to UNKNOWN_HOST_REDIRECT (or 404 when it is unset)
curl -sI https://<username>.example.com/<slug> with a desktop User-Agent 200 QR page, or 302 to a desktop redirect or fallback URL if the link has one
curl -s https://<username>.example.com/.well-known/apple-app-site-association The AASA JSON once an app with bundle id and Team ID exists, otherwise 404 {"error":{"code":"not_found","message":"Configuration not found"}}
npx wrangler deployments list --env production The new version at the top
Cloudflare dashboard, Worker, Settings, Triggers Cron triggers 17 3 * * * and 7 * * * *

npx wrangler tail --env production streams the Worker’s console output, including the password-reset link when RESEND_API_KEY is unset and the warnings from webhook deliveries and Apple Search Ads calls.

npx wrangler deployments list --env production
npx wrangler rollback --env production # or: rollback <version-id> --env production

D1 data persists across rollbacks. A rollback does not undo migrations, and migrations are never reverted: an older Worker then runs against a newer schema. Migrations that add nullable columns or indexes are harmless for it; a migration that rebuilds a table or adds a NOT NULL column without a default is not, so read the migration you are rolling back across. The list is on Database and migrations.

Behavior Without it With it
Bootstrap token Required only when BOOTSTRAP_TOKEN is set Always required; with no token configured every bootstrap call is 403 Bootstrap not permitted
Demo seed (empty-body bootstrap) Allowed on an empty database 403 Demo seed is disabled in production
IP_SALT missing Falls back to dtg-default-salt with a console warning Throws on every request that hashes an IP

Nothing else reads ENVIRONMENT. Signup stays open unless OPEN_SIGNUPS="0" is set, CORS follows WEB_URL, and the Secure cookie flag follows the request scheme (https), not this variable. The demo-login hint on the login page disappears with the static export (NEXT_OUTPUT=export), not with this variable.

BRAND_NAME renames the product on the link pages (page titles, “This … link does not exist”, the Go to … button and the “Powered by” footer of the QR page) and in the password-reset email. A few DecTog references stay in code: the Go to button’s target (https://dectog.com/), the reset email’s Reply-To: hello@dectog.com and default sender (apps/worker/src/email.ts), and the marketing pages of the Next.js app. Change them in the source before you build if they matter to you. The signup form’s link-domain hint follows your ROOT_DOMAIN.