FastAPI to ECS the Smart Way: Load Balanced & Custom Branded
This isn’t just another ECS walkthrough. It’s a fast, clean way to run a FastAPI backend on AWS Fargate — with a load balancer, your custom domain, and everything wired up using CloudFormation and a no-nonsense shell script.
That said, this guide keeps things deliberately lean. It’s made for PoCs, hackathons, and early internal tools — not hardened for production. No EC2s, no guesswork, no AWS Console clicking at 2 AM.
Use it as a launchpad, not a landing pad.
Meet the Stack: Fargate + ALB + Route53
What we’re working with:
- FastAPI: Our async backend framework of choice.
- AWS Fargate: Serverless containers — no instance management.
- Application Load Balancer: For traffic routing and health checks.
- Route53: To hook up a pretty domain name.
This setup is ideal for scalable APIs, lightweight microservices, or anything you want to host on a container but don’t want to babysit.
Dockerfile: Keep It Slim, Keep It Clean
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies and MS SQL driver
RUN apt-get update && apt-get install -y \
build-essential \
curl \
gnupg2 \
unixodbc \
unixodbc-dev \
default-jdk \
tesseract-ocr \
fonts-liberation \
&& curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /etc/apt/trusted.gpg.d/microsoft.gpg \
&& curl -o /etc/apt/sources.list.d/mssql-release.list https://packages.microsoft.com/config/debian/11/prod.list \
&& apt-get update \
&& ACCEPT_EULA=Y apt-get install -y msodbcsql18 \
&& rm -rf /var/lib/apt/lists/* \
&& ln -s /usr/bin/tesseract /usr/local/bin/tesseract
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN echo $'from fastapi import FastAPI\napp = FastAPI()\[email protected]("/health")\ndef health():\n return {"status": "healthy"}' > main.py
EXPOSE 8000
ENV PYTHONPATH=/app
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
This image is:
- Slim and fast to build
- Equipped for OCR, DB, and cloud search
- Health-check ready
Start with a minimal base, toss in only what you need, and keep your container predictable.
Key choices:
- Python 3.11 slim base
- Tesseract, ODBC drivers, Java, and other tools baked in
- Health check endpoint directly wired in (
/health
)
We’re exposing port 8000
and launching Uvicorn from main.py
.
CloudFormation Template
This template is where most of the AWS setup happens. It defines your entire infrastructure as code, so it’s repeatable and consistent.
AWSTemplateFormatVersion: "2010-09-09"
Description: "FastAPI App Infrastructure - ECS Fargate with Private Subnets and NAT Gateway"
Parameters:
Environment:
Type: String
Default: dev
ContainerPort:
Type: Number
Default: 8000
HealthCheckPath:
Type: String
Default: /health
VpcCidr:
Type: String
Default: 10.0.0.0/16
PublicSubnet1Cidr:
Type: String
Default: 10.0.1.0/24
PublicSubnet2Cidr:
Type: String
Default: 10.0.2.0/24
PrivateSubnet1Cidr:
Type: String
Default: 10.0.3.0/24
PrivateSubnet2Cidr:
Type: String
Default: 10.0.4.0/24
Resources:
VPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: !Ref VpcCidr
EnableDnsHostnames: true
EnableDnsSupport: true
InternetGateway:
Type: AWS::EC2::InternetGateway
AttachIGW:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
InternetGatewayId: !Ref InternetGateway
VpcId: !Ref VPC
### Elastic IP for NAT ###
NATGatewayEIP:
Type: AWS::EC2::EIP
Properties:
Domain: vpc
### Public Subnets & Routing ###
PublicSubnet1:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: !Ref PublicSubnet1Cidr
AvailabilityZone: !Select [0, !GetAZs '']
MapPublicIpOnLaunch: true
PublicSubnet2:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: !Ref PublicSubnet2Cidr
AvailabilityZone: !Select [1, !GetAZs '']
MapPublicIpOnLaunch: true
PublicRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
PublicRoute:
Type: AWS::EC2::Route
DependsOn: AttachIGW
Properties:
RouteTableId: !Ref PublicRouteTable
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref InternetGateway
PublicRouteAssoc1:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnet1
RouteTableId: !Ref PublicRouteTable
PublicRouteAssoc2:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnet2
RouteTableId: !Ref PublicRouteTable
### NAT Gateway ###
NATGateway:
Type: AWS::EC2::NatGateway
Properties:
AllocationId: !GetAtt NATGatewayEIP.AllocationId
SubnetId: !Ref PublicSubnet1
### Private Subnets & Routing ###
PrivateSubnet1:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: !Ref PrivateSubnet1Cidr
AvailabilityZone: !Select [0, !GetAZs '']
PrivateSubnet2:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: !Ref PrivateSubnet2Cidr
AvailabilityZone: !Select [1, !GetAZs '']
PrivateRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
PrivateRoute:
Type: AWS::EC2::Route
Properties:
RouteTableId: !Ref PrivateRouteTable
DestinationCidrBlock: 0.0.0.0/0
NatGatewayId: !Ref NATGateway
PrivateRouteAssoc1:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PrivateSubnet1
RouteTableId: !Ref PrivateRouteTable
PrivateRouteAssoc2:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PrivateSubnet2
RouteTableId: !Ref PrivateRouteTable
### Log Group ###
AppLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub "/ecs/${AWS::StackName}"
RetentionInDays: 14
### ECS ###
ECSCluster:
Type: AWS::ECS::Cluster
TaskExecutionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: ecs-tasks.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
TaskDefinition:
Type: AWS::ECS::TaskDefinition
Properties:
Family: !Sub "${AWS::StackName}-task"
Cpu: "1024"
Memory: "2048"
NetworkMode: awsvpc
RequiresCompatibilities: [FARGATE]
ExecutionRoleArn: !GetAtt TaskExecutionRole.Arn
ContainerDefinitions:
- Name: app-container
Image: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/your-ecr-repo:latest"
PortMappings:
- ContainerPort: !Ref ContainerPort
Environment:
- Name: ENVIRONMENT
Value: !Ref Environment
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: !Ref AppLogGroup
awslogs-region: !Ref AWS::Region
awslogs-stream-prefix: app
Essential: true
### ALB ###
ALBSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Allow HTTP/HTTPS access
VpcId: !Ref VPC
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 80
ToPort: 80
CidrIp: 0.0.0.0/0
- IpProtocol: tcp
FromPort: 443
ToPort: 443
CidrIp: 0.0.0.0/0
ALB:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Scheme: internet-facing
Subnets:
- !Ref PublicSubnet1
- !Ref PublicSubnet2
SecurityGroups:
- !Ref ALBSecurityGroup
TargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
VpcId: !Ref VPC
Port: !Ref ContainerPort
Protocol: HTTP
TargetType: ip
HealthCheckPath: !Ref HealthCheckPath
Listener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref ALB
Port: 443
Protocol: HTTPS
Certificates:
- CertificateArn: arn:aws:acm:us-east-1:xxx:certificate/xxx
DefaultActions:
- Type: forward
TargetGroupArn: !Ref TargetGroup
ECSService:
Type: AWS::ECS::Service
Properties:
Cluster: !Ref ECSCluster
TaskDefinition: !Ref TaskDefinition
DesiredCount: 2
LaunchType: FARGATE
NetworkConfiguration:
AwsvpcConfiguration:
AssignPublicIp: DISABLED
Subnets:
- !Ref PrivateSubnet1
- !Ref PrivateSubnet2
LoadBalancers:
- ContainerName: app-container
ContainerPort: !Ref ContainerPort
TargetGroupArn: !Ref TargetGroup
### DNS ###
DNSRecord:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneName: example.com.
Name: api.example.com
Type: A
AliasTarget:
DNSName: !GetAtt ALB.DNSName
HostedZoneId: !GetAtt ALB.CanonicalHostedZoneID
Outputs:
LoadBalancerDNS:
Description: Public Load Balancer DNS
Value: !GetAtt ALB.DNSName
Let’s break it down piece by piece:
-
VPC with Public + Private Subnets — Public subnets host the ALB; private subnets isolate ECS tasks while still enabling outbound access.
-
Internet Gateway + NAT Gateway — The IGW serves the ALB, while the NAT allows ECS tasks in private subnets to make outbound API calls securely.
-
ECS Fargate + TaskDefinition — Fully managed, serverless containers with awsvpc networking. Tasks live in private subnets and log to CloudWatch.
-
Application Load Balancer (ALB) — Public-facing with HTTPS listener, forwarding traffic to ECS tasks. Health checks point to FastAPI’s /health.
-
CloudWatch Log Group — All container logs are shipped to a dedicated log group, keeping observability in place from day one.
-
Route53 DNS — One clean DNS record (api.example.com) wired to the ALB — ready for production use.
In a production environment, this single CloudFormation template would typically be broken into modular stacks — for example: networking.yaml, ecs.yaml, alb.yaml, and dns.yaml — to improve reusability and maintainability across environments.
🚀 The deploy.sh
: Where Dev Meets Ops
#!/bin/bash
set -euo pipefail
REGION="us-east-1"
STACK_NAME="your-app-stack"
REPO_NAME="your-ecr-repo"
# Get AWS account ID
AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
# Full image URI
IMAGE_URI="$AWS_ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO_NAME:latest"
# Ensure ECR repo exists
echo "Ensuring ECR repo exists..."
aws ecr describe-repositories --repository-names "$REPO_NAME" --region "$REGION" >/dev/null 2>&1 || \
aws ecr create-repository --repository-name "$REPO_NAME" --region "$REGION"
# Build and push Docker image
echo "Logging into ECR..."
aws ecr get-login-password --region "$REGION" | docker login --username AWS --password-stdin "$AWS_ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com"
echo "Building Docker image..."
docker build --platform linux/arm64 -t "$REPO_NAME" .
echo "Tagging image as: $IMAGE_URI"
docker tag "$REPO_NAME:latest" "$IMAGE_URI"
echo "Pushing image to ECR..."
docker push "$IMAGE_URI"
# Deploy CloudFormation stack
echo "Deploying CloudFormation stack..."
aws cloudformation deploy \
--template-file template.yaml \
--stack-name "$STACK_NAME" \
--capabilities CAPABILITY_NAMED_IAM \
--region "$REGION" \
--parameter-overrides \
Environment=dev \
ContainerPort=8000 \
HealthCheckPath=/health
# Force ECS service redeployment
echo "Forcing ECS service to redeploy with the new image..."
CLUSTER_NAME=$(aws cloudformation describe-stacks \
--region "$REGION" \
--stack-name "$STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='ClusterName'].OutputValue" \
--output text)
SERVICE_NAME=$(aws cloudformation describe-stacks \
--region "$REGION" \
--stack-name "$STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='ServiceName'].OutputValue" \
--output text)
aws ecs update-service \
--cluster "$CLUSTER_NAME" \
--service "$SERVICE_NAME" \
--force-new-deployment \
--region "$REGION"
# Retrieve ALB DNS
echo "Retrieving Load Balancer DNS..."
API_URL=$(aws cloudformation describe-stacks \
--region "$REGION" \
--stack-name "$STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='LoadBalancerDNS'].OutputValue" \
--output text)
echo -e "\n✅ Your API is available at: https://$API_URL"
This one-liner shell script:
- Builds the Docker image
- Pushes to ECR
- Deploys the CloudFormation stack
- Extracts your backend URL from stack outputs
- Shows useful AWS CLI commands
Scale Without Thinking
The service is stateless, so scale up or down based on need. Want auto-scaling? Easy — just attach an ECS Target Tracking Policy to the ALB Target Group.
Memory- or CPU-based scaling works best here.
🎉 Wrapping It All Up
A secure, scalable, and domain-mapped FastAPI service that:
- Runs in containers without managing servers
- Scales horizontally without breaking a sweat
- Lives behind a clean HTTPS endpoint
- Is deployable with one script and one template