Contents

Building a Portable FastAPI Backend for AWS Lambda and ECS Using Terraform

In the previous post, we explored how to deploy a FastAPI application on AWS Lambda using an ASGI adapter. This is a great option for early-stage projects: it requires zero infrastructure management, supports rapid iteration, and scales automatically.

But as your application matures, Lambda’s trade-offs can become limiting:

  • Cost scaling with consistent traffic
  • Compute/memory coupling and lack of vertical scaling
  • Package size limits and cold starts

That’s why many teams adopt a container-based workflow that can run on both Lambda (via container images) and ECS Fargate. With a little planning, you can build once and deploy to either platform with minimal friction.

For demonstration purposes, this setup uses an ALB to route traffic to both Lambda and ECS.

The full project code is available here.


Demo Architecture

/building-fastapi-backend-for-aws-lambda-and-ecs-using-terraform/architecture.png
Fig 1. Architecture

In this setup, all incoming user requests are directed to an Application Load Balancer (ALB), which is deployed in the public subnet of a VPC. The ALB listens on port 80 and is responsible for routing traffic to backend targets. The ALB is configured with two target groups—one for ECS and one for Lambda. Requests are evenly distributed between two targets.

The two backend targets consist of an AWS Lambda function and an ECS Fargate service. Both serve the same FastAPI application via Docker containers. The ECS service runs in private subnets, ensuring it’s not directly accessible from the internet, while the Lambda function runs fully managed in AWS’s serverless environment.

Project Structure

The goal is to deploy the same FastAPI app to both AWS Lambda and ECS using Docker and Terraform. Below is the example directory structure.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
├── infra/
│   ├── 0-versions.tf           # provider versions
│   ├── 1-providers.tf          # provider configuration
│   ├── 2-outputs.tf
│   ├── 4-locals.tf
│   ├── 5-vpc.tf                # vpc configuration
│   ├── 6-alb.tf                # application load balancer configuration
│   ├── 7-lambda.tf             # lambda module
│   ├── 8-ecs.tf                # ecs module
│   ├── ecs/                    # ecs module definition
│   │   ├── 0-versions.tf
│   │   ├── 1-variables.tf
│   │   ├── 2-docker.tf         # ecr and docker build resources
│   │   └── 3-ecs.tf            # ecs cluster and task definition
│   ├── lambda/                 # lambda module definition
│   │   ├── 0-versions.tf
│   │   ├── 2-outputs.tf
│   │   ├── 3-variables.tf
│   │   ├── 4-docker.tf         # ecr and docker build resources
│   │   └── 5-lambda.tf         # lambda resources
├── test-api/                   # FastAPI app with dockerfiles
│   ├── dockerfiles/            # dockerfiles for lambda and ecs deployments
│   │   ├── ecs.Dockerfile
│   │   └── lambda.Dockerfile
│   ├── main.py                 # FastAPI application with Mangum
│   ├── pyproject.toml          # project configuration with uv
│   └── uv.lock

FastAPI Backend

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import os
import uvicorn
from fastapi import FastAPI
from mangum import Mangum

app = FastAPI()

@app.get("/", response_model=str)
def list_items():
    return f"Hello from {os.environ.get('EXECUTION_ENVIRONMENT')}"

handler = Mangum(app)

This is a minimal FastAPI application that exposes a single route at the root path (/). When accessed, it returns a string response indicating the environment it’s running in. This value is derived from the EXECUTION_ENVIRONMENT environment variable, which is set in the Dockerfile depending on the deployment target—either “lambda” or “ecs”.

The app is wrapped using Mangum, a Python adapter that makes it compatible with AWS Lambda. Mangum translates between the AWS Lambda proxy integration format and the ASGI protocol that FastAPI uses. This makes it possible to run the same ASGI-based app in a serverless environment without changing the application code.

Although the Mangum handler is required for Lambda, the app itself is compatible with ECS as well. In that environment, the app is served using a traditional ASGI server like Uvicorn. This approach enables you to maintain a single, unified codebase that works seamlessly across multiple execution environments.


Dockerfile for Lambda

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
FROM ghcr.io/astral-sh/uv:0.6.14 AS uv

FROM public.ecr.aws/lambda/python:3.13 AS builder

ENV UV_COMPILE_BYTECODE=1
ENV UV_NO_INSTALLER_METADATA=1
ENV UV_LINK_MODE=copy

