03 — Deployment & Management¶
Deploy target¶
| Service | Platform | Runtime | Region |
|---|---|---|---|
adventive-admin-api |
Cloudflare Workers | Cloudflare edge (global) | Smart placement — no pin needed |
adventive-admin-ui |
Cloudflare Pages | Static SPA + CDN | Global |
adventive-admin-api (legacy DB) |
Cloudflare Hyperdrive | MySQL connection pooler | Collocated 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.
Environments¶
| Env | API URL | UI URL | Trigger | Notes |
|---|---|---|---|---|
| Local | http://localhost:8787 |
http://localhost:5173 |
wrangler dev / vite dev |
Uses .dev.vars for secrets; proxies to Hyperdrive staging binding |
| Preview | admin-api-<branch>.adventive-admin.workers.dev |
CF Pages preview URL (per PR) | PR opened or commit pushed to non-main branch | Automated — CF Pages creates preview per commit; Worker preview via wrangler deploy --env preview |
| Staging | admin-api-staging.adventive.com |
admin-staging.adventive.com |
Merge to staging branch |
Shares staging DB with legacy admin-staging; all Cloudflare Access policies active |
| Production | admin-api.adventive.com |
admin.adventive.com |
Merge to main branch after staging sign-off |
Requires explicit manual promotion step — no auto-deploy to prod |
Promotion rules¶
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.
Build & deploy pipeline¶
admin-api (Cloudflare Worker)¶
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:
cd admin-api
cp .dev.vars.example .dev.vars # fill in staging secrets
wrangler dev --env staging # proxies to staging Hyperdrive
Manual hotfix deploy:
cd admin-api
wrangler deploy --env staging # staging
wrangler deploy --env production # production (requires CLOUDFLARE_API_TOKEN in local env)
admin-ui (Cloudflare Pages)¶
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:
OpenAPI type generation¶
Run after any admin-api schema change, before committing admin-ui changes:
# 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.
Secrets & configuration¶
Rule: No secrets in source. No secrets in wrangler.toml. All environment-specific values use Wrangler secrets (Worker) or Cloudflare Pages environment variables (UI).
admin-api secrets¶
| Secret name | Description | Set via | Rotation |
|---|---|---|---|
HYPERDRIVE_DATABASE_URL |
MySQL DSN (injected by Hyperdrive binding — not a manual secret) | wrangler.toml binding |
Rotated at DB credential rotation |
STRIPE_SECRET_KEY |
Stripe API secret key (restricted key — invoice read, customer read only) | wrangler secret put STRIPE_SECRET_KEY |
Annually or on compromise |
CF_ACCESS_AUD |
Cloudflare Access Application AUD tag (JWT audience validation) | wrangler secret put CF_ACCESS_AUD |
On Access Application re-creation |
CF_ACCESS_TEAM_DOMAIN |
e.g. adventive.cloudflareaccess.com |
wrangler secret put CF_ACCESS_TEAM_DOMAIN |
On Access team domain change |
RBAC_SUPER_ADMIN_EMAILS |
Comma-separated list of super-admin email addresses | wrangler secret put RBAC_SUPER_ADMIN_EMAILS |
When operator list changes |
RBAC_BILLING_EMAILS |
Comma-separated list of billing-tier email addresses | wrangler secret put RBAC_BILLING_EMAILS |
When operator list changes |
R2_OVERRIDE_BUCKET |
R2 bucket name for override files (injected by R2 binding — not a manual secret) | wrangler.toml binding |
N/A |
Setting secrets (run once per environment):
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=...
admin-ui environment variables¶
UI has no secrets — it only knows the API base URL. Set in Cloudflare Pages dashboard:
| Variable | Staging value | Production value |
|---|---|---|
VITE_API_BASE_URL |
https://admin-api-staging.adventive.com |
https://admin-api.adventive.com |
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
Cloudflare Access configuration¶
Application definitions¶
| Application name | URL pattern | Policy | Session duration |
|---|---|---|---|
Adventive Admin UI |
https://admin.adventive.com/* |
Adventive Operators group | 8 hours |
Adventive Admin API |
https://admin-api.adventive.com/* |
Adventive Operators group + service token (for E2E) | 8 hours |
Adventive Admin UI (Staging) |
https://admin-staging.adventive.com/* |
Adventive Operators group | 8 hours |
Adventive Admin API (Staging) |
https://admin-api-staging.adventive.com/* |
Adventive Operators group + service token | 8 hours |
Policy — Adventive Operators group¶
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)¶
Playwright tests authenticate to Access using a CF Access service token:
# 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.
Adding a new operator¶
- Navigate to Cloudflare Zero Trust dashboard → Access → Access Groups → Adventive Operators
- Add the operator's email address to the email list
- Notify operator of the new admin URL and confirm they can authenticate
- If adding a super-admin or billing-tier operator, also update
RBAC_SUPER_ADMIN_EMAILSorRBAC_BILLING_EMAILSWrangler secrets
Revoking operator access¶
- Remove the operator's email from the Access group
- If the operator has billing or super-admin RBAC tier, remove from the corresponding Wrangler secret and redeploy the Worker (
wrangler deploy --env production) - Revoke any active Access sessions: Cloudflare dashboard → Access → Revoke User Sessions → enter email
Observability¶
Workers Analytics Engine¶
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)
Logpush¶
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.
Tail Workers (real-time debug)¶
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.
Alerting¶
| Alert | Threshold | Channel |
|---|---|---|
| Worker error rate > 5% (5-min window) | > 5% of requests returning 5xx | PagerDuty / Slack #admin-alerts |
| Worker CPU time approaching limit | p95 > 30ms (warning), > 45ms (critical) | Slack #admin-alerts |
| Hyperdrive connection failures | Any sustained DB error (> 3 consecutive failures) | PagerDuty |
| Access auth failures spike | > 10 failed Access checks in 5 minutes | Slack #admin-alerts |
| Pages build failure | Any failed build on main or staging |
Slack #deploy-notifications |
Status page¶
Add the admin Worker to the existing Adventive status page. External health check endpoint: GET /health → 200 {"status":"ok","version":"x.y.z"}.
Cost model¶
All costs are on top of existing Cloudflare account (Workers Paid plan assumed — consistent with Public API project).
| Cost driver | Unit | Estimated monthly usage | Estimated 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 Pages | Free (unlimited builds, 500 builds/month included) | ~50 builds/month | $0 |
| Hyperdrive | Included 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 Engine | Free (included in Workers Paid) | — | $0 |
| Logpush | Free 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.
Rollback procedures¶
Worker rollback (admin-api)¶
Cloudflare Workers retains the 10 most recent deploys. Roll back in under 60 seconds:
# 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.
Pages rollback (admin-ui)¶
# 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
Cohort rollback (operator routing)¶
During the parallel-run period, rolling a cohort back to the legacy admin requires only redirecting the operator:
- Send the operator the legacy admin URL (
https://admin-legacy.adventive.com) - No data migration needed — both admins read/write the same DB
- Document the rollback reason in the cohort migration log
- Investigate the failure before re-attempting cohort migration
No code change or deployment is required for a cohort rollback.
Cohort migration playbook¶
Pre-migration checklist (for each cohort)¶
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¶
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.
Cohort 2 migration — Billing Operators¶
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¶
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.
Post-Cohort-3 freeze¶
After all operators have been stable on the new admin for 2 weeks:
- Put legacy admin into read-only mode (disable all POST/PATCH routes in CodeIgniter controllers, or add a maintenance banner to the login page)
- Start 30-day freeze window clock
- At day 30: if no rollback requests, proceed to decommission
Decommission checklist (Phase 10)¶
- 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.phpscheduled deliveries) migrated to Cloudflare Cron Trigger Workers -
application/config/adventive.phpsecrets already rotated (Phase 0 — verify) - BitBucket repo archived (not deleted)
- EC2 instance terminated (coordinate with DevOps)
- DNS record for
admin-legacy.adventive.comremoved - Cloudflare Access application for legacy admin removed
- CI/CD pipeline for legacy repo disabled