FastAPI to ECS the Smart Way: Load Balanced & Custom Branded

· 11 min

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:

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:

Start with a minimal base, toss in only what you need, and keep your container predictable.

Key choices:

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:

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:

  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

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: