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

Mar 28, 2025 - 20:28
 0
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:
      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.