Cloudflare Workers
One Worker on your own hostname under /ats: D1 for the database, R2 for uploaded files, Cloudflare Access for sign-in, a Cron Trigger for the hourly retention and audit jobs, and Workers Static Assets for the web app. Everything lives in your Cloudflare account.
https://ats.example.com/ats/* ──▶ Cloudflare Access ──▶ Worker "ats"
├─ /ats/api/* JSON API (Access JWT verified again)
├─ /ats/ingest HMAC-signed intake (Access bypassed)
└─ /ats/* web app from Workers Static AssetsWhat you need#
- A Cloudflare account with your domain on it, and a Cloudflare Zero Trust organization (its team domain looks like
yourteam.cloudflareaccess.com). - Workers, D1 and R2 available on the account. Nothing in ATS requires a paid plan, but free tiers have usage and CPU limits and some products ask for a payment method first: check Cloudflare's current pricing.
- Node.js
>=22.18and npm on your machine (for Wrangler and the UI build).
Use a hostname of its own#
Serve ATS from a hostname that serves nothing else, such as ats.example.com, rather than a path on your public website. ATS accepts state-changing requests that come from its own origin, so any script running on a shared origin (CMS embeds, analytics or tag-manager scripts) could act with an HR user's Access session. A dedicated hostname keeps that origin to ATS alone. More on the CSRF model.
The Worker is attached with a route, so the hostname needs a proxied (orange-cloud) DNS record in your zone. See Cloudflare's documentation on Workers routes for the record to create if the hostname doesn't exist yet.
Deploy#
- Unpack the kit, install, and log in to Cloudflare
tar -xzf ats-0.1.0.tar.gz cd ats-0.1.0 npm ci cd apps/cloudflare npx wrangler login - Create the storage
npx wrangler d1 create ats # prints a database_id npx wrangler r2 bucket create ats-filesPaste the
database_idintowrangler.toml:[[d1_databases]] binding = "DB" database_name = "ats" database_id = "paste-the-id-here" [[r2_buckets]] binding = "FILES" bucket_name = "ats-files" - Set the secrets
Secrets never go in
wrangler.toml. Generate the ingest secret once and keep a copy, because your website needs the same value:INGEST_SECRET=$(openssl rand -hex 32) echo "$INGEST_SECRET" # store it in your password manager printf '%s' "$INGEST_SECRET" | npx wrangler secret put INGEST_SECRET npx wrangler secret put GRAPH_CLIENT_SECRET # only if sending mail via Microsoft GraphIf Wrangler offers to create the
atsWorker first so it can hold the secret, accept. - Fill in
[vars]and the routeworkers_dev = false preview_urls = false routes = [{ pattern = "ats.example.com/ats*", zone_name = "example.com" }] [vars] PUBLIC_URL = "https://ats.example.com" BASE_PATH = "/ats" ACCESS_TEAM_DOMAIN = "yourteam.cloudflareaccess.com" ACCESS_AUD = "replace-with-access-application-aud-tag" BOOTSTRAP_ADMINS = "you@example.com" MAIL_FROM = "" GRAPH_TENANT_ID = "" GRAPH_CLIENT_ID = "" GRAPH_MAILBOX = ""Keep
workers_devandpreview_urlsoff: those hostnames would sit outside Cloudflare Access.BASE_PATHmust match the route. You fill inACCESS_AUDin the next step. - Protect it with Cloudflare Access
In Zero Trust, go to Access → Applications and add a Self-hosted application:
- Application domain
ats.example.com, pathats(covers/ats/*). Allow policy: your HR people or identity-provider group. - Copy the application's AUD tag into
ACCESS_AUD.
Then add a second application for path
ats/ingestwith a Bypass policy (Everyone). The endpoint authenticates itself with the HMAC signature. Alternatively, give it a Service Auth policy and have your website send a service token (CF-Access-Client-Id/CF-Access-Client-Secret) in addition to the signature.Fails closed. The Worker verifies the
Cf-Access-Jwt-Assertiontoken (issuer, audience and signature) on every API call, so a missing or misconfigured Access policy results in 401s, never open access. - Application domain
- Deploy
cd ../.. # back to the kit root npm run deploy -w @ats/cloudflare # builds the UI, then wrangler deployDatabase migrations run automatically on the first request of each Worker isolate and are idempotent. If configuration is invalid, the Worker logs which variable is wrong (by name, never by value) and answers every request with a generic 500 until it is fixed.
- Sign in and invite your team
Open
https://ats.example.com/ats/, sign in through Access as a bootstrap admin, and add people under Settings → Users & access. Access decides who can reach ATS; ATS decides what each person may do.
Posting from a Pages Function or another Worker#
If your careers website is a Pages project or Worker on the same Cloudflare account, post applications over a Service Binding instead of the public internet. That path never passes through Access, so it needs no bypass.
services = [{ binding = "ATS", service = "ats" }]// ...validate the form, then build payload + body exactly as in "Connect a form"...
const res = await env.ATS.fetch("https://ats.internal/ats/ingest", {
method: "POST",
body, // multipart: "payload" + file parts
headers: { "X-ATS-Timestamp": ts, "X-ATS-Signature": signature },
});The hostname in the URL is ignored by a Service Binding; the path must include BASE_PATH. The full signing code is in Connect a form.
Configuration reference#
Bindings, variables and secrets are listed in Configuration → Cloudflare.
Local development#
cp apps/cloudflare/.dev.vars.example apps/cloudflare/.dev.vars
npm run build -w @ats/ui
npm run dev:cf # http://localhost:8787/ats/ (local D1/R2)
curl -X POST "http://localhost:8787/cdn-cgi/local/scheduled" # fire the cron by hand.dev.vars sets ENVIRONMENT=development and DEV_USER_EMAIL, which replaces Access with a fixed dev identity. Never set ENVIRONMENT in production. npm run check -w @ats/cloudflare bundles without deploying.
Backups#
D1 can be exported to SQL with Wrangler, for example npx wrangler d1 export ats --remote --output ats-backup.sql, and Cloudflare offers point-in-time restore for D1. Uploaded files live in the ats-files R2 bucket; copy them with any S3-compatible tool if you want an off-Cloudflare backup. Check Cloudflare's documentation for current retention windows.
Upgrading#
Unpack the new kit, carry over your edited wrangler.toml values, run npm ci and deploy again. Secrets and data stay in your account. See Upgrading.