Cloud Console Own Projects

The console is one Cloudflare Worker with a D1 database and two kinds of SQLite-backed Durable Object: one object per queue and one per job. There is no Cloudflare Queue. The engine is the console's own.

This page covers the parts, the layers of the code, what each store keeps, the migrations, how the console is deployed and backed up, and where the code lives. What each part costs on the free plan is on Limits and free plan.

Worker

1

Durable Object classes

2

Cron trigger

Every minute

D1 migrations on main

21

The parts

Console pages (their API calls)  ─┐
Apps (/v1, a project API key)     ├─▶  Worker "cloud-console"  ──▶  D1
Cron trigger (every minute)      ─┘          │
                                             ├─▶ QueueObject "<project>/<queue>"   (its tasks; alarm at the next due one)
                                             │        └─ dispatches ─▶ target URL ─▶ 2xx done, or retry
                                             ├─▶ JobObject, one per job or check   (its schedule; alarm at the next run)
                                             │        └─ runs ─▶ target URL, or adds a task to a QueueObject
                                             ├─▶ mail over SMTP   (invitations, reset links, notices, alerts)
                                             └─▶ B2 bucket        (the daily sealed backup)
PartWhat it isWhat it does
The Worker
cloud-console, entry src/worker.ts
Its fetch handler answers the console's API, sign-in, the apps' /v1, /health and the OIDC keys at /.well-known. Its scheduled handler runs the every-minute cron. It exports the two Durable Object classes.
Static assets
The console pages in dist/web
TypeScript and plain HTML and CSS, bundled by esbuild, with no framework and no runtime dependency. A request for a file is answered without running the Worker. web/_headers gives the pages the same security headers and CSP as the API. No source maps and no single-page fallback.
D1
One SQLite database, bound as DB
What the pages list and edit: people, projects, keys, secrets (sealed), queue and job definitions, run logs and the audit log. Also the day's usage, the quotas and the console's own upkeep tables.
QueueObject
A SQLite-backed Durable Object per queue, named <project>/<queue>, bound as QUEUES
Holds the queue's tasks and applies its settings itself: rate, concurrency, attempts, backoff, pause and resume. It calls each target with the task's auth option, sets an alarm for the next due task, and writes the attempts its logging keeps to D1.
JobObject
A SQLite-backed Durable Object per job or uptime check, bound as JOBS
Keeps the job's cron, time zone and next run, and sets an alarm for it. At the alarm it calls the HTTP target, or adds a task to one of the project's queues, with the job's retry settings.
Cron trigger
One trigger, * * * * *
A safety net that wakes queues and jobs whose alarm should have fired, plus the console's upkeep (below).
Mail
The console's own small SMTP client on Workers' connect(), in src/mail.ts
Sends invitations, reset links, notices, alerts and the daily summary. It uses implicit TLS (port 465) or STARTTLS, which is then required: mail is never sent in plain text. A failure is logged with the step and the server's reply code only.
Backup to B2
src/backup/, on the cron's minutes
Dumps D1 once a day, seals and signs the file, and uploads it to a Backblaze B2 bucket through its S3 API. The Worker signs its own S3 requests and holds only a key that can write files (below).
Workers Logs
Cloudflare's log store
Keeps what the Worker logs for a few days, searchable in the dashboard. Half the requests are sampled.

Who holds what. A route writes a queue's or job's definition to D1, then tells its object (configure). The object keeps its runtime state in its own SQLite and writes its run logs and last-run fields to D1. A project's alert settings are not pushed to the objects: each object reads them from D1 when it may alert, so saving them asks no object.

The audit line. Every change writes its audit line in the same D1 batch. A change a Durable Object holds (a task's create, run, retry or delete, a purge, a job's run) cannot share a batch with D1, so its line is written right after the object answers.

What the every-minute cron does

The cron runs its parts side by side in one invocation. Each runs on its own: one that fails is logged by name and does not stop the others. After them, the cron notes its heartbeat for the dashboard.

