Last quarter we worked with a Series A fintech startup that had been running their entire product on a single EC2 instance with a manually managed PostgreSQL database and no CI/CD pipeline. Deployments were SSH sessions into production. Their lead engineer was the only person who knew how to restart the app. They had just closed a funding round and were about to run a marketing campaign that would 10× their traffic.
This is a common story. Most early-stage startups correctly prioritise shipping over infrastructure — then reach a funding event or growth inflection point where the technical debt becomes existential. This is a walkthrough of what we actually built for them, and why we made each decision.
Starting point audit
Before writing a line of Terraform, we spent a week auditing what existed:
- Single `t3.large` EC2 instance running Node.js app + Nginx + PostgreSQL - No automated backups (last manual backup was 6 weeks prior) - No SSL certificate renewal automation (cert expired once in the past) - Deployment: SSH + git pull + pm2 restart - Monitoring: none. Uptime discovered via user complaints. - IAM: one root account shared among three engineers - Secrets: hardcoded in a `.env` file on the server, committed to a private GitHub repo
The risk surface was significant. One bad deployment, one hardware failure, or one traffic spike would take down the entire product with no automated recovery path.
The target architecture
We designed for three goals: resilience (no single point of failure), observability (know what is happening before users complain), and automation (deployments and infrastructure changes require no manual steps).
Compute: ECS Fargate
We chose ECS Fargate over EC2, Kubernetes (EKS), or serverless Lambda for this workload. Fargate gives you containers without managing servers — no patching, no capacity planning, no node pools. The app was a standard Node.js API with consistent request patterns, which is exactly the ECS Fargate sweet spot. EKS would have added significant operational complexity for a team of four engineers; Lambda would have required architectural changes to the existing codebase.
The application runs as a service with a minimum of two tasks across two availability zones. If one task fails, ECS replaces it automatically. If one availability zone goes down, traffic routes to the surviving AZ.
Database: RDS PostgreSQL with Multi-AZ
We moved PostgreSQL from the EC2 instance to RDS with Multi-AZ enabled. Multi-AZ keeps a synchronous standby replica in a second availability zone. In the event of a primary failure, RDS promotes the standby automatically — typically within 60 to 120 seconds. Automated daily snapshots are retained for 14 days. Point-in-time recovery allows restoration to any second within the retention window.
We also added a `db.t3.medium` read replica for the analytics queries that were previously running against the production database and causing periodic slowdowns during business hours.
Networking: VPC with private subnets
The old setup had the database port open to the internet. We rebuilt the network from scratch:
- VPC with public subnets (load balancer only) and private subnets (app containers + database) - Application Load Balancer in the public subnets, terminating SSL - ECS tasks in private subnets, no public IP - RDS in isolated subnets, no internet access - NAT Gateway for outbound internet access from private subnets (package downloads, third-party API calls) - Security groups: ALB accepts 443 from 0.0.0.0/0; containers accept traffic only from ALB security group; RDS accepts traffic only from container security group
Nothing in the database layer is reachable from the internet. Database credentials exist only in AWS Secrets Manager, accessed by tasks at runtime via IAM role — the `.env` file with hardcoded credentials was deleted.
CI/CD: GitHub Actions
The deployment pipeline runs on every merge to `main`:
1. Run unit and integration tests (against a test database spun up in the action) 2. Run ESLint and TypeScript type check 3. Build Docker image 4. Run `trivy` image vulnerability scan — fails the pipeline if critical CVEs are found 5. Push image to ECR with the Git SHA as the tag 6. Register new ECS task definition pointing to the new image 7. Update ECS service — triggers a rolling deployment with zero downtime 8. Post deployment status to Slack
Total pipeline time: 4–6 minutes. Before this, a deployment was a 20-minute manual process with a non-zero chance of leaving the app in a broken state.
Observability: CloudWatch + Grafana
We set up structured JSON logging from the Node application, shipping to CloudWatch Logs. Container-level metrics (CPU, memory, request count, response time) are in CloudWatch Metrics.
We provisioned a Grafana instance (on a small ECS task) connected to CloudWatch as a data source, with dashboards for: - Request rate, error rate, and p50/p95/p99 latency - Database connection pool utilisation and query duration - ECS task CPU and memory headroom - ALB 4xx/5xx rates
Alerting is configured in Grafana: PagerDuty for P1 (service down, error rate > 5%), Slack for P2 (high latency, disk pressure).
Infrastructure as code: Terraform
Every resource described above is defined in Terraform, organised into modules: `networking`, `compute`, `database`, `ci`, `monitoring`. The state file lives in S3 with DynamoDB locking.
Any engineer can run `terraform plan` to see exactly what will change before applying. Every infrastructure change goes through a pull request — the plan output is posted as a PR comment by a GitHub Action. No more clicking in the console.
Results after 90 days
- Deployment frequency: 3–4 times per day (was once per week, due to manual process fear) - Mean time to recovery: 90 seconds for task failures (automatic), 2 minutes for AZ failures (automatic) - Cost vs old setup: 12% lower monthly AWS bill despite significantly higher reliability, primarily from right-sizing compute and eliminating idle resources - Security posture: No public database exposure, IAM least-privilege throughout, secrets in managed vault, image scanning on every deploy
The lead engineer's comment after handoff: "I slept properly for the first time in a year."
If your infrastructure story sounds like their starting point, [we can help](/contact). An audit takes one week and gives you a prioritised list of exactly what needs to change and a clear plan to get there.