Skip to content

03 — Deployment & Management

ServicePlatformRuntimeRegion
adventive-admin-apiCloudflare WorkersCloudflare edge (global)Smart placement — no pin needed
adventive-admin-uiCloudflare PagesStatic SPA + CDNGlobal
adventive-admin-api (legacy DB)Cloudflare HyperdriveMySQL connection poolerCollocated with DB region

Production URLs (to be provisioned):

  • API: https://admin-api.adventive.com (Workers custom domain)
  • UI: https://admin.adventive.com (Pages custom domain)
  • Legacy admin (during transition): https://admin-legacy.adventive.com (existing EC2)

Both new services sit behind Cloudflare Access. Access policy enforces operator authentication before a single byte of the API or UI is reachable.


EnvAPI URLUI URLTriggerNotes
Localhttp://localhost:8787http://localhost:5173wrangler dev / vite devUses .dev.vars for secrets; proxies to Hyperdrive staging binding
Previewadmin-api-<branch>.adventive-admin.workers.devCF Pages preview URL (per PR)PR opened or commit pushed to non-main branchAutomated — CF Pages creates preview per commit; Worker preview via wrangler deploy --env preview
Stagingadmin-api-staging.adventive.comadmin-staging.adventive.comMerge to staging branchShares staging DB with legacy admin-staging; all Cloudflare Access policies active
Productionadmin-api.adventive.comadmin.adventive.comMerge to main branch after staging sign-offRequires explicit manual promotion step — no auto-deploy to prod
feature branch → PR → preview deploy (automatic)
↓ review + approve
staging branch → staging deploy (automatic on merge)
↓ operator acceptance sign-off (cohort checklist)
main → production deploy (manual workflow_dispatch trigger)

Production deploys require: (1) staging deploy succeeded, (2) operator checklist signed off, (3) manual trigger by Jeffrey or Patrick.

No wrangler deploy --env production of either the admin-api Worker or the admin-ui Pages/Worker proceeds until the AWS Secrets Manager IdP-recovery implementation checklist at src/content/docs/platform/aws/secrets-manager/01-idp-recovery.md §6 is complete and verified. Dev and stg are exempt; this gate applies only at prd promotion. Per canonical CLAUDE.md hard rule §3.9.


File: .github/workflows/deploy-api.yml

name: Deploy admin-api
on:
push:
branches: [staging, main]
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
default: staging
type: choice
options: [staging, production]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: admin-api/package-lock.json
- run: npm ci
working-directory: admin-api
- run: npm run type-check
working-directory: admin-api
- run: npm run test
working-directory: admin-api
deploy:
needs: test
runs-on: ubuntu-latest
environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: admin-api/package-lock.json
- run: npm ci
working-directory: admin-api
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: admin-api
command: deploy --env ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}

Local development:

Terminal window
cd admin-api
cp .dev.vars.example .dev.vars # fill in staging secrets
wrangler dev --env staging # proxies to staging Hyperdrive

Manual hotfix deploy:

Terminal window
cd admin-api
wrangler deploy --env staging # staging
wrangler deploy --env production # production (requires CLOUDFLARE_API_TOKEN in local env)

File: .github/workflows/deploy-ui.yml