PartWhat it does
Signing keys
Rotates the OIDC signing keys every 90 days. The next key is published a day before it first signs; a retired key stays an hour, then is deleted. A key that no longer opens is retired and replaced at once.
Wake queues
Wakes the queues whose next due task should have been sent a minute ago, found by their next due time in D1 (an index). It skips the queues a maintenance switch holds.
Prune queue data
Deletes run logs past 7 days, oldest first, a bounded batch a minute. Each hour, it also prunes old daily counts and alert throttles.
Watch usage
Compares the day's counts with the free limits. From 80% it holds low-priority queues and alerts the admins; at 100% it sets the console-wide hold.
Sweep jobs
Wakes jobs whose next run is overdue. A job whose object has no definition, such as a seeded one, is told it from D1; a seeded job's headers and body are sealed in place first.
Prune rate limits
Deletes rate-limit rows whose window has passed, at most 500 a run.
Backups
Starts the daily backup at 02:20 UTC and moves it on a few pages each minute.
Maintenance reminder
Alerts once when a maintenance switch has been on for 24 hours.
Waiting alerts
When a project's quiet hours end, sends the alerts that waited as one message. At most 8 projects a minute.
Daily summaries
Sends the opt-in daily summary at 08:00 in each person's zone, at most 5 a minute.
Expiry warnings
Warns about an API key or secret 7 days and 1 day before it ends.
Prune audit log
Deletes audit lines older than 400 days, up to 500 a minute.
Heartbeat
Notes the time and the names of the parts that failed, in one row. It writes only when the row is 5 minutes old or the failed parts changed. The dashboard calls the cron Stale after 10 minutes without a note.

The layers

All business logic is in the backend, in three layers. Each layer imports only from the ones before it. There are no repository interfaces and no dependency-injection layers.

  1. The domain: src/domain/Pure rules and the shared types: validation with a message per field, defaults, limits, cron and time zones, backoff, rate decisions, URL and header safety, allowed actions, labels, throttling, quota and uptime. No env, D1, Durable Object, network, clock or randomness: the time and any random draw are passed in, so every rule tests without a runtime.
    A validator answers the value with its defaults, or a message for each field.
  2. Services and the enginesrc/auth/, src/projects/, src/queues/, src/scheduler/ and src/engine/ load the data, call the domain, save the result and do the I/O. The engine holds the two Durable Objects and the shared services: making one call to a target, raising a throttled alert, counting against the quota.
    The objects answer records; the routes add the labels and allowed actions.
  3. The routes: src/routes.ts and the handlersEach reads the request, checks the project and the person's permission first, calls the service and answers. A validator's field messages become 400 invalid.
    The pages show what the API answers. They never validate, compute a default, a label or a permission.

What the lint rules enforce

D1: the main tables

Tables are STRICT, ids are INTEGER PRIMARY KEY, and times are Unix milliseconds in UTC. A secret is never stored plain: it is hashed one way, or sealed with AES-GCM under a Worker secret, and a test checks the column names say which. Every index costs a written row, so only the lists and the cron's every-minute queries have one.

