Raxx · internal docs

internal · gated

CI Self-Hosted EC2 — Decision Package

Date: 2026-07-03 UTC Author: software-architect Status: Proposed — awaiting operator decision ADR: 0134-ci-ec2-self-hosted-runner-fleet.md Supersedes: ADR-0033 (Ubicloud trigger threshold, now clearly exceeded) Parent issues: #726 (billing posture), #728 (Ubicloud migration card), #1595 (CodeBuild migration)


1. Context

GitHub Actions is the current CI platform. Since ADR-0033 was written (April 2026), the workflow inventory has grown from ~34 to 85 files. Monthly minutes are now estimated at 15,000–26,000 (basis: measured run density below), well past the 5,000 min/month Ubicloud trigger threshold set in ADR-0033. The operator wants to migrate compute to ci.moosequest.net on EC2 with defense-in-depth, a load balancer in front, and the design decision made here, not in a sprint.

The billing API returns 404/410 for this repo configuration. Monthly cost is estimated from run-history timestamps (GH API, 2026-07-03 sample). Billing numbers below are estimates, not actuals; OQ-5 flags how to close this.


2. Measured Workload (Ground Truth)

Workflow inventory (85 files)

Class Count Examples
Cron / nightly 36 nightly-security-scan, billing-collector-cron, mbt-drift-daily, bcp-vault-snapshot-daily
Push CI (develop/release/main) 31 ci.yml, ci-boundary.yml, deploy-heroku.yml, deploy-antlers-next-*
PR gates 21 ci-pr.yml, antlers-next-ci.yml, migration-collision-check, lint-*
Manual dispatch only 70 (overlap) Nearly all workflows expose workflow_dispatch as fallback
Reusable (workflow_call) 2 synthetic-gate.yml, slack-notify.yml

Note: counts overlap because most workflows have multiple triggers.

Runner types

Runner Job occurrences Notes
ubuntu-latest 231 All Linux workloads
ubuntu-22.04 5 Queue C++ jobs
macos-14 1 ios-ci.yml only

Service containers

postgres:15 appears in at least 10 workflows (ci.yml, ci-pr.yml, alembic-version-cron, billing-collector-cron, freescout-backup, mbt-* crons, review-app-console, bcp-smoke-monthly). Any solution must support Docker service containers on the runner.

Heaviest jobs

Job Timeout Observed
backend-tests-postgres (ci.yml) 90 min ~78 min (2026-07-03 sample)
queue-docker-smoke 30 min ~2 min
mbt-drift-per-symbol-weekly 15 min
iOS CI (macos-14) uncapped ~5 min (2026-07-03 sample)

Composite actions (5 actions, 22 workflows depend on load-vault-secrets)

Load-bearing release pipeline

Workflow Trigger Risk if broken
gatekeeper-develop-to-release.yml release-* tag push Blocks all staging deploys
promote-release-to-main.yml Manual dispatch Blocks prod promotion
deploy-heroku.yml Push release/main + dispatch Prod deploy
deploy-antlers-next-prod.yml Push main Prod frontend
deploy-console.yml Push main Prod console
deploy-queue.yml Push main Queue service deploy
deploy-velvet.yml Push main Velvet deploy
nightly-security-scan.yml Schedule Detection gap if missed

Estimated monthly burn

From 2026-07-03 GH API sample (100 runs, ~6.5-hour window):

At GitHub Pro ($4/mo, 3,000 min included, $0.008/min overage): - Overage: 22,700 min × $0.008 = ~$182/mo + $4 base = ~$186/mo

This estimate may be ±40%. OQ-5 asks the operator to confirm from the billing page.


3. Invariants That Apply


4. Candidate A — GHA Orchestration + EC2 Runner Fleet

GitHub Actions remains the CI control plane. Compute migrates to self-hosted ephemeral EC2 runners managed by the philips-labs/terraform-aws-github-runner autoscaler.

ci.moosequest.net becomes the runner fleet observability dashboard (CloudWatch dashboards, spend tracking, runner utilization) — not a CI control plane UI.

Flow

