Skip to content

6. Environment variables

"Environment variables" are settings the app reads at runtime — like who the admins are. Some are plain settings; one kind (secrets) is for sensitive values. You set these in the Cloudflare Pages dashboard so they apply to the live site.

The settings this app uses

Name Required? What it does Example
ADMIN_EMAILS Yes Comma-separated emails that are always admins. Bootstraps the very first admin. robertbettencourt@totlcom.com
APP_ENV Yes Which environment this is. production
DEFAULT_SCHEDULE_WEEK_START Yes The day weeks start on. Locked to Wednesday for TOTLCOM. wednesday
ONCALL_CHANGEOVER_WEEKDAY No Weekday the home page rolls to the next on-call week (name or 0–6, Sun=0). Should match the week-start day. Defaults to Wednesday. wednesday
ONCALL_CHANGEOVER_TIME No Local 24-hour time (HH:MM) the roll happens. Defaults to 00:01. 00:01
ONCALL_CHANGEOVER_TZ No IANA timezone the weekday/time are read in. Defaults to America/Los_Angeles. America/Los_Angeles
CWM_ENC_KEY Yes, to use ConnectWise (secret) Encrypts the stored ConnectWise keys. See below. (32-byte base64)
CWM_SYNC_TOKEN No (secret) Only if you want to trigger a sync over HTTP from outside the app. Not needed for the scheduled sync, which runs as a Worker. Leave unset. (unset)
GEMINI_API_KEY No (secret) Optional AI helpers. Leave unset to keep AI fully off. Core scheduling never uses it. (unset)
GEMINI_MODEL_ORDER No Fallback order for the optional AI. Only matters if the key above is set. gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite

Changing when the schedule flips to the next week

The Schedule home page shows who's on call "now". It advances to the next week at ONCALL_CHANGEOVER_TIME on ONCALL_CHANGEOVER_WEEKDAY, read in ONCALL_CHANGEOVER_TZ — by default Wednesday 00:01 America/Los_Angeles. Because it's evaluated in a real timezone, it no longer flips early in the prior evening (which happened when "today" was read in UTC). Leave these unset to keep the defaults; set them (and redeploy) to change the cutover.

These also appear in wrangler.toml

The non-secret ones (APP_ENV, DEFAULT_SCHEDULE_WEEK_START, ADMIN_EMAILS) are listed under [vars] in wrangler.toml, which is the default. Setting them in the dashboard too is the reliable way to be sure production has the right values — the dashboard wins. Set ADMIN_EMAILS in the dashboard to be safe.

Set them in the dashboard

  1. Open your Pages project → Settings → Variables and secrets (older dashboards: Environment variables).
  2. Under Production, click Add variable for each plain setting:
    • ADMIN_EMAILS = your real admin email(s), comma-separated, no spaces needed: you@totlcom.com,boss@totlcom.com
    • APP_ENV = production
    • DEFAULT_SCHEDULE_WEEK_START = wednesday
  3. Click Save.

Set ADMIN_EMAILS before relying on the app

If ADMIN_EMAILS is empty in production, no one is an admin, the Admin tab is unreachable, and you can't promote anyone. Always set your own address here first.

The ConnectWise encryption key

The ConnectWise credentials themselves are not set here — they're entered on the app's ConnectWise tab so they can be rotated without a deploy. But something has to protect them where they're stored, and that's CWM_ENC_KEY.

Generate one:

node -e "console.log(Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64'))"

Set it as a secret for both Production and Preview:

npx wrangler pages secret put CWM_ENC_KEY

Write it down somewhere you'll find it

Cloudflare will never show you this value again — secrets are write-only once set. You'll need it again if you add the scheduled-sync Worker, so put it in IT Glue or a password manager now rather than rediscovering the problem later.

Rotating it

Changing CWM_ENC_KEY makes the saved ConnectWise credentials unreadable until you re-enter them. That's recoverable in about five minutes, and nothing else is affected — the schedule, member mapping, effective date, ledger and pending queue are all plain data.

  1. Generate a new key and save it somewhere durable.
  2. Set it on Pages for both Production and Preview.
  3. Set the same value on the scheduled-sync Worker, if you've deployed it: npm run cron:secret.
  4. Redeploy Pages.
  5. On the ConnectWise tab, re-enter all five ConnectWise values and Test connection.

Between steps 4 and 5 the credentials are unreadable and syncing will fail, so don't do it while someone is mid-push. The app will say so plainly — the ConnectWise tab shows a red banner explaining exactly this, rather than a green "configured" tick over a sync that can't run.

