Deploying a container to production still trips up more engineering teams than it should. You build a Docker image, it runs fine on your laptop, and then the handoff to AWS turns into a maze of task definitions, IAM roles, and load balancer target groups. This tutorial walks through the entire path: building a containerized app, pushing it to Amazon ECR, and deploying it to AWS ECS Fargate with a GitHub Actions pipeline that runs on every push to main. By the end you’ll have a repeatable CI/CD setup that AWS itself is now pushing teams toward with the newer ECS Express Mode tooling.
Fargate removes the server-management layer from ECS. You define a task, tell AWS how much CPU and memory it needs, and the platform runs your container without you ever touching an EC2 instance. Search interest around “aws fargate” sits at roughly 5,400 monthly searches in the US with low competition, and “aws ecs” adds another 3,600, according to keyword data pulled in August 2026. That volume tracks with what’s happening on the ground: more teams are moving away from self-managed EC2 clusters and even away from full EKS deployments for workloads that don’t need Kubernetes-level orchestration.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Why AWS ECS Fargate Instead of EKS or Raw EC2
Before writing a single line of Terraform or YAML, it’s worth being honest about when Fargate is the right call. ECS Fargate is a serverless compute engine for containers — AWS provisions and scales the infrastructure behind your task definitions, and you pay per vCPU-second and per GB-second of memory actually consumed. There’s no cluster of EC2 instances to patch, no capacity planning, and no auto-scaling groups to babysit.
Compare that to Amazon EKS, which gives you full Kubernetes semantics — custom resource definitions, operators, and a massive ecosystem of Helm charts — but at the cost of a steeper learning curve and cluster management overhead even when running EKS with Fargate profiles. If your team is running a handful of services rather than dozens of microservices needing service mesh and complex scheduling rules, ECS Fargate is usually the faster path to production. AWS’s own ECS developer guide frames Fargate as the default recommendation for teams that want container orchestration without operating the orchestrator.
The tradeoff shows up in flexibility. Fargate tasks can’t mount certain storage types as easily as EC2-backed ECS tasks, GPU workloads aren’t supported the same way, and very high-density bin-packing scenarios are cheaper on self-managed EC2 capacity. If you’ve already priced out the alternatives, our AWS Fargate vs Cloud Run vs Container Apps comparison breaks down the per-vCPU pricing gap across the three major serverless container platforms.
Prerequisites and Tool Versions
Get these installed and verified before starting. Version mismatches are the single most common cause of failed deployments in this workflow, so check each one against what’s listed here.
| Tool | Minimum Version | Purpose | Install Check |
|---|---|---|---|
| AWS CLI | v2.27.0+ | Provision ECR, ECS, IAM resources | aws --version |
| Docker Engine | 27.0+ | Build and test the container image locally | docker --version |
| Git | 2.40+ | Version control, GitHub Actions triggers | git --version |
| GitHub CLI (optional) | 2.60+ | Manage repo secrets from the terminal | gh --version |
| jq | 1.7+ | Parse JSON task definitions in shell scripts | jq --version |
You’ll also need an AWS account with billing enabled, a GitHub repository (public or private, both work with GitHub Actions), and IAM permissions sufficient to create ECR repositories, ECS clusters and services, IAM roles, and an Application Load Balancer. If you’re working inside an organization AWS account, ask your platform team for a role with AmazonECS_FullAccess, AmazonEC2ContainerRegistryFullAccess, and IAMFullAccess scoped to a sandbox account before touching production.
One architectural decision to make upfront: this tutorial uses GitHub’s OIDC (OpenID Connect) identity provider to authenticate from GitHub Actions to AWS, rather than storing long-lived AWS access keys as GitHub secrets. This is now the standard recommended pattern — AWS and GitHub both discourage static credentials in CI pipelines, and the aws-actions/configure-aws-credentials action has supported OIDC natively for several years. Static keys that leak in a build log or a fork’s pull request are a recurring cause of AWS account compromise, so skipping this step to save 10 minutes isn’t worth it.
Step 1: Containerize Your Application
Start with a minimal, production-ready Dockerfile. This example uses a multi-stage build for a Node.js API, but the same pattern applies to Python, Go, or Java services — build in one stage, copy only the compiled artifacts into a slim runtime image.
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./
EXPOSE 3000
USER node
CMD ["node", "dist/server.js"]
Test the build locally before touching AWS at all:
docker build -t my-fargate-app:local .
docker run -p 3000:3000 my-fargate-app:local
curl http://localhost:3000/health
If the container doesn’t run cleanly on your machine, it won’t run cleanly on Fargate. Confirm the health check endpoint returns a 200 response before moving forward — ECS will use this same endpoint to decide whether your task is healthy enough to receive traffic.
Step 2: Create the Amazon ECR Repository
Amazon ECR is where your container images live before ECS pulls them. Create a private repository with image scanning enabled — this catches known CVEs in your base image and dependencies at push time, not after an incident.
aws ecr create-repository \
--repository-name my-fargate-app \
--image-scanning-configuration scanOnPush=true \
--region us-east-1
The command returns a repositoryUri value that looks like 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-fargate-app. Save this — it goes into both your GitHub Actions workflow and your ECS task definition. Reference the full lifecycle policy options in the Amazon ECR user guide if you want to auto-expire untagged images and control storage costs.
Step 3: Set Up the OIDC Trust Between GitHub and AWS
This is the step most tutorials skip, and it’s the one that actually matters for security. Create an IAM OIDC identity provider that trusts GitHub’s token issuer, then a role that GitHub Actions can assume — scoped to your specific repository and branch.
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
Next, create the trust policy that restricts which repo and branch can assume the role:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:your-org/my-fargate-app:ref:refs/heads/main"
}
}
}
]
}
Attach a permissions policy scoped to ECR push and ECS deploy actions — resist the temptation to attach AdministratorAccess just to get past this step. A deployment role only needs ecr:GetAuthorizationToken, ecr:BatchCheckLayerAvailability, ecr:PutImage, ecs:UpdateService, ecs:DescribeServices, ecs:RegisterTaskDefinition, and iam:PassRole for the task execution role.
Understanding ECS Fargate Networking Modes
Fargate tasks always use the awsvpc network mode, which gives each task its own elastic network interface (ENI) with a private IP address inside your VPC — unlike EC2-backed ECS tasks, which can share a host’s network namespace under the older bridge mode. This matters for two practical reasons: security group rules apply per-task rather than per-host, giving you much finer-grained network control, and each task consumes an IP address from whatever subnet it lands in.
That second point trips up teams running Fargate at scale in a small VPC. A /24 subnet has roughly 251 usable IP addresses after AWS reserves a handful, and every running Fargate task — plus its associated ENI during a rolling deployment when old and new tasks briefly coexist — consumes one. If you’re planning to run more than a few dozen concurrent tasks across multiple services, size your subnets at /22 or larger from the start, since resizing a VPC’s subnet CIDR after the fact means recreating it.
For the public-facing service in this tutorial, tasks sit in public subnets with assignPublicIp=ENABLED so they can reach ECR and pull the container image without a NAT gateway. For production workloads handling sensitive data, the more common pattern is placing tasks in private subnets and routing outbound traffic (to ECR, CloudWatch, and other AWS services) through NAT gateways or, more cost-effectively, through VPC interface endpoints for ECR, S3, and CloudWatch Logs specifically — this avoids NAT gateway data-processing charges for traffic that never actually needs to leave AWS’s network.
Step 4: Create the ECS Cluster
An ECS cluster is a logical grouping — with Fargate, there’s no actual server capacity to reserve, so creation is fast:
aws ecs create-cluster \
--cluster-name my-fargate-cluster \
--capacity-providers FARGATE FARGATE_SPOT \
--default-capacity-provider-strategy capacityProvider=FARGATE,weight=1
Including FARGATE_SPOT as a capacity provider lets you later shift non-critical or batch workloads to spot pricing, which typically runs 50-70% cheaper than standard Fargate pricing. Keep production-facing services on standard FARGATE capacity until you’ve validated your app tolerates task interruption gracefully.
Step 5: Write the ECS Task Definition
The task definition describes what container to run, how much CPU/memory to allocate, and which IAM roles apply. Save this as task-definition.json in your repo root — GitHub Actions will read, patch, and register a new revision of it on every deploy.
{
"family": "my-fargate-app",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "512",
"memory": "1024",
"executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
"taskRoleArn": "arn:aws:iam::123456789012:role/ecsTaskRole",
"containerDefinitions": [
{
"name": "my-fargate-app",
"image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/my-fargate-app:latest",
"portMappings": [{ "containerPort": 3000, "protocol": "tcp" }],
"essential": true,
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/my-fargate-app",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
},
"healthCheck": {
"command": ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"],
"interval": 30,
"timeout": 5,
"retries": 3
}
}
]
}
512 CPU units (0.5 vCPU) and 1024 MB memory is a reasonable starting point for a small API. Fargate bills per-second at those allocations, so oversizing directly inflates your bill — right-size after you’ve watched real CloudWatch metrics for a week rather than guessing upfront.
Step 6: Create the ECS Service and Load Balancer
The service keeps your desired number of tasks running and, when attached to an Application Load Balancer, handles rolling deployments without downtime. Create the ALB, target group, and listener first, then register the service against them:
aws ecs create-service \
--cluster my-fargate-cluster \
--service-name my-fargate-app-svc \
--task-definition my-fargate-app \
--desired-count 2 \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[subnet-abc123,subnet-def456],securityGroups=[sg-0123456789],assignPublicIp=ENABLED}" \
--load-balancers "targetGroupArn=arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/my-tg/abc123,containerName=my-fargate-app,containerPort=3000" \
--health-check-grace-period-seconds 60
Running desired-count 2 from the start means your service survives a single task failing a health check without dropping to zero capacity — a common cause of brief outages during first deploys is launching with just one task.
Step 7: Build the GitHub Actions Workflow File
This is the file that ties everything together. Save it as .github/workflows/deploy.yml. It builds the Docker image, pushes it to ECR, patches the task definition with the new image tag, and deploys it to the ECS service — waiting for the rollout to stabilize before marking the job successful.
name: Deploy to ECS Fargate
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
env:
AWS_REGION: us-east-1
ECR_REPOSITORY: my-fargate-app
ECS_SERVICE: my-fargate-app-svc
ECS_CLUSTER: my-fargate-cluster
ECS_TASK_DEFINITION: task-definition.json
CONTAINER_NAME: my-fargate-app
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-ecs-deploy
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build, tag, and push image
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> "$GITHUB_OUTPUT"
- name: Render new task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: ${{ env.ECS_TASK_DEFINITION }}
container-name: ${{ env.CONTAINER_NAME }}
image: ${{ steps.build-image.outputs.image }}
- name: Deploy to Amazon ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: ${{ env.ECS_SERVICE }}
cluster: ${{ env.ECS_CLUSTER }}
wait-for-service-stability: true
The wait-for-service-stability: true flag matters more than it looks. Without it, the GitHub Actions job reports success the moment the deploy command is accepted, even if the new tasks then crash-loop on the health check. With it, the job polls ECS and fails loudly if the new deployment never reaches a steady state — which is exactly when you want to know, not five minutes later from a user complaint.
Step 8: Configure GitHub Repository Secrets and Permissions
With OIDC, there’s no AWS access key or secret key to store. You only need the role ARN, and even that can live directly in the workflow file since it’s not sensitive on its own — the trust policy from Step 3 is what actually restricts access. If you prefer not to hardcode the ARN, add it as a repository variable:
gh variable set AWS_ROLE_ARN --body "arn:aws:iam::123456789012:role/github-actions-ecs-deploy"
Then reference it in the workflow as ${{ vars.AWS_ROLE_ARN }} instead of the literal string. This makes it trivial to point staging and production deploys at different AWS accounts using GitHub Environments, without duplicating workflow files.
Step 9: Add Branch Protection and a Staging Gate
Pushing straight to production on every merge to main works for small teams and side projects, but most organizations want at minimum a staging deploy plus a manual approval gate before production. GitHub Environments handle this natively — create a production environment in repo settings, require a reviewer, and reference it in the job:
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
# ...same steps as above
With this in place, the workflow pauses after the staging job completes and waits for a named reviewer to click approve before the production job runs. It’s a small addition that prevents the class of incident where a bad merge auto-deploys to every customer at 2am.
Step 10: Try the New ECS Express Mode Deploy Action
AWS shipped a notable simplification to this whole workflow in 2026: the Amazon ECS Deploy Express Service GitHub Action, built specifically for ECS Express Mode services. Express Mode is AWS’s opinionated, lower-configuration path into ECS Fargate — it auto-generates sensible defaults for load balancing, networking, and service discovery so teams don’t have to hand-write every piece of infrastructure described in Steps 4-6. AWS’s containers blog post announcing the feature frames it as a way to cut the boilerplate that traditionally made ECS setup feel heavier than it needed to be for straightforward web services.
If you’re starting a brand-new service rather than migrating an existing ECS setup, it’s worth trying Express Mode first and falling back to the manual task-definition approach only if you hit a configuration limit it doesn’t support yet. For teams already running production ECS services with custom networking, sticking with the manual path from Steps 4-9 still gives you more control over VPC placement, security groups, and multi-container task definitions.
Step 11: Verify the Deployment
After the GitHub Actions job finishes, confirm the service is actually serving traffic correctly, not just that the pipeline reported green:
aws ecs describe-services \
--cluster my-fargate-cluster \
--services my-fargate-app-svc \
--query "services[0].{running:runningCount,desired:desiredCount,status:status}"
# Expected output:
# {
# "running": 2,
# "desired": 2,
# "status": "ACTIVE"
# }
curl -I https://app.example.com/health
# Expected: HTTP/1.1 200 OK
If runningCount never matches desiredCount, your tasks are failing to start or failing health checks — go to Step 12’s troubleshooting table before assuming the pipeline is broken.
Step 12: Set Up CloudWatch Logging and Alarms
Every Fargate task writes stdout/stderr to CloudWatch Logs via the awslogs driver configured in Step 5. Create the log group before your first deploy so ECS doesn’t fail trying to write to a group that doesn’t exist:
aws logs create-log-group --log-group-name /ecs/my-fargate-app
aws logs put-retention-policy --log-group-name /ecs/my-fargate-app --retention-in-days 30
Then add a CloudWatch alarm on the ECS service’s CPUUtilization and MemoryUtilization metrics so you get paged before a task gets OOM-killed rather than after. A 30-day retention policy also keeps your CloudWatch bill from growing unbounded — logs older than that rarely get looked at outside of a compliance audit.
Auto Scaling Your ECS Fargate Service
Running a fixed desired-count works for predictable, low-traffic services, but most production workloads see traffic swings that make a static task count wasteful during quiet hours and risky during peaks. Application Auto Scaling integrates directly with ECS services and can scale your task count up or down based on CPU, memory, or a custom CloudWatch metric like request count per target.
Register the service as a scalable target, then attach a target-tracking policy that keeps average CPU utilization near a set percentage:
aws application-autoscaling register-scalable-target \
--service-namespace ecs \
--resource-id service/my-fargate-cluster/my-fargate-app-svc \
--scalable-dimension ecs:service:DesiredCount \
--min-capacity 2 \
--max-capacity 10
aws application-autoscaling put-scaling-policy \
--service-namespace ecs \
--resource-id service/my-fargate-cluster/my-fargate-app-svc \
--scalable-dimension ecs:service:DesiredCount \
--policy-name cpu-target-tracking \
--policy-type TargetTrackingScaling \
--target-tracking-scaling-policy-configuration '{
"TargetValue": 60.0,
"PredefinedMetricSpecification": {
"PredefinedMetricType": "ECSServiceAverageCPUUtilization"
},
"ScaleInCooldown": 120,
"ScaleOutCooldown": 60
}'
A shorter scale-out cooldown (60 seconds) versus scale-in cooldown (120 seconds) is intentional — you want the service to react quickly when demand rises but to be more conservative about removing capacity, since flapping between scale-up and scale-down events every few minutes creates unnecessary task churn and cold-start latency for users hitting freshly-launched tasks. A min-capacity of 2 also keeps the service resilient to a single availability zone issue, which matters more once you’re relying on auto scaling to handle real traffic rather than manually watching dashboards.
For request-driven services behind an ALB, scaling on ALBRequestCountPerTarget instead of CPU often produces smoother results, since request count correlates more directly with user-facing latency than CPU load does for I/O-bound applications waiting on database calls.
Security Best Practices for ECS Fargate Deployments
Fargate removes the burden of patching the underlying host OS, but that doesn’t mean the security work disappears — it shifts to the container image, the IAM roles, and the network boundary. A handful of practices consistently separate teams that pass a security review from teams that don’t.
Run containers as a non-root user, as shown in the Dockerfile in Step 1 with the USER node directive. A container running as root inside Fargate doesn’t grant host-level access the way it might on a shared EC2 instance, but it still expands the blast radius if an application-level vulnerability is exploited, since a root process can write to more of the container’s filesystem and interact with mounted volumes more freely.
Keep the task role and execution role separate and minimally scoped, as covered in Step 5 — a task role with S3 read access to one specific bucket is a very different risk profile than a task role with broad s3:* permissions across the account. Enable readonlyRootFilesystem: true in the container definition where your application doesn’t need to write to its own filesystem at runtime; this blocks a whole class of attacks that rely on writing a malicious script to disk and then executing it.
Store secrets like database passwords and API keys in AWS Secrets Manager or Systems Manager Parameter Store, referenced from the task definition’s secrets block rather than baked into environment variables in plaintext:
"secrets": [
{
"name": "DATABASE_PASSWORD",
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/db-password-AbCdEf"
}
]
This keeps credentials out of your task definition JSON entirely — anyone with read access to view task definitions in the console sees only the secret’s ARN, not its value, which matters for audit trails and for limiting what a compromised CI token can expose.
Common Pitfalls When Deploying to ECS Fargate
These are the mistakes that show up repeatedly in ECS Fargate deployments, based on patterns documented across AWS support cases and community troubleshooting threads.
- Forgetting
assignPublicIp=ENABLEDin a public subnet. Fargate tasks in a public subnet without a public IP can’t reach ECR to pull the image, and the task fails silently with a vague “CannotPullContainerError.” - Task execution role vs. task role confusion. The execution role lets ECS pull images and write logs; the task role is what your application code uses to call other AWS services (like S3 or DynamoDB). Mixing these up causes permission errors that look like application bugs.
- Health check grace period too short. If your app takes 45 seconds to boot but the grace period is set to 30, ECS kills the task before it’s ready and loops forever. Match the grace period to your actual cold-start time plus a buffer.
- Skipping
wait-for-service-stability. Without it, a broken deploy reports “success” in GitHub Actions while the service quietly fails behind the load balancer. - Overprovisioning CPU/memory “just in case.” Fargate bills per allocated resource whether you use it or not — a task sized at 2 vCPU / 4GB that only needs 0.5 vCPU / 1GB costs 4x more for no benefit.
- Not pinning action versions. Using
@mainor@latestfor third-party GitHub Actions instead of pinning to a specific version or commit SHA opens the door to a supply-chain surprise if the action’s maintainer pushes a breaking or malicious update. - Security group misconfiguration between ALB and tasks. The ALB’s security group needs explicit inbound access to the container port on the task’s security group — a surprisingly common one-line miss that causes 504 Gateway Timeout errors.
ECS Fargate vs. Alternatives: Cost and Complexity Comparison
Fargate isn’t the only serverless container option, and it’s worth knowing where it sits relative to the alternatives before committing infrastructure code to one platform.
| Platform | Server Management | Kubernetes API | Best For |
|---|---|---|---|
| AWS ECS Fargate | None (serverless) | No | Small-to-mid teams already on AWS, simple service topologies |
| AWS EKS (with Fargate profiles) | None for pods, cluster control plane managed | Yes | Teams needing full Kubernetes ecosystem/Helm charts |
| Google Cloud Run | None (serverless) | No | Request-driven workloads, scale-to-zero use cases |
| Azure Container Apps | None (serverless) | Partial (KEDA-based) | Azure-native teams wanting event-driven scaling |
| Self-managed ECS on EC2 | Full (patching, scaling) | No | High-density workloads needing max cost control |
If your team is choosing between these platforms rather than committed to AWS, our full AWS Fargate vs Cloud Run vs Container Apps breakdown covers the per-vCPU pricing delta in detail, and it’s a meaningfully different number depending on region and commitment level.
Estimating Your Monthly Fargate Bill
One reason teams hesitate before committing to Fargate is that the pricing model — per-vCPU-second and per-GB-second — feels harder to reason about than a flat EC2 instance price. In practice it’s straightforward once you run the numbers for your actual task size. Using the task definition from Step 5 (0.5 vCPU, 1 GB memory) running 2 tasks continuously, here’s roughly what that costs in the us-east-1 region before any Savings Plan discount, based on current on-demand Fargate pricing.
| Resource | Allocation | Approx. Monthly Cost (2 tasks, 24/7) |
|---|---|---|
| vCPU | 0.5 vCPU × 2 tasks | ~$29 |
| Memory | 1 GB × 2 tasks | ~$6 |
| Data transfer + ALB | Varies by traffic | ~$18-25 (ALB base cost + LCU usage) |
| Estimated total | — | ~$53-60/month |
These figures are directional — always confirm against the official AWS Fargate pricing page for your specific region, since per-second rates vary by region and change periodically. The bigger lever than region, though, is right-sizing: doubling your task’s memory allocation “to be safe” roughly doubles that line item’s cost, even if your application never uses more than half of what’s allocated. Pull actual CPU and memory utilization from CloudWatch after a week of real traffic and adjust the task definition’s cpu and memory fields down if you see consistent headroom — this is often the single fastest way to cut a Fargate bill without any application changes.
AWS Compute Savings Plans also apply to Fargate usage and can cut the compute portion of this bill by up to 20% in exchange for a 1- or 3-year commitment to a baseline level of usage — worth evaluating once a service’s traffic pattern and task count have stabilized enough to commit to a floor.
Troubleshooting Guide
These are the errors you’re most likely to hit, in roughly the order teams encounter them during a first deployment.
| Symptom | Likely Cause | Fix |
|---|---|---|
| CannotPullContainerError | Task has no route to ECR (no public IP, no NAT gateway, or wrong VPC endpoint) | Enable public IP or add an ECR VPC endpoint in the subnet’s route table |
| Task stuck in PENDING | Insufficient subnet IP addresses or missing capacity provider | Check subnet CIDR size and confirm FARGATE is in the cluster’s capacity providers |
| Task starts then immediately stops (STOPPED) | Container crashes on launch, often a missing env var or bad CMD | Check the “stoppedReason” field via aws ecs describe-tasks |
| ALB returns 503 Service Unavailable | No healthy targets registered in the target group | Verify container port matches target group port and health check path is correct |
| ALB returns 504 Gateway Timeout | Security group blocking ALB-to-task traffic | Allow inbound from the ALB security group on the container port |
| GitHub Actions: “Not authorized to perform sts:AssumeRoleWithWebIdentity” | OIDC trust policy subject condition doesn’t match the branch/repo | Confirm the “sub” claim in the trust policy matches your exact repo and ref |
| Deployment hangs at “wait-for-service-stability” | New tasks failing health checks in a loop, old tasks never drained | Check CloudWatch logs for the new task revision’s startup errors |
| ECR image scan blocks deploy | Scan-on-push found a critical CVE in the base image | Update the base image tag (e.g. node:20-alpine to latest patch) and rebuild |
| “ClientException: Unable to assume role” during deploy step | Task execution role trust policy doesn’t include ecs-tasks.amazonaws.com | Verify the execution role’s trust relationship includes the ECS service principal |
Advanced Tips for Production ECS Fargate Pipelines
Once the basic pipeline is working reliably, a few refinements make a real difference in production. First, adopt blue/green deployments through AWS CodeDeploy integration rather than the default rolling update — this lets you shift traffic gradually and roll back instantly if error rates spike, instead of waiting for a full rolling deploy to complete before you notice a problem.
Second, cache Docker layers between GitHub Actions runs using the docker/build-push-action with GitHub Actions cache backend — a typical Node.js or Python image rebuild that takes 3-4 minutes cold can drop to under a minute with proper layer caching, which matters when you’re deploying multiple times a day.
Third, consider Fargate Spot for non-production environments. Since staging and preview environments don’t need the same availability guarantees as production, running them on FARGATE_SPOT capacity can meaningfully cut your non-prod AWS bill without any code changes — just a different capacity provider strategy in the service definition.
Finally, if you’re running more than 3-4 services, invest in a shared Terraform module for the ECS service/task definition/ALB target group pattern rather than copy-pasting the same resource blocks per service. Our Terraform on AWS setup guide covers the module structure that scales best for multi-service ECS environments.
Complete Working Project Structure
Here’s how all the pieces from this tutorial fit together in a single repository:
my-fargate-app/
├── .github/
│ └── workflows/
│ └── deploy.yml # Step 7 workflow
├── src/
│ └── server.js # Your application code
├── Dockerfile # Step 1 multi-stage build
├── task-definition.json # Step 5 ECS task definition
├── package.json
└── README.md
With this structure in place, the full lifecycle is: push to main, GitHub Actions builds and pushes the image, registers a new task definition revision, updates the ECS service, and waits for the rollout to stabilize before reporting success. From an empty repo to a live, load-balanced, auto-deploying Fargate service, this whole setup takes about 90 minutes for a first-timer and closer to 20 minutes once you’ve done it a second time with a template repo.
Frequently Asked Questions
Is AWS Fargate more expensive than running ECS on EC2?
Per-vCPU-hour, Fargate typically costs more than an equivalent EC2 instance running the same containers, because you’re paying for the convenience of not managing servers. For low-to-moderate density workloads the operational savings usually outweigh the compute premium; for very high-density, always-on workloads with predictable capacity needs, self-managed EC2 or Savings Plans can be meaningfully cheaper. Check current rates on the AWS Fargate pricing page for your region.
Do I need Terraform to deploy to ECS Fargate?
No. This tutorial uses the AWS CLI directly for initial infrastructure setup, which is fine for a single service or a learning environment. For multiple services or teams managing infrastructure changes through pull requests, Terraform or CloudFormation becomes worth the setup overhead fairly quickly.
What’s the difference between ECS Express Mode and standard ECS?
Express Mode is a newer, more opinionated setup path that auto-configures networking, load balancing, and service discovery with sensible defaults, reducing the manual steps described in this tutorial. Standard ECS gives you full control over every resource but requires configuring each piece yourself, as shown in Steps 4 through 6.
Can I use GitHub Actions to deploy to ECS without OIDC?
Yes, you can store an AWS access key and secret key as GitHub repository secrets instead. It works, but it’s no longer the recommended pattern — long-lived credentials in CI systems are a known attack vector, and OIDC removes that risk entirely by issuing short-lived, scoped tokens per workflow run.
How long does a typical ECS Fargate deployment take?
A rolling deployment usually takes 2-5 minutes from the moment the new task definition is registered to the moment old tasks are fully drained, depending on your health check grace period and how many tasks are running. Cold image pulls on a first deploy can add another minute or two.
What happens if my Fargate task fails its health check after deployment?
ECS marks the task as unhealthy, stops routing traffic to it through the load balancer, and (depending on your deployment configuration) either retries the task or rolls back to the previous stable task definition. This is why setting wait-for-service-stability: true in the GitHub Actions workflow matters — it surfaces this failure in your CI pipeline instead of silently leaving a broken deploy live.
Is ECS Fargate a good fit for stateful applications?
Fargate supports EFS volume mounts for persistent storage, which covers many stateful use cases, but it’s still best suited to stateless or externally-backed services (with state in RDS, DynamoDB, or S3). Databases and workloads needing local block storage or GPU access are usually better run outside Fargate.
Should I use one GitHub Actions workflow for staging and production, or two separate ones?
One workflow file with GitHub Environments (as shown in Step 9) is generally easier to maintain than duplicate workflow files, since you avoid drift between staging and production deploy logic. Use environment-specific variables and secrets to handle the differences in cluster names, role ARNs, and approval requirements.
Related Coverage
- AWS Fargate vs Cloud Run vs Container Apps: $29.55 Gap [2026]
- Kubernetes on AWS EKS Setup: 12 Steps, 100 Min [2026]
- AWS Elastic Beanstalk Setup: 12 Steps, 90 Min [2026]
- How to Set Up AWS Lambda: 12 Steps, 90 Min [2026]
- How to Set Up Terraform on AWS: 13 Steps, 90 Min [2026]
- How to Set Up Google Cloud Run: 13 Steps, 80 Min [2026]