GitHub webhook (workflow_job.queued event)
  → API Gateway → Lambda (webhook processor)
    → SQS → Lambda (runner provisioner)
      → EC2 spot instance spins up (ephemeral)
        → runner registers via JIT token (job-scoped, one-time)
          → job runs
            → runner deregisters → EC2 instance terminates

YAML migration scope

None required. runs-on: ubuntu-latest continues to route to the self-hosted fleet once philips-labs is configured with that label mapping. A separate mechanical PR can add a custom label ([self-hosted, linux, x64]) after the fleet is stable — this does not affect required status check names.

iOS carve-out

ios-ci.yml uses macos-14. EC2 Mac Dedicated Hosts require a 24-hour minimum allocation (~$650+/month). For one iOS job running ~5 min several times per week, this is not economical. ios-ci.yml stays on GH-hosted macOS indefinitely. iOS minutes (macOS-14 at $0.08/min) remain as a small fixed GitHub bill (~$1-2/month).

Cost estimate (Candidate A)

Line item $/month
EC2 spot compute — c5.xlarge for standard jobs ($0.05/hr, ~250 runner-hours) $12.50
EC2 on-demand — c5.2xlarge for heavy jobs (90-min postgres tests, ~2/day × 78 min) $13
Lambda + SQS (webhook + provisioner, millions of invocations are negligible) < $1
ALB (base $16 + LCU) $18
NAT Gateway (hourly + data transfer) $8
EBS (runner root volumes, ~20 GB ephemeral per launch) $4
CloudWatch logs $3
SSM Parameter Store < $1
GitHub Pro — iOS macOS minutes only $5
Total estimated ~$65/mo

Savings vs current ~$186/mo GHA bill: ~$121/month. Infrastructure build effort (12–20 engineering hours) breaks even within the first month.

Migration effort

Category Effort
Terraform (VPC, ALB, WAF, IAM, runner fleet) M — 2-3 engineering days
GH App provisioning (raxx-ci-runner) XS — 1 hour
Pilot (2-3 low-risk workflows) XS — 1 hour + 48-hour soak
Full fleet rollout (all 85 workflows) S — mechanical, 1-2 hours
Console Ops Dispatch None — workflow_dispatch API unchanged
Composite actions None — unchanged
Required status check names None — unchanged
Total ~3-4 engineering days

Risks

Risk Severity Mitigation
Spot interruption during 90-min backend-tests job Medium Use on-demand for that runner class only (already in cost estimate)
philips-labs module operational complexity Low-Medium Battle-tested at scale; Terraform module; active maintenance
GH Actions control-plane outage still blocks CI Low Unchanged from today
Runner AMI drift (CVEs) Low Ephemeral runners always launch from latest AMI; monthly AMI refresh cadence
Cold-start latency per job Low 30-60s typical for c5; philips-labs pre-warm pool option available

5. Candidate B — Woodpecker CI (Full Platform on EC2)

Woodpecker CI (Apache 2.0, active community fork of Drone CI) replaces the GitHub Actions control plane entirely. The Woodpecker server runs on EC2 in the private subnet. ci.moosequest.net becomes the Woodpecker UI.

Buildkite evaluated and excluded: Buildkite has a SaaS control plane that pipeline logs and scheduling metadata transit through — it is not fully self-hosted. For an operator requirement of full infrastructure ownership at ci.moosequest.net, Buildkite does not qualify. Woodpecker is the correct Candidate B.

What changes

iOS carve-out

Same as Candidate A — macos-14 stays GH-hosted. This makes Candidate B a permanent hybrid: Woodpecker for all Linux workflows, GH Actions for ios-ci.yml only.

Cost estimate (Candidate B)

Line item $/month
Woodpecker server (t3.medium on-demand, always-on) $30
EC2 spot agents (same compute as Candidate A) $25.50
RDS t3.micro (Woodpecker pipeline state) $13
ALB $18
NAT Gateway $8
EBS $4
CloudWatch logs $3
GitHub Pro — iOS macOS only $5
Total estimated ~$107/mo

Savings vs current ~$186/mo: ~$79/month — vs $121/month for Candidate A. Candidate B saves $42/month less while requiring 15-20x more migration effort.

Migration effort

Category Effort
Terraform (VPC, ALB, WAF, Woodpecker server, agent fleet, RDS) M — 3-4 days
Woodpecker server config + GitHub OAuth App wiring S — 1 day
YAML migration: 85 workflow files → .woodpecker.yml XL — 4-6 weeks
Composite action ports (5 actions → Docker images / init steps) L — 1-2 weeks
Console Ops Dispatch: workflow_dispatch → Woodpecker REST API S — 1-2 days
Required status check names updated on branch protection S — 1 day
iOS hybrid maintenance (permanent GH Actions + Woodpecker dual-system) Ongoing
Total ~6-9 weeks engineering

Risks

Risk Severity Mitigation
YAML migration volume (85 files, 5 composite actions) High Parallel run: GH Actions and Woodpecker both active during migration
Release pipeline disruption during 6-9 week migration High Migrate gatekeeper and deploy workflows last
Woodpecker community longevity (fork of Drone) Medium Apache 2.0; can fork; but ecosystem is smaller
No native GH deployment environments Medium Woodpecker posts status checks via API but no deployment tracking
Woodpecker server is a stateful dependency (RDS) Medium RDS Multi-AZ or litestream-to-S3 for SQLite fallback
Console Ops Dispatch coordinated cutover required Medium Ops Dispatch must flip atomically with Woodpecker GA

6. Decision Matrix

Dimension Candidate A (GHA + EC2 runners) Candidate B (Woodpecker CI)
Monthly cost estimate ~$65/mo ~$107/mo
Savings vs current ~$121/mo ~$79/mo
Migration effort 3-4 days 6-9 weeks
YAML migration None required 85 files, full rewrite
Composite action migration None 5 actions ported
Console Ops Dispatch change None API endpoint change
Required status check names Unchanged Change required
iOS (macOS) GH-hosted (hybrid) GH-hosted (permanent hybrid)
Service containers (postgres:15) Native Docker on EC2 Native Docker on agents
load-vault-secrets (22 workflows) Unchanged Must port to Docker init steps
Control plane dependency GitHub Actions (existing) Self-operated Woodpecker server
GH ecosystem depth Full Partial (status checks only)
Rollback Single PR reverts runs-on Maintain GH Actions in parallel
Ops overhead Low (Lambda + stateless) Medium (server, RDS, patching)
License GHA: proprietary; runner: MIT Apache 2.0
Control plane longevity GitHub (very low risk) Woodpecker community (medium)

Recommendation: Candidate A.

Candidate A saves $42/month more than Candidate B while requiring 15-20x less engineering effort. The migration risk difference is decisive: Candidate A changes nothing in 85 workflow files; Candidate B requires a full rewrite under a live release pipeline. Candidate A's rollback is a one-line PR reverting runs-on. Candidate B's rollback during a 6-9 week migration window is complex. The operator retains full compute ownership while keeping GitHub as the well-understood CI control plane.


7. Production Architecture (Candidate A)

VPC Layout

Region: us-east-1

VPC cidr: 10.42.0.0/16
│
├── Public subnet A  10.42.0.0/24  (us-east-1a)  — ALB only
├── Public subnet B  10.42.1.0/24  (us-east-1b)  — ALB only
│
├── Private subnet A 10.42.10.0/24 (us-east-1a)  — Lambda, EC2 runners
└── Private subnet B 10.42.11.0/24 (us-east-1b)  — Lambda, EC2 runners (AZ failover)

Internet Gateway → public subnets
NAT Gateway (in public subnet A) → private subnet egress
  → GitHub API, Infisical vault.raxx.app, Heroku API, Cloudflare API, PyPI, npm

The ALB sits in front of the observability dashboard only. The GH Actions control plane is GitHub-hosted; there is no private backend behind the ALB for CI scheduling. The Lambda webhook processor is in the private subnet with NAT egress.

Security Groups

