Infrastructure Briefing · fea/auto-scaling-terraform

Delta moves to Cape Town — and learns to scale.

A replatform from a single Forge-managed box in Ireland to an auto-scaling, infrastructure-as-code stack in AWS Cape Town. This is the map: how it's shaped, how you'll operate it, what it costs, and where it's deliberately imperfect.

Region eu-west-1 af-south-1 Latency ~180 ms ~10 ms Web tier 1 box 1–5 auto Cost ~$60 ~$130–175/mo Managed by Forge Terraform + Packer
00

The whole thing in four ideas

If you hold four ideas in your head, almost everything else follows. The rest of this doc is detail hanging off these.

Idea 1

Web instances are cattle. They hold no state — sessions and cache live in Redis, files in S3/EFS, data in RDS. AWS creates and destroys them freely: more during school hours, fewer at night, a fresh one whenever the load balancer decides one is sick. You never fix a web box. You terminate it, and a healthy one boots from the same image in ~2 minutes.

Idea 2

The worker is the one pet. Anything that must run exactly once — Horizon queues, the scheduler (cron), the Reverb WebSocket server — lives on a single worker instance. It's the one machine we care about individually, and the one genuine single point of failure in the compute tier.

Idea 3

Nothing is configured by hand. Every AWS resource is Terraform code in infrastructure/; every server is built from a Packer image; the app's config is one .env file in AWS Secrets Manager. Clicking around the console to change things is doing it wrong (looking is fine). One deliberate exception: the production database is managed out-of-band, not by Terraform — see §02.

Idea 4

Deploys are just files. GitHub builds a tarball, uploads it to S3, and every instance downloads and atomically switches to it. New instances boot from the same tarball. Rollback = point everyone back at the last known-good tarball — which happens automatically if the health check fails.

Prime directive

During any incident: the database and Redis are the only components with real state. Everything else can be rebuilt from code in minutes. So — be calm about compute, paranoid about data.

01

What we're leaving: everything on one box

Today Delta is a single c7g.large in Ireland, managed by Laravel Forge, running every role — web, queues, cron, WebSockets, and Redis — on the one machine.

It has served us well, but it's out of room:

  • ~180 ms latency tax. Users are in South Africa, the server is in Ireland. For an Inertia app where every navigation is a full server round-trip, that's seconds of dead time per session.
  • Single point of failure. A runaway job, memory spike, or Redis hiccup takes down everything at once.
  • No isolation. Horizon workers fight PHP-FPM for CPU and RAM — a heavy import or export degrades page loads for everyone.
  • No way to scale. Peak school hours (08:00–15:00 SAST) push the one box near capacity, with no headroom to absorb a spike.
02

What we're moving to: split roles, auto-scaling web

Cloudflare → a load balancer → a fleet of stateless web boxes that grows and shrinks, plus one worker for the run-once jobs. State is pushed out to managed services (Redis, MySQL, EFS). All in Cape Town, ~10 ms from users.