COPY . /project
WORKDIR /project/test-api

RUN --mount=from=uv,source=/uv,target=/bin/uv \
    --mount=type=cache,target=/root/.cache/uv \
    uv export --frozen --no-emit-workspace --no-dev --no-editable -o requirements.txt && \
    uv pip install -r requirements.txt --target "${LAMBDA_TASK_ROOT}"

FROM public.ecr.aws/lambda/python:3.13


ENV EXECUTION_ENVIRONMENT=lambda

# Copy the runtime dependencies from the builder stage.
COPY --from=builder ${LAMBDA_TASK_ROOT} ${LAMBDA_TASK_ROOT}

# Copy the application code.
COPY ./test-api ${LAMBDA_TASK_ROOT}

# Set the AWS Lambda handler.
CMD ["main.handler"]

This Dockerfile uses a multi-stage build to produce a container image optimized for AWS Lambda. Multi-stage builds help keep the final image size small by separating the dependency installation process from the runtime environment.

The first stage pulls the uv binary from the official image. This tool is used later to install the Python dependencies defined in the pyproject.toml file.

The second stage performs the build. It uses AWS’s official Python 3.13 base image for Lambda and sets several environment variables to improve performance and determinism. The application source code is copied into the image and dependencies are exported with uv export, then installed into the directory ${LAMBDA_TASK_ROOT}—this is where AWS Lambda expects to find all code and packages.

The final stage is also based on the AWS Lambda base image. It copies both the installed dependencies and the application code from the previous stage. The EXECUTION_ENVIRONMENT environment variable is set to “lambda” so the application can detect where it’s running. Finally, the container is configured to run the handler function in main.py as the Lambda entry point.

This layout ensures the image is compatible with AWS Lambda’s container runtime while minimizing cold start latency and ensuring reproducibility.


Dockerfile for ECS

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
FROM python:3.12-slim

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

ENV EXECUTION_ENVIRONMENT=ecs

COPY test-api /api
WORKDIR /api

RUN uv sync --frozen --no-cache

CMD ["/api/.venv/bin/fastapi", "run", "main.py", "--port", "80", "--host", "0.0.0.0"]

This Dockerfile builds a container image designed for running the FastAPI application on AWS ECS using the Fargate launch type. It uses the official python:3.12-slim image as the base to keep the image small and production-ready.

The uv dependency management tool is copied into the image from a prebuilt source so that it can be used to install project dependencies. This avoids needing to re-download uv and supports consistent builds.

The environment variable EXECUTION_ENVIRONMENT is set to “ecs”. This is used by the application code to return which environment it’s running in, making it easier to verify and debug deployments.

The application code is copied into the /api directory, and the working directory is set accordingly. Dependencies are installed using uv sync which reads from the existing uv.lock file to ensure a reproducible install inside a virtual environment.

Finally, the image runs the application using the fastapi run CLI (a wrapper around Uvicorn) listening on port 80. This exposes the FastAPI app in a way compatible with ECS load balancers and networking.


Terraform Provider Setup

infra/1-providers.tf:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
data "aws_ecr_authorization_token" "token" {}

provider "aws" {
  region = "eu-west-1"
}

provider "docker" {
  registry_auth {
    address  = "ACCOUNT-ID.dkr.ecr.eu-west-1.amazonaws.com"
    username = data.aws_ecr_authorization_token.token.user_name
    password = data.aws_ecr_authorization_token.token.password
  }
}

This configuration sets up the Terraform providers used throughout the project. It starts by retrieving an authorization token for Amazon Elastic Container Registry (ECR) using the aws_ecr_authorization_token data source. This token includes temporary credentials that allow Terraform to authenticate Docker builds with AWS ECR.

The aws provider specifies the target AWS region—in this case, eu-west-1. The docker provider then uses the ECR credentials to configure registry authentication. This allows Terraform to push container images built locally (from ECS and Lambda modules) directly to ECR during provisioning.

With these providers configured, Terraform can interact with both AWS infrastructure and Docker image registries as part of a unified build and deploy workflow.


Docker Image Build with Terraform (ECS and Lambda)

Both ECS and Lambda deployments use similar Terraform configurations to build and push Docker images to AWS Elastic Container Registry (ECR). While each environment has its own Terraform module, they both leverage the same docker-build module from the AWS Lambda Terraform module collection. This ensures consistent behavior and simplifies maintenance.