Resource Ingress allowed Egress allowed
ALB sg :443 from 0.0.0.0/0; :80 from 0.0.0.0/0 (redirect only) CloudWatch only
Lambda sg API Gateway invoke (no public ingress) :443 to 0.0.0.0/0 via NAT
EC2 runner sg None (no public IP; no ingress rule) :443 to 0.0.0.0/0 via NAT

No port 22 on any resource. SSM Session Manager is the only shell-access path. IMDSv2 required on all EC2 instances (HttpTokens = required in launch template).

ALB Configuration

Webhook auth bypass:

sequenceDiagram
    participant GH as GitHub
    participant ALB
    participant Lambda
    participant SQS
    participant EC2

    GH->>ALB: POST /webhook (X-Hub-Signature-256: sha256=...)
    Note over ALB: Path rule: skip OIDC
    ALB->>Lambda: forward (no auth)
    Lambda->>Lambda: validate HMAC(payload, secret from SSM)
    alt HMAC invalid
        Lambda-->>GH: 401
    else HMAC valid
        Lambda->>SQS: enqueue runner job
        SQS->>Lambda: provision EC2 runner
        EC2->>GH: register via JIT token
        GH->>EC2: dispatch job
    end

AWS WAF (attached to ALB)

Rule Source Action
AWSManagedRulesCommonRuleSet AWS managed Block
AWSManagedRulesLinuxRuleSet AWS managed Block
Rate limit — dashboard paths Custom: 100 req/5min/IP Block
Rate limit — webhook path Custom: 500 req/5min/IP Block
GitHub IP set (optional) Custom IP set from GitHub meta API Allow on /webhook, block others

WAF logs → CloudWatch → daily ops digest (pre-launch digest posture).

Runner Fleet (philips-labs/terraform-aws-github-runner)

Key parameters:

# Standard Linux runners (ubuntu-latest equivalent)
runner_instance_types = ["c5.xlarge", "c5a.xlarge"]   # 4 vCPU / 8 GB
runner_enable_spot    = true
runner_max_size       = 10   # hard cap; tune after pilot
runner_min_size       = 0    # zero idle cost

# Heavy runner for backend-tests-postgres (90-min job, spot interruption risk)
# Separate runner configuration in Terraform:
heavy_instance_types  = ["c5.2xlarge"]   # 8 vCPU / 16 GB
heavy_enable_spot     = false            # on-demand; can't afford mid-job interruption
heavy_max_size        = 2

# Ephemeral: terminate after one job
runner_enable_ephemeral  = true

# JIT registration (token is job-scoped, single-use)
runner_enable_jit_config = true

# Runner group: private, scoped to raxx-app org
runner_runner_group_name = "ec2-self-hosted"

AMI: Amazon Linux 2023 with Docker pre-installed. Monthly AMI refresh via SSM Automation document (new launch template version → apply). Ephemeral runners always pick up the latest AMI at launch.

Secrets in SSM Parameter Store

All CI infrastructure secrets are AWS-resident; SSM SecureString encrypted with aws/ssm KMS key:

Secret SSM path Rotation path
GH App private key (PEM) /ci/gh-app/private-key Re-issue in GitHub App settings → update SSM → Lambda reads at cold start
GH App ID /ci/gh-app/app-id Update SSM only
Webhook HMAC secret /ci/webhook/hmac-secret Update in GitHub webhook settings + SSM
Google OIDC client ID /ci/oidc/client-id Update in Google Cloud Console + SSM → ALB listener update
Google OIDC client secret /ci/oidc/client-secret Same as above

Nothing in Terraform .tf files or workflow YAML. Terraform references SSM paths by ARN; values are never in tfvars or state in plaintext.

GitHub App (raxx-ci-runner)

A new GitHub App, separate from raxx-ops-bot and raxx-pm-bot, following the existing bot identity pattern. Least-privilege permissions:

Permission Level Why
Actions Read Receive workflow_job events
Checks Write Post runner status
Contents Read Clone repo in runner
Metadata Read GitHub requirement

Events subscribed: workflow_job only.

Installation scope: single repository (TradeMasterAPI) for Phase 1. Expand to org-wide when additional repos onboard in Phase 5.

