DEV Community

Sathish
Sathish

Posted on • Originally published at sathishsaravanan.com

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"] 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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" 
Enter fullscreen mode Exit fullscreen mode

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:

  1. Builds the Docker image
  2. Pushes to ECR
  3. Deploys the CloudFormation stack
  4. Extracts your backend URL from stack outputs
  5. 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 
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)