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.

request flow
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 Assets

What 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.18 and 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#

  1. Unpack the kit, install, and log in to Cloudflare
    Terminal
    tar -xzf ats-0.1.0.tar.gz
    cd ats-0.1.0
    npm ci
    cd apps/cloudflare
    npx wrangler login
  2. Create the storage
    Terminal
    npx wrangler d1 create ats            # prints a database_id
    npx wrangler r2 bucket create ats-files

    Paste the database_id into wrangler.toml:

    apps/cloudflare/wrangler.toml
    [[d1_databases]]
    binding = "DB"
    database_name = "ats"
    database_id = "paste-the-id-here"
    
    [[r2_buckets]]
    binding = "FILES"
    bucket_name = "ats-files"
  3. 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:

    Terminal
    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 Graph

    If Wrangler offers to create the ats Worker first so it can hold the secret, accept.

  4. Fill in [vars] and the route
    apps/cloudflare/wrangler.toml
    workers_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_dev and preview_urls off: those hostnames would sit outside Cloudflare Access. BASE_PATH must match the route. You fill in ACCESS_AUD in the next step.

  5. Protect it with Cloudflare Access

    In Zero Trust, go to Access → Applications and add a Self-hosted application:

    • Application domain ats.example.com, path ats (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/ingest with 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-Assertion token (issuer, audience and signature) on every API call, so a missing or misconfigured Access policy results in 401s, never open access.

  6. Deploy
    Terminal
    cd ../..                                  # back to the kit root
    npm run deploy -w @ats/cloudflare         # builds the UI, then wrangler deploy

    Database 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.

  7. 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.

your website's wrangler.toml
services = [{ binding = "ATS", service = "ats" }]
functions/apply.js (excerpt)
// ...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#

Terminal
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.