App private key stored in SSM only. Never in repository files, Terraform state, or environment variables (Lambda reads from SSM at cold start).

IAM (two new roles; claude-infisical-bootstrap is NOT modified)

Role: ci-runner-fleet-role (Lambda execution role)

ssm:GetParameter, ssm:GetParameters          — /ci/* only
ec2:RunInstances                             — runners only (condition on Name tag)
ec2:TerminateInstances                       — runners only
ec2:Describe*                                — runners only (tag filter)
ec2:CreateTags                               — runners only
sqs:SendMessage, sqs:ReceiveMessage          — runner queue only
sqs:DeleteMessage, sqs:GetQueueAttributes
cloudwatch:PutMetricData                     — /CI/* namespace only
logs:CreateLogGroup, logs:PutLogEvents       — /ci/* log groups only

Role: ci-runner-instance-role (EC2 instance profile for runners)

ssm:GetParameter*                            — /ci/runner/* only (not App key or HMAC)
ssm:StartSession                             — self (SSM Session Manager break-glass)
logs:CreateLogGroup, logs:PutLogEvents       — /ci/runner-output/* only
ecr:GetDownloadUrlForLayer, ecr:BatchGetImage — if private Docker images added later

IaC placement: infra/ci/ in this repo for Phase 1; migrate to raxx-infra repo when a second repo onboards (see OQ-4).

Ops

Patching: Ephemeral runners always launch from the latest AMI in the Terraform launch template. Monthly SSM Automation document updates the AMI ID; terraform apply propagates it. Lambda functions are stateless; redeploy on schedule.

Backup / restore: No self-hosted control-plane state to back up (GH holds pipeline history). SSM parameters are versioned (20 versions retained). CloudWatch logs retained 90 days. No RDS.

Capacity alarms: - SQS queue depth > 5 for > 10 minutes → CloudWatch alarm → ops@ daily digest - EC2 cost tag purpose=ci-runner daily spend > $10 → billing alarm → ops@ digest

Kill-switch: Set runner_max_size = 0 + terraform apply (< 2 minutes). Or disable the GitHub App webhook in GitHub App settings (30-second path). All queued jobs wait in GH Actions queue; no jobs run. To resume: re-enable webhook or scale up.


8. Phased Build Plan

Phase 0 — Design sign-off

This document. Operator reviews candidates, confirms Candidate A, answers OQ-1 through OQ-5. No infra deployed.

Rollback: N/A.

Phase 1 — Terraform baseline

Sub-card: infra/ci Terraform (VPC, subnets, IGW, NAT, security groups, ALB, ACM, WAF, SSM parameter stubs, IAM roles, philips-labs runner module).

Gate: ACM cert validated (OQ-1 DNS record added). 5+ successful runner registrations visible in GitHub → Settings → Actions → Runners.

Rollback: terraform destroy. No traffic yet; zero blast radius.

Phase 2 — Pilot on low-risk cron workflows

Target: ci-digest-cron.yml, postmark-delivery-monitor.yml, queue-zero-dyno-monitor.yml (short, no service containers, no secrets, cron-only).

Soak: 48 hours, 10+ successful runs per workflow. Confirm cold-start p95 < 90 seconds.

Rollback: revert runner label → GH-hosted instant resume.

Phase 3 — PR gates + heavy CI

Target: ci.yml (backend-tests-postgres 90-min job), ci-pr.yml, antlers-next-ci.yml, ci-console.yml.

Gate before Phase 3: validate postgres:15 service container on AL2023 runner AMI (OQ-6 smoke test).

Soak: 7 days, 20+ backend-postgres successful runs on heavy on-demand runner.

Rollback: one-PR revert of runs-on change. < 5-minute blast radius.

Phase 4 — Deploy + release workflows (highest risk)

Sequence within Phase 4: 1. Staging-only deploys first (deploy-heroku to staging, deploy-antlers-next-staging) 2. 5-day soak on staging 3. Prod deploys (deploy-heroku prod, deploy-console, deploy-queue, deploy-velvet) 4. Release pipeline last (gatekeeper-develop-to-release, promote-release-to-main)

Rollback: revert runner label per-workflow. Because workflows are stateless, any single workflow can revert independently in under 5 minutes.

Phase 5 — Decommission + expand


9. /onboard-ci Skill (1-page sketch)

Modeled on the /onboard-vault skill pattern. Script at scripts/skills/onboard-ci.sh.

/onboard-ci <github-org>/<repo> [--runner-label <label>] [--canary-workflow <name>]

Steps (idempotent — re-runs are safe):

  1. App install check: verify raxx-ci-runner GitHub App is installed on target repo. If not, output install URL and exit 1.

  2. Webhook registration: POST to GitHub API POST /repos/{owner}/{repo}/hooks with workflow_job event and ALB /webhook endpoint + HMAC secret. Skip if identical webhook already exists (check by URL match).

  3. Runner group repo scope: update runner_runner_group_repos in Terraform (or via GH API PUT /orgs/{org}/actions/runner-groups/{id}/repositories/{id}) to include the new repo. Apply if changed.

  4. Status check audit: read branch protection rules via GitHub API; print any required status check names that will change if runner labels change. No auto-update; operator confirms.

  5. Canary run: trigger lowest-risk workflow via workflow_dispatch (default: the first workflow_dispatch-enabled workflow in the repo). Poll for completion. Confirm the run's runner_name contains ip-10.42 (self-hosted runner IP range).

  6. Output: Repo: raxx-app/TargetRepo GH App: INSTALLED Webhook: REGISTERED (id: 99887766) Runner group: UPDATED (ec2-self-hosted) Canary run: #42 SUCCESS (runner: ip-10-42-10-12.ec2.internal, 52s cold start) Status: ONBOARDED

The skill has no persistent state. Language: shell (Tier 2 classification applies; no Tier 1 criteria met — not a latency-critical path, no auth credential handling).


10. Security / GDPR Checklist


11. Open Questions (required before sub-cards can be claimed)

OQ-1 (blocking Phase 1): Oracle Dyn DNS records for ACM and ci subdomain ACM needs a DNS validation CNAME at _acm-challenge.ci.moosequest.net. After Terraform apply outputs the ALB DNS name, a second Dyn CNAME ci.moosequest.net → <alb-dns> is required. Both are manual steps in Oracle Dyn. Estimate: 15 minutes. Operator does this once after Phase 1 Terraform first-apply.

OQ-2 (blocking Phase 1): OIDC IdP choice for ALB Proposed: Google Workspace OAuth 2.0 (operator already uses Google; 2FA + passkey enforced at Google account level). Alternative: GitHub OAuth (simpler but weaker isolation). Confirm choice so Terraform configures the ALB OIDC rule and the Google Cloud Console OAuth app is created.

OQ-3 (design clarification): WebAuthn invariant boundary for ci.moosequest.net The Raxx product invariant (passkeys/WebAuthn only) applies to raxx.app. ci.moosequest.net is an internal ops tool. ALB OIDC → Google (with passkey at Google account level) is proposed as consistent with the spirit of the invariant. Operator confirms this interpretation, or instructs otherwise.

OQ-4 (Phase 1 scoping): IaC repo placement Options: (a) infra/ci/ in this repo (simpler for Phase 1), (b) new raxx-infra repo (better secrets isolation; Terraform state stays separate from application code; better for Phase 5 multi-repo expansion). Recommendation: start in infra/ci/ and move to raxx-infra before Phase 5. Operator confirms.

OQ-5 (cost verification): Confirm actual monthly GH Actions minutes The estimate (25,700 min/month, ~$186/mo) was derived from a run-density sample. Actual figure is in GitHub Settings → Billing → Actions usage. Operator provides the number (or confirms the estimate is in the right order of magnitude) to validate the savings case.

OQ-6 (Phase 3 gate): postgres:15 service container smoke on AL2023 AMI Before migrating ci.yml, a 1-hour smoke test must confirm that postgres:15 service containers start correctly on Amazon Linux 2023 with Docker. This is an engineering verification, not an operator decision — flagged here as a required gate before Phase 3.