The request path: a browser hits a tenant domain → Cloudflare → the ALB. Normal traffic goes to whichever web instance is free; anything under /app/* (Reverb WebSockets) is routed to the worker. Web boxes and the worker both live in private subnets with no public IP — the only way in from the internet is through the ALB, and the only way out is through the NAT Gateway.

Why private subnets?

Synchronous outbound calls from web requests (Zoho CRM during enrolment, FreePaid SOAP) need a stable source IP that vendors can whitelist. Public web boxes would each have a rotating IP. Routing all egress through one NAT Gateway with one Elastic IP gives every outbound call a single, whitelistable address.

The one hand-managed thing

Production RDS is deliberately not in Terraform. It was restored from the Ireland snapshot during migration and is managed out-of-band (retention, resizing done via the AWS CLI, recorded in rds.tf comments). Terraform only manages an RDS instance in the staging workspace. This keeps a terraform destroy gone wrong from ever touching the real database.

03

Old vs new, at a glance

Same application, very different operational shape. The amber column is what we're retiring; teal is where we're going.

AspectOld — Ireland / ForgeNew — Cape Town / IaC
Region & latencyeu-west-1 · ~180 msaf-south-1 · ~10 ms
TopologyOne box runs everythingWeb fleet + isolated worker + managed data services
Web scalingFixed — 1 instance manual resize only1–5, auto scheduled 07:00/17:00 SAST + CPU target 60%
ProvisioningForge UI, click-opsTerraform + Packer reviewed, version-controlled
DeploysForge git-pull webhookGitHub Actions → S3 artifact → SSM health-gated, auto-rollback
Config / secrets.env on the serverOne .env in Secrets Manager
Server accessSSH via ForgeSSM Session Manager no SSH keys, no public IPs
TLS / edgeTerminated on the boxACM on the ALB · Cloudflare-only ingress
RedisOn the box (shared RAM)ElastiCache t4g.small TLS + auth · noeviction
ObservabilityForge + ad-hoc SSHNightwatch (app) + CloudWatch (infra, ~12 alarms)
BackupsRDS snapshots + weekly dumpRDS snapshots + logical dump → private S3 + EFS AWS Backup
Main failure pointseverything, togetherWorker · single-AZ NAT · single-AZ RDS web tier now self-heals
Cost (excl. RDS/S3)~$60/mo~$130–175/mo
04

How does a deploy work now?

You push to main. That's the whole action. GitHub Actions builds a release, ships it to the worker first (so migrations run once), then to the web fleet, verifies the load balancer sees everyone healthy, and rolls back automatically if it doesn't. About 3–4 minutes end to end.

Push to main
tests already passed as the PR gate — not re-run here
GitHub Actions builds
composer --no-dev · npm ci + build · tar.gz
Upload immutable artifact to S3
releases/sha/<commit>.tar.gz · versioned · encrypted
SSM Run Command · worker FIRST
Worker deploys
migrate --force · operations:process · ProductionSeeder
→ atomic symlink switch → restart Horizon/Reverb
only after worker succeeds
Web fleet deploys
download → symlink shared storage → fetch .env → switch → reload PHP-FPM
Health gate → promote pointers
both target groups healthy → promote latest → converge scale-out boxes → 2nd gate → promote stable

A few things worth knowing:

  • Worker-first ordering is load-bearing. The worker runs migrations before any web box switches to the new code, so web instances never run against a schema that doesn't exist yet. (This is why the manual deploy scripts warn against targeting all instances at once.)
  • Tests are the PR gate, not the deploy. The full Pest matrix runs on every PR into main (on Blacksmith runners). The deploy pipeline deliberately has no test step, to stay fast. The accepted exposure: a direct push to main deploys untested — branch protection is the fix and is tracked separately.
  • Two pointers: latest and stable. New instances boot from latest. stable is only advanced after the final health check passes, so it's always a known-good release to roll back to.
  • Rollback is automatic. If the health gate fails, the pipeline restores stablelatest and redeploys every running box in rollback mode. Forward-only migrations are not reversed — recovery is code-level, which is why we enforce expand-and-contract migrations.
Design choice

Everything has one 15-minute timeout. Deploys run ~2–3 min, so 15 min is a generous ceiling, not a target — anything that legitimately needs longer doesn't belong in a deploy (chunk it, or run it out-of-band).

05

Once we're live, how do I change things?

Depends what you're changing. There are four distinct paths, and it's worth being clear on which is which — they have very different blast radii.

To change…You…Blast radius
Application codeMerge a PR to main → auto-deploysafe health-gated, auto-rollback
App config (.env)Edit the Secrets Manager secret, then trigger a deploy to pick it upmedium re-read fleet-wide on next deploy
InfrastructureEdit Terraform → plan → review → apply; risky changes go through staging firsthigh depends on the resource
Capacity (for an event)Raise the ASG floor manually; it drifts back down on schedulesafe costs a bit more, briefly

Changing app config

The .env lives in Secrets Manager (/delta/production/env), not on any server. Pull it, edit it, run the same preflight the boxes run (APP_ENV/APP_KEY present, ≤64 KB), push a new secret version, then deploy so instances re-fetch it. A failed fetch never overwrites the live file.

Changing infrastructure

Edit the Terraform in infrastructure/terraform/, run terraform plan, get a review, apply. How careful you need to be scales with the change:

Change typeApproach
Tweak a variable ASG max, alarm threshold, cron, instance typeplan against prod, eyeball the diff, apply. No staging.
Add a resource new alarm, IAM policy, SSM docplan usually enough. Staging optional.
Modify existing security groups, AMIs, ALB rules, networkSpin up staging, validate, then apply to prod.
Destructive rename, replace RDS, restructure VPCStaging first. The "I'd lose sleep" changes.

Staging is an ephemeral mirror — the same Terraform in a second workspace (10.1.0.0/16, smaller instances, its own RDS restored from a PII-sanitised seed snapshot). Spin it up (staging-up.sh, ~25–35 min), test, tear it down (staging-down.sh) so idle cost is near-zero. A one-time full rehearsal on staging is a gate before the production cutover.

Getting a shell

No SSH keys, no public IPs. Everything is AWS Session Manager: aws ssm start-session --target <instance-id>. You'll almost always want the worker — that's where Horizon, the scheduler, and artisan commands live (cd /var/www/delta/current && sudo -u deploy php artisan …).

Scaling for a known event

Big onboarding tomorrow? Raise the floor ahead of time with aws autoscaling set-desired-capacity … --desired-capacity 4. It drifts back down automatically (schedules re-assert the floor at 07:00/17:00 SAST; CPU-based scaling handles surprises in between). The ASG max of 5 is the real ceiling.

06

How do I know it's healthy — and debug it?

Two layers. Nightwatch is the first stop for anything app-level; CloudWatch owns infra metrics, logs, and the alarms that email you. Alerts arrive by email from CloudWatch via SNS.

Nightwatch — the app layer

Laravel-native ingestion of requests, exceptions, jobs, and logs, each linked to what produced it. Its agent runs on every instance (web boxes too, not just the worker — a subtle bug caught in the audit). This is where you go first for "why did that request 500" or "why did that job fail."

CloudWatch — the infra layer

Nginx and PHP-FPM logs (/delta/web/*, 30-day retention), infra metrics, and ~12 alarms wired to an SNS email topic. The raw laravel.log also lands on EFS as a durable archive. (A Slack Chatbot integration is scaffolded but not yet enabled.)

What's alarmed

SignalFires whenAction
ALB 5xx>10 target 5xx (or >5 ALB-generated) in 5 minemail
ALB latencytarget response time > 2 semail
No healthy targetsweb or reverb group empty for 2 min missing data = breachingemail
Unhealthy hosts≥1 web box failing /upemail
Web CPUASG average > 80% for 3 minemail (60% already triggers scale-out)
Surplus CPU creditsburstable surcharge billed 3 h runningemail — "time to leave t4g"
Redis CPU / memoryeither > 80%email memory is the early warning before write errors
NAT port allocationgateway exhausts ports to one destinationemail
Worker system checkhardware faultauto-recover EC2 reboots on new hardware
Worker instance checkOS-level fault (OOM, disk, panic)email — manual intervention
Budgetspend hits 80% / 100% / forecast of $150email

Triage order for common symptoms lives in §07 and the ops docs. Rule of thumb: 5xx or user-reported errors → CloudWatch nginx/php-fpm logs + Nightwatch; jobs stuck → SSM into the worker, php artisan horizon:status.

07

What happens when something breaks?

Mostly, the system heals itself — that's the point of cattle. The exceptions are the stateful and single-instance pieces, and those are worth knowing cold.

If this dies…What happensImpact
A web instanceALB marks it unhealthy; ASG terminates and replaces it (~2 min boot). Don't debug it — kill it.self-heals
The worker (hardware)EC2 auto-recovers on new hardware — same instance ID, IP, and EIP. Horizon/Reverb/scheduler resume after reboot.auto brief pause
The worker (OS-level)Alarm emails; needs a human. Queued jobs wait in Redis; WebSocket clients reconnect automatically once it's back.~mins, manual
RedisReal state — sessions, cache, queues, locks. Single node; managed failover is off. Pressure surfaces as loud write errors (noeviction), never silent job loss.STATE
RDSSingle-AZ. Recover from automated snapshots / point-in-time. Multi-AZ is a future toggle (doubles cost).STATE
AZ-A outageTakes the NAT Gateway with it → both private subnets lose internet egress, SSM access, and external integrations. ALB ingress and in-VPC data traffic keep working.accepted risk
A bad deployHealth gate fails → automatic rollback to stable across the fleet.self-heals
Restated

The two red STATE rows — Redis and RDS — are the only places a failure means data, not just downtime. Everything else is rebuildable from code in minutes. That's the line between "stay calm" and "move carefully."

08

Is this more or less secure than before?

Materially more, on almost every axis — with one deliberate, contained exception (a publicly-reachable database endpoint for BI tools).

  • Origin lockdown. The ALB only accepts traffic from Cloudflare's published edge ranges (plus admin/VPN CIDRs). Anyone who finds the ALB DNS name via Certificate Transparency logs still can't bypass Cloudflare's DDoS/WAF/cache. A weekly CI job fails loudly if Cloudflare's IP list drifts.
  • No SSH. Access is SSM Session Manager only; instances have no public IPs and IMDSv2 is required. No key material to leak or rotate.
  • Private compute. Web and worker sit in private subnets; the only inbound path is the ALB, enforced by security-group-to-security-group rules (not IP ranges).
  • Secrets stay secret. The .env is in Secrets Manager, never committed, never baked into an image. Deploys authenticate via GitHub OIDC — no static AWS keys, and trust is scoped to the main branch.
  • Encryption everywhere. At rest on EBS, EFS, RDS, Redis, and S3; in transit to Redis (TLS + auth token).
The deliberate exception

RDS is publicly accessible so Looker Studio and TablePlus can connect directly. It's constrained to port 3306 from the application security groups plus an explicit IP allow-list — but it's a broader surface than a private-only database. A conscious trade for the team's BI workflow, documented as such.

09

What does it cost, and what's the ceiling?

Roughly $130–175/month excluding RDS and S3, up from ~$60. The variability is the web fleet flexing with the school day. The hard ceiling is the ASG max of 5 instances — the budget alarm informs, it can't stop spending.

Projected monthly, af-south-1 (excl. RDS & existing S3)
ComponentMonthlyNote
NAT Gateway (+ EIP + per-GB)~$35–50~$25–35 of this is the managed-vs-self-managed premium
ALB~$25 + LCU
Web ASG~$16–801× off-peak → 3×+ school hours, max 5
Worker (24/7)~$16t4g.small
ElastiCache Redis~$25t4g.small, 1.37 GB
EFS~$2–5shared storage only
CloudWatch + S3 deploy~$5detailed monitoring
Total~$126–174vs ~$60 today

What the ~$70–110/mo increase buys: the 10–15× latency win, a self-healing auto-scaling web tier, an isolated worker so jobs can't degrade page loads, managed Redis with real headroom, zero-downtime deploys with instant rollback, and the whole thing as reviewable, reproducible code.

Watch-outs

The budget alarm only fires once the Project cost-allocation tag is activated in Billing (a one-time manual step; up to 24 h lag). And the biggest single premium — the managed NAT Gateway — was a conscious "pay ~$25–35/mo to delete the most fragile self-managed component in the stack" decision (see §11 / §12).

10

How are we backed up, and can we actually restore?

Three independent layers for the database and shared files — and, importantly, restore drills are a documented, rehearsed step, not a hope.

LayerWhat & whereCadence / retention
RDS automatedAWS snapshots + point-in-time recovery5-day retention
Logical DB dumpspatie/laravel-backup → private, encrypted, versioned S3 bucket (worker role only can read/write)daily · create 01:30, monitor 04:30 SAST
EFS shared filesAWS Backup vault (storage/app + logs)daily · 30-day retention
  • EFS is protected in prod with Terraform prevent_destroy — removing that guard is itself a reviewed code change.
  • Restore is proven, not assumed. Ops docs carry step-by-step drills for both the DB dump and the EFS recovery point — restored into an isolated throwaway target, with row-count checks recorded. Proving a restore is a gate before cutover.
  • What we don't back up ourselves: user files live in S3/EFS with their own durability, so the logical backup is deliberately database-only — nightly zips of storage/ added size for no recovery value.
11

What's actually harder now? (the honest column)

A distributed system buys resilience and scale at the price of moving parts. These are the real trade-offs and the risks we've knowingly accepted — the parts a colleague should push on.

Things that genuinely got harder

  • Maintenance mode is per-instance. php artisan down writes a marker into local storage/framework, so it only downs the box you ran it on. Fleet-wide maintenance needs an SSM fan-out (documented) or a Cloudflare/ALB rule.
  • Debugging spans machines. No single box holds the whole story any more. Mitigated by centralising app telemetry in Nightwatch — but it's a mindset shift from "SSH into the box."
  • The deploy pipeline is more than a git pull. More capable (health gates, rollback, worker-first) but more machinery to understand when something in it misbehaves.
  • No more Forge dashboard. Ad-hoc server management is now SSM + Terraform + AWS console instead of a friendly UI. More power, steeper day-to-day.
  • Shared storage has NFS latency. storage/app on EFS is slower than local disk — which is exactly why compiled views and framework caches were kept on local disk, off the request hot path.

Accepted risks (deliberate, with upgrade paths)

Single-AZ NAT

Both private subnets share one NAT Gateway in AZ-A. An AZ-A outage removes egress, SSM, and external integrations from both AZs. Chosen over ~$25–35/mo for a second gateway. Upgrade: add a NAT in AZ-B with per-AZ route tables.

Single-AZ RDS

No automatic database failover. Covered by snapshots + PITR. Upgrade: enable Multi-AZ (doubles RDS cost).

Worker is a SPOF

One instance for queues, WebSockets, and cron. It won't auto-update its AMI (that would silently take Horizon down on a routine apply); it's replaced deliberately, off-peak, with ~3–5 min impact. Its fixed private IP survives replacement.

Boot-from-latest race

New instances always boot the latest pointer, so a box launching mid-deploy could briefly run code ahead of the schema. Bounded by worker-first ordering + enforced expand-and-contract migrations; chosen over boot-from-stable, which could strand a box on old code indefinitely.

12

How do we actually get from Ireland to Cape Town?

A canary phase against an isolated copy of the data, then a short Saturday maintenance window built around promoting a cross-region database replica. Rollback is free right up until the database point-of-no-return.

Canary — prove it in isolation (days 1–3)

Cape Town connects only to a dedicated delta-canary RDS restored from an Ireland snapshot — never the live database. A single test tenant is pointed at the new ALB via a DNS override. External integrations get canary-safe overrides (e.g. a canary_ Algolia prefix, mail to log) so week-old snapshot data can't write to production systems. The phases:

  1. Smoke test — infra health, ALB, EFS, worker services, WebSocket ingress.
  2. Single-tenant canary — one real tenant on the new stack + 24 h soak.
  3. Load test — verify scale-out, scale-in, and scheduled scaling actually work.
  4. Deploy pipeline — a real deploy, a real rollback, deploy under traffic, scale-out during a deploy.

Cutover — the Saturday window (phase 5)

Days ahead, a cross-region read replica of the Ireland RDS is created in Cape Town and left syncing. The window itself is short:

  1. Old site → maintenance; stop the scheduler; let Horizon drain; stop Horizon (the drain gate — no in-flight jobs lost).
  2. Confirm replica lag is zero.
  3. Promote the Cape Town replica — minutes, and the point of no return.
  4. Update the .env secret to point at the promoted database; deploy so everyone picks it up.
  5. Switch the wildcard DNS records to the ALB; verify.

After (phases 6–7)

The old Ireland box stays as a silenced cold standby for a week — in maintenance mode with Horizon and the scheduler stopped, so it can't double-send notifications or double-sync Zoho — then it's decommissioned.

Rollback boundary

Before the replica is promoted, rollback is a free DNS flip. After users start writing to the Cape Town database, a DNS-only rollback loses that data — past that point, rollback becomes a data decision, not a switch.

13

What did we deliberately not do?

Scope was held tight to de-risk the migration. These are known, deferred, and cheap to revisit when the need is real.

  • Multi-AZ NAT egress — a second NAT Gateway + per-AZ routing. Revisit when the availability gain justifies the monthly cost.
  • Multi-AZ RDS — automatic DB failover; doubles RDS cost.
  • Moving S3 buckets to af-south-1 — app buckets stay in Ireland initially; user downloads go browser-direct from S3, so impact is mostly on background jobs.
  • CloudFront — may be redundant given Cloudflare already does edge caching. Evaluate before adding.
  • PostgreSQL — planned separately to avoid compounding migration risk.
  • Containers (ECS/Fargate) — only if sustained scale outgrows ~3 instances. The ASG approach is simpler and sufficient now.
Already done during the audit

The self-managed NAT instance was replaced by a managed NAT Gateway ahead of launch — a "pay a bit more to delete the stack's most fragile component" call. A 38-finding pre-launch audit (AUDIT-FINDINGS.md) drove dozens of fixes before any of this ran in production.

14

Operator cheat sheet

The handful of commands that cover 90% of day-to-day operations. All in af-south-1.

Deploy
# Just push to main.
git push origin main
# Watch it in the repo's Actions tab.
Get a shell (usually the worker)
aws ssm start-session \
  --target <instance-id> \
  --region af-south-1
Run artisan (on the worker)
cd /var/www/delta/current
sudo -u deploy php artisan horizon:status
Scale up for an event
aws autoscaling set-desired-capacity \
  --auto-scaling-group-name delta-web-asg \
  --desired-capacity 4 --region af-south-1
Tail infra logs
aws logs tail /delta/web/nginx \
  --follow --region af-south-1
# app-level: Nightwatch dashboard
One unhealthy instance?
# Don't debug it. Terminate it —
# the ASG boots a healthy replacement.

Sources, all on fea/auto-scaling-terraform: infrastructure/README.md · ARCHITECTURE.md · OPERATIONS.md · AUDIT-FINDINGS.md · CANARY-PLAYBOOK.md · STAGING.md, cross-checked against the Terraform (terraform/*.tf), Packer image, and deploy pipeline (scripts/). Figures reflect the code as reviewed, not a running environment — nothing here has been terraform apply'd to production yet.