name: Deploy admin-ui
on:
push:
branches: [staging, main]
pull_request:
branches: [staging, main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: admin-ui/package-lock.json
- run: npm ci
working-directory: admin-ui
- run: npm run type-check
working-directory: admin-ui
- run: npm run build
working-directory: admin-ui
env:
VITE_API_BASE_URL: ${{ github.ref == 'refs/heads/main' && 'https://admin-api.adventive.com' || 'https://admin-api-staging.adventive.com' }}
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: adventive-admin-ui
directory: admin-ui/dist
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
branch: ${{ github.ref_name }}

Local development:

Terminal window
cd admin-ui
cp .env.example .env.local # VITE_API_BASE_URL=http://localhost:8787
npm run dev

Run after any admin-api schema change, before committing admin-ui changes:

Terminal window
# From repo root
cd admin-api && npm run generate:openapi # emits openapi.json
cd ../admin-ui && npm run generate:types # runs openapi-typescript against openapi.json

The generated admin-ui/src/api/generated.ts is committed to source. A CI check validates it is not stale.


Rule: No secrets in source. No secrets in wrangler.toml. All environment-specific values use Wrangler secrets (Worker) or Cloudflare Pages environment variables (UI).

Secret nameDescriptionSet viaRotation
HYPERDRIVE_DATABASE_URLMySQL DSN (injected by Hyperdrive binding — not a manual secret)wrangler.toml bindingRotated at DB credential rotation
STRIPE_SECRET_KEYStripe API secret key (restricted key — invoice read, customer read only)wrangler secret put STRIPE_SECRET_KEYAnnually or on compromise
CF_ACCESS_AUDCloudflare Access Application AUD tag (JWT audience validation)wrangler secret put CF_ACCESS_AUDOn Access Application re-creation
CF_ACCESS_TEAM_DOMAINe.g. adventive.cloudflareaccess.comwrangler secret put CF_ACCESS_TEAM_DOMAINOn Access team domain change
RBAC_SUPER_ADMIN_EMAILSComma-separated list of super-admin email addresseswrangler secret put RBAC_SUPER_ADMIN_EMAILSWhen operator list changes
RBAC_BILLING_EMAILSComma-separated list of billing-tier email addresseswrangler secret put RBAC_BILLING_EMAILSWhen operator list changes
R2_OVERRIDE_BUCKETR2 bucket name for override files (injected by R2 binding — not a manual secret)wrangler.toml bindingN/A

Setting secrets (run once per environment):

Terminal window
wrangler secret put STRIPE_SECRET_KEY --env staging
wrangler secret put STRIPE_SECRET_KEY --env production
# etc. for each secret

Local dev secrets (.dev.vars, never committed):

STRIPE_SECRET_KEY=sk_test_...
CF_ACCESS_AUD=...
CF_ACCESS_TEAM_DOMAIN=adventive.cloudflareaccess.com
RBAC_SUPER_ADMIN_EMAILS=jeffrey@adventive.com,patrick@adventive.com
RBAC_BILLING_EMAILS=...

UI has no secrets — it only knows the API base URL. Set in Cloudflare Pages dashboard:

VariableStaging valueProduction value
VITE_API_BASE_URLhttps://admin-api-staging.adventive.comhttps://admin-api.adventive.com

Legacy secret migration (Phase 0 prerequisite)

Section titled “Legacy secret migration (Phase 0 prerequisite)”

application/config/adventive.php contains production AWS keys (AKIAIAPRP6JG4QQK3YZA), S3 secret, and BitBucket OAuth credentials. These must be rotated and moved to AWS Secrets Manager before any other work begins. This is a blocker, not a Phase 1 task.

Rotation checklist (Phase 0):

  1. Generate new AWS access key pair — deactivate old key in IAM console
  2. Generate new Bitbucket OAuth credentials — revoke old tokens
  3. Move all values to AWS Secrets Manager (or Cloudflare secrets if already accessible from the EC2 host)
  4. Update CodeIgniter config to read from environment / Secrets Manager at runtime
  5. Purge adventive.php secret values from source
  6. Rewrite git history to remove committed secrets (git filter-repo or BFG Repo Cleaner)
  7. Invalidate all existing CI artifacts that may cache the old commit

Application nameURL patternPolicySession duration
Adventive Admin UIhttps://admin.adventive.com/*Adventive Operators group8 hours
Adventive Admin APIhttps://admin-api.adventive.com/*Adventive Operators group + service token (for E2E)8 hours
Adventive Admin UI (Staging)https://admin-staging.adventive.com/*Adventive Operators group8 hours
Adventive Admin API (Staging)https://admin-api-staging.adventive.com/*Adventive Operators group + service token8 hours
Rule: Emails → [list of ~10 operator email addresses]
Identity provider: JumpCloud (or Google Workspace — deferred per ADR)
Require MFA: Yes

Service token (for E2E / Playwright automation)

Section titled “Service token (for E2E / Playwright automation)”

Playwright tests authenticate to Access using a CF Access service token:

Terminal window
# Create the service token (one-time, per environment)
# Cloudflare dashboard: Access → Service Auth → Create Service Token
# Store client_id and client_secret in GitHub Actions secrets
# CF_ACCESS_CLIENT_ID, CF_ACCESS_CLIENT_SECRET

Playwright config sets CF-Access-Client-Id and CF-Access-Client-Secret headers in all requests.

  1. Navigate to Cloudflare Zero Trust dashboard → Access → Access Groups → Adventive Operators
  2. Add the operator’s email address to the email list
  3. Notify operator of the new admin URL and confirm they can authenticate
  4. If adding a super-admin or billing-tier operator, also update RBAC_SUPER_ADMIN_EMAILS or RBAC_BILLING_EMAILS Wrangler secrets
  1. Remove the operator’s email from the Access group
  2. If the operator has billing or super-admin RBAC tier, remove from the corresponding Wrangler secret and redeploy the Worker (wrangler deploy --env production)
  3. Revoke any active Access sessions: Cloudflare dashboard → Access → Revoke User Sessions → enter email

The admin-api Worker emits structured events to Workers Analytics Engine on every request:

// Captured per request
env.ANALYTICS.writeDataPoint({
blobs: [route, method, operatorEmail, rbacTier],
doubles: [responseStatus, durationMs],
indexes: [route]
});

Dimensions available: route, HTTP method, operator email, RBAC tier, response status, request duration.

Dashboards (to be built in Cloudflare Analytics or Grafana):

  • Request rate by route (operator activity heatmap)
  • p50/p95/p99 duration by route
  • Error rate (4xx, 5xx) by route
  • Operator session activity (login frequency, last seen)

Configure Logpush to forward Workers logs to the existing log sink (same destination as Public API Worker):

Cloudflare dashboard → Analytics → Logpush → Create job
Dataset: Workers trace events
Destination: [existing log sink — S3 or Splunk]
Filter: worker name = adventive-admin-api

Log entries include: timestamp, outcome (ok/exception/exceededCpu), cpuTime, wallTime, request URL, response status, CF-Ray ID.

Terminal window
wrangler tail adventive-admin-api --env production
wrangler tail adventive-admin-api --env staging --format pretty

Use tail Workers during cohort migrations to watch request patterns live.

AlertThresholdChannel
Worker error rate > 5% (5-min window)> 5% of requests returning 5xxPagerDuty / Slack #admin-alerts
Worker CPU time approaching limitp95 > 30ms (warning), > 45ms (critical)Slack #admin-alerts
Hyperdrive connection failuresAny sustained DB error (> 3 consecutive failures)PagerDuty
Access auth failures spike> 10 failed Access checks in 5 minutesSlack #admin-alerts
Pages build failureAny failed build on main or stagingSlack #deploy-notifications

Add the admin Worker to the existing Adventive status page. External health check endpoint: GET /health200 {"status":"ok","version":"x.y.z"}.


All costs are on top of existing Cloudflare account (Workers Paid plan assumed — consistent with Public API project).

Cost driverUnitEstimated monthly usageEstimated monthly cost
Workers Paid plan$5/month flat$5.00
Worker requests (admin-api)$0.30 / million (after 10M free)~50K requests/month (~10 operators × ~200 req/day × 25 days)~$0 (well within free tier)
Worker CPU time$0.02 / million ms (after 30M free)~500K ms/month~$0
Cloudflare PagesFree (unlimited builds, 500 builds/month included)~50 builds/month$0
HyperdriveIncluded with Workers Paid$0 (first 1M queries/month free)
R2 storage (override files)$0.015/GB/month< 1 GB< $0.02
R2 operations$0.36 / million Class B< 10K/month< $0.01
Workers Analytics EngineFree (included in Workers Paid)$0
LogpushFree for Workers$0

Estimated total incremental cost: ~$5–6/month (dominated by Workers Paid plan flat fee, already paid for Public API).

Cost ceiling: At 10 operators with heavy usage (1,000 req/day each), monthly requests = 250K — still well within free request tier. This service will not require re-architecture based on cost at any realistic operator scale.


Cloudflare Workers retains the 10 most recent deploys. Roll back in under 60 seconds:

Terminal window
# List available versions
wrangler deployments list --env production
# Roll back to a specific version
wrangler rollback [deployment-id] --env production
# Or roll back to the previous version (no ID needed)
wrangler rollback --env production

Verify rollback: curl https://admin-api.adventive.com/health → check version field.

Terminal window
# Via Cloudflare dashboard:
# Pages → adventive-admin-ui → Deployments → click previous deployment → "Rollback to this deployment"
# Or via Wrangler CLI:
wrangler pages deployment list --project-name adventive-admin-ui
wrangler pages deployment rollback [deployment-id] --project-name adventive-admin-ui

During the parallel-run period, rolling a cohort back to the legacy admin requires only redirecting the operator:

  1. Send the operator the legacy admin URL (https://admin-legacy.adventive.com)
  2. No data migration needed — both admins read/write the same DB
  3. Document the rollback reason in the cohort migration log
  4. Investigate the failure before re-attempting cohort migration

No code change or deployment is required for a cohort rollback.


Before notifying any operator:

  • Feature parity checklist for the cohort’s workflows is signed off (Jeffrey + Patrick)
  • Staging has been dogfooded by Jeffrey for at least 3 days
  • All Vitest tests passing in CI
  • E2E Playwright tests passing against staging
  • Rollback procedure tested: confirm legacy admin URL still works
  • Analytics Engine dashboard live and showing staging traffic
  • Logpush forwarding verified to log sink
  • Cloudflare Access policy verified: each operator on the list can authenticate
  • RBAC secrets set correctly: super-admin and billing emails in Wrangler secrets

Cohort 1 migration — Account Management Operators

Section titled “Cohort 1 migration — Account Management Operators”

Scope: Workflows 1–12 (account list, detail, create, activate, cancel, user lookup, unlock, settings, service levels, override files, tools)

Target operators: Operators who primarily use account management and do not require billing access.

Migration day procedure:

1. Send Slack DM to cohort operators: "[New Admin] You're being migrated to the new
Adventive Admin today. URL: https://admin.adventive.com — log in with your JumpCloud
credentials through Cloudflare Access. Your old bookmarks still work at
admin-legacy.adventive.com if you need them."
2. Monitor Workers Analytics Engine dashboard for 30 minutes post-notification:
- Watch for 4xx or 5xx spikes
- Confirm each operator shows up in the request log (first request = Access JWT validated)
3. Stand by in Slack for 2 hours. Answer questions. Fix anything that surfaces.
4. 48 hours later: check-in with operators. Note any reported issues.
5. At 2-week mark with no rollback requests: mark Cohort 1 as stable. Gate to Cohort 2 is open.

Rollback trigger: Any operator reports a workflow they cannot complete that they could complete in the legacy admin. Immediately send them to admin-legacy.adventive.com, document the gap, and fix before re-attempting.

Scope: Workflows 13–25 (invoice list, invoice detail, payment, billing profiles, delinquent accounts, revenue history, managed services list/CRUD)

Dependencies: Stripe Billing Transition Phase B must be stable before this cohort migrates. Coordinate timing with that project.

Migration day procedure: Same as Cohort 1. Additional monitoring: watch Stripe API call success rate from admin-api.

Special note: The billing surface is high-risk. Run Cohort 2 alongside the legacy admin for a minimum of 2 weeks, not just 2 weeks total. If Stripe Billing Transition is in the middle of a cutover when this cohort is ready, hold the cohort until Stripe-side is stable.

Cohort 3 migration — Remaining Operators

Section titled “Cohort 3 migration — Remaining Operators”

Scope: Workflows 26–34 (campaigns, managed service jobs, override files, tooling, ad types, benchmarks)

Migration day procedure: Same. After 2-week stable window, all operators are on the new admin.

After all operators have been stable on the new admin for 2 weeks:

  1. Put legacy admin into read-only mode (disable all POST/PATCH routes in CodeIgniter controllers, or add a maintenance banner to the login page)
  2. Start 30-day freeze window clock
  3. At day 30: if no rollback requests, proceed to decommission
  • All operators confirmed on new admin ≥ 30 days with no rollback
  • Legacy admin access logs show zero operator sessions in past 14 days
  • Cron jobs (Reporting.php scheduled deliveries) migrated to Cloudflare Cron Trigger Workers
  • application/config/adventive.php secrets already rotated (Phase 0 — verify)
  • BitBucket repo archived (not deleted)
  • EC2 instance terminated (coordinate with DevOps)
  • DNS record for admin-legacy.adventive.com removed
  • Cloudflare Access application for legacy admin removed
  • CI/CD pipeline for legacy repo disabled

Update — 2026-04-30: UI deploy target changed to Worker + Static Assets

Section titled “Update — 2026-04-30: UI deploy target changed to Worker + Static Assets”

The UI is no longer deploying as a Cloudflare Pages project. It now deploys as a Worker with Static Assets (assets = { directory = "./dist", not_found_handling = "single-page-application" } in wrangler.toml).

Why: Cloudflare has converged on Workers as the modern unit of deployment for both static and dynamic. A Worker-with-Assets gives us one deployment unit, one wrangler config, and a clean upgrade path when Phase 2 adds server-side routes (OAuth callbacks, edge auth glue, JWT extraction for the typed API client) to the same Worker without changing the deploy target.

ServicePlatformRuntimeRegion
adv-ui-admin-{env}Cloudflare Workers (Static Assets)Cloudflare edge (global)Smart placement
adv-svc-admin-api-{env}Cloudflare WorkersCloudflare edge (global)Smart placement
DB_CONSOLE, DB_BILLING, DB_AGGREGATECloudflare HyperdriveMySQL connection poolerCollocated with DB
EnvUI URLAPI URLUI Worker nameAPI Worker name
Localhttp://localhost:5173http://localhost:8787vite devwrangler dev --env dev
Dev (preview)genesis-admin.adventive.devadmin-api.adventive.devadv-ui-admin-devadv-svc-admin-api-dev
Stagingadmin.adventivestg.comadmin-api.adventivestg.comadv-ui-admin-stgadv-svc-admin-api-stg
Productionadmin.adventive.comadmin-api.adventive.comadv-ui-admin-prdadv-svc-admin-api-prd

For each Worker custom hostname, pre-create an AAAA record pointing to 100:: proxied through Cloudflare. Cloudflare does not auto-create DNS for Worker route bindings.

ZoneRecords to pre-create
adventive.devgenesis-admin AAAA → 100:: proxied; admin-api AAAA → 100:: proxied
adventivestg.comadmin AAAA → 100:: proxied; admin-api AAAA → 100:: proxied
adventive.comadmin AAAA → 100:: proxied; admin-api AAAA → 100:: proxied

The UI’s .github/workflows/deploy-preview.yml now runs wrangler deploy --env dev (was wrangler pages deploy). The API’s deploy workflow follows the same pattern. Rollback for the UI is wrangler rollback --env <env> (Workers Versions) — not Pages Deployments.

  • public/_redirects — superseded by not_found_handling = "single-page-application" in wrangler config.
  • wrangler pages project create ... — replaced by wrangler deploy (project provisions automatically on first deploy).
  • Pages Deployments concept — replaced by Workers Versions (with the same shape: per-deploy URLs, atomic promote/rollback).

public/_headers is retained — Workers Static Assets honors the same Pages-format headers file.

Phase 2 dev binds only DB_CONSOLE (Hyperdrive 059838c4abb64a92a4aece2a6a533a29). DB_BILLING and DB_AGGREGATE Hyperdrives are not yet provisioned in any environment. Endpoints that depend on those bindings throw 503 with a clear message until provisioned. Full provisioning needs to land before stg/prd promotion. Owner: platform team / Stripe Billing Transition project.