Setting create_ecr_repo = true ensures that Terraform creates the repository if it doesn’t already exist, making the workflow idempotent. Once the image is built, it’s pushed to the respective ECR repository, where it becomes available to either ECS or Lambda during deployment.

infra/ecs/2-docker.tf:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
resource "random_pet" "this" {
  length = 2
}

module "docker_build_ecr" {
  source  = "terraform-aws-modules/lambda/aws//modules/docker-build"
  version = "7.20.2"

  create_ecr_repo = true
  ecr_repo        = "${random_pet.this.id}-ecr"

  use_image_tag     = true
  image_tag         = "0.1"
  source_path       = var.docker_source_path
  docker_file_path  = var.docker_file_path
}

infra/lambda/4-docker.tf:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
resource "random_pet" "this" {
  length = 2
}

module "docker_build_lambda" {
  source  = "terraform-aws-modules/lambda/aws//modules/docker-build"
  version = "7.20.2"

  create_ecr_repo = true
  ecr_repo        = "${random_pet.this.id}-lambda"

  use_image_tag     = true
  image_tag         = "0.1"
  source_path       = var.docker_source_path
  docker_file_path  = var.docker_file_path
}

Lambda Function Deployment

infra/lambda/5-lambda.tf:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
module "lambda_function" {
  source  = "terraform-aws-modules/lambda/aws"
  version = "7.20.2"

  function_name  = "${random_pet.this.id}-lambda-with-docker-build-from-ecr"
  create_package = false

  image_uri    = module.docker_build_lambda.image_uri
  package_type = "Image"
}

This Terraform module deploys the Lambda function using a container image that was previously built and pushed to ECR. The terraform-aws-modules/lambda/aws module abstracts much of the boilerplate required for creating and configuring a Lambda function.

The image_uri parameter references the image built by the docker_build_lambda module, and package_type = "Image" tells AWS to deploy the function using that container image.


ECS Cluster & Service

infra/ecs/3-ecs.tf:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
module "ecs_cluster" {
  source  = "terraform-aws-modules/ecs/aws//modules/cluster"
  version = "5.12.1"

  cluster_name = var.cluster_name

  fargate_capacity_providers = {
    FARGATE = {
      default_capacity_provider_strategy = {
        weight = 50
      }
    }
    FARGATE_SPOT = {
      default_capacity_provider_strategy = {
        weight = 50
      }
    }
  }
}

module "ecs_service" {
  source  = "terraform-aws-modules/ecs/aws//modules/service"

  name        = "ecs-demo"
  cluster_arn = module.ecs_cluster.arn
  cpu         = 1024
  memory      = 4096
  enable_execute_command = true

  container_definitions = {
    (var.container_name) = {
      cpu       = 512
      memory    = 1024
      image     = module.docker_build_ecr.image_uri
      port_mappings = [
        {
          name          = var.container_name
          containerPort = var.container_port
          hostPort      = var.container_port
          protocol      = "tcp"
        }
      ]
      readonly_root_filesystem = false
    }
  }

  load_balancer = {
    service = {
      target_group_arn = var.alb_target_group_arn
      container_name   = var.container_name
      container_port   = var.container_port
    }
  }

  subnet_ids = var.subnet_ids
  security_group_rules = {
    alb_ingress = {
      type                     = "ingress"
      from_port                = var.container_port
      to_port                  = var.container_port
      protocol                 = "tcp"
      source_security_group_id = var.alb_security_group_id
    }
    egress_all = {
      type        = "egress"
      from_port   = 0
      to_port     = 0
      protocol    = "-1"
      cidr_blocks = ["0.0.0.0/0"]
    }
  }
}

resource "aws_service_discovery_http_namespace" "this" {
  name        = var.cluster_name
  description = "CloudMap namespace for ${var.cluster_name}"
}

This section defines the ECS infrastructure using two main modules. First, the ecs_cluster module creates an ECS cluster that supports both FARGATE and FARGATE_SPOT capacity providers, allowing the service to balance cost and availability.

The ecs_service module sets up the actual service deployment. It runs a container built earlier, configured with specified CPU and memory limits. The service definition includes port mappings and integrates with an Application Load Balancer (ALB) by attaching to a specified target group.

