FastAPI to ECS the Smart Way: Load Balanced & Custom Branded
Containers are great. But containers that deploy, scale, and route themselves? That’s chef’s kiss DevOps. This isn't just another guide on ECS. This is how you run a FastAPI backend on AWS Fargate, with a load balancer and your own custom domain — all spun up with a CloudFormation template and a no-nonsense shell script. That said, this tutorial is purposefully simplified. It's designed for quick PoC deployments or early-stage internal tools — not production-critical workloads. Use this as a springboard, not a finish line. Let’s walk through deploying FastAPI the way modern backend teams dream of — serverless containers, automated infra, and zero EC2 drama. Because who enjoys SSH-ing into boxes at 2 AM?: no EC2s, no guesswork, and definitely no manual clicking in the AWS console. 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()\n@app.get("/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. Here’s an abridged (and anonymized) view of what the CloudFormation stack covers: 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: Subnet

Containers are great. But containers that deploy, scale, and route themselves? That’s chef’s kiss DevOps.
This isn't just another guide on ECS. This is how you run a FastAPI backend on AWS Fargate, with a load balancer and your own custom domain — all spun up with a CloudFormation template and a no-nonsense shell script.
That said, this tutorial is purposefully simplified. It's designed for quick PoC deployments or early-stage internal tools — not production-critical workloads.
Use this as a springboard, not a finish line.
Let’s walk through deploying FastAPI the way modern backend teams dream of — serverless containers, automated infra, and zero EC2 drama. Because who enjoys SSH-ing into boxes at 2 AM?: no EC2s, no guesswork, and definitely no manual clicking in the AWS console.
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()\n@app.get("/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.
Here’s an abridged (and anonymized) view of what the CloudFormation stack covers:
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.