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()\[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.
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.
🚀 The deploy.sh
: Where Dev Meets Ops
Here’s a simplified deploy script that builds, pushes, and deploys your app:
#!/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 script handles everything from Docker to DNS with the elegance of a DevOps ballet — copy, tweak, and ship confidently.
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
It’s how deployments should be — boring, predictable, scriptable.
Test It
Once deployed:
curl https://api.example.com/health
-
docker ps
and logs from AWS Console / CloudWatch - Hit the Route53 domain and watch the routing magic
⚠️ Common Gotchas to Avoid
- Security Group mix-ups → ECS should only allow ALB
- Certificate ARN typos → HTTPS won’t work
- SSM Param not found → ECS task fails silently
- Wrong container port → Target group health check fails
Debug from bottom up: logs, task status, target group health.
Custom Domains? Yes Please
You’ll get a public DNS like:
api.example.com
All thanks to Route53::RecordSet
that connects your ALB DNS to your hosted zone.
Just make sure the certificate exists in ACM in us-east-1, even if you’re deploying elsewhere.
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
And just like that — you’ve got scalable backend infra you don’t have to babysit. That’s time back in your day, and fewer pager alerts at night.