09-Jul-2026 — Secrets, Deployment, Integration Worker, Fly Tokens, and Cloudflare Configuration #114 #125 #304
This article explains how secrets flow through NGX Ramblers — from storage, through the deployment pipeline, to runtime use by the backend and frontend. It covers the three-tier secret storage model, the Fly.io deployment process, the shared integration worker (browser automation, HTML fetch, Flickr scraping, document conversion, migration jobs and Serenity walks uploads, with report upload and lifecycle audit events), the per-environment Fly API token encrypted into the FLY_CONFIG bundle (powering machine restart and memory-diagnostics metrics), and how Cloudflare email routing configuration reaches managed environments.
Overview
NGX Ramblers uses a layered approach to secret management. Secrets live in three places, with a clearly-defined precedence: CI reads from MongoDB, local development reads from server/.env and non-vcs/secrets/ files, and GitHub Secrets carry only the handful of bootstrap values the deploy script itself needs.
A shared integration worker runs as a separate Fly.io app alongside the origin environments. Its secrets follow the same DB-first model but are synced to GitHub as a separate secret (INTEGRATION_WORKER_SECRETS_JSON) alongside the main CONFIGS_JSON.
flowchart LR
GH@{ icon: "logos:github-icon", label: "GitHub Secrets", pos: "b", h: 48 }
DB@{ icon: "ngx:mongodb", label: "MongoDB Atlas", pos: "b", h: 48 }
LOCAL@{ icon: "ngx:file", label: "Local Files", pos: "b", h: 48 }
GH --- DB --- LOCAL
| Tier | Location | Used By | Contents |
|---|---|---|---|
| GitHub Secrets | GitHub Actions | CI/CD pipelines | CONFIGS_JSON, INTEGRATION_WORKER_SECRETS_JSON, FLY_API_TOKEN, ADMIN_MONGODB_URI, Docker credentials |
| MongoDB Atlas | config collection |
Runtime + deployment | Per-environment secrets, integration worker config, per-environment Fly token, Cloudflare config, AWS credentials, MongoDB URIs |
| Local Files | server/.env, non-vcs/secrets/ |
Local development only | Developer overrides and per-app .env files, all git-ignored |
Deployment Flow
When code is pushed to main, or a manual deployment is triggered, the following sequence occurs:
flowchart LR
GHA@{ icon: "logos:github-actions", label: "GitHub Actions", pos: "b", h: 48 }
TS@{ icon: "logos:typescript-icon", label: "Deploy Script", pos: "b", h: 48 }
QUERY@{ icon: "ngx:mongodb", label: "Query MongoDB", pos: "b", h: 48 }
DEPLOY@{ icon: "logos:fly-icon", label: "Deploy to Fly.io", pos: "b", h: 48 }
GHA -->|"Run deploy"| TS
TS -->|"ADMIN_MONGODB_URI set"| QUERY
QUERY --> MERGE["Encrypt & merge secrets"]
MERGE --> IMPORT["flyctl secrets import"]
IMPORT --> DEPLOY
How the deploy runner reads from MongoDB
The GitHub Actions runner opens a short-lived connection to the staging MongoDB cluster during each deploy. The connection string lives in the GitHub secret ADMIN_MONGODB_URI; the deploy script aliases it to MONGODB_URI and uses it to read the ENVIRONMENTS config document from the config collection. Per-environment secrets never pass through CONFIGS_JSON — they travel directly from MongoDB into flyctl secrets import on the target Fly app.
Secret Building Priority
During deployment, buildSecretsFromDatabaseConfig() merges secrets in a specific order. Later values override earlier ones:
- Global secrets from
globalConfig.secrets - Global AWS config from
globalConfig.aws - Per-environment AWS config (overrides global if present)
- MongoDB URI built from
envConfig.mongocredentials NGX_LITEflag derived fromenvConfig.ngxLite- Per-environment secrets from
envConfig.secrets - Integration worker callback base URL — defaulted to
https://<appName>.fly.devwhen not already set - Fly config —
{apiToken, appName}encrypted with AES-256-GCM and stored asFLY_CONFIG(fromenvConfig.flyio.apiKey) - Cloudflare config — encrypted with AES-256-GCM and stored as
CLOUDFLARE_CONFIG
Database vs File Import
The deploy script has exactly two modes in importSecrets():
- CI mode (the only mode exercised on a GitHub runner):
ADMIN_MONGODB_URIis always set, soimportSecretsFromDatabase()runs. The runner has no access tonon-vcs/secrets/— those files are git-ignored and simply don't exist on the runner, so the file path can never execute. - Local-developer mode: when a developer runs
deploy-to-environments.tsfrom a laptop withoutADMIN_MONGODB_URIset,importSecretsFromFile()readsnon-vcs/secrets/secrets.<appName>.envfrom disk. This is a convenience for local testing of the deploy script itself — it is not a production fallback.
flowchart LR
START@{ icon: "ngx:ramblers", label: "deploy script", pos: "b", h: 48 }
QUERY@{ icon: "ngx:mongodb", label: "Query MongoDB", pos: "b", h: 48 }
FILE@{ icon: "ngx:file", label: "Read non-vcs/secrets/*.env", pos: "b", h: 48 }
IMPORT@{ icon: "logos:fly-icon", label: "flyctl secrets import", pos: "b", h: 48 }
START --> CI{"ADMIN_MONGODB_URI set?"}
CI -->|"Yes (CI)"| QUERY
QUERY --> IMPORT
CI -->|"No (local dev)"| FILE
FILE --> IMPORT
If importSecretsFromDatabase() returns empty for an environment, the CI run simply continues without importing — Fly keeps whatever secrets are already on the app. There is no "file fallback" on the runner because there are no files.
Integration Worker Deployment
The integration worker is a general machine-to-machine worker. It runs Serenity browser automation for Ramblers walks uploads, but also handles synchronous browser operations (HTML fetch, Flickr album scraping, migration scrapes), image resizing, and document conversion. It is deployed as a separate Fly.io app (ngx-ramblers-integration-worker) that shares the same Docker image as the origin but runs npm run worker-server (which starts integration-worker-server) instead of npm run server.
The worker exposes several route groups, all under /api/integration-worker:
| Route group | Purpose |
|---|---|
/jobs, /progress, /result |
Ramblers walks upload jobs (queued Serenity runs with signed progress and result callbacks) |
/browser/html-fetch, /browser/flickr-user-albums |
Synchronous headless-browser HTML fetch and Flickr album scraping |
/migration/* |
Legacy-site migration scrapes |
/resize/* |
Image resize jobs |
/document-conversion/convert |
Document conversion |
The walks-upload flow below is the most involved of these and is documented in detail; the other route groups follow the same signed request/response pattern.
flowchart LR
PUSH@{ icon: "logos:github-icon", label: "Push to main", pos: "b", h: 48 }
BUILD@{ icon: "logos:docker-icon", label: "Build image", pos: "b", h: 48 }
ORIGIN@{ icon: "logos:fly-icon", label: "Deploy origin", pos: "b", h: 48 }
FILTER["Worker change detection"]
WORKER@{ icon: "logos:fly-icon", label: "Deploy worker", pos: "b", h: 48 }
PUSH --> BUILD
BUILD --> ORIGIN
BUILD --> FILTER
FILTER -->|"Runtime changes in worker graph"| WORKER
FILTER -->|"No change"| SKIP["Skip — keep warm"]
How Worker Deployment Works
- The main build workflow (
build-push-and-deploy-ngx-ramblers-docker-image.yml) runs a Detect worker-relevant changes step (server/deploy/detect-worker-changes.ts) that decides whether the worker needs redeploying - When worker-relevant runtime changes are found, the workflow builds and pushes a
ngx-ramblers:integration-worker-<run_number>image and a conditional Deploy Ramblers upload worker step runsserver/deploy/deploy-integration-worker.tswith that image tag - When nothing worker-relevant changed, the build and deploy steps are skipped entirely — the worker stays warm with no restart
- After a successful worker deploy, the workflow advances a
worker-deployedgit ref (force-pushed toHEAD) so the next run can diff against the last thing actually deployed to the worker, not merely the previous commit - A standalone
workflow_dispatch-only workflow (build-and-deploy-integration-worker.yml) exists as a manual escape hatch for redeploying a chosen image tag
Worker Change Detection
Rather than a static path glob, detect-worker-changes.ts walks the worker's real dependency graph. It runs madge from the worker entry point (server/lib/ramblers/integration-worker-server.ts) to resolve every .ts file the worker actually imports, then unions that set with a fixed list of infrastructure files. A change is only treated as worker-relevant if it falls inside that set and changes the emitted JavaScript — type-only edits (identical transpiled output) are ignored.
The dependency graph plus the following explicit infrastructure files make up the detection scope:
| Category | Paths |
|---|---|
| Worker dependency graph | Every .ts file reachable from server/lib/ramblers/integration-worker-server.ts (resolved at build time via madge) |
| Container + Fly config | Dockerfile, fly.integration-worker.toml |
| Server packages | server/package.json, server/package-lock.json, server/playwright.config.ts |
| Deploy + detection scripts | server/deploy/deploy-integration-worker.ts, server/deploy/detect-worker-changes.ts |
| Workflows | .github/workflows/build-push-and-deploy-ngx-ramblers-docker-image.yml, .github/workflows/build-and-deploy-integration-worker.yml |
Changes are compared against the worker-deployed ref when it exists, otherwise against HEAD~1.
Routing Uploads to the Worker
Environments route uploads to the worker by setting INTEGRATION_WORKER_URL in their per-environment secrets. When set, the dispatcher sends signed job requests to the worker instead of spawning Serenity locally. The worker exposes three walks-upload endpoints under /api/integration-worker:
| Endpoint | Direction | Purpose |
|---|---|---|
POST /jobs |
Origin → Worker | Submit a signed, AES-encrypted job. Worker runs it immediately or queues behind the active job, returning {queued, queuePosition, activeJobId} |
POST /progress |
Worker → Origin | Stream per-line stdout events, parsed test steps, and lifecycle markers back to the origin |
POST /result |
Worker → Origin | Announce completion, exit status, and the S3 report key prefix |
flowchart LR
subgraph origin["Origin (e.g. EKWG)"]
DISPATCH@{ icon: "ngx:express", label: "Dispatcher", pos: "b", h: 48 }
end
subgraph worker["Worker"]
RECEIVE@{ icon: "ngx:express", label: "Job receiver", pos: "b", h: 48 }
SERENITY["Serenity spawn"]
end
DISPATCH -->|"POST /jobs — HMAC signed, AES-encrypted credentials"| RECEIVE
RECEIVE --> SERENITY
SERENITY -->|"POST /progress & /result — signed callbacks"| DISPATCH
style origin fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
style worker fill:#EEE8F5,stroke:#AB9BC8,stroke-width:2px,rx:12,ry:12,color:#404143
Credentials are AES-encrypted in the job request and decrypted on the worker side. The worker never connects to MongoDB — Login.performAs short-circuits its Mongo lookup when RAMBLERS_USERNAME and RAMBLERS_PASSWORD are set in the environment (they always are on the worker, because the runner writes them from the decrypted payload before spawning Serenity).
Serenity Report Upload to S3
Every upload run — success or failure — uploads the complete Serenity HTML report to the origin's own S3 bucket so that operators can open the detailed test output from the Walk Export UI.
flowchart LR
subgraph worker["Worker"]
RUN["Serenity run"] --> GEN["Report generated at target/site/serenity"]
GEN --> UP@{ icon: "ngx:aws", label: "uploadSerenityReportToS3", pos: "b", h: 48 }
end
subgraph origin["Origin"]
RESULT@{ icon: "ngx:express", label: "POST /result", pos: "b", h: 48 }
AUDIT@{ icon: "ngx:mongodb", label: "recordReportLocation", pos: "b", h: 48 }
WS["WebSocket broadcast"]
UI@{ icon: "ngx:ramblers", label: "View Report button", pos: "b", h: 48 }
end
S3@{ icon: "ngx:aws", label: "S3 ramblers-upload-reports/<csvFileName>/", pos: "b", h: 48 }
UP --> S3
UP -->|"reportBucket + reportKeyPrefix"| RESULT
RESULT --> AUDIT
AUDIT --> WS --> UI
UI -->|"/api/aws/s3/.../index.html"| S3
style origin fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
style worker fill:#EEE8F5,stroke:#AB9BC8,stroke-width:2px,rx:12,ry:12,color:#404143
End-to-end flow:
- Origin's dispatcher computes
reportKeyPrefix = ramblers-upload-reports/<csvFileName without .csv>and sends it, together with AES-encrypted S3 credentials (reusing the environment's ownAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY) in the/jobsrequest. - After the Serenity subprocess exits,
finishJob()callsuploadSerenityReportToS3()which walkstarget/site/serenityand uploads every file viauploadDirectoryToS3, using the centralisedcontentTypeForExtension/fileContentTypeMappingshelpers inprojects/ngx-ramblers/src/app/models/content-metadata.model.ts(shared with the origin's AWS utilities). - The worker then posts
/resultwithreportBucketandreportKeyPrefixpopulated. - The origin's
recordReportLocationpersists the prefix onto the audit session and broadcasts an audit row over the WebSocket so the Walk Export UI renders a View Report button next to Back To Walks Admin. - The UI fetches the report through the existing
/api/aws/s3/.../index.htmlproxy — no public bucket access, no extra credentials.
Lifecycle Audit Events
Beyond the per-step SUCCESS / TEST_STEP rows parsed from stdout, the worker emits additional lifecycle markers via the IntegrationWorkerEventType.LIFECYCLE type. The origin's recordLifecycleEvent writes each one as a SUMMARY audit row and pushes it over the WebSocket:
| Marker | When |
|---|---|
Scenario execution complete |
First time the Serenity report writer prints [report] to stderr |
Serenity report generation starting |
Immediately after, before the report writer runs |
Serenity report generation complete, uploading to S3 |
After Serenity exits, before uploadSerenityReportToS3 is invoked |
Report upload to S3 complete |
After the report directory has been uploaded to S3, before the final result callback |
These make the "silent" stretch between the last scenario step and the final result callback visible in the audit log.
Local Developer Ergonomics
ngx-cli local dev honours four worker-related overrides in server/.env:
INTEGRATION_WORKER_URLINTEGRATION_WORKER_CALLBACK_BASE_URLINTEGRATION_WORKER_SHARED_SECRETINTEGRATION_WORKER_ENCRYPTION_KEY
The local.ts command reads these values via readServerEnvValue and applies them on top of the secrets resolved from the database (see SERVER_ENV_OVERRIDE_KEYS). A developer can therefore flip between a locally-running worker container and the Fly-hosted worker purely by editing .env, without touching the database or redeploying anything.
Worker Secret Management
Worker configuration lives in the uploadWorker field of the environments config document — alongside environments[], not inside it. This ensures "deploy all environments" never touches the worker.
flowchart LR
UI@{ icon: "ngx:ramblers", label: "Admin UI", pos: "b", h: 48 }
DB@{ icon: "ngx:mongodb", label: "MongoDB config", pos: "b", h: 48 }
PUSH["Push to GitHub"]
GH1@{ icon: "logos:github-icon", label: "CONFIGS_JSON", pos: "b", h: 48 }
GH2@{ icon: "logos:github-icon", label: "INTEGRATION_WORKER_SECRETS_JSON", pos: "b", h: 48 }
UI -->|"Save"| DB
DB --> PUSH
PUSH --> GH1
PUSH --> GH2
| Config location | What it holds | Managed via |
|---|---|---|
value.uploadWorker in environments config |
App name, HMAC secret, AES key, memory, scale, org-scoped Fly apiKey |
Global Settings tab in admin UI |
value.environments[].secrets per routing env |
INTEGRATION_WORKER_URL, _SHARED_SECRET, _ENCRYPTION_KEY |
Per-environment settings in admin UI |
GitHub secret INTEGRATION_WORKER_SECRETS_JSON |
JSON blob with HMAC + AES keys | "Push to GitHub" button (synced alongside CONFIGS_JSON) |
GitHub variable INTEGRATION_WORKER_APP_NAME |
Fly app name | "Push to GitHub" button |
Key Rotation
To rotate the worker's HMAC or AES key:
- Edit the key in the Global Settings tab (Upload Worker Configuration section)
- Update the matching key in every routing environment's secrets (currently EKWG only)
- Click Save Configuration
- Go to GitHub Secrets tab and click Push to GitHub — this updates both
CONFIGS_JSONandINTEGRATION_WORKER_SECRETS_JSONatomically - Redeploy the worker and the routing environment(s)
Cloudflare Configuration
Cloudflare configuration deserves special attention because it contains a sensitive API token that must never reach the frontend, yet the frontend needs certain non-sensitive fields to render the committee email routing UI.
Encryption at Deploy Time
During deployment, the Cloudflare config is encrypted using AES-256-GCM before being stored as a Fly.io secret:
flowchart LR
DBCF@{ icon: "ngx:mongodb", label: "MongoDB config", pos: "b", h: 48 }
ENVVAR@{ icon: "logos:fly-icon", label: "CLOUDFLARE_CONFIG", pos: "b", h: 48 }
DBCF --> ENCRYPT["AES-256-GCM encrypt"] --> ENVVAR
The encrypted payload structure is:
| Bytes | Content |
|---|---|
| 0-12 | Initialisation vector (IV) |
| 12-28 | Authentication tag |
| 28+ | Ciphertext |
Runtime: Backend to Frontend
At runtime, the backend decrypts CLOUDFLARE_CONFIG and uses the full config (including apiToken) for Cloudflare API calls. The frontend only receives non-sensitive fields:
flowchart LR
subgraph backend["Backend"]
DECRYPT["Decrypt"] --> FULL["Full config"]
FULL --> API@{ icon: "ngx:cloudflare", label: "Cloudflare API", pos: "b", h: 48 }
end
subgraph frontend["Frontend"]
SAFE["Safe config"] --> UI@{ icon: "ngx:ramblers", label: "Committee UI", pos: "b", h: 48 }
end
FULL -->|"Non-sensitive only"| SAFE
UI -->|"Via backend API"| FULL
Non-Sensitive Config Fields
The /api/cloudflare/email-routing/config endpoint returns only:
| Field | Type | Purpose |
|---|---|---|
configured |
boolean |
Whether Cloudflare is set up for this environment |
accountId |
string |
Used to build Cloudflare dashboard links |
zoneId |
string |
Used to build Cloudflare dashboard links |
baseDomain |
string |
The domain for email routing (e.g. nwkramblers.org.uk) |
ownsZone |
boolean |
Whether this site owns its Cloudflare zone or is a subdomain of a shared zone (drives the apex-vs-subdomain inbox setup) |
zoneName |
string |
The resolved Cloudflare zone name for the domain |
The apiToken is never sent to the frontend. All Cloudflare operations go through authenticated backend endpoints.
Fly API Token & the FLY_CONFIG Bundle
The Fly API token follows the identical encrypted-bundle pattern as the Cloudflare config. Each environment carries one org-scoped Fly API token in flyio.apiKey (alongside flyio.appName) in the ENVIRONMENTS config document in MongoDB. That single token does everything Fly-related for the environment: it authenticates the deploy (flyctl deploy and flyctl secrets import), it restarts a stuck machine from the NGX admin UI, and it reads Fly's Prometheus metrics for the Memory Diagnostics charts.
Encryption at Deploy Time
During deployment, buildSecretsFromDatabaseConfig() encrypts {apiToken, appName} into a FLY_CONFIG Fly secret using AES-256-GCM keyed by ENVIRONMENT_SETUP_API_KEY — the same mechanism, same encryption key, and same payload layout used for CLOUDFLARE_CONFIG.
flowchart LR
DBFLY@{ icon: "ngx:mongodb", label: "flyio.apiKey + appName", pos: "b", h: 48 }
ENVVAR@{ icon: "logos:fly-icon", label: "FLY_CONFIG", pos: "b", h: 48 }
DBFLY --> ENCRYPT["AES-256-GCM encrypt"] --> ENVVAR
Runtime Token Resolution
At runtime, flyRuntimeConfig() resolves the token in a fixed precedence order and stops at the first source that yields both a token and an app name:
- Fly-injected environment variables — Fly sets
FLY_APP_NAMEandFLY_MACHINE_IDon every deployed machine; combined with aFLY_API_TOKENenv var this is the fast path - Decrypted
FLY_CONFIGbundle — decrypted withENVIRONMENT_SETUP_API_KEY, this is how deployed machines normally obtain the token - Environments config in the database — matched on the Mongo database name, used as a last-resort fallback
flowchart LR
START@{ icon: "logos:fly-icon", label: "flyRuntimeConfig()", pos: "b", h: 48 }
ENV@{ icon: "logos:fly-icon", label: "FLY_* env vars", pos: "b", h: 48 }
BUNDLE@{ icon: "logos:fly-icon", label: "Decrypt FLY_CONFIG", pos: "b", h: 48 }
DB@{ icon: "ngx:mongodb", label: "Environments config", pos: "b", h: 48 }
TOKEN["Resolved token + appName"]
START --> ENV
ENV -->|"token + appName present"| TOKEN
ENV -->|"missing"| BUNDLE
BUNDLE -->|"decrypted"| TOKEN
BUNDLE -->|"absent"| DB
DB --> TOKEN
What the Token Powers
| Use | Where | Notes |
|---|---|---|
Deploy + flyctl secrets import |
CI deploy runner | The per-environment apiKey is loaded into FLY_API_TOKEN for that environment's flyctl commands |
| Restart a stuck machine | Admin UI (#304) | restartCurrentMachine() calls the Fly Machines API with the resolved token |
| Read memory / metric history | Memory Diagnostics charts (#304) | fly-metrics queries Fly's Prometheus API with the resolved token |
An org-scoped token is required for the metrics path: an app-scoped token can manage and restart the app but returns 403 from Fly's Prometheus API.
Originally five environments carried a separate app-scoped deploy token plus a distinct org-scoped metricsToken. On 9 Jul 2026 this was consolidated to a single org-scoped apiKey per environment and the metricsToken field was removed, so one token now covers deploy, restart and metrics.
Required Environment Variables
Origin Environments
| Variable | Required | Purpose |
|---|---|---|
AUTH_SECRET |
Yes | JWT authentication secret |
AWS_ACCESS_KEY_ID |
Yes | S3 file storage (also reused to upload Serenity reports) |
AWS_SECRET_ACCESS_KEY |
Yes | S3 file storage |
AWS_REGION |
Yes | S3 region (e.g. eu-west-2) |
AWS_BUCKET |
Yes | S3 bucket name |
MONGODB_URI |
Yes | Database connection string |
CLOUDFLARE_CONFIG |
No | Encrypted Cloudflare config (enables email routing) |
FLY_CONFIG |
No | Encrypted per-environment Fly token + app name (enables machine restart + metrics) |
ENVIRONMENT_SETUP_API_KEY |
No | Encryption key for both CLOUDFLARE_CONFIG and FLY_CONFIG |
INTEGRATION_WORKER_URL |
No | Worker URL (enables remote upload routing) |
INTEGRATION_WORKER_SHARED_SECRET |
If worker URL set | HMAC signing key for worker requests |
INTEGRATION_WORKER_ENCRYPTION_KEY |
If worker URL set | AES key for credential encryption |
Worker App
| Variable | Required | Purpose |
|---|---|---|
INTEGRATION_WORKER_SHARED_SECRET |
Yes | HMAC signature validation |
INTEGRATION_WORKER_ENCRYPTION_KEY |
Yes | Credential + report-credential decryption |
CHROME_BIN |
Built-in | Chrome path (baked into Docker image via ENV) |
CHROMEDRIVER_PATH |
Built-in | Chromedriver path (baked into Docker image via ENV) |
The worker deliberately has no MONGODB_URI. RAMBLERS_USERNAME and RAMBLERS_PASSWORD are set by the runner from the decrypted job payload immediately before Serenity spawns, so Login.performAs takes its env-var fast path and never attempts a Mongo lookup.
Deploy-Only Variables
| Variable | Required | Purpose |
|---|---|---|
FLY_API_TOKEN |
Deploy only | GitHub secret — the deploy runner's ambient Fly authentication. Distinct from the per-environment flyio.apiKey, which the deploy loads into FLY_API_TOKEN per environment and which is also baked into that environment's FLY_CONFIG secret |
ADMIN_MONGODB_URI |
Deploy only | Staging database for secrets import |
INTEGRATION_WORKER_SECRETS_JSON |
Deploy only | GitHub secret — worker HMAC + AES keys as JSON |
INTEGRATION_WORKER_APP_NAME |
Deploy only | GitHub variable — worker Fly app name |
Issue History
The secrets and deployment infrastructure was built across several issues:
flowchart BT
I146@{ icon: "logos:github-icon", label: "#146 Env Setup", pos: "b", h: 48 }
I156@{ icon: "ngx:cloudflare", label: "#156 DNS/SSL", pos: "b", h: 48 }
I125@{ icon: "ngx:cloudflare", label: "#125 Email Routing", pos: "b", h: 48 }
I82["#82 Brevo Auth"]
I130["#130 Replies Rejected"]
I114@{ icon: "logos:fly-icon", label: "#114 Integration Worker", pos: "b", h: 48 }
I304@{ icon: "logos:fly-icon", label: "#304 Machine Restart & Metrics", pos: "b", h: 48 }
TODAY@{ icon: "logos:fly-icon", label: "Today: Worker, Fly Tokens & Secrets", pos: "b", h: 48 }
I146 -->|"secrets"| I125
I146 -->|"config"| I156
I156 -->|"Cloudflare"| I125
I156 -->|"DNS"| I82
I130 -->|"motivated"| I125
I125 --> TODAY
I82 --> TODAY
I114 -->|"worker + reports + lifecycle"| TODAY
I304 -->|"Fly token + restart + metrics"| TODAY
| Issue | Title | Key Contribution |
|---|---|---|
| #146 | Automated environment creation | Database-first secrets loading, GitHub secrets sync, global AWS |
| #156 | DNS/SSL automation | Cloudflare DNS and certificate management in environment setup |
| #130 | Email replies rejected | The original problem that drove Cloudflare email routing work |
| #125 | Cloudflare email routing | Committee email forwarding, worker scripts, email logs UI |
| #82 | Brevo domain authentication | DKIM/SPF DNS records via Cloudflare for email deliverability |
| #114 | Distributed integration worker | Shared worker (Serenity uploads, browser fetch, Flickr scraping, migration, resize, document conversion), queued job execution, dependency-graph deploy detection, S3 Serenity report upload, lifecycle audit events, env-var login short-circuit, .env overrides for local dev |
| #304 | Machine restart & memory diagnostics | Per-environment org-scoped Fly token encrypted into FLY_CONFIG, admin restart of a stuck Fly machine, Fly Prometheus metric-history charts, consolidation of deploy + metrics tokens into one apiKey |
Why No Code Changes Were Needed (Original Cloudflare Work)
The application code already handled Cloudflare gracefully — showing a warning when config was missing and enabling features when config was present. The missing piece was the deployment pipeline: managed environments had no CLOUDFLARE_CONFIG secret because the deploy script did not import secrets from the database. Adding importSecretsFromDatabase() to the deploy flow was the final link in the chain.
What Changed for the Integration Worker
The integration worker required changes across several layers:
- Model:
UploadWorkerConfigadded toEnvironmentsConfigas a sibling toenvironments[], keeping it out of the deploy-all matrix;IntegrationWorkerEventType.LIFECYCLEadded for non-stdout audit markers - Worker runner: Serenity subprocess uploads
target/site/serenitytos3://<envBucket>/ramblers-upload-reports/<csvFileName>/viauploadSerenityReportToS3, and emits lifecycle progress events around report generation and upload - Origin audit notifier: new
recordLifecycleEventandrecordReportLocationhelpers persist the extra rows and broadcast the report location over the WebSocket so the UI can surface a "View Report" link - Admin UI: New section in the Global Settings tab for worker app name, org-scoped Fly
apiKey, HMAC secret, AES key, memory, and scale; Walk Export UI renders the report link against the selected session - GitHub sync: the "Push to GitHub" action writes
INTEGRATION_WORKER_SECRETS_JSONalongsideCONFIGS_JSONwhen worker config exists - CI workflow: a dependency-graph deploy step (
detect-worker-changes.ts) piggybacked on the main build, reusing the same Docker image with a different Fly config (fly.integration-worker.toml) viadeploy-integration-worker.ts - Content-type mapping:
contentTypeForExtension+fileContentTypeMappingscentralised inprojects/ngx-ramblers/src/app/models/content-metadata.model.tsand shared between the S3 report uploader andaws-utils - Local dev:
ngx-cli local devreads four worker env vars fromserver/.envso a developer can flip between a local worker and the Fly worker without redeploying