A small Hospital Management System used as the vehicle to build, operate, and document a real production-style AWS environment with Terraform and GitHub Actions. The interesting work happens under
terraform/and.github/workflows/β not in the web UI.
Region ap-south-1 (Mumbai) Β· IaC Terraform 1.9+ Β·
Backend Python 3.12 + FastAPI Β· Frontend React 18 + Vite Β·
Database PostgreSQL 16 on RDS Β· CI/CD GitHub Actions + OIDC
This project is intentionally focused on the DevOps and SRE side: infrastructure-as-code, networking, IAM, deployment pipelines, observability, and cost control. The application logic (patients, appointments) is the simplest possible CRUD that gives the infrastructure something to host.
UI/UX is out of scope. The web frontend exists to prove the stack is wired up end-to-end (
CloudFront β S3for assets,CloudFront β ALB β EC2 β RDSfor the API). It is not designed as a product, and visual polish was not a goal. To evaluate this project, read the Terraform stacks, the workflows, and the architecture sections below.
Twenty-one tools, grouped by what they do in the system.
| Infrastructure declared once, applied from anywhere; remote state with locking. | Custom VPC across 2 AZs; defense-in-depth via SG chain plus stateless NACL backstops. |
Auto-healing ASG of t3.micro instances behind an ALB; container images in ECR.
|
| Relational data on private RDS; audit events on DynamoDB; DB password generated into Secrets Manager. | HTTP APIs in front of Lambda; SES for transactional email from the contact form. | One HTTPS origin for the whole app; static SPA from private S3 via Origin Access Control. |
| Metrics, logs, and alarms across every tier; SNS fan-out; X-Ray traces the serverless paths. | Least-privilege IAM with condition keys; keyless CI auth via OIDC federation. | Python 3.12 backend, React 18 SPA built with Vite β the simplest CRUD needed to wire the stack. |
The ten disciplines this project deliberately exercises. These are the things to grep the repo for, not "features":
| # | Practice | Where to find it |
|---|---|---|
| 1 | Remote state with locking β Terraform safe to run from multiple machines / CI | terraform/bootstrap/ (S3 + DynamoDB) |
| 2 | Per-stack state isolation β cross-stack reads via terraform_remote_state, never re-declaration |
every stack's backend.s3.key |
| 3 | Least-privilege IAM β resource-scoped policies, condition keys (e.g. ses:FromAddress, aws:SourceArn) |
terraform/{compute,serverless-contact,cdn}/iam.tf |
| 4 | Multi-AZ networking β public / private app / private DB subnets across two AZs | terraform/network/{subnets,routing}.tf |
| 5 | Immutable image deploys β ECR push + ASG start-instance-refresh rollout |
.github/workflows/backend.yml |
| 6 | Static assets through CloudFront β private S3 via OAC, cache invalidation on every deploy | terraform/cdn/ + .github/workflows/frontend.yml |
| 7 | Workflow concurrency β prevents state-lock and refresh races on rapid pushes | .github/workflows/terraform.yml concurrency: |
| 8 | Observability across three pillars β metrics, logs, traces (X-Ray) | terraform/observability/, Lambda tracing_config |
| 9 | Cost controls β Budgets, billing alarm, free-tier-aware sizing, NAT instance over Gateway | Doc 03, terraform/compute/nat.tf |
| 10 | Keyless GitHub Actions auth β OIDC federation, sub claim pinned to repo + refs |
terraform/cicd/oidc.tf |
- Architecture at a glance
- Architecture diagram
- Request flow
- Network topology
- Security architecture
- Data model
- Repository structure
- Infrastructure modules
- Prerequisites
- Quick start β deploy from scratch
- Local development
- CI/CD pipeline
- Observability
- Cost
- Teardown
- Documentation & learning path
Users hit CloudFront. Static React assets come from an S3 bucket via
Origin Access Control. API requests (/api/*) are forwarded to an
Application Load Balancer, which routes them to an Auto Scaling Group
of EC2 instances running the FastAPI backend container pulled from ECR. The
backend talks to a private RDS PostgreSQL instance using credentials
fetched at boot from Secrets Manager via the instance's IAM role.
Side flows run on serverless: a contact form posts to
API Gateway β Lambda β SES, and audit events go to
API Gateway β Lambda β DynamoDB with X-Ray tracing. CloudWatch collects
metrics and logs across every tier, with alarms fanning out through SNS to
email.
The full system at a glance. Public traffic enters through CloudFront; the three-tier path sits in a VPC; two serverless features hang off API Gateway.
flowchart TB
User([Users])
subgraph Edge["AWS Edge"]
CF["Amazon CloudFront<br/>(HTTPS, free *.cloudfront.net cert)"]
end
S3["S3 bucket<br/>(React SPA, private + OAC)"]
subgraph VPC["VPC 10.0.0.0/16 β ap-south-1"]
IGW{{Internet Gateway}}
subgraph Pub["Public subnets (AZ-a, AZ-b)"]
ALB["Application<br/>Load Balancer"]
NAT["NAT Instance<br/>t3.micro"]
end
subgraph AppT["Private app subnets (AZ-a, AZ-b)"]
ASG["Auto Scaling Group<br/>EC2 t3.micro<br/>FastAPI in Docker"]
end
subgraph DBT["Private DB subnets (AZ-a, AZ-b)"]
RDS[("RDS PostgreSQL<br/>encrypted, single-AZ")]
end
end
subgraph AWSsvc["AWS Services"]
ECR[("ECR<br/>Docker images")]
SM[("Secrets Manager<br/>DB credentials")]
CW["CloudWatch<br/>logs Β· metrics Β· alarms"]
end
subgraph SL["Serverless slices"]
APIa["API Gateway<br/>Audit"]
Laud["Lambda<br/>audit-handler"]
DDB[("DynamoDB<br/>audit events")]
APIc["API Gateway<br/>Contact"]
Lcon["Lambda<br/>contact-handler"]
SES["Amazon SES"]
XR["AWS X-Ray"]
end
User -- HTTPS --> CF
CF -- "/*" --> S3
CF -- "/api/*" --> ALB
IGW --- ALB
IGW --- NAT
ALB -- ":8000" --> ASG
ASG -- ":5432" --> RDS
ASG -.->|pull image| NAT
NAT --> IGW
ASG -. pull .-> ECR
ASG -. GetSecretValue .-> SM
ASG -. logs/metrics .-> CW
User -- HTTPS --> APIa
User -- HTTPS --> APIc
APIa --> Laud
Laud --> DDB
Laud -.-> XR
APIc --> Lcon
Lcon --> SES
The data tier is unreachable from anywhere except the app tier: it sits in a private subnet, has no public route, and its security group only trusts the app security group. Three independent locks.
A typical "load the patients page, then add a patient" sequence, with first-time boot calls included:
sequenceDiagram
actor User
participant CF as CloudFront
participant S3 as S3 (SPA)
participant ALB as ALB
participant EC2 as EC2 (FastAPI)
participant SM as Secrets Manager
participant RDS as RDS PostgreSQL
rect rgb(238,245,255)
Note over User,RDS: First page load (static)
User->>CF: GET /
CF->>S3: GET index.html (OAC, SigV4)
S3-->>CF: 200 OK
CF-->>User: 200 (cached at edge)
end
rect rgb(238,255,238)
Note over User,RDS: First boot of an instance
EC2->>SM: GetSecretValue(cloudcare/db/credentials)
SM-->>EC2: { username, password, host, ... }
end
rect rgb(255,245,235)
Note over User,RDS: API call
User->>CF: POST /api/patients { ... }
CF->>ALB: forward (origin: alb-api)
ALB->>EC2: forward to healthy target :8000
EC2->>RDS: INSERT INTO patients ...
RDS-->>EC2: row
EC2-->>ALB: 201 Created
ALB-->>CF: 201
CF-->>User: 201 Created
end
Six subnets across two AZs, three tiers, two route tables:
flowchart TB
Internet((Internet))
IGW{{Internet Gateway}}
subgraph VPC["VPC β 10.0.0.0/16"]
subgraph AZa["Availability Zone ap-south-1a"]
PubA["Public subnet<br/>10.0.0.0/24<br/>(ALB, NAT)"]
AppA["Private app subnet<br/>10.0.10.0/24<br/>(EC2)"]
DbA["Private db subnet<br/>10.0.20.0/24<br/>(RDS)"]
end
subgraph AZb["Availability Zone ap-south-1b"]
PubB["Public subnet<br/>10.0.1.0/24<br/>(ALB, standby)"]
AppB["Private app subnet<br/>10.0.11.0/24<br/>(EC2)"]
DbB["Private db subnet<br/>10.0.21.0/24<br/>(RDS standby)"]
end
RTpub["Public route table<br/>0.0.0.0/0 β IGW"]
RTpriv["Private route table<br/>0.0.0.0/0 β NAT instance"]
end
Internet --- IGW
IGW --- PubA
IGW --- PubB
PubA -.assoc.-> RTpub
PubB -.assoc.-> RTpub
AppA -.assoc.-> RTpriv
AppB -.assoc.-> RTpriv
DbA -.assoc.-> RTpriv
DbB -.assoc.-> RTpriv
| CIDR | Tier | Public? | Purpose |
|---|---|---|---|
10.0.0.0/24, 10.0.1.0/24 |
Public | β (route β IGW) | ALB, NAT instance |
10.0.10.0/24, 10.0.11.0/24 |
App (private) | β (egress via NAT) | EC2 ASG |
10.0.20.0/24, 10.0.21.0/24 |
DB (private) | β (local only) | RDS PostgreSQL |
The DB subnets have no NAT route either β the database has zero egress. Only the app subnets route through the NAT instance, and only for outbound connections initiated from inside.
Each tier accepts traffic only from the tier directly in front of it, by referencing the upstream security group, not an IP range:
flowchart LR
I([Internet]) -- ":80, :443" --> ALB["alb-sg"]
ALB -- ":8000<br/>(SG reference)" --> A["app-sg"]
A -- ":5432<br/>(SG reference)" --> D["db-sg"]
style ALB fill:#dbeafe,stroke:#1d4ed8,color:#000
style A fill:#fef3c7,stroke:#b45309,color:#000
style D fill:#fee2e2,stroke:#b91c1c,color:#000
| SG | Ingress | Source | Egress |
|---|---|---|---|
alb-sg |
80, 443 (TCP) | 0.0.0.0/0 |
all |
app-sg |
8000 (TCP) | alb-sg |
all |
db-sg |
5432 (TCP) | app-sg |
all |
NACLs sit one layer below as stateless subnet guards (allow VPC-internal, allow ephemeral return ports on public). They're coarse on purpose β the SGs do the precise work.
- No long-lived AWS keys in GitHub β CI authenticates via GitHub OIDC β
sts:AssumeRoleWithWebIdentityβ 1-hour creds per job, scoped via thesubclaim torepo:owner/name:ref:refs/heads/mainandpull_request. - No credentials on EC2 β instances use an IAM instance profile with
secretsmanager:GetSecretValuescoped to the exact DB secret ARN. - Contact-form Lambda cannot impersonate other senders β
ses:SendEmailis conditioned onses:FromAddress = <our verified sender>. - CloudFront-only S3 access β S3 bucket policy allows reads from
cloudfront.amazonaws.comonly whenaws:SourceArnmatches this distribution's ARN. - IMDSv2 enforced on EC2 (
http_tokens = "required") to block SSRF-based credential theft.
erDiagram
PATIENTS ||--o{ APPOINTMENTS : has
PATIENTS {
int id PK
string full_name
date date_of_birth
string phone
datetime created_at
}
APPOINTMENTS {
int id PK
int patient_id FK
datetime scheduled_for
string reason
string status
}
AUDIT_EVENTS {
string event_id PK
string ts
string entity_type
string entity_id
string action
string actor
}
PATIENTS and APPOINTMENTS live in RDS PostgreSQL (the relational, joined
data). AUDIT_EVENTS lives in DynamoDB (high-volume, write-heavy, simple
key access) β exactly the split DynamoDB and a relational DB exist for.
cloud-care/
βββ README.md β this file
βββ docs/ β 21 numbered teaching docs (00β20)
β βββ 00-roadmap.md
βββ app/
β βββ backend/ β FastAPI + Dockerfile + docker-compose
β β βββ app/{main,config,database,models,schemas}.py
β β βββ Dockerfile
β β βββ docker-compose.yml
β β βββ requirements.txt
β βββ frontend/ β React + Vite
β βββ src/{main,App,api}.jsx
β βββ index.html
β βββ package.json
βββ terraform/ β 9 independent stacks (own state key each)
β βββ bootstrap/ β S3 state bucket + DynamoDB lock table
β βββ network/ β VPC, subnets, IGW, route tables, NACLs, SGs
β βββ database/ β RDS + Secrets Manager
β βββ compute/ β ALB, ASG, ECR, NAT, IAM
β βββ cdn/ β S3 + CloudFront + OAC
β βββ serverless-audit/ β API Gateway + Lambda + DynamoDB + X-Ray
β βββ serverless-contact/ β API Gateway + Lambda + SES
β βββ observability/ β Dashboard + alarms + SNS
β βββ cicd/ β GitHub OIDC provider + deploy role
βββ .github/workflows/ β terraform.yml Β· backend.yml Β· frontend.yml
βββ resourse_images/ β reference AWS architecture diagrams
Each Terraform stack owns its own state key in the shared backend (s3:// cloudcare-tfstate-<account>/<stack>/terraform.tfstate). Stacks consume each
other's outputs via terraform_remote_state, never by redeclaration.
flowchart TB
BS["bootstrap<br/>S3 state + DynamoDB lock"]
NET["network<br/>VPC Β· subnets Β· IGW Β· RTs Β· NACLs Β· SGs"]
DB["database<br/>RDS Β· subnet group Β· Secrets Manager"]
CMP["compute<br/>ALB Β· ASG Β· ECR Β· NAT Β· IAM"]
CDN["cdn<br/>S3 Β· CloudFront Β· OAC"]
SA["serverless-audit<br/>API Gateway Β· Lambda Β· DynamoDB"]
SC["serverless-contact<br/>API Gateway Β· Lambda Β· SES"]
OBS["observability<br/>Dashboard Β· Alarms Β· SNS"]
CICD["cicd<br/>OIDC provider Β· deploy IAM role"]
BS -.->|hosts state for all| NET
NET --> DB
NET --> CMP
DB --> CMP
CMP --> CDN
CMP --> OBS
DB --> OBS
SA --> OBS
SC --> OBS
| Stack | State key | Reads from | Free-tier risk |
|---|---|---|---|
bootstrap |
bootstrap/terraform.tfstate (local) |
β | β ~cents/mo |
network |
network/... |
β | β free |
database |
database/... |
network |
|
compute |
compute/... |
network, database |
|
cdn |
cdn/... |
compute |
β free within tier |
serverless-audit |
serverless/audit/... |
β | β free within tier |
serverless-contact |
serverless/contact/... |
β | β free within tier |
observability |
observability/... |
compute, database, both serverless |
β free within tier |
cicd |
cicd/... |
β | β free |
- AWS account with a non-root IAM admin user, MFA enabled, and budgets configured (see docs/03)
- AWS CLI v2 authenticated as that admin (
aws sts get-caller-identitysucceeds) - Terraform
>= 1.5 - Docker + Docker Compose
- Node.js 20+ (for the frontend)
- Python 3.12 (for local backend dev, optional β Docker is enough)
- A GitHub repo if you want CI/CD (Phase 8)
The stacks must be applied in dependency order. Each phase has a dedicated doc with full explanation; below is the minimal command sequence.
export AWS_PROFILE=cloudcare
export AWS_REGION=ap-south-1
cd terraform/bootstrap
terraform init
terraform apply -var="state_bucket_name=cloudcare-tfstate-$(aws sts get-caller-identity --query Account --output text)"for stack in network database compute; do
( cd "terraform/$stack" && terraform init && terraform apply -auto-approve )
doneREGION=ap-south-1
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
REPO=$(cd terraform/compute && terraform output -raw ecr_repository_url)
aws ecr get-login-password --region "$REGION" \
| docker login --username AWS --password-stdin "$ACCOUNT.dkr.ecr.$REGION.amazonaws.com"
( cd app/backend && docker build -t "$REPO:latest" . && docker push "$REPO:latest" )
# Roll the ASG so instances pull the freshly-pushed image:
aws autoscaling start-instance-refresh \
--auto-scaling-group-name "$(cd terraform/compute && terraform output -raw asg_name)"( cd terraform/cdn && terraform init && terraform apply -auto-approve )
BUCKET=$(cd terraform/cdn && terraform output -raw frontend_bucket)
DIST=$(cd terraform/cdn && terraform output -raw cloudfront_distribution_id)
( cd app/frontend && npm ci && npm run build )
aws s3 sync app/frontend/dist/ "s3://$BUCKET/" --delete
aws cloudfront create-invalidation --distribution-id "$DIST" --paths "/*"for stack in serverless-audit serverless-contact observability cicd; do
( cd "terraform/$stack" && terraform init && terraform apply )
doneCF=$(cd terraform/cdn && terraform output -raw cloudfront_domain_name)
echo "Open https://$CF/ β that's CloudCare."
curl "https://$CF/health"
curl "https://$CF/api/patients"Run the backend and a throwaway Postgres locally with Docker Compose:
cd app/backend
docker compose up --build
# API on http://localhost:8000 | Swagger UI on http://localhost:8000/docsThen run the frontend (Vite dev server, hot reload):
cd app/frontend
npm install
npm run dev
# Open http://localhost:5173Configure the frontend's API base via an env file:
# app/frontend/.env.local
VITE_API_URL=http://localhost:8000To point the local frontend at the deployed API:
VITE_API_URL="https://$(cd ../../terraform/cdn && terraform output -raw cloudfront_domain_name)" npm run devGitHub Actions authenticates to AWS via OIDC β no long-lived AWS keys are ever stored in GitHub. Each workflow only runs when files in its scope change.
flowchart LR
Dev([Developer]) -->|push / PR| GH[GitHub Repo]
subgraph GHA["GitHub Actions"]
TF["terraform.yml<br/>plan on PR<br/>apply on main"]
BE["backend.yml<br/>build Β· push Β· roll ASG"]
FE["frontend.yml<br/>build Β· sync Β· invalidate"]
end
GH --> TF
GH --> BE
GH --> FE
TF -- "OIDC<br/>AssumeRoleWithWebIdentity" --> STS[(AWS STS)]
BE -- "OIDC<br/>AssumeRoleWithWebIdentity" --> STS
FE -- "OIDC<br/>AssumeRoleWithWebIdentity" --> STS
STS --> Role["IAM role:<br/>cloudcare-github-deploy<br/>(trust: repo:owner/name<br/>refs: main Β· PR)"]
Role --> ECR[ECR push]
Role --> ASG[ASG instance refresh]
Role --> S3O[S3 sync]
Role --> CFI[CloudFront invalidation]
Role --> TFA[terraform apply]
| Trigger | Workflow | What runs |
|---|---|---|
PR touches terraform/** |
terraform.yml |
terraform plan for every stack |
Push to main, terraform/** |
terraform.yml |
terraform apply for every stack (dependency-ordered) |
Push to main, app/backend/** |
backend.yml |
docker build/push + start-instance-refresh |
Push to main, app/frontend/** |
frontend.yml |
npm run build + s3 sync + CloudFront invalidate |
A single CloudWatch dashboard (cloudcare-overview) shows ALB traffic & errors,
healthy host count, RDS CPU/connections/storage, and Lambda invocations &
errors β at a glance.
Alarms publish to one SNS topic (cloudcare-ops-alerts) which fans out to email
today and can fan out to Slack/PagerDuty later without changing any alarm:
| Alarm | Threshold | Why this threshold |
|---|---|---|
cloudcare-alb-5xx |
β₯ 5 5xx in 5 min |
Single error is noise; sustained is signal |
cloudcare-alb-no-healthy-hosts |
< 1 healthy for 2 min |
The site is down β page immediately |
cloudcare-rds-cpu-high |
> 80% avg over 10 min |
Brief spikes are normal; sustained means trouble |
cloudcare-rds-storage-low |
< 2 GB free |
Lead time to expand before writes fail |
cloudcare-rds-connections-high |
> 80 conns avg over 10 min |
db.t3.micro caps near 100 |
cloudcare-audit-lambda-errors |
β₯ 1 in 5 min |
Lambda errors should be 0 |
cloudcare-contact-lambda-errors |
β₯ 1 in 5 min |
Same |
cloudcare-ddb-throttled |
β₯ 1 throttle in 5 min |
On-demand shouldn't ever throttle at our scale |
Cost telemetry: Budgets (Doc 03) for tripwire alerts, Cost Explorer for
attribution by Project = cloudcare tag, Compute Optimizer for right-sizing
recommendations.
Designed to live inside the AWS Free Tier when run for β€ 750 hours/month of each free-tier-eligible resource. The key habits:
- One
t3.microapp instance (desired = 1); scale to 2 only briefly - Single-AZ RDS
db.t3.micro(Multi-AZ written butfalseby default) - A NAT instance, not a NAT Gateway (~$32/mo saved)
- Frontend on CloudFront's always-free tier (1 TB out + 10M HTTPS requests/mo)
- Lambda + DynamoDB + X-Ray always-free quotas dwarf lab usage
- Destroy after each lab β only
network/andbootstrap/are left running
The roadmap doc tracks a four-month part-time learning pace; the only things intentionally left running are nearly free. A surprise bill should be impossible β Doc 03's budgets, billing alarm, and free-tier alerts all email you long before any real spend.
Destroy in reverse-dependency order to return to ~$0:
for stack in observability cdn compute database serverless-contact \
serverless-audit cicd network; do
( cd "terraform/$stack" && terraform destroy -auto-approve )
doneLeave bootstrap/ alone β it holds the state for everything else and costs
cents per month. Bring the whole stack back with one apply loop in
reverse order (see Quick start or
docs/20 for the complete recipe).
This repository was built incrementally as a complete teaching project for AWS
SRE/DevOps fundamentals. The 21 docs in docs/ walk through every
phase with the what, why, and how β full Terraform code, design
trade-offs, AWS console verification steps, and interview-relevant framing.
Start at the roadmap for the full 8-phase plan, or jump to any phase below:
| Phase | Topic | Docs |
|---|---|---|
| 0 | Foundations Β· account Β· tooling Β· Terraform Β· state backend | 00β06 |
| 1 | Networking Β· VPC Β· SGs Β· NACLs | 07, 08 |
| 2 | Compute Β· ASG Β· ALB | 09, 10 |
| 3 | Database Β· RDS Β· Secrets Manager | 11 |
| 4 | Application Β· FastAPI Β· EC2 deploy Β· React | 12, 13, 14 |
| 5 | Content delivery Β· S3 Β· CloudFront | 15 |
| 6 | Serverless Β· Lambda Β· DynamoDB Β· SES Β· X-Ray | 16, 17 |
| 7 | Observability & cost | 18 |
| 8 | CI/CD Β· teardown Β· the interview story | 19, 20 |
Architecture references: AWS Well-Architected Framework Β· AWS Skill
Builder "Optimizing a cloud architecture" (original diagrams under
resourse_images/).