Infrastructure-as-Code for a product search & management platform (.NET microservices, Angular admin panel, React Native app). Fully modular Terraform on AWS, engineered to run within a strict cost budget.
Region: ap-south-1 · IaC: Terraform (AWS provider ~> 5.0) · Custom VPC (no
default VPC used anywhere)
Everything lives in a dedicated 10.0.0.0/16 VPC across 2 AZs:
- Public subnets — ALB + the ECS EC2 host (public IP, but locked down by security group). This is what faces the internet.
- Private subnets — RDS, ElastiCache, OpenSearch. No route to the internet at all (no NAT). They only talk to ECS.
- No default VPC — the module builds its own VPC, IGW, subnets, route tables,
and security groups.
terraform destroyremoves all of it cleanly.
The app/ directory holds a working implementation of the platform — an
internal MRO spare-parts store — that runs the entire architecture locally
with Docker Compose, against the same components the Terraform provisions
(real OpenSearch, real SQS/S3 via LocalStack — no substitutes), without touching AWS.
cd app && docker compose up --build
# admin panel: http://localhost:8090 (admin@appstack.local / Admin123!)
# Grafana: http://localhost:3000 · Prometheus: http://localhost:909016 containers: 8 .NET microservices + 2 SQS workers, Postgres, OpenSearch,
LocalStack (SQS+S3), Prometheus + Grafana, and an nginx gateway that stands in
for the ALB path-routing and the CloudFront/S3 SPA origin. Both event pipelines
(price-sync → OpenSearch, S3 pdf-ingest → Postgres) work end-to-end. See
app/README.md for the full walkthrough.
The same app has been provisioned and verified end-to-end on real AWS. A merge
to main runs one pipeline (apply.yml):
terraform apply → publish SPA to S3 + CloudFront invalidate
→ build & push 11 images to ECR
→ db-migrate (schema/seed into RDS, one-shot ECS task)
→ roll the 10 ECS services
- The
ecs_servicesmodule turns the cluster into running services: per-service ECS task definitions, services, ALB target groups + path rules. - CloudFront serves the SPA (S3) and the API (ALB) from one origin → no CORS.
- Validated live through the CloudFront URL: login, OpenSearch search, catalog, suppliers, requisition approve (stock deduct), inventory, low-stock alerts, and the CSV bulk-upload pipeline (S3 → SQS → worker → Postgres → search).
- Runs on 4× t3.micro hosts and tears down cleanly with
destroy.yml.
The platform has two planes: a synchronous request plane (user → API → data) and an asynchronous event plane (SQS workers). They are intentionally separated.
User / Admin → CloudFront ─(static SPA)→ S3
→ ALB ─(HTTP)→ ECS service ─→ Redis (cache)
─→ OpenSearch (reads/search)
─→ Postgres (writes)
- The Angular admin SPA is static files in a private S3 bucket, served by
CloudFront via Origin Access Control (OAC). S3 is never public; only this
one CloudFront distribution can read it. 403/404 fall back to
/index.htmlso client-side routing works. - The React Native app and admin SPA call the API through the ALB. The ALB is the only thing in front of ECS; the ECS security group accepts traffic only from the ALB's security group.
- .NET microservices run as containers on a single ECS-on-EC2 host. They read/write Postgres, use Redis for caching/sessions, and query OpenSearch for search. Search requests hit OpenSearch only — never Postgres.
A) Admin uploads PDF → S3 (ObjectCreated) → SQS pdf-ingest → pdf-ingest-worker → Postgres
B) Service writes price → SQS price-sync → search-sync-worker → OpenSearch
- PDF ingestion — admin uploads a 5–6k-item PDF to the PDF S3 bucket. S3
emits an
ObjectCreated(.pdf) event straight to thepdf-ingestSQS queue. Thepdf-ingest-worker(a container on ECS, not Lambda) parses it and batch-inserts into Postgres. - DB → Search sync — when a microservice changes a price in Postgres it
publishes a message to the
price-syncqueue. Thesearch-sync-workerconsumes it and updates the matching OpenSearch document. This keeps the search index eventually-consistent with the DB without coupling the write path to OpenSearch. - Each queue has a DLQ (
maxReceiveCountredrive) so poison messages don't loop forever.
- Reads are cheap and fast — search served by OpenSearch, hot data by Redis, so Postgres handles writes + transactional reads only.
- Spiky/heavy work is decoupled — PDF parsing and index syncing run off queues, so an upload burst can't take down the API.
- Workers are containers, not Lambda — deliberately, to demonstrate ECS/queue-consumer patterns and reuse the existing EC2 capacity.
Modules are composed in environments/dev/main.tf. vpc_network is the root —
everything else consumes its subnet IDs and security-group IDs:
| Module | Consumes from vpc_network |
Produces |
|---|---|---|
ecr_registry |
— | 10 ECR repos (8 svc + 2 workers) |
rds_postgres |
private_subnet_ids, data_sg_id |
Postgres endpoint |
elasticache_redis |
private_subnet_ids, data_sg_id |
Redis endpoint |
opensearch |
private_subnet_ids[0], data_sg_id |
Search domain endpoint |
ecs_compute |
vpc_id, public_subnet_ids, alb_sg_id, ecs_sg_id |
ECS cluster, ASG, ALB DNS |
ecs_services |
cluster, ALB listener, ECR URLs, DB/OpenSearch/SQS | 10 task defs + services + TGs + path rules, db-migrate task |
s3_cloudfront_frontend |
alb_dns_name (API origin) |
CloudFront domain (SPA + API, one origin) |
sqs_messaging |
— (own queues + PDF bucket) | Queue URLs, worker IAM policy |
internet ──► ALB SG ──► ECS SG ──► Data SG (RDS / Redis / OpenSearch)
:80/:443 from ALB 5432 / 6379 / 443 — from ECS only
- ALB SG — 80/443 from
0.0.0.0/0. - ECS SG — ingress only from the ALB SG.
- Data SG — Postgres/Redis/OpenSearch ports, ingress only from the ECS SG. No public IPs on any database.
- Private access for ops — SSM Session Manager (the ECS instance role has
AmazonSSMManagedInstanceCore). No bastion, no VPN, no SSH keys.
| Service | Choice | Monthly allowance used |
|---|---|---|
| Compute | ECS on EC2 t3.micro (not Fargate) | 750 hrs/mo |
| Database | RDS Postgres db.t3.micro single-AZ | 750 hrs, 20 GB |
| Cache | ElastiCache cache.t3.micro | 750 hrs |
| Search | OpenSearch t3.small.search single-node | 750 hrs, 10 GB |
| Frontend | S3 + CloudFront | 5 GB S3, 1 TB CF out |
| Messaging | SQS | 1M requests/mo |
| Registry | ECR (lifecycle: keep 5 images) | 500 MB |
| NAT | None — public-subnet ECS + S3/DynamoDB gateway endpoints | avoids ~$32/mo |
| Private access | SSM Session Manager (no VPN/bastion) | free |
| CI/CD | GitHub Actions + OIDC (no static keys) | free |
The gateway endpoints (S3 + DynamoDB) are free and let the private route table reach those services without a NAT Gateway — that's the single biggest cost avoided in this stack.
modules/
vpc_network/ VPC, public/private subnets, IGW, gateway endpoints, SGs
ecr_registry/ Per-service ECR repos + lifecycle policy
rds_postgres/ Postgres (private, logical replication on)
elasticache_redis/ Redis (private)
ecs_compute/ ECS cluster, EC2 ASG, ALB, IAM (incl. SSM)
ecs_services/ Per-service task defs + services + ALB target groups/rules + db-migrate
s3_cloudfront_frontend/ Admin SPA: private S3 + CloudFront (S3 + ALB origins, no CORS)
sqs_messaging/ price-sync + pdf-ingest queues, DLQs, PDF bucket events
opensearch/ Full-text search domain (VPC, SG-locked)
environments/
dev/ Wires modules with dev-grade sizes
app/ Runnable MRO-store app (8 services + 2 workers + SPA)
bootstrap/ One-time S3 + DynamoDB remote state backend
.github/workflows/ plan (PR) · apply (merge → provision + deploy) · destroy (manual)
# 0. One-time: create the remote-state backend (S3 bucket + DynamoDB lock table)
cd bootstrap && terraform init && terraform apply
# 1. Per environment
cd ../environments/dev
export TF_VAR_db_password='<strong-password>' # never commit; env var only
terraform init # uses the S3 backend from step 0
terraform plan
terraform apply| Name | Default | Notes |
|---|---|---|
project_name |
appstack |
prefix on every resource name |
region |
ap-south-1 |
— |
vpc_cidr |
10.0.0.0/16 |
dedicated VPC CIDR |
az_count |
2 |
AZs (ALB requires ≥ 2) |
db_password |
(required) | RDS master password — TF_VAR_db_password only |
This repo provisions the platform, not the application images. To run a real workload on top:
- Build & push images —
docker buildeach .NET service, tag, and push to its ECR repo (terraform outputlists the repo URLs). Workers (pdf-ingest-worker,search-sync-worker) push the same way. - Add ECS task definitions + services (deferred in this repo) — one task def
per service pointing at its ECR image, registered to the ECS cluster, fronted
by an ALB target group + listener rule (path-based routing per service). Inject
config via task-def env vars / SSM Parameter Store:
- DB host = RDS endpoint output, secret =
db_password - Redis host = ElastiCache endpoint output
- OpenSearch host = domain endpoint output
- Queue URLs = SQS outputs
- DB host = RDS endpoint output, secret =
- Wire the workers to the queues — the
sqs_messagingmodule already emits an IAM policy granting consume/send on the right queues + read on the PDF bucket; attach it to the worker task role. - Deploy the admin SPA —
ng build, syncdist/to the frontend S3 bucket, invalidate the CloudFront distribution. - Point the mobile app + admin SPA at the ALB DNS name (or a Route 53 record / ACM cert in front of it for HTTPS).
- Observability — run Prometheus + Grafana as containers on ECS; scrape the services + node exporter. (Self-hosted to stay free.)
- Add an
environments/prod/that reuses the same modules with bigger sizes: multi-AZ RDS, ≥2 ECS hosts behind the ASG, OpenSearch with 2+ data nodes. - Put HTTPS on the ALB (ACM cert) and redirect 80→443.
- Move to private ECS subnets + NAT (or VPC endpoints for ECR/CloudWatch/SSM) once cost is no longer the priority.
- Tighten OpenSearch access policy and enable fine-grained access control.
.github/workflows/ (OIDC — no static AWS keys):
plan.yml— runsterraform planread-only on pull requests.apply.yml— runsterraform applyon merge tomain, gated by aproductionGitHub environment approval.destroy.yml—workflow_dispatchonly; requires typing a confirmation string. Never runs automatically.
Required repo secrets: AWS_PLAN_ROLE_ARN, AWS_APPLY_ROLE_ARN,
TF_VAR_DB_PASSWORD. The two role ARNs are IAM roles with a GitHub OIDC trust
policy (not yet provisioned — see roadmap).
- OIDC IAM roles + GitHub environment/secrets so the workflows can run
- ECS task definitions + services + ALB listener rules per microservice
- Prometheus + Grafana self-hosted stack
-
environments/prod/with production sizing
