Production‑Ready FastAPI Backend with AWS ECS Fargate & Terraform.
A lightweight, modular and production‑ready FastAPI backend for tracking personal expenses — fully containerized with Docker and deployed to AWS ECS Fargate using Terraform.
This project demonstrates real‑world DevOps practices:
- Infrastructure as Code (Terraform)
- Containerization (Docker)
- Cloud deployment (AWS ECS Fargate + ALB + RDS)
- Secure VPC networking
- Logs and observability with CloudWatch
- CI/CD‑ready architecture
- Built With
- Features
- Tech Stack
- High‑Level Architecture
- Project Structure
- Authentication Flow
- API Endpoints
- Example cURL Commands
- Running Locally (Docker)
- Running Locally (without Docker)
- Environment Variables
- AWS Infrastructure (Terraform)
- Deploying to AWS
- Screenshots
- Troubleshooting
- AWS Resource Cleanup / Shutdown Guide
- Cost Optimization Tips
- License
- JWT‑based authentication (register + login)
- CRUD operations for expenses
- SQLite locally, PostgreSQL (RDS) in production
- Modular FastAPI architecture (api/core/models/schemas)
- Dockerfile + docker‑compose for local containerized runs
- Interactive Swagger UI at
/docs - Production deployment on AWS ECS Fargate
- Infrastructure managed with Terraform
- FastAPI
- Python 3.11
- SQLAlchemy ORM
- Pydantic v2
- Uvicorn
- Terraform
- AWS ECS Fargate
- AWS ECR
- AWS RDS (PostgreSQL)
- AWS Application Load Balancer (ALB)
- AWS VPC (public + private subnets)
- AWS CloudWatch Logs
- IAM roles & policies
Internet
|
Application Load Balancer (HTTP :80, public subnets)
|
ECS Fargate Task (FastAPI container, private subnets)
|
RDS PostgreSQL (private subnets)
VPC:
- Public subnets: ALB, NAT Gateway
- Private subnets: ECS tasks, RDS
- Security groups: least‑privilege access between ALB → ECS → RDS
finance-tracker-api/
│
├── app/
│ ├── api/
│ │ ├── auth.py
│ │ └── expenses.py
│ ├── core/
│ │ ├── auth.py
│ │ ├── database.py
│ │ └── security.py
│ ├── models/
│ ├── schemas/
│ ├── utils/
│ └── main.py
│
├── infra/ # Terraform IaC
│ ├── main.tf # Root module wiring
│ ├── vpc.tf # VPC, subnets, routes, IGW, NAT
│ ├── ecs.tf # ECS cluster, task definition, service
│ ├── rds.tf # RDS PostgreSQL instance
│ ├── security.tf # Security groups, IAM roles/policies
│ ├── variables.tf # Input variables
│ ├── outputs.tf # ALB DNS, RDS endpoint, etc.
│ ├── terraform.tfvars.example
│ └── ...
│
├── Dockerfile
├── docker-compose.yml
├── .gitignore
└── README.md
-
Register a user
-
Log in to receive an
access_token -
Use the token in the
Authorizationheader:Authorization: Bearer <your_token_here>
| Method | Endpoint | Description |
|---|---|---|
| POST | /auth/register | Register a new user |
| POST | /auth/login | Login and get JWT |
| Method | Endpoint | Description |
|---|---|---|
| POST | /expenses/ | Create an expense |
| GET | /expenses/ | Get all expenses |
| GET | /expenses/{id} | Get a single expense |
| PUT | /expenses/{id} | Update an expense |
| DELETE | /expenses/{id} | Delete an expense |
curl -X POST "http://127.0.0.1:8000/expenses/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{
"title": "Groceries",
"description": "Weekly food shopping",
"amount": 45.58,
"category": "Food"
}'
curl -X GET "http://127.0.0.1:8000/expenses/" \
-H "Authorization: Bearer <token>"
docker-compose up --build
uvicorn app.main:app --reload
DATABASE_URL— defaults to SQLite (e.g.sqlite:///./finance.db)JWT_SECRET_KEYJWT_ALGORITHM(e.g.HS256)ACCESS_TOKEN_EXPIRE_MINUTES
DATABASE_URL— RDS PostgreSQL connection stringJWT_SECRET_KEY— stored securely (e.g. SSM / Secrets Manager)JWT_ALGORITHMACCESS_TOKEN_EXPIRE_MINUTES
- VPC with CIDR block
- Public subnets (ALB, NAT Gateway)
- Private subnets (ECS tasks, RDS)
- Internet Gateway + NAT Gateway
- Route tables and associations
- ECS Cluster
- Task Definition (FastAPI container)
- ECS Service (Fargate)
- Application Load Balancer (HTTP 80)
- Target Group + Listener
- Health checks for FastAPI (e.g.
/docsor/)
- PostgreSQL instance in private subnets
- Security group allowing traffic only from ECS tasks
- Outputs for RDS endpoint
- Security groups:
- ALB → ECS
- ECS → RDS
- IAM roles and policies for:
- ECS task execution (ECR pull, CloudWatch logs)
- ECS task role (app‑level permissions if needed)
variables.tf— input variables (region, VPC CIDR, DB config, etc.)outputs.tf— ALB DNS name, RDS endpoint, etc.terraform.tfvars.example— template for realterraform.tfvars(not committed)
Ensure
terraform.tfvarsis created locally (not committed) based onterraform.tfvars.example.
cd infra
terraform init
terraform plan
terraform apply
This will create:
- VPC, subnets, routes, gateways
- ECS cluster, ALB, target group, listener
- RDS PostgreSQL instance
- Security groups, IAM roles
- CloudWatch log groups
From project root:
docker build -t finance-api .
docker tag finance-api:latest <aws_account_id>.dkr.ecr.<region>.amazonaws.com/finance-api:latest
docker push <aws_account_id>.dkr.ecr.<region>.amazonaws.com/finance-api:latest
- ECS Fargate service is configured to pull the image from ECR
- When the new image tag is used, ECS deploys a new task revision
Use the ALB DNS name from Terraform outputs:
http://<alb_dns_name>/docs
Command used:
curl -X POST "http://127.0.0.1:8000/expenses/" \
-H "Authorization: Bearer <your_jwt_token>"Command used:
curl -X GET "http://127.0.0.1:8000/expenses/" \
-H "Authorization: Bearer <your_jwt_token>"Command used:
terraform apply -refresh-only- No secrets committed
.gitignoreexcludes:terraform.tfvarsterraform.tfstate/terraform.tfstate.backup.terraform/.terraform.lock.hcl__pycache__/,*.pyc.DS_Store.vscode/finance.db
- RDS in private subnets, not publicly accessible
- ECS tasks communicate with RDS via security groups
- ALB is the only public entry point
- Add categories & budgets
- Add monthly reports and analytics
- Add user‑specific dashboards
- Add CI/CD pipeline (GitHub Actions → ECS Fargate)
- Add CloudWatch alarms + autoscaling policies
- Add WAF in front of ALB
Symptom
ECS task fails with:
CannotPullContainerError: failed to copy: httpReadSeeker: failed open: dial tcp <ECR-IP>:443: i/o timeout
The task remains in STOPPED state and never reaches RUNNING.
When running ECS Fargate tasks in private subnets, the task must access:
- ECR API endpoint
- ECR DKR endpoint
- S3 Gateway endpoint (required for downloading image layers)
Even if the ECR endpoints are correct, the task cannot pull the image unless the S3 endpoint is attached to the same route table used by the private subnets.
In this project:
- Private subnets used route table:
rtb-02396af79ff6455d4 - But the S3 endpoint was attached to a different route table:
rtb-0f985c9995115a4c4(public)
This prevented the ECS task ENI from reaching S3, causing the image pull to fail.
Attach the S3 VPC endpoint to the private route table:
- Go to VPC → Endpoints
- Open the S3 endpoint (
com.amazonaws.<region>.s3) - Click Manage route tables
- Select the private route table:
rtb-02396af79ff6455d4 - Save changes
- Force a new ECS deployment
After this change, the ECS task could reach S3, download image layers, and start successfully.
ECR stores image metadata in ECR, but stores image layers in S3.
Without S3 access, the ECS task cannot download the actual container filesystem.
This is one of the most common — and most confusing — ECS networking issues.
To prevent unnecessary AWS charges, make sure all infrastructure created for this project is fully destroyed. This section outlines the exact steps to safely shut down every component deployed with Terraform and AWS ECS.
Terraform created the majority of your infrastructure, including:
- VPC, subnets, route tables
- NAT Gateway (one of the most expensive resources)
- ECS Cluster, Task Definitions, Services
- Application Load Balancer (ALB)
- RDS PostgreSQL instance
- Security groups, IAM roles
- CloudWatch log groups
From the infra/ directory:
terraform destroyThis command removes all Terraform‑managed AWS resources.
Wait for the destroy process to complete fully before moving on.
Terraform does not delete ECR repositories or images.
To avoid storage charges:
- Go to ECR → Repositories
- Open your repository (e.g.,
finance-api) - Select all images → Delete
- Then delete the repository itself
ECS automatically creates log groups if they don’t exist.
To remove them:
- Go to CloudWatch → Logs → Log groups
- Search for:
/ecs/finance-api/aws/ecs/containerinsights/...
- Delete them
Even after terraform destroy, double‑check:
- Go to ECS → Clusters
- Ensure:
- No clusters remain
- No services
- No running tasks
If anything remains, delete it manually.
Sometimes ECS or VPC endpoints leave ENIs behind.
To clean them:
- Go to EC2 → Network Interfaces
- Filter by status: available
- Delete any ENIs related to:
- ECS
- VPC endpoints
- Load balancers
If you used S3 for Terraform backend or storage:
- Empty the bucket
- Delete the bucket
NAT Gateways cost money per hour, even when idle.
After terraform destroy, verify:
- Go to VPC → NAT Gateways
- Ensure none remain
After completing all steps, verify:
- No VPCs
- No subnets
- No ALBs
- No ECS clusters
- No RDS instances
- No ECR repositories
- No CloudWatch log groups
- No ENIs
- No NAT Gateways
Your AWS bill should now drop to near zero.
Running workloads on AWS can become expensive if resources are left running unintentionally. Below are practical, real‑world cost‑saving strategies that apply directly to this project’s architecture.
ECS Fargate charges per second. To reduce costs:
- Stop services when not actively testing
- Use Fargate Spot for non‑critical workloads
- Keep CPU/memory definitions minimal (e.g., 0.25 vCPU / 0.5 GB)
NAT Gateways cost per hour + per GB. They are one of the most expensive components in small projects.
To reduce costs:
- Use VPC Endpoints (S3, ECR API, ECR DKR) to avoid NAT traffic
- Destroy NAT Gateway when not needed
- For dev environments, consider public subnets only (no NAT)
RDS instances incur:
- Hourly cost
- Storage cost
- Backup cost
For development:
- Use SQLite locally
- Use RDS t3.micro or t4g.micro for minimal cost
- Turn off automatic backups for dev environments
- Destroy RDS when not in use
ECR charges for storage, Over time, unused images accumulate.
Best practices:
- Delete old image tags
- Enable ECR lifecycle policies (e.g., keep last 5 images)
- Remove entire repositories when shutting down the project
CloudWatch Logs charge per GB stored.
To reduce costs:
- Delete old log groups
- Set retention policies (e.g., 7 or 14 days)
- Avoid storing large debug logs in production
This project is licensed under the MIT License