Skip to content

Deploying a Containerized App to AWS ECS Fargate


The image has been sitting in ECR since the last chapter, built and tagged and going nowhere. This chapter is where it actually starts serving traffic — on Amazon ECS, using the Fargate launch type, which runs your container without you ever provisioning or patching an EC2 instance yourself. This also becomes the pipeline the rest of this section builds on: the next three chapters all extend what you set up here rather than starting over.

The Shape of What You’re Building

    flowchart LR
    Internet((Internet)) -->|HTTP :8080| Task[Fargate task<br/>devstuffs-hello container]
    Task -.runs inside.-> Service[ECS service<br/>desired count: 1]
    Service -.runs inside.-> Cluster[ECS cluster<br/>devstuffs-cluster]
    ECR[(ECR repo<br/>devstuffs-hello)] -.pulls image.-> Task
  

No load balancer in this setup — the Fargate task gets a public IP directly, which keeps this chapter’s moving parts down to exactly the ones this chapter is teaching. A load balancer is a genuinely common addition to a real ECS service, but it’s a separate concept (health checks, target groups, listener rules) that would dilute the actual lesson here: registering and deploying a task definition.

An IAM Role Fargate Needs Before Anything Else

Fargate needs permission to pull your image from ECR and write logs on your behalf — that’s a separate concern from the IAM user your workflow authenticates as, since this role is assumed by the AWS-managed infrastructure running your task, not by GitHub Actions:

aws iam create-role \
  --role-name devstuffsEcsTaskExecutionRole \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": { "Service": "ecs-tasks.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }]
  }'

aws iam attach-role-policy \
  --role-name devstuffsEcsTaskExecutionRole \
  --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

Provisioning the Cluster, Task Definition, and Service

The cluster — an ECS cluster on Fargate is mostly just a logical grouping; there’s no capacity to size upfront:

aws ecs create-cluster --cluster-name devstuffs-cluster

The task definition — describes the container itself. Replace <account-id> with your actual AWS account ID (the same one from the ECR repository URI in the last chapter):

task-definition.json:

{
  "family": "devstuffs-hello",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "256",
  "memory": "512",
  "executionRoleArn": "arn:aws:iam::<account-id>:role/devstuffsEcsTaskExecutionRole",
  "containerDefinitions": [
    {
      "name": "devstuffs-hello",
      "image": "<account-id>.dkr.ecr.us-east-1.amazonaws.com/devstuffs-hello:latest",
      "portMappings": [{ "containerPort": 8080, "protocol": "tcp" }],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/devstuffs-hello",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "ecs"
        }
      }
    }
  ]
}
aws logs create-log-group --log-group-name /ecs/devstuffs-hello --region us-east-1
aws ecs register-task-definition --cli-input-json file://task-definition.json --region us-east-1

Networking — a Fargate task in awsvpc mode needs a subnet and a security group. Use your account’s default VPC to keep this concise:

VPC_ID=$(aws ec2 describe-vpcs --filters Name=isDefault,Values=true --query 'Vpcs[0].VpcId' --output text)
SUBNET_ID=$(aws ec2 describe-subnets --filters Name=vpc-id,Values=$VPC_ID --query 'Subnets[0].SubnetId' --output text)

SG_ID=$(aws ec2 create-security-group \
  --group-name devstuffs-fargate-sg \
  --description "Allow inbound 8080 for devstuffs-hello" \
  --vpc-id $VPC_ID \
  --query 'GroupId' --output text)

aws ec2 authorize-security-group-ingress \
  --group-id $SG_ID --protocol tcp --port 8080 --cidr 0.0.0.0/0

The service — this is what actually keeps a task running continuously and is what the deploy workflow will update on every push:

aws ecs create-service \
  --cluster devstuffs-cluster \
  --service-name devstuffs-service \
  --task-definition devstuffs-hello \
  --desired-count 1 \
  --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={subnets=[$SUBNET_ID],securityGroups=[$SG_ID],assignPublicIp=ENABLED}"

Give it a minute, then find the task’s public IP to confirm it’s actually serving before wiring up CI:

TASK_ARN=$(aws ecs list-tasks --cluster devstuffs-cluster --service-name devstuffs-service --query 'taskArns[0]' --output text)
ENI_ID=$(aws ecs describe-tasks --cluster devstuffs-cluster --tasks $TASK_ARN --query 'tasks[0].attachments[0].details[?name==`networkInterfaceId`].value' --output text)
aws ec2 describe-network-interfaces --network-interface-ids $ENI_ID --query 'NetworkInterfaces[0].Association.PublicIp' --output text

curl that IP on port 8080 — you should get the same Hello from devstuffs CI/CD pipeline! you tested locally back in the Docker chapter.

The Workflow

This job runs after the build-and-push job from the last chapter, using the image it just pushed:

name: Deploy to ECS Fargate

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: aws-ecs
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v6
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Log in to Amazon ECR
        id: ecr-login
        uses: aws-actions/amazon-ecr-login@v1

      - name: Build and push image
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.ecr-login.outputs.registry }}
          ECR_REPOSITORY: devstuffs-hello
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker image build -t "$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" .
          docker image push "$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
          echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> "$GITHUB_OUTPUT"

      - name: Fetch current task definition
        run: |
          aws ecs describe-task-definition \
            --task-definition devstuffs-hello \
            --query taskDefinition \
            > task-definition.json

      - name: Render new image into task definition
        id: render
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: task-definition.json
          container-name: devstuffs-hello
          image: ${{ steps.build-image.outputs.image }}

      - name: Deploy to ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: ${{ steps.render.outputs.task-definition }}
          service: devstuffs-service
          cluster: devstuffs-cluster
          wait-for-service-stability: true

This chapter’s workflow deliberately builds and pushes the image itself with plain docker image build / docker image push commands rather than reusing docker/build-push-action — worth understanding why it’s written this way: the deploy job needs the exact resolved image reference ($ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG) as a step output to hand to amazon-ecs-render-task-definition in the very next step, and building it directly like this keeps that value in your own hands rather than parsing it back out of another action’s outputs.

The three-step render-then-deploy dance is worth understanding piece by piece:

  • describe-task-definition fetches the currently registered task definition as a starting point, rather than you hand-maintaining a full copy of it in the repo that could drift out of sync with what’s actually deployed.
  • amazon-ecs-render-task-definition takes that JSON and swaps in the new image URI for the named container, producing an updated task definition as a step output — it doesn’t register or deploy anything itself, it only edits the JSON in memory.
  • amazon-ecs-deploy-task-definition registers that rendered JSON as a brand-new task definition revision (ECS never overwrites a revision, it always creates the next one) and updates the service to use it. wait-for-service-stability: true makes the workflow step actually block until ECS confirms the new tasks are healthy and old ones have drained — without it, the workflow would report success the instant the API call was accepted, even if the new container immediately crash-loops.

Verifying the Deployment

Re-run the same task-IP lookup from the provisioning section above — the public IP will likely have changed, since Fargate replaced the task with a new one running the new image. curl it again to confirm it’s still serving. You can also watch the deployment happen in real time from the ECS console → Clusters → devstuffs-cluster → Services → devstuffs-service → Deployments tab, which shows the old task definition revision draining as the new one spins up.

Cleanup

An ECS Fargate service with desired-count: 1 bills for that vCPU and memory continuously, the same way an EC2 instance would — there’s no “serverless, scales to zero” behavior here unless you explicitly scale the service down:

aws ecs update-service --cluster devstuffs-cluster --service devstuffs-service --desired-count 0
aws ecs delete-service --cluster devstuffs-cluster --service devstuffs-service
aws ecs delete-cluster --cluster devstuffs-cluster
aws ec2 delete-security-group --group-id $SG_ID
aws logs delete-log-group --log-group-name /ecs/devstuffs-hello --region us-east-1

Warning

If you’re moving straight on to the next few chapters, skip this cleanup — they extend this exact cluster and service with deployment gates and multi-environment promotion, rather than standing up a new one.

Last updated on