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.
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.
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.
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.
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.
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.
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.
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.
stateless · auto-scaling
Scheduler · Nightwatch
(out-of-band)
(shared files)
.env, fetched on boot and every deploy.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.
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.
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.
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.
| Aspect | Old — Ireland / Forge | New — Cape Town / IaC |
|---|---|---|
| Region & latency | eu-west-1 · ~180 ms | af-south-1 · ~10 ms |
| Topology | One box runs everything | Web fleet + isolated worker + managed data services |
| Web scaling | Fixed — 1 instance manual resize only | 1–5, auto scheduled 07:00/17:00 SAST + CPU target 60% |
| Provisioning | Forge UI, click-ops | Terraform + Packer reviewed, version-controlled |
| Deploys | Forge git-pull webhook | GitHub Actions → S3 artifact → SSM health-gated, auto-rollback |
| Config / secrets | .env on the server | One .env in Secrets Manager |
| Server access | SSH via Forge | SSM Session Manager no SSH keys, no public IPs |
| TLS / edge | Terminated on the box | ACM on the ALB · Cloudflare-only ingress |
| Redis | On the box (shared RAM) | ElastiCache t4g.small TLS + auth · noeviction |
| Observability | Forge + ad-hoc SSH | Nightwatch (app) + CloudWatch (infra, ~12 alarms) |
| Backups | RDS snapshots + weekly dump | RDS snapshots + logical dump → private S3 + EFS AWS Backup |
| Main failure points | everything, together | Worker · single-AZ NAT · single-AZ RDS web tier now self-heals |
| Cost (excl. RDS/S3) | ~$60/mo | ~$130–175/mo |
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.
→ atomic symlink switch → restart Horizon/Reverb
latest → converge scale-out boxes → 2nd gate → promote stableA 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 tomaindeploys untested — branch protection is the fix and is tracked separately. - Two pointers:
latestandstable. New instances boot fromlatest.stableis 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
stable→latestand 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.
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).
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 code | Merge a PR to main → auto-deploy | safe health-gated, auto-rollback |
App config (.env) | Edit the Secrets Manager secret, then trigger a deploy to pick it up | medium re-read fleet-wide on next deploy |
| Infrastructure | Edit Terraform → plan → review → apply; risky changes go through staging first | high depends on the resource |
| Capacity (for an event) | Raise the ASG floor manually; it drifts back down on schedule | safe 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 type | Approach |
|---|---|
| Tweak a variable ASG max, alarm threshold, cron, instance type | plan against prod, eyeball the diff, apply. No staging. |
| Add a resource new alarm, IAM policy, SSM doc | plan usually enough. Staging optional. |
| Modify existing security groups, AMIs, ALB rules, network | Spin up staging, validate, then apply to prod. |
| Destructive rename, replace RDS, restructure VPC | Staging 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.
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
| Signal | Fires when | Action |
|---|---|---|
| ALB 5xx | >10 target 5xx (or >5 ALB-generated) in 5 min | |
| ALB latency | target response time > 2 s | |
| No healthy targets | web or reverb group empty for 2 min missing data = breaching | |
| Unhealthy hosts | ≥1 web box failing /up | |
| Web CPU | ASG average > 80% for 3 min | email (60% already triggers scale-out) |
| Surplus CPU credits | burstable surcharge billed 3 h running | email — "time to leave t4g" |
| Redis CPU / memory | either > 80% | email memory is the early warning before write errors |
| NAT port allocation | gateway exhausts ports to one destination | |
| Worker system check | hardware fault | auto-recover EC2 reboots on new hardware |
| Worker instance check | OS-level fault (OOM, disk, panic) | email — manual intervention |
| Budget | spend hits 80% / 100% / forecast of $150 |
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.
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 happens | Impact |
|---|---|---|
| A web instance | ALB 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 |
| Redis | Real state — sessions, cache, queues, locks. Single node; managed failover is off. Pressure surfaces as loud write errors (noeviction), never silent job loss. | STATE |
| RDS | Single-AZ. Recover from automated snapshots / point-in-time. Multi-AZ is a future toggle (doubles cost). | STATE |
| AZ-A outage | Takes 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 deploy | Health gate fails → automatic rollback to stable across the fleet. | self-heals |
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."
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
.envis in Secrets Manager, never committed, never baked into an image. Deploys authenticate via GitHub OIDC — no static AWS keys, and trust is scoped to themainbranch. - Encryption everywhere. At rest on EBS, EFS, RDS, Redis, and S3; in transit to Redis (TLS + auth token).
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.
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.
| Component | Monthly | Note |
|---|---|---|
| NAT Gateway (+ EIP + per-GB) | ~$35–50 | ~$25–35 of this is the managed-vs-self-managed premium |
| ALB | ~$25 + LCU | |
| Web ASG | ~$16–80 | 1× off-peak → 3×+ school hours, max 5 |
| Worker (24/7) | ~$16 | t4g.small |
| ElastiCache Redis | ~$25 | t4g.small, 1.37 GB |
| EFS | ~$2–5 | shared storage only |
| CloudWatch + S3 deploy | ~$5 | detailed monitoring |
| Total | ~$126–174 | vs ~$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.
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).
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.
| Layer | What & where | Cadence / retention |
|---|---|---|
| RDS automated | AWS snapshots + point-in-time recovery | 5-day retention |
| Logical DB dump | spatie/laravel-backup → private, encrypted, versioned S3 bucket (worker role only can read/write) | daily · create 01:30, monitor 04:30 SAST |
| EFS shared files | AWS 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.
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 downwrites a marker into localstorage/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/appon 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)
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.
No automatic database failover. Covered by snapshots + PITR. Upgrade: enable Multi-AZ (doubles RDS cost).
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.
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.
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:
- Smoke test — infra health, ALB, EFS, worker services, WebSocket ingress.
- Single-tenant canary — one real tenant on the new stack + 24 h soak.
- Load test — verify scale-out, scale-in, and scheduled scaling actually work.
- 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:
- Old site → maintenance; stop the scheduler; let Horizon drain; stop Horizon (the drain gate — no in-flight jobs lost).
- Confirm replica lag is zero.
- Promote the Cape Town replica — minutes, and the point of no return.
- Update the
.envsecret to point at the promoted database; deploy so everyone picks it up. - 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.
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.
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.
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.
Operator cheat sheet
The handful of commands that cover 90% of day-to-day operations. All in af-south-1.
# Just push to main.
git push origin main
# Watch it in the repo's Actions tab.
aws ssm start-session \
--target <instance-id> \
--region af-south-1
cd /var/www/delta/current
sudo -u deploy php artisan horizon:status
aws autoscaling set-desired-capacity \
--auto-scaling-group-name delta-web-asg \
--desired-capacity 4 --region af-south-1
aws logs tail /delta/web/nginx \
--follow --region af-south-1
# app-level: Nightwatch dashboard
# 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.