Skip to content

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:

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

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

  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

Revoking operator access

  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

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 /health200 {"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:

  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.


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:

  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

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.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