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.
One click on workers.dev
Section titled “One click on workers.dev”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.
-
Click the button and sign in to Cloudflare. Choose the repository name, the Worker name (
dectogunless you change it) and the database name. -
Fill in the secrets:
JWT_SECRETandIP_SALT, each fromopenssl rand -hex 32.IP_SALTsalts every stored IP hash, so never change it afterwards; the Worker refuses to run without it. -
Fill in the variables with the Worker’s own URL. Your workers.dev subdomain is on Workers & Pages > Overview; with the Worker named
dectogthe URL ishttps://dectog.<subdomain>.workers.dev.Variable Value PUBLIC_BASE_URL,WEB_URL,SITE_URLhttps://dectog.<subdomain>.workers.devAPP_HOST,ROOT_DOMAINdectog.<subdomain>.workers.devA wrong value is corrected later under the Worker’s Settings > Variables and Secrets; the Worker reads them on every request.
-
Deploy. The first build takes a few minutes (two
npm ciruns and the Next.js export).https://dectog.<subdomain>.workers.dev/readyzthen answers200with no missing migrations. -
Create your account at
https://dectog.<subdomain>.workers.dev/login?tab=signup: the bootstrap route is closed (ENVIRONMENT=production, noBOOTSTRAP_TOKEN) and signups are open. Then addOPEN_SIGNUPS=0to 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 withbaseUrland nolinkRootDomain; 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_TOKENand no demo seed. - The R2 exports bucket and customers’ custom link domains are not bound. Add them to your copy’s
wrangler.jsoncasapps/worker/wrangler.jsoncdescribes.
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.
Cloudflare prerequisites
Section titled “Cloudflare prerequisites”- 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 onAPP_HOST(app.<ROOT_DOMAIN>), and the two records cover all three. - Two routes with
zone_nameset:<root>/*and*.<root>/*. A more specific route in the same zone (for example a separate docs site ondocs.<root>/*) takes precedence over the wildcard without any change here. - Usernames
app,www,api,links,dashboard,admin,mail,static,cdn,docs,status,helpandsupport, and every name starting witht-(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 withOPEN_SIGNUPS="0". - Node 22.12 or newer and a
wrangler loginsession on the account that owns the zone (npx wrangler whoami).
The production environment
Section titled “The production environment”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.
"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.
One-command setup
Section titled “One-command setup”From apps/worker, logged in with npx wrangler login:
npm run setup -- --domain example.com --email you@example.com --username acmeIt 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.
Infrastructure as code with Terraform
Section titled “Infrastructure as code with Terraform”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.
First-time setup (by hand)
Section titled “First-time setup (by hand)”-
Create the database and copy its id into
env.production.d1_databases[0].database_id.cd apps/workernpx wrangler whoaminpx wrangler d1 create dectog # add --location <weur|eeur|apac|oc|wnam|enam> to pin a region -
Set the secrets.
JWT_SECRETandIP_SALTare mandatory;BOOTSTRAP_TOKENis needed once.npx wrangler secret put JWT_SECRET --env production # openssl rand -hex 32npx wrangler secret put IP_SALT --env production # openssl rand -hex 32npx wrangler secret put BOOTSTRAP_TOKEN --env production # any random string; deleted after step 6# optionalnpx wrangler secret put RESEND_API_KEY --env productionnpx wrangler secret put GOOGLE_CLIENT_ID --env productionnpx wrangler secret put GOOGLE_CLIENT_SECRET --env productionWithout
IP_SALTa production Worker throws (IP_SALT is not set, naming thewrangler secret putcommand) 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. -
Apply the migrations to the remote database.
npx wrangler d1 migrations apply DB --remote --env production -
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 -
Deploy.
npx wrangler deploy --env productionWrangler names the Worker
dectog-worker-productionand attaches the two routes, the crons and the assets. -
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_SECRETyou set in step 2). Status codes and validation messages are on the Local quick start. Then remove the token; withENVIRONMENT=productionand no token, every further bootstrap call is403.npx wrangler secret delete BOOTSTRAP_TOKEN --env production -
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 fetchhttps://<username>.example.com/.well-known/apple-app-site-associationandassetlinks.jsonas a device would. See Apps and Platform setup.
Every release
Section titled “Every release”Run these in order from apps/worker:
git pull, thennpm cihere and in../web.npm testandnpm run typecheck(worker),npm run typecheck(web). Optional, recommended.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 productionshows what is pending.(cd ../web && npm run build:static)so the deploy ships the current dashboard. Skipping this deploys the Worker with whateverapps/web/outalready holds.npx wrangler deploy --env production.- The smoke checks below.
Smoke checks
Section titled “Smoke checks”| 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.
Rollback
Section titled “Rollback”npx wrangler deployments list --env productionnpx wrangler rollback --env production # or: rollback <version-id> --env productionD1 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.
What ENVIRONMENT=production changes
Section titled “What ENVIRONMENT=production changes”| 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.
Strings that stay hard-coded
Section titled “Strings that stay hard-coded”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.