Security groups are configured to allow inbound traffic from the ALB and outbound traffic to the internet.


Application Load Balancer

infra/6-alb.tf:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
module "alb" {
  source  = "terraform-aws-modules/alb/aws"
  version = "~> 9.0"

  name                 = local.name
  load_balancer_type   = "application"
  vpc_id               = module.vpc.vpc_id
  subnets              = module.vpc.public_subnets
  enable_deletion_protection = false

  security_group_ingress_rules = {
    all_http = {
      from_port   = 80
      to_port     = 80
      ip_protocol = "tcp"
      cidr_ipv4   = "0.0.0.0/0"
    }
  }

  security_group_egress_rules = {
    all = {
      ip_protocol = "-1"
      cidr_ipv4   = module.vpc.vpc_cidr_block
    }
  }

  listeners = {
    ex-http-weighted-target = {
      port     = 80
      protocol = "HTTP"
      weighted_forward = {
        target_groups = [
          {
            target_group_key = "ex_lambda"
            weight           = 50
          },
          {
            target_group_key = "ex_ecs"
            weight           = 50
          }
        ]
      }
    }
  }

  target_groups = {
    ex_lambda = {
      name_prefix              = "l2-"
      target_type              = "lambda"
      target_id                = module.lambda_function.lambda_function_arn
      attach_lambda_permission = true
    }

    ex_ecs = {
      backend_protocol                  = "HTTP"
      backend_port                      = 80
      target_type                       = "ip"
      deregistration_delay              = 5
      load_balancing_cross_zone_enabled = true

      health_check = {
        enabled             = true
        healthy_threshold   = 5
        interval            = 30
        matcher             = "200"
        path                = "/"
        port                = "traffic-port"
        protocol            = "HTTP"
        timeout             = 5
        unhealthy_threshold = 2
      }

      create_attachment = false
    }
  }
}

This module sets up the Application Load Balancer (ALB) that distributes traffic between the ECS and Lambda backends. It is deployed in a VPC using public subnets and listens on HTTP port 80.

Security groups are configured to allow HTTP access from the internet and outbound traffic to the VPC CIDR range. The listeners block defines a single HTTP listener that uses weighted target group forwarding. Traffic is evenly split between the ECS and Lambda target groups (50/50 in this example).

Two target groups are defined: one for the Lambda function and another for the ECS service. The Lambda target group uses the lambda target type and references the deployed Lambda function ARN. The ECS target group uses ip mode and expects IP addresses from ECS tasks to be registered.


Deployment

Before you begin, make sure your terminal environment has valid AWS credentials loaded. These are required for Terraform to authenticate and interact with your AWS account.

Navigate to the infra directory of the project. From there, initialize the Terraform working directory:

1
terraform init

Next, review and apply the infrastructure plan:

1
2
terraform plan
terraform apply -auto-approve

Once Terraform completes, it will output the URL for the Application Load Balancer. This is your main entry point for accessing the application.

Validate that the infrastructure is deployed ok with the following command. Replace <load-balancer-url> with the actual URL output by Terraform. You should see alternating responses like Hello from lambda and Hello from ecs, confirming that the ALB is balancing traffic between both backends.

1
repeat 10 { curl <load-balancer-url> && echo }

Cleanup the resources.

1
terraform destroy -auto-approve

Conclusion

This post demonstrated how to build and deploy a FastAPI backend that runs on both AWS Lambda and ECS Fargate, using Docker containers and Terraform. Starting from a unified application and Docker setup, we walked through how to configure Lambda- and ECS-specific Dockerfiles, define infrastructure using Terraform modules, and wire everything together behind a shared Application Load Balancer for testing.

The benefits of this approach become clear as your application matures. With Lambda, you get rapid prototyping and zero infrastructure management. With ECS, you gain more control over resources and cost optimization for steady traffic. By using container images and shared infrastructure definitions, the transition between the two is seamless.

Our test setup used weighted routing to direct traffic evenly between Lambda and ECS, confirming that both environments can serve the same application image without changes to the code. While this dual-backend approach is not intended for production use, it’s a powerful way to validate portability and plan for scaling strategies.

If you’re just starting out, Lambda provides a fast, low-maintenance option. If you’re ready to optimize and scale, ECS offers the flexibility you need. This setup lets you start with one and grow into the other—without rewriting your application or deployment logic.

Happy engineering!