TableWhat it holds
users
Each person: email, name, role (admin or member), Google's user id once linked, disabled, last sign-in, a grant on every project, daily summary settings. The break-glass admin's password is a peppered hash; its authenticator secret is sealed.
sessions
Signed-in browsers. Only an HMAC of each session token is kept.
backup_codes
The break-glass admin's one-time codes, hashed.
passkeys, passkey_challenges
A person's passkeys, at most 10: credential id, public key, sign counter and the person's name for it. The spent challenges, as HMACs, for 2 minutes.
invitations
Pending invitations: email, role, projects and permissions, who invited, and when it ends (7 days). The link's token is kept only as a hash.
projects
Each app's project (FlowDeck, CapExpert and others) and its daily cap setting.
project_members
A member's permissions in each project. No row: the project does not exist for them.
api_keys
Each project's keys: name, last used, revoked, end date. Only the key's HMAC is kept.
project_secrets
The credentials the auth options use, and Google Chat webhooks: name, kind, allowed origins, end date. The secret part is sealed.
oauth_tokens
OAuth access tokens fetched with a client secret, sealed, until shortly before they expire.
service_accounts
The identities OIDC tokens are issued for, with their allowed origins and a disabled flag.
signing_keys
The console's ES256 key pairs for OIDC tokens. The public half is published; the private half is sealed.
queues
Each queue's settings (rate, retries, logging, priority, codes not retried, dead-letter target), its hold, its next due time for the cron, and the counts the dashboard reads.
task_attempts
A queue's run logs: each attempt's time, duration, status or error kind, content type and the first 1 KB of the answer. Never the request's headers, secrets or body.
jobs
Each job and uptime check: schedule, time zone, target, auth by name, retry and overlap settings, quiet-hours choice, a check's expectations, last and next run, and 24-hour totals. Headers and body are sealed.
job_runs, job_attempts
One row per run, scheduled or forced, with its status and skip reason; and each run's attempts, at most 6, for the attempt inspector.
audit_log
Every change: when, who, the project, the action, its target and what changed (never a secret). Kept 400 days.
usage, project_usage
The day's count of each free-plan metric; and each project's task creations and job runs per UTC day, for its quota.
console_hold
The console-wide hold while a free limit is over: one row at most, with its end and the metric that was over.
alert_settings, alert_throttle
A project's alert channels and thresholds; and the last alert of each kind and subject, with how many were held back since.
rate_limits
Counts in time windows: sign-in tries, the per-person budgets, task-list scans, exports and imports.
maintenance
The maintenance switches that are on: one row per project, and project 0 for the whole console. Switching off deletes the row.
quiet_hours, quiet_alerts
A project's daily quiet window and its two switches; and the alerts waiting for a window to end, one row per kind and subject.
backups, backup_parts
One row per backup run (state, file name, size, rows, the backup key's id, progress); and the SQL a running backup has dumped so far, deleted once it is uploaded or has failed.
cron_heartbeat
One row: when the cron last ran, as noted, and the names of the parts that failed.

Two tables are gone: allowed_domains, when invitations replaced allow lists, and tasks, when a queue's tasks moved into its own object.

What the objects keep

A queue's object

Its own SQLite, one object per queue.

  • config: the queue's settings, as a route last told them.
  • tasks: waiting, running and finished tasks, until they are forgotten (24 hours after success or delete; 7 days when failed). Headers and body are sealed while a task waits. Two indexes, by state and due time and by state and done time, so a page reads about one row more than it shows, per state.
  • counts: one row per state, kept by triggers, so counting never scans the tasks.
  • minutes and hours: the metrics. A row per minute in which something happened (2 days), rolled into a row per ended hour (30 days).
  • state: small named values, such as the hour's counts of task lists.
  • One alarm, at the next due task, or for housekeeping. An alarm sends a batch of due tasks; the next comes at once for the rest, or when the rate allows.

It writes its run logs to D1, counts its retries against the quota, and notes its next due time and waiting tasks when they move (about once a minute at most). Its last 24 hours' counts go with them at most every 15 minutes, and only after tasks were added or attempts made.

A job's object

Its own SQLite-backed storage, as named values, one object per job or check.

  • Its definition, as a route last told it, headers and body sealed.
  • Its runtime: the next run, the runs under way, a waiting run and its failures in a row. A run and each attempt are marked as taken before any wait, so a second alarm or the cron's wake never starts one twice.
  • Hourly outcomes for 24 hours, for the dashboard.
  • For a check: its last 100 results and hourly uptime for 7 days.
  • One alarm, for the next thing to do.

It writes its runs and their attempts to D1. At each run's end the 24-hour totals go in the same statement that writes the job's last run, so they cost no extra write.

The migrations

MigrationWhat it adds
0001_init
The first schema: people, sessions, projects, keys, secrets, service accounts, signing keys, queues, tasks, run logs, jobs, the audit log and usage.
0002_sign_in
Rate-limit counts, and the last authenticator step accepted.
0003_password_reset
Forgot password for the break-glass admin.
0004_access
Invitations and permissions per project; the allow lists go.
0005_queue_engine
The queue engine: tasks move from D1 into each queue's object. Alert settings and throttles, daily counts per project, chat webhooks and allowed origins.
0006_scheduler
Jobs with kinds and checks; one row per run, and each run's attempts.
0007_free_plan_guard
The default daily cap, the console-wide hold, and attempts that keep no body.
0008_seed_flowdeck
FlowDeck's project, its queues, its service account and ten jobs.
0009_backups
Backup runs and their dumped parts.
0010_retry_rules
Status codes not retried, and the jobs' overlap policy.
0011_maintenance
The maintenance switches.
0012_maintenance_by_key
Whether a key or a person holds a switch.
0013_quiet_hours
Quiet hours, and the alerts waiting for them.
0014_dashboard
The counts the dashboard reads, on queues and jobs.
0015_daily_summary
A person's daily summary email.
0016_quiet_alert_retry
A waiting alert's due time and tries.
0017_key_secret_expiry
End dates on API keys and secrets.
0018_dead_letter
A queue's dead-letter target, and two triggers that keep it pointing at a queue that exists.
0019_passkeys
Passkeys and spent challenges.
0020_primary_sign_in
When a person last signed in with Google or a password: a passkey signs in only within 30 days of it.
0021_cron_heartbeat
The every-minute cron's heartbeat.

How it is deployed

WayWho runs itWhat it does
A merge into main
GitHub Actions, on the merge
Deploys the code and the D1 migrations, once the pull request's tests have passed. It pushes no Worker secret.
The deploy launcher
The owner, from the desktop
Takes a clean copy of origin/main and runs install, typecheck, lint, the tests, the scripts' checks and shellcheck, then the deploy script's code-only mode. It stops at the first step that fails and pushes no secret. It is for deploying now, or when Actions cannot run.
The deploy script
The owner, on their own machine
The first setup, and any change to a setting. It finds or makes the D1 database, notes a restore point, applies the migrations, and makes the seal key and the backup signing key when they are missing. Then it pushes the Worker secrets, deploys, and waits for /health.

A merge into main

  1. Tests on the pull requestTypecheck, lint, the tests, the scripts' checks and shellcheck run on every pull request into main, with no secret.
    Red: the change waits. The owner merges only when it is green.
  2. Build and test again, without keysAfter the merge, the deploy workflow checks that the pull request's test run passed, then builds and tests again without any key.
    The pull request's run did not pass, or this one fails: nothing deploys.
  3. The deploy job: the same pages, and a narrow tokenA second job, with no install scripts, builds the pages again and deploys them only when they equal, byte for byte, the ones the first job tested. It sends its GitHub OIDC token to a small keys Worker, which trusts only this repository's deploy workflow on main: it checks the repository's name, its owner's GitHub id, the ref and the workflow. It hands back a Cloudflare token for Workers Scripts and D1 only, and the account to deploy to. GitHub stores no secret.
    Pages that differ, or a token from any other repository, branch or workflow: nothing deploys.
  4. Migrate, deploy, checkIt notes a restore point, applies the D1 migrations, deploys the Worker, and checks /health.
    A failed migration stops it, and nothing is deployed.

Worker secrets come only from the owner's script. The settings live in a git-ignored file on the owner's machine. The deploy script parses it without running it and pushes only the names on its allow-list to the Worker, each value on standard input, never printed. The Cloudflare tokens, the database's name and the backup bucket's read key are never pushed to the Worker. CI never pushes a Worker secret, so a changed setting reaches the Worker only through the owner's script.

The broader account token never leaves the owner's machine. The keys Worker is set up from there, and only the narrow deploy token, with the account it is for, is stored for it.

One deploy at a time. The rule is that the launcher, the deploy script or a migration is not started while another of them, or GitHub's deploy, is running.

Rolling back. The code goes back to an earlier Worker version at once; then the change is reverted on main, or the next merge brings it back. The database goes back to a deploy's restore point with D1 Time Travel, within 7 days. The database is restored first, then the code.

Backups

  1. Daily, at 02:20 UTCCaught up at the next minute when missed, and tried again an hour after a failure. An admin can also start one with Back up now.
    Without the backup key's public half or the signing key, nothing is backed up: never a plain or unsigned file.
  2. DumpEvery table as SQL, one INSERT per row, a few pages each cron minute. The short-lived tables are left out: sessions, rate limits, cached OAuth tokens, alert throttles, the free-plan hold, the backups' own state and the cron's heartbeat.
    Still running after 3 hours: the run fails.
  3. SealAES-256-GCM with a fresh key per file. That key is wrapped with RSA-OAEP SHA-256 by the public half of an RSA-4096 backup key. The owner keeps the private half, with two copies offline. It is never in the repo, on Cloudflare or on GitHub.
    The Worker can seal a backup, but never open one.
  4. SignAn HMAC-SHA256 over the sealed bytes, the file's name in the bucket and its time. The bucket never holds the signing key.
    A restore trusts only a file this signature proves the Worker made.
  5. Upload with a write-only keyTo a dated path in the bucket. The Worker's key can only write files to that bucket; the owner's bucket check proves it cannot list or delete them. Every upload carries the body's SHA-256.
    A failed upload shows the bucket's status and error code, and is tried again an hour later.
  6. Kept and watchedObject Lock keeps each file 29 days, and the lifecycle rule removes it at 30. /health/backup answers 200 while the newest backup is under 26 hours old, else 503. A GitHub workflow asks it every morning.
    A failed or stale backup mails the admins, at most every 6 hours each.
  7. The monthly restore drillThe owner runs it by hand: it needs the bucket's read key, the signing key and the private half, and GitHub holds no secret. It fetches the newest backup, checks its signature before decrypting anything, and loads it into a scratch D1 database. Then it checks each table's rows against the backup's header, the migrations, PRAGMA quick_check, PRAGMA foreign_key_check and the dead-letter targets.
    It drops the scratch database however it ends, and passes only when every check passed. The live database is never written.

A real restore works the same way, into a new D1 database. It prints each table's rows and the steps to switch the console over. Sessions are not in backups, so everyone signs in again.

The code layout

WhereWhat is in it
src/worker.ts
The entry: fetch and scheduled; exports the Durable Object classes.
src/routes.ts
Every route, who may call it (open, signed in, admin), and what each object read is charged.
src/http/
The router (origin, session, CSRF and role checks), JSON answers with the security headers, input checks, cookies, sealing and HMACs.
src/auth/
Google sign-in, invitations, users, permissions, the break-glass sign-in, sessions, rate limits and budgets, API keys, the OIDC signing keys and passkeys.
src/projects/
Projects, secrets, service accounts, maintenance, quiet hours, the dashboard, the daily summary, export and import, and expiry.
src/domain/
The pure rules and the shared types.
src/engine/
QueueObject and JobObject, their contract, the call to a target, alerts, the quota and usage counts.
src/queues/
The queue, task, run-log, alert, quota, usage and defaults routes, and the queues' share of the cron.
src/scheduler/
The job and check routes, jobs in D1, and the jobs' sweep.
src/backup/
The daily backup: the resumable dump, sealing and signing, S3 request signing, the upload and its routes.
src/cron.ts, src/mail.ts, src/audit.ts
The cron's parts, the SMTP client, and the audit log.
src/db/migrations/
The D1 schema, one folder per migration.
web/src/
The pages' TypeScript: a file per page in web/src/pages/, the shell, the API client, the h() helper, charts and samples.
web/console.css, web/build.mjs
The console's one stylesheet, and the esbuild build into dist/web, which also writes the version.

Beside them: test/ (Vitest inside the Workers runtime, with a local D1 and Durable Objects, a fake Google and a fake mail server; the pages in happy-dom), scripts/ (the owner's deploy and backup scripts), deploy-keys/ (the keys Worker) and .github/workflows/.

Cloud Console, as it is on main. 22 Oct 2026.