Skip to main contentUkasyah Nata Dewastoro
Cloud

Stage 3: An immutable AWS platform for the product

The startup has raised a Series A, the team is doubling, and the CTO wants every environment reproducible from code instead of console clicks. Three environments in Terraform behind Atlantis, CIS-hardened golden AMIs scored before release, app versions pinned in SSM, and fleets that switch off outside office hours.

0 hand-edited servers
Role
Team of four; I built the network, VPN, Terraform workflow and delivery tooling
Scale
3 environments, 3 AZs, 8 Auto Scaling groups
Stack
Terraform, Atlantis, Packer
Targets
No hand-edited servers, CIS score gate, off-hours shutdown

Context

By this stage the startup has funding and a growing team, and clicking through the cloud console no longer scales. The product is a backend with background workers, a web client and a mobile app, all needing a home that behaves the same in dev, staging and production. The application itself is not the subject of this page; the platform under it is.

The team was four engineers. I built the network foundation (VPC, NAT and the OpenVPN gateway), the Terraform workflow on Atlantis, and the delivery tooling. In the revision I added a self-hosted Git and Jenkins host when the hosted CI hit its workload limits, the web deploy pipeline, and the office-hours scheduler.

Constraints:

  • A restricted IAM role that could not create IAM roles or large instances
  • Three environments that must stay identical in shape
  • Every change reviewable before it reaches AWS

Architecture and failover

Each environment gets its own subnets across three availability zones, an Application Load Balancer, Auto Scaling groups for the web, API and worker tiers, RDS Postgres and a Valkey cache. Production adds SQS. A shared-services zone, reachable only over VPN, runs the CI runner, Atlantis, the monitoring stack and SonarQube behind an internal certificate authority.

App servers are never configured after boot. Packer bakes a CIS Level 1 golden image per role, the pipeline scores and scans it, and SSM Parameter Store holds the image ID and the app version each fleet should run. A deploy uploads the artifact to S3, moves the SSM pointer, and restarts the instances through SSM. Long-lived tooling hosts, the pets, are the only machines Ansible touches.

Losing an instance is routine: the Auto Scaling group replaces it with an identical one. Losing the load balancer's targets is not, which is what the incident above taught.

Golden image pipeline
  1. 1
    Build the CIS Level 1 base image

    Packer, then a role image (API, web, tooling) on top.

  2. 2
    Scan

    OpenSCAP scores CIS compliance; Trivy checks packages; goss checks the running image.

  3. 3
    GateScore gate

    A CIS score under 80 fails the build.

    If it fails: The image is never published. Fix the build and rerun.

  4. 4
    Publish to SSM

    The image ID goes to a parameter per role; the last three are kept.

  5. 5
    GateManual promote

    A person decides when fleets move to the new image.

  6. 6
    DeployFleets roll to the new image

    Auto Scaling groups replace instances one at a time.

App release through the SSM pointer
  1. 1
    GateQuality checks and SonarQube gate

    If it fails: Nothing is uploaded.

  2. 2
    Upload the artifact to S3
  3. 3
    Move the SSM version pointer

    CI writes the new version for the role.

  4. 4
    DeployRestart instances through SSM

    Each instance pulls the pinned version.

  5. 5
    GateHealth checks

    If it fails: CI writes the previous version back to the pointer and restarts.

Try it: replay the port change, or roll out a release through the SSM pointer.

Immutable AWS platform, three environments topologyEach environment has an Application Load Balancer in front of Auto Scaling groups running golden AMIs. The app version is a pointer in SSM Parameter Store. Data is in RDS Postgres and Valkey. Terraform changes go through Atlantis on pull requests; CI publishes artifacts and moves the SSM pointer.One environment (dev, staging, prod alike)Shared services, VPN onlyversionpublishapplyLoad balancer4 target groupsWeb fleetASG, golden AMIAPI fleetASG, golden AMISSM pointerversion per roleWorkersASGRDS Postgresper environmentValkeyper environmentAtlantisTerraform on PRsCI runnerbuild, publishLGTM monitoringmetrics, logs, traces
  • Primary / serving
  • Recovering
  • Degraded
  • Down
  • Replication / control

Pick a failure to see how the system detects and recovers from it.

Key decisions

Golden images for app fleets, Ansible only for tooling

  • immutable
  • AMI

Configuring app servers after boot lets them drift apart, and a replaced instance may not match the rest.

  • Ansible on every serverFlexible, but each host slowly becomes unique.
  • Golden AMIs for app fleets, Ansible for long-lived tooling hostsChosenFleets are cattle: replaced, never patched. Only pets like the VPN and CI runner get configured in place.

Trade-off: Every OS or dependency change means a new image build, which is slower than editing a running host.

Lifecycle

  1. Open a pull request on the Terraform repo.
  2. Atlantis posts the plan on the pull request and locks the state.
  3. A reviewer approves, then Atlantis applies.
  4. Terraform creates anything a listener depends on before it destroys the old copy.

Failure modes

FailureBlast radiusDetectionRecovery
Instance lostNoneAuto Scaling health checkAutomatic replacement from the golden image
Target groups replaced in the wrong orderEvery route returns 503Load balancer health checksPort in the name, create_before_destroy
Image fails the CIS scoreNone: it is never publishedPipeline gateFix the build and rerun
Bad releaseOne fleetHealth checks after deployAutomatic rollback of the SSM pointer
Jenkins crash-looping while Ansible reports successCI unavailableService status, not the playbook resultFix the unit and verify the service, not the task

Results

0app servers configured by hand after boot
80minimum CIS score for an image to ship
17resources shut down outside office hours
14 subnetsacross 3 AZs in production

What I'd change

I would review every Terraform plan for the word "replace" before applying it. The port change that took all four target groups offline showed a replacement in its plan, and nobody read it closely. Naming target groups by port with create_before_destroy now makes that change safe.

I would put integration tests in the pipeline from the start. The build hosts had no containers, so the backend's integration tests stayed switched off and the gate checked less than it appeared to.

I would make Ansible verify the service itself instead of trusting its own task results. Jenkins crash-looped twenty times while the playbook reported success.

I would give each tool its own repository from the first week. We split the monorepo in the revision, and every split cost a migration we could have avoided.

Next stage: the product moves into containers, and the images shrink by almost six times.