You need the ConnectWise private key to finish

Step 5 needs all five values, and ConnectWise shows a private key only once. If you don't have it saved, generate a new key pair first (System → Members → API Members → your API member → API Keys) — the member ID and everything else stays the same.

Use a different key locally

Your local .dev.vars should have its own CWM_ENC_KEY. A development secret shouldn't be guarding production keys.

Automatic ConnectWise sync (optional)

Cloudflare Pages cannot run anything on a schedule — only Workers have cron triggers. So the scheduled sync is a small separate Worker (workers/cwm-cron) bound to the same D1 database as the app. It runs entirely on Cloudflare; nothing has to stay switched on at the office.

Only set this up after the manual push has been running happily for a full rotation cycle.

# 1. Deploy the Worker (creates it the first time)
npm run cron:deploy

# 2. Give it the SAME encryption key as the Pages project — it reads the same
#    stored ConnectWise credentials, so a different key cannot decrypt them.
npm run cron:secret        # prompts for CWM_ENC_KEY

Then on the app's ConnectWise tab, tick Allow unattended sync and set the refusal limit.

That's it. It runs every four hours, and only touches weeks already marked pending by a schedule change.

The key must match

CWM_ENC_KEY on the Worker has to be byte-identical to the one on the Pages project. If it isn't, every run logs "Stored ConnectWise credentials cannot be decrypted" at error level — it checks this on every tick, not only when there is work to do, so a mismatch shows up immediately rather than weeks later when somebody is waiting on a sync.

The Worker has no public URL

workers_dev = false in its config. A workers.dev subdomain is publicly reachable by default, and this Worker writes to real calendars — so cron is the only way in. To run it on demand, use the Cloudflare dashboard (this Worker → Settings → Triggers → Trigger Cron Event) or npm run cron:dev locally.

Checking on it

npm run cron:tail          # live logs

Each run logs one line: what it did, or why it did nothing.

To fire one immediately rather than waiting up to four hours:

npm run cron:run

That runs the scheduled handler against the real database. Then, in a second terminal:

curl.exe http://localhost:8787/__scheduled

Use curl.exe, not curl, in PowerShell

PowerShell aliases curl to Invoke-WebRequest, which parses the response as HTML and prompts about script execution. curl.exe calls the real thing.

cron:run does not use the Worker's uploaded secret

wrangler dev reads secrets from a .dev.vars file beside the config, not from what wrangler secret put uploaded. So npm run cron:run needs workers/cwm-cron/.dev.vars containing CWM_ENC_KEY=..., and it tells you about that key rather than the deployed one.

To check the deployed Worker, watch a real tick in npm run cron:tail instead — or temporarily set crons = ["*/1 * * * *"], redeploy, watch, and set it back. A run that logs CWM_ENC_KEY is not set means the file is missing; is not the key the stored credentials were sealed with means it is genuinely the wrong value.

If you'd rather test the deployed Worker exactly as it runs on schedule, temporarily set crons = ["*/1 * * * *"] in workers/cwm-cron/wrangler.toml, run npm run cron:deploy, watch one tick in cron:tail, then set it back and redeploy. A refused batch logs as a warning with the counts; a routine "nothing pending" stays at info level so the log is worth reading.

To change the schedule, edit crons in workers/cwm-cron/wrangler.toml and redeploy. To turn it off, untick Allow unattended sync in the app — no redeploy needed, and the Worker keeps running harmlessly and doing nothing.

Secrets (only if you use optional AI)

Secrets are hidden values. The app's only optional secret is GEMINI_API_KEY, and you can skip it entirely — AI features are off by default and core scheduling never sends names or phone numbers anywhere.

If you do want the optional AI helpers, set the key as a secret (not a plain variable) either in the dashboard (mark it as a secret) or from the terminal:

npx wrangler pages secret put GEMINI_API_KEY

It prompts you to paste the value, which is then encrypted.

Make changes take effect

Environment variable changes apply on the next deploy. Trigger one:

  • the easy way — Pages project → Deployments → ⋯ on the latest → Retry deployment, or
  • push any commit to GitHub.

Local development settings (not production)

For running the app on your own machine, settings live in local files that are never committed: .env (copied from .env.example) and .dev.vars. In particular, .dev.vars contains DEV_ADMIN=1, which makes your local machine act as an admin during development (production never sets this). See Deploying updates → Running locally.

Run database migrations