DevOps from Zero to Hero: Deploying Your API to AWS ECS with Fargate
Support this blog
If you find this content useful, consider supporting the blog.
Introduction
Welcome to article eight of the DevOps from Zero to Hero series. In the previous article we learned how to provision AWS infrastructure with Terraform. Now it is time to put that knowledge to work and deploy our TypeScript task API (the one we built in article two) to a real cloud environment using AWS ECS with Fargate.
ECS (Elastic Container Service) is AWS’s own container orchestration platform. It lets you run Docker containers without having to manage the underlying infrastructure yourself. When you pair it with Fargate, you do not even need to think about EC2 instances. You just define what your container needs, and AWS takes care of the rest. This is a great starting point before we get into Kubernetes later in the series.
In this article we will cover the core ECS concepts, push our Docker image to a registry, write Terraform code to provision everything (cluster, service, load balancer, auto-scaling), deploy the API, and verify it is running. By the end you will have a production-ready deployment that scales automatically based on demand.
Let’s get into it.
What is ECS?
Amazon Elastic Container Service (ECS) is a fully managed container orchestration service. Instead of installing and managing your own orchestrator (like Kubernetes), you hand your container definitions to ECS and it handles scheduling, scaling, and networking for you.
There are four key concepts you need to understand:
- Cluster: A logical grouping of resources where your containers run. Think of it as the boundary that holds everything together. A cluster can contain multiple services.
- Task Definition: A blueprint for your container. It specifies which Docker image to use, how much CPU and memory to allocate, what ports to expose, which environment variables to set, and where to send logs. It is versioned, so you can roll back to a previous definition if needed.
- Task: A running instance of a task definition. If the task definition is the recipe, the task is the actual dish being served. Each task runs one or more containers.
- Service: A long-running construct that ensures a specified number of tasks are always running. If a task crashes, the service automatically starts a new one. Services also handle rolling deployments when you update your task definition.
Here is how these pieces fit together:
ECS Cluster
└── Service (maintains desired count of tasks)
├── Task 1 (running container based on task definition v3)
├── Task 2 (running container based on task definition v3)
└── Task 3 (running container based on task definition v3)
ECS launch types: Fargate vs EC2
When you create an ECS service, you choose a launch type that determines where your containers actually run:
- EC2 launch type: You manage a fleet of EC2 instances. ECS schedules containers onto those instances. You are responsible for patching, scaling, and maintaining the instances. More control, more work.
- Fargate launch type: AWS manages the compute. You just specify CPU and memory for each task, and Fargate provisions the right amount of compute behind the scenes. No servers to manage, no capacity planning, no OS patches.
For this article we are using Fargate because it removes an entire layer of complexity. You pay a small premium compared to EC2, but you save a lot of operational effort. For most teams starting out, Fargate is the right choice.
ECS vs EKS: a brief comparison
You might wonder why we are not going straight to Kubernetes. AWS offers EKS (Elastic Kubernetes Service) for that. Here is the quick comparison:
- ECS is simpler to set up, tightly integrated with AWS services, and has no control plane cost with Fargate. If your workloads are AWS-only, ECS gets you running faster.
- EKS gives you the full Kubernetes API, portability across clouds, and access to the massive Kubernetes ecosystem. It is more complex but more flexible.
We will cover EKS in depth later in this series. For now, ECS with Fargate is the perfect stepping stone because it teaches you container orchestration concepts without the Kubernetes learning curve.
Pushing your Docker image to ECR
Before ECS can run your container, the image needs to be stored in a container registry that ECS can access. AWS provides ECR (Elastic Container Registry) for this purpose. You could also use GitHub Container Registry (GHCR) or Docker Hub, but ECR integrates seamlessly with ECS, so it is the simplest option.
First, create an ECR repository using the AWS CLI:
aws ecr create-repository \
--repository-name task-api \
--region us-east-1 \
--image-scanning-configuration scanOnPush=true
The scanOnPush=true flag enables automatic vulnerability scanning on every push. This is a free
feature and there is no reason not to use it.
Now authenticate Docker with ECR, build the image, tag it, and push:
# Get the login token and pipe it to docker login
aws ecr get-login-password --region us-east-1 | \
docker login --username AWS --password-stdin \
123456789012.dkr.ecr.us-east-1.amazonaws.com
# Build the image (using the Dockerfile from article 2)
docker build -t task-api .
# Tag it for ECR
docker tag task-api:latest \
123456789012.dkr.ecr.us-east-1.amazonaws.com/task-api:latest
# Push to ECR
docker push \
123456789012.dkr.ecr.us-east-1.amazonaws.com/task-api:latest
Replace 123456789012 with your actual AWS account ID. You can find it by running
aws sts get-caller-identity --query Account --output text.
The Terraform project structure
We are going to provision everything with Terraform, building on the foundations from article seven. Here is the project structure we will end up with:
infra/
├── main.tf # Provider and backend configuration
├── variables.tf # Input variables
├── outputs.tf # Output values
├── vpc.tf # VPC, subnets, internet gateway
├── ecr.tf # ECR repository
├── ecs.tf # ECS cluster, task definition, service
├── alb.tf # Application Load Balancer
├── autoscaling.tf # Auto-scaling policies
├── iam.tf # IAM roles and policies
└── security_groups.tf # Security groups
Let’s start with the provider configuration and variables.
Provider and variables
The main.tf file configures the AWS provider and the Terraform backend:
# main.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "ecs/task-api/terraform.tfstate"
region = "us-east-1"
}
}
provider "aws" {
region = var.aws_region
}
Now define the variables we will use throughout the configuration:
# variables.tf
variable "aws_region" {
description = "AWS region to deploy to"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Name of the project, used for resource naming"
type = string
default = "task-api"
}
variable "environment" {
description = "Deployment environment"
type = string
default = "production"
}
variable "container_port" {
description = "Port the container listens on"
type = number
default = 3000
}
variable "container_cpu" {
description = "CPU units for the container (1024 = 1 vCPU)"
type = number
default = 256
}
variable "container_memory" {
description = "Memory in MiB for the container"
type = number
default = 512
}
variable "desired_count" {
description = "Number of tasks to run"
type = number
default = 2
}
variable "container_image" {
description = "Docker image URI for the container"
type = string
}
Networking: VPC and subnets
Our ECS service needs a VPC with public and private subnets. The ALB will sit in the public subnets, and the Fargate tasks will run in the private subnets:
# vpc.tf
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.project_name}-vpc"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 1}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-${count.index + 1}"
}
}
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 10}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.project_name}-private-${count.index + 1}"
}
}
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "${var.project_name}-nat-eip"
}
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id
tags = {
Name = "${var.project_name}-nat"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "${var.project_name}-public-rt"
}
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}
tags = {
Name = "${var.project_name}-private-rt"
}
}
resource "aws_route_table_association" "public" {
count = 2
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private" {
count = 2
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}
A few things to note here. The public subnets have a route to the internet gateway, which is where our ALB will live. The private subnets route through a NAT gateway, which lets our Fargate tasks pull images from ECR and send logs to CloudWatch without being directly exposed to the internet. This is a standard pattern for production workloads.
Security groups
We need two security groups: one for the ALB (allows inbound HTTP traffic from the internet) and one for the ECS tasks (allows traffic only from the ALB):
# security_groups.tf
resource "aws_security_group" "alb" {
name = "${var.project_name}-alb-sg"
description = "Security group for the Application Load Balancer"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from anywhere"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-alb-sg"
}
}
resource "aws_security_group" "ecs_tasks" {
name = "${var.project_name}-ecs-tasks-sg"
description = "Security group for ECS tasks"
vpc_id = aws_vpc.main.id
ingress {
description = "Allow traffic from ALB"
from_port = var.container_port
to_port = var.container_port
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-ecs-tasks-sg"
}
}
This is the principle of least privilege applied to networking. The ECS tasks only accept traffic from the ALB, not from the public internet directly. The ALB is the single entry point.
IAM roles for ECS
ECS tasks need two IAM roles: an execution role (used by ECS itself to pull images and write logs) and a task role (used by your application code to access AWS services):
# iam.tf
resource "aws_iam_role" "ecs_execution_role" {
name = "${var.project_name}-ecs-execution-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy_attachment" "ecs_execution_role_policy" {
role = aws_iam_role.ecs_execution_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_iam_role" "ecs_task_role" {
name = "${var.project_name}-ecs-task-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}
]
})
}
The execution role gets the managed AmazonECSTaskExecutionRolePolicy, which grants permissions
to pull images from ECR and write logs to CloudWatch. The task role starts empty. As your application
grows and needs access to other AWS services (S3, DynamoDB, SQS, etc.), you would attach policies
to this role. Keep them separate so you maintain clear boundaries between what ECS needs and what
your app needs.
The ECR repository in Terraform
Instead of creating the ECR repository manually with the CLI, let’s manage it with Terraform so everything is in code:
# ecr.tf
resource "aws_ecr_repository" "app" {
name = var.project_name
image_tag_mutability = "MUTABLE"
force_delete = true
image_scanning_configuration {
scan_on_push = true
}
tags = {
Name = var.project_name
}
}
resource "aws_ecr_lifecycle_policy" "app" {
repository = aws_ecr_repository.app.name
policy = jsonencode({
rules = [
{
rulePriority = 1
description = "Keep only the last 10 images"
selection = {
tagStatus = "any"
countType = "imageCountMoreThan"
countNumber = 10
}
action = {
type = "expire"
}
}
]
})
}
The lifecycle policy is important. Without it, your ECR repository will accumulate old images indefinitely, and you will pay for the storage. This policy keeps only the last 10 images and expires the rest automatically.
ECS cluster, task definition, and service
Now the main event. We are going to create the ECS cluster, define our task, and create a service that keeps it running:
# ecs.tf
resource "aws_cloudwatch_log_group" "app" {
name = "/ecs/${var.project_name}"
retention_in_days = 30
tags = {
Name = var.project_name
}
}
resource "aws_ecs_cluster" "main" {
name = "${var.project_name}-cluster"
setting {
name = "containerInsights"
value = "enabled"
}
tags = {
Name = "${var.project_name}-cluster"
}
}
resource "aws_ecs_task_definition" "app" {
family = var.project_name
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = var.container_cpu
memory = var.container_memory
execution_role_arn = aws_iam_role.ecs_execution_role.arn
task_role_arn = aws_iam_role.ecs_task_role.arn
container_definitions = jsonencode([
{
name = var.project_name
image = var.container_image
essential = true
portMappings = [
{
containerPort = var.container_port
protocol = "tcp"
}
]
environment = [
{
name = "NODE_ENV"
value = "production"
},
{
name = "PORT"
value = tostring(var.container_port)
}
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.app.name
"awslogs-region" = var.aws_region
"awslogs-stream-prefix" = "ecs"
}
}
healthCheck = {
command = ["CMD-SHELL", "curl -f http://localhost:${var.container_port}/health || exit 1"]
interval = 30
timeout = 5
retries = 3
startPeriod = 60
}
}
])
}
resource "aws_ecs_service" "app" {
name = "${var.project_name}-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = var.desired_count
launch_type = "FARGATE"
deployment_minimum_healthy_percent = 50
deployment_maximum_percent = 200
health_check_grace_period_seconds = 60
network_configuration {
subnets = aws_subnet.private[*].id
security_groups = [aws_security_group.ecs_tasks.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.app.arn
container_name = var.project_name
container_port = var.container_port
}
deployment_circuit_breaker {
enable = true
rollback = true
}
depends_on = [aws_lb_listener.http]
}
There is a lot happening here, so let’s break it down piece by piece.
The CloudWatch log group is where all container logs will be sent. Setting retention_in_days
to 30 prevents logs from accumulating forever and running up your bill.
The cluster is straightforward. We enable Container Insights for better monitoring metrics.
The task definition is the most detailed part:
network_mode = "awsvpc"gives each task its own elastic network interface. This is required for Fargate.cpuandmemorydefine the Fargate sizing. 256 CPU units (0.25 vCPU) and 512 MiB is the smallest configuration and works well for a lightweight API.- The
container_definitionsblock defines the container: image, port mappings, environment variables, log configuration, and health check.- The health check runs
curlagainst the/healthendpoint every 30 seconds. If three consecutive checks fail, ECS marks the task as unhealthy and replaces it.
The service ties everything together:
desired_count = 2means ECS will always try to keep two tasks running.deployment_minimum_healthy_percent = 50means during a deployment, at least one task (50% of 2) must stay healthy. This allows rolling updates without downtime.deployment_maximum_percent = 200means ECS can temporarily run up to four tasks during a deployment (the old ones plus the new ones).- The
deployment_circuit_breakerautomatically rolls back a deployment if the new tasks fail to stabilize. This prevents a bad image from taking down your service.
Application Load Balancer
The ALB sits in front of your ECS service, distributes traffic across tasks, and provides a stable endpoint for clients. It also handles health checks to ensure traffic only goes to healthy tasks:
# alb.tf
resource "aws_lb" "app" {
name = "${var.project_name}-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = aws_subnet.public[*].id
tags = {
Name = "${var.project_name}-alb"
}
}
resource "aws_lb_target_group" "app" {
name = "${var.project_name}-tg"
port = var.container_port
protocol = "HTTP"
vpc_id = aws_vpc.main.id
target_type = "ip"
health_check {
enabled = true
healthy_threshold = 3
unhealthy_threshold = 3
timeout = 5
interval = 30
path = "/health"
protocol = "HTTP"
matcher = "200"
}
deregistration_delay = 30
tags = {
Name = "${var.project_name}-tg"
}
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.app.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
A few important details:
target_type = "ip"is required for Fargate. With the EC2 launch type you would useinstance, but Fargate tasks get their own IP addresses.- The health check hits
/healthand expects a200response. If a task fails three consecutive checks, the ALB stops sending it traffic and ECS replaces it.deregistration_delay = 30gives in-flight requests 30 seconds to complete before a task is removed from the target group during deployments. The default is 300 seconds, which is too long for most APIs.
In production you would add HTTPS support with an ACM certificate and a listener on port 443. We are keeping it simple with HTTP for now, but do not expose production APIs over plain HTTP.
Auto-scaling
Running a fixed number of tasks works, but it wastes money during low-traffic periods and risks overload during spikes. ECS integrates with Application Auto Scaling to adjust the task count based on metrics:
# autoscaling.tf
resource "aws_appautoscaling_target" "ecs" {
max_capacity = 10
min_capacity = 2
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.app.name}"
scalable_dimension = "ecs:service:DesiredCount"
service_namespace = "ecs"
}
resource "aws_appautoscaling_policy" "cpu" {
name = "${var.project_name}-cpu-scaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.ecs.resource_id
scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension
service_namespace = aws_appautoscaling_target.ecs.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageCPUUtilization"
}
target_value = 70.0
scale_in_cooldown = 300
scale_out_cooldown = 60
}
}
resource "aws_appautoscaling_policy" "memory" {
name = "${var.project_name}-memory-scaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.ecs.resource_id
scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension
service_namespace = aws_appautoscaling_target.ecs.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageMemoryUtilization"
}
target_value = 80.0
scale_in_cooldown = 300
scale_out_cooldown = 60
}
}
Here is what this does:
- Minimum 2, maximum 10 tasks. You always have at least two tasks running for availability, and you cap at ten to control costs.
- CPU target: 70%. If average CPU across all tasks exceeds 70%, ECS adds more tasks. If it drops well below 70%, ECS removes tasks (down to the minimum of 2).
- Memory target: 80%. Same idea, but for memory utilization.
- Scale-out cooldown: 60 seconds. After adding tasks, wait at least 60 seconds before considering adding more. This prevents thrashing.
- Scale-in cooldown: 300 seconds. After removing tasks, wait 5 minutes before considering removing more. This is deliberately slower to avoid premature scale-down.
Target tracking is the simplest auto-scaling strategy and it works well for most workloads. You tell AWS “keep CPU around 70%” and it figures out how many tasks to run. If your scaling needs are more complex, you can use step scaling policies or scheduled scaling, but target tracking is a solid default.
Outputs
Finally, define outputs so you can easily find the ALB URL and other useful information after deploying:
# outputs.tf
output "alb_dns_name" {
description = "DNS name of the Application Load Balancer"
value = aws_lb.app.dns_name
}
output "ecr_repository_url" {
description = "URL of the ECR repository"
value = aws_ecr_repository.app.repository_url
}
output "ecs_cluster_name" {
description = "Name of the ECS cluster"
value = aws_ecs_cluster.main.name
}
output "ecs_service_name" {
description = "Name of the ECS service"
value = aws_ecs_service.app.name
}
output "cloudwatch_log_group" {
description = "CloudWatch log group for the ECS tasks"
value = aws_cloudwatch_log_group.app.name
}
Deploying with Terraform
With all the configuration in place, deploying is a matter of running the standard Terraform workflow:
cd infra
# Initialize Terraform (download providers, configure backend)
terraform init
# Review the execution plan
terraform plan -var="container_image=123456789012.dkr.ecr.us-east-1.amazonaws.com/task-api:latest"
# Apply the changes
terraform apply -var="container_image=123456789012.dkr.ecr.us-east-1.amazonaws.com/task-api:latest"
Terraform will show you everything it plans to create before it does anything. Review the plan
carefully, then type yes to proceed. The first deployment takes a few minutes because it needs
to create the VPC, subnets, NAT gateway, ALB, and ECS resources.
When it finishes, Terraform will print the outputs. Grab the alb_dns_name value, that is your
API endpoint.
Testing the deployment
Let’s verify everything is working. Use the ALB DNS name from the Terraform output:
# Check the health endpoint
curl http://task-api-alb-123456789.us-east-1.elb.amazonaws.com/health
# Expected response:
# {"status":"healthy","uptime":42.123,"timestamp":"2026-05-12T10:30:00.000Z"}
Try creating a task:
# Create a new task
curl -X POST \
http://task-api-alb-123456789.us-east-1.elb.amazonaws.com/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Deploy to ECS", "description": "First task from production!"}'
# List all tasks
curl http://task-api-alb-123456789.us-east-1.elb.amazonaws.com/tasks
If everything is working, you should see healthy responses. If something is wrong, check the CloudWatch logs:
# View recent logs from the ECS tasks
aws logs tail /ecs/task-api --follow --since 10m
You can also check the ECS service events to see if tasks are starting and stopping properly:
aws ecs describe-services \
--cluster task-api-cluster \
--services task-api-service \
--query 'services[0].events[:10]' \
--output table
Deploying updates: the rolling deployment flow
When you push a new version of your Docker image, you need to tell ECS to pick it up. The simplest way is to force a new deployment:
# Build, tag, and push the new image
docker build -t task-api .
docker tag task-api:latest \
123456789012.dkr.ecr.us-east-1.amazonaws.com/task-api:latest
docker push \
123456789012.dkr.ecr.us-east-1.amazonaws.com/task-api:latest
# Force ECS to pull the new image
aws ecs update-service \
--cluster task-api-cluster \
--service task-api-service \
--force-new-deployment
Here is what happens during a rolling deployment:
- ECS starts new tasks with the updated image alongside the existing ones (up to
deployment_maximum_percent)- The ALB health checks verify the new tasks are healthy
- Once the new tasks pass health checks, the ALB starts routing traffic to them
- ECS drains connections from the old tasks (respecting
deregistration_delay)- The old tasks are stopped
- If the new tasks fail to become healthy, the deployment circuit breaker automatically rolls back to the previous version
This entire process happens with zero downtime. Your users never see an error during the deployment because the old tasks keep serving traffic until the new ones are ready.
In a real CI/CD pipeline (which we covered earlier in the series), you would automate this entire flow. Push to main, CI builds the image, pushes to ECR, and triggers the ECS deployment. No manual steps required.
Cost considerations
Before we wrap up, let’s talk about what this costs. Fargate pricing is based on the CPU and memory you allocate to each task, billed per second with a one-minute minimum:
- 0.25 vCPU, 512 MiB (our configuration): roughly $0.01/hour per task
- With 2 tasks running 24/7: approximately $15/month for compute
- NAT gateway: about $32/month (this is often the largest cost for small deployments)
- ALB: approximately $16/month plus data transfer
- ECR: $0.10/GB/month for storage, first 500 MB free
- CloudWatch Logs: $0.50/GB ingested
For a small API, you are looking at roughly $65-80/month total. The NAT gateway is the single
most expensive component. If cost is a concern, you could run your tasks in public subnets with
assign_public_ip = true and skip the NAT gateway, but this is not recommended for production
workloads because it exposes your tasks directly to the internet.
Closing notes
You now have a production-grade deployment of your TypeScript API on AWS ECS with Fargate. The setup includes a proper VPC with public and private subnets, an Application Load Balancer for traffic distribution and health checking, auto-scaling to handle variable load, a deployment circuit breaker for safety, and centralized logging in CloudWatch. All managed as code with Terraform.
ECS with Fargate is a great choice when you want container orchestration without the complexity of Kubernetes. It integrates tightly with the AWS ecosystem, requires minimal operational overhead, and scales well for most workloads.
In the next article, we will look at more advanced AWS services and prepare for the jump to Kubernetes with EKS. If you have followed along this far, you already understand the fundamentals of container orchestration, which will make Kubernetes much easier to learn.
Hope you found this useful and enjoyed reading it, until next time!
Errata
If you spot any error or have any suggestion, please send me a message so it gets fixed.
Also, you can check the source code and changes in the sources here
$ Comments
Online: 0Please sign in to be able to write comments.