DEV Community

Deploying a 2048 Game Application on ECS in an automated way

In this article, we will learn about how to automate the deployment of a simple game 2048 application in a containerized environment using AWS ECS combined with AWS CloudFormation for Infrastructure-as-code template.


Why This Architecture?

  • AWS ECS: Managed container orchestration for scalable deployments in AWS environment.

  • CloudFormation: Infrastructure-as-Code (IaC) for repeatable, version-controlled setups.

  • Automation: By combining the use of AWS CloudFormation and ECS, we can automate the entire process of launching resources and deploying services.

  • Cost Efficiency: Pay only for resources used (ALB, ECS tasks, ECR storage).


Prerequisites

  1. An active AWS account with CLI or GUI access.

  2. A S3 bucket to store your template files.

  3. Docker installed locally (if you want to test in local first)

  4. Basic knowledge of YAML and containers.


Architecture Diagram

Below is high-level overview diagram of architecture which we will deploy on this blog post:

Architecture Diagram


Step-by-step Implementation

In this stage, we will perform a step-by-step implementation guide and walk you through the process of creating YAML files for CloudFormation and actually deploying an open-source docker image for 2048 game application.

Step 0: Create a S3 bucket to store your child templates

Before doing anything else, we will first create a s3 bucket to store our CloudFormation templates that will later be referenced from the parent stack in a nested stack fashion.

I will just go into AWS console and create s3 bucket like this:

S3 bucket create

After creating S3 bucket for storage, we will move onto creating CF template files that we will use in this tutorial.

Step 1: Create a CloudFormation YAML template for based resources like VPC, ECS cluster and ECR repo

First thing first, we will create a stack named base.yaml with following content for creating necessary base resources like:

  • a VPC with two public subnets, two private subnets and a NAT Gateway for internet connection from private subnets.

  • an ECS cluster with FARGATE and FARGATE_SPOT capacity provider for serverless computing.

  • a private ECR repository for storing application container image with proper lifecycle policy defined.

AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  CloudFormation template for VPC with public/private subnets, ECS Cluster, and ECR Repository

Parameters:
  VpcCidr:
    Description: CIDR block for the VPC
    Type: String

  PublicSubnet1Cidr:
    Description: CIDR block for Public Subnet 1
    Type: String

  PublicSubnet2Cidr:
    Description: CIDR block for Public Subnet 2
    Type: String

  PrivateSubnet1Cidr:
    Description: CIDR block for Private Subnet 1
    Type: String

  PrivateSubnet2Cidr:
    Description: CIDR block for Private Subnet 2
    Type: String

  ClusterName:
    Description: Name for the ECS Cluster
    Type: String

  ECRRepoName:
    Description: Name for the ECR Repository
    Type: String

Resources:
  # VPC Configuration
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VpcCidr
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-vpc

  # Internet Gateway
  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-igw

  GatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref Vpc
      InternetGatewayId: !Ref InternetGateway

  # Public Subnets
  PublicSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      CidrBlock: !Ref PublicSubnet1Cidr
      AvailabilityZone: !Select [0, !GetAZs ""]
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-public-subnet-1

  PublicSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      CidrBlock: !Ref PublicSubnet2Cidr
      AvailabilityZone: !Select [1, !GetAZs ""]
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-public-subnet-2

  # Private Subnets
  PrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      CidrBlock: !Ref PrivateSubnet1Cidr
      AvailabilityZone: !Select [0, !GetAZs ""]
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-private-subnet-1

  PrivateSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      CidrBlock: !Ref PrivateSubnet2Cidr
      AvailabilityZone: !Select [1, !GetAZs ""]
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-private-subnet-2

  # NAT Gateway
  NatGatewayEIP:
    Type: AWS::EC2::EIP
    DependsOn: GatewayAttachment
    Properties:
      Domain: vpc

  NatGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt NatGatewayEIP.AllocationId
      SubnetId: !Ref PublicSubnet1
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-nat-gateway

  # Route Tables
  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref Vpc
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-public-rt

  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref Vpc
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-private-rt

  PublicRoute:
    Type: AWS::EC2::Route
    DependsOn: GatewayAttachment
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway

  PrivateRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref NatGateway

  # Route Table Associations
  PublicSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet1
      RouteTableId: !Ref PublicRouteTable

  PublicSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet2
      RouteTableId: !Ref PublicRouteTable

  PrivateSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet1
      RouteTableId: !Ref PrivateRouteTable

  PrivateSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet2
      RouteTableId: !Ref PrivateRouteTable

  # ECS Cluster
  ECSCluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: !Ref ClusterName
      CapacityProviders:
        - FARGATE
        - FARGATE_SPOT
      DefaultCapacityProviderStrategy:
        - CapacityProvider: FARGATE
          Weight: 1
      ClusterSettings:
        - Name: containerInsights
          Value: enabled

  # ECR Repository
  ECRRepository:
    Type: AWS::ECR::Repository
    Properties:
      RepositoryName: !Ref ECRRepoName
      LifecyclePolicy:
        LifecyclePolicyText: |
          {
            "rules": [
              {
                "rulePriority": 1,
                "description": "Keep only 5 images",
                "selection": {
                  "tagStatus": "any",
                  "countType": "imageCountMoreThan",
                  "countNumber": 5
                },
                "action": {
                  "type": "expire"
                }
              }
            ]
          }
      ImageScanningConfiguration:
        ScanOnPush: true

Outputs:
  VpcId:
    Description: VPC ID
    Value: !Ref Vpc
    Export:
      Name: !Sub ${AWS::StackName}-VpcId

  PublicSubnet1Id:
    Description: Public Subnet 1 ID
    Value: !Ref PublicSubnet1
    Export:
      Name: !Sub ${AWS::StackName}-PublicSubnet1Id

  PublicSubnet2Id:
    Description: Public Subnet 2 ID
    Value: !Ref PublicSubnet2
    Export:
      Name: !Sub ${AWS::StackName}-PublicSubnet2Id

  PrivateSubnet1Id:
    Description: Private Subnet 1 ID
    Value: !Ref PrivateSubnet1
    Export:
      Name: !Sub ${AWS::StackName}-PrivateSubnet1Id

  PrivateSubnet2Id:
    Description: Private Subnet 2 ID
    Value: !Ref PrivateSubnet2
    Export:
      Name: !Sub ${AWS::StackName}-PrivateSubnet2Id

  ECSClusterName:
    Description: ECS Cluster Name
    Value: !Ref ECSCluster
    Export:
      Name: !Sub ${AWS::StackName}-ECSClusterName

  ECRRepositoryName:
    Description: ECR Repository Name
    Value: !Ref ECRRepository
    Export:
      Name: !Sub ${AWS::StackName}-ECRRepositoryName
Enter fullscreen mode Exit fullscreen mode

Copy above template file to your created s3 bucket in Step0 using command like:

aws s3 cp --region us-east-1 base.yaml s3://2048-game-templates-bucket
Enter fullscreen mode Exit fullscreen mode

Next, we need to pass parameter values defined in the stack like VpcCidr, ClusterName, ECRRepoName and Subnet CIDRs. For that, we will create a vpc-parameters.json file with values:

[
  {
    "ParameterKey": "VpcCidr",
    "ParameterValue": "10.0.0.0/16"
  },
  {
    "ParameterKey": "PublicSubnet1Cidr",
    "ParameterValue": "10.0.1.0/24"
  },
  {
    "ParameterKey": "PublicSubnet2Cidr",
    "ParameterValue": "10.0.2.0/24"
  },
  {
    "ParameterKey": "PrivateSubnet1Cidr",
    "ParameterValue": "10.0.3.0/24"
  },
  {
    "ParameterKey": "PrivateSubnet2Cidr",
    "ParameterValue": "10.0.4.0/24"
  },
  {
    "ParameterKey": "ClusterName",
    "ParameterValue": "test-ecs-cluster"
  },
  {
    "ParameterKey": "ECRRepoName",
    "ParameterValue": "app-repository"
  }
]
Enter fullscreen mode Exit fullscreen mode

After defining all the above steps, we can create our base CloudFormation stack from the command line using:

aws cloudformation create-stack --region us-east-1 --stack-name game-2048 --template-url https://2048-game-templates-bucket.s3.us-east-1.amazonaws.com/base.yaml --parameters file://vpc-parameters.json 
Enter fullscreen mode Exit fullscreen mode

Step 2: Preparing container image to be used with the service stack

After the base stack with VPC configuration, ECS cluster and ECR repository is created, we will have a private ECR repository with given name and lifecycle policy of keeping 5 versions of our application image. Let’s prepare and push the image we will use in this example to our private repository.

  • First, download and pull the image from public ECR repository that hosts the 2048 game using command:
docker pull public.ecr.aws/kishorj/docker-2048:latest
Enter fullscreen mode Exit fullscreen mode
  • Login to the private repository we created using command: (You can also get the login command from AWS ECR repository)

ECR Repo

aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 757641753030.dkr.ecr.us-east-1.amazonaws.com
Enter fullscreen mode Exit fullscreen mode
  • Tag the image with our private repository name and push to the repository with latest tag:
docker tag public.ecr.aws/kishorj/docker-2048:latest 757641753030.dkr.ecr.us-east-1.amazonaws.com/app-repository:latest
Enter fullscreen mode Exit fullscreen mode
docker push 757641753030.dkr.ecr.us-east-1.amazonaws.com/app-repository:latest
Enter fullscreen mode Exit fullscreen mode

Step 3: Create a CloudFormation template for ECS service and ALB

The next step is to create another CF template for creating ECS service and its dependencies such as TaskRole, Execution Role, Auto Scaling Policies and Application Load Balancer (ALB) for exposing traffic to users.

Create a service.yaml file with following content:

AWSTemplateFormatVersion: 2010-09-09

Description: >
  This template shows how to create Amazon Elastic Container Service
  (Amazon ECS) using clusters powered by AWS Fargate with CloudFormation.
  Stack Name will be used as S3 artifacts bucket name and fargate service name.

Metadata:
  "AWS::CloudFormation::Interface":
    ParameterGroups:
      - Label:
          default: "Common Parameters"
        Parameters:
          - Environment
      - Label:
          default: "VPC Parameters"
        Parameters:
          - VPC
          - AppSubnetA
          - AppSubnetB
      - Label:
          default: "Container Parameters"
        Parameters:
          - ClusterName
          - ContainerPort
          - FargateCPU
          - FargateMemory
          - ContainerRegistery
          - LoadBalancerPort
      - Label:
          default: "LoadBalancer Parameters"
        Parameters:
          - LoadBalancerType
          - LBSubnetA
          - LBSubnetB
          - HealthCheckPath
          - MinContainers
          - MaxContainers
          - SSLCertificateARN
          - HttpPort
          - IsEnableHTTPS
      - Label:
          default: "AutoScaling Parameters"
        Parameters:
          - TargetCPUUtilization
          - TargetMemoryUtilization
    ParameterLabels:
      Environment:
        default: "A tag is a label as well as Environment that you or AWS assigns to an AWS resource. You can use tags to organize your resources, and cost allocation tags to track your AWS costs on a detailed level."
      VPC:
        default: "Which VPC should this be deployed to?"

Parameters:
  VPC:
    Type: AWS::EC2::VPC::Id
  AppSubnetA:
    Type: AWS::EC2::Subnet::Id
  AppSubnetB:
    Type: AWS::EC2::Subnet::Id
  LBSubnetA:
    Type: AWS::EC2::Subnet::Id
  LBSubnetB:
    Type: AWS::EC2::Subnet::Id
  ClusterName:
    Type: String
  FargateCPU:
    Type: String
    Default: 512
  FargateMemory:
    Type: String
    Default: 1GB
  ContainerPort:
    Type: Number
    Default: 80
  LoadBalancerPort:
    Type: Number
    Default: 443
  HttpPort:
    Type: Number
    Default: 80
  HealthCheckPath:
    Type: String
    Default: /
  MinContainers:
    Type: Number
    Default: 1
  MaxContainers:
    Type: Number
    Default: 10
  ContainerRegistery:
    Type: String
    Description: ECR Repo Name + tag name
  SSLCertificateARN:
    Type: String
    Default: arn:aws:acm:us-east-1:757641753030:certificate/b7c279cd-8658-4a08-8155-e1eb9786aa7b
  Environment:
    Type: String
    Default: testing
    Description: Environment name to be used with service.
    AllowedPattern: '[a-zA-Z0-9\-_]+'
    ConstraintDescription: Tag name should be alpha numberic letter
  LoadBalancerType:
    Type: String
    Default: internet-facing
    AllowedValues: [internet-facing, internal]
  IsEnableHTTPS:
    Type: String
    Default: "true"
    AllowedValues:
      - "true"
      - "false"
    Description: To disable TLS, it's false. If not, choose true.
  TargetCPUUtilization:
    Type: Number
    Default: 70
  TargetMemoryUtilization:
    Type: Number
    Default: 70
  ServiceName:
    Type: String
    Description: The name of the service to be created.

Conditions:
  ShouldCreateHTTPS: !Equals [!Ref IsEnableHTTPS, "true"]

Resources:
  PipelineBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Join ["-", [!Ref Environment, !Ref AWS::StackName]]
      Tags:
        - Key: "Environment"
          Value: !Ref Environment
        - Key: "Name"
          Value: !Join ["-", [!Ref Environment, !Ref AWS::StackName]]

  TaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: !Join ["", [!Ref AWS::StackName, TaskDefinition]]
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      # 256 (.25 vCPU) - Available memory values: 0.5GB, 1GB, 2GB
      # 512 (.5 vCPU) - Available memory values: 1GB, 2GB, 3GB, 4GB
      # 1024 (1 vCPU) - Available memory values: 2GB, 3GB, 4GB, 5GB, 6GB, 7GB, 8GB
      # 2048 (2 vCPU) - Available memory values: Between 4GB and 16GB in 1GB increments
      # 4096 (4 vCPU) - Available memory values: Between 8GB and 30GB in 1GB increments
      Cpu: !Ref FargateCPU
      # 0.5GB, 1GB, 2GB - Available cpu values: 256 (.25 vCPU)
      # 1GB, 2GB, 3GB, 4GB - Available cpu values: 512 (.5 vCPU)
      # 2GB, 3GB, 4GB, 5GB, 6GB, 7GB, 8GB - Available cpu values: 1024 (1 vCPU)
      # Between 4GB and 16GB in 1GB increments - Available cpu values: 2048 (2 vCPU)
      # Between 8GB and 30GB in 1GB increments - Available cpu values: 4096 (4 vCPU)
      Memory: !Ref FargateMemory
      # A role needed by ECS.
      # "The ARN of the task execution role that containers in this task can assume. All containers in this task are granted the permissions that are specified in this role."
      # "There is an optional task execution IAM role that you can specify with Fargate to allow your Fargate tasks to make API calls to Amazon ECR."
      ExecutionRoleArn: !Ref ExecutionRole
      TaskRoleArn: !Ref TaskRole
      ContainerDefinitions:
        - Name: !Ref AWS::StackName
          Image: !Sub ${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${ContainerRegistery}
          PortMappings:
            - ContainerPort: !Ref ContainerPort
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: fargate
      Tags:
        - Key: "Environment"
          Value: !Ref Environment
        - Key: "Name"
          Value: !Join ["-", [!Ref Environment, TaskDefinition]]

  ExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Join ["-", [!Ref AWS::StackName, ExecutionRole]]
      AssumeRolePolicyDocument:
        Statement:
          - Effect: Allow
            Principal:
              Service: ecs-tasks.amazonaws.com
            Action: "sts:AssumeRole"
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
      Tags:
        - Key: "Environment"
          Value: !Ref Environment
        - Key: "Name"
          Value: !Join ["-", [!Ref Environment, ExecutionRole]]

  TaskRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Join ["-", [!Ref AWS::StackName, TaskRole]]
      AssumeRolePolicyDocument:
        Statement:
          - Effect: Allow
            Principal:
              Service: ecs-tasks.amazonaws.com
            Action: "sts:AssumeRole"
      Tags:
        - Key: "Environment"
          Value: !Ref Environment
        - Key: "Name"
          Value: !Join ["-", [!Ref Environment, TaskRole]]

  AutoScalingRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Join ["-", [!Ref AWS::StackName, AutoScalingRole]]
      AssumeRolePolicyDocument:
        Statement:
          - Effect: Allow
            Principal:
              Service: ecs-tasks.amazonaws.com
            Action: "sts:AssumeRole"
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceAutoscaleRole"
      Tags:
        - Key: "Environment"
          Value: !Ref Environment
        - Key: "Name"
          Value: !Join ["-", [!Ref Environment, AutoScalingRole]]

  ContainerSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription:
        !Join ["", [!Ref AWS::StackName, ContainerSecurityGroup]]
      VpcId: !Ref VPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: !Ref ContainerPort
          ToPort: !Ref ContainerPort
          SourceSecurityGroupId: !Ref LoadBalancerSecurityGroup
      Tags:
        - Key: "Environment"
          Value: !Ref Environment
        - Key: "Name"
          Value: !Join ["-", [!Ref Environment, ContainerSecurityGroup]]

  LoadBalancerSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription:
        !Join ["", [!Ref AWS::StackName, LoadBalancerSecurityGroup]]
      VpcId: !Ref VPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: !Ref LoadBalancerPort
          ToPort: !Ref LoadBalancerPort
          CidrIp: 0.0.0.0/0
        - IpProtocol: tcp
          FromPort: !Ref HttpPort
          ToPort: !Ref HttpPort
          CidrIp: 0.0.0.0/0
      Tags:
        - Key: "Environment"
          Value: !Ref Environment
        - Key: "Name"
          Value: !Join ["-", [!Ref Environment, lb-sg]]

  Service:
    DependsOn:
      - ListenerHTTP
    Type: AWS::ECS::Service
    Properties:
      ServiceName: !Ref AWS::StackName
      Cluster: !Ref ClusterName
      TaskDefinition: !Ref TaskDefinition
      DeploymentConfiguration:
        MinimumHealthyPercent: 100
        MaximumPercent: 200
      DesiredCount: !Ref MinContainers
      # This may need to be adjusted if the container takes a while to start up
      HealthCheckGracePeriodSeconds: 300
      LaunchType: FARGATE
      NetworkConfiguration:
        AwsvpcConfiguration:
          # change to DISABLED if you're using private subnets that have access to a NAT gateway
          AssignPublicIp: DISABLED
          Subnets:
            - !Ref AppSubnetA
            - !Ref AppSubnetB
          SecurityGroups:
            - !Ref ContainerSecurityGroup
      LoadBalancers:
        - ContainerName: !Ref AWS::StackName
          ContainerPort: !Ref ContainerPort
          TargetGroupArn: !Ref TargetGroup
      Tags:
        - Key: "Environment"
          Value: !Ref Environment
        - Key: "Name"
          Value: !Join ["-", [!Ref Environment, svc]]

  TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      HealthCheckIntervalSeconds: 10
      # will look for a 200 status code by default unless specified otherwise
      HealthCheckPath: !Ref HealthCheckPath
      HealthCheckTimeoutSeconds: 5
      UnhealthyThresholdCount: 2
      HealthyThresholdCount: 2
      Name: !Join ["-", [!Ref AWS::StackName, tg]]
      Port: !Ref ContainerPort
      Protocol: HTTP
      TargetGroupAttributes:
        - Key: deregistration_delay.timeout_seconds
          Value: 60 # default is 300
      TargetType: ip
      VpcId: !Ref VPC
      Tags:
        - Key: "Environment"
          Value: !Ref Environment
        - Key: "Name"
          Value: !Join ["-", [!Ref Environment, tg]]

  ListenerHTTPS:
    Condition: ShouldCreateHTTPS
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      DefaultActions:
        - TargetGroupArn: !Ref TargetGroup
          Type: forward
      LoadBalancerArn: !Ref LoadBalancer
      Port: !Ref LoadBalancerPort
      Protocol: HTTPS
      Certificates:
        - CertificateArn: !Ref SSLCertificateARN

  ListenerHTTP:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      DefaultActions:
        - !If
          - ShouldCreateHTTPS
          - Type: redirect
            RedirectConfig:
              StatusCode: "HTTP_301"
              Host: "#{host}"
              Path: "/#{path}"
              Port: 443
              Protocol: "HTTPS"
              Query: "#{query}"
          - TargetGroupArn: !Ref TargetGroup
            Type: forward

      LoadBalancerArn: !Ref LoadBalancer
      Port: !Ref HttpPort
      Protocol: HTTP

  LoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      LoadBalancerAttributes:
        # this is the default, but is specified here in case it needs to be changed
        - Key: idle_timeout.timeout_seconds
          Value: 60
        - Key: "routing.http2.enabled"
          Value: "true"
      Name: !Join ["-", [!Ref AWS::StackName, lb]]
      # "internal" is also an option
      Scheme: !Ref LoadBalancerType
      SecurityGroups:
        - !Ref LoadBalancerSecurityGroup
      Subnets:
        - !Ref LBSubnetA
        - !Ref LBSubnetB
      Tags:
        - Key: "Environment"
          Value: !Ref Environment
        - Key: "Name"
          Value: !Join ["-", [!Ref Environment, lb]]

  LogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Join ["", [/fargate/, !Ref AWS::StackName, Logs]]
      RetentionInDays: 60

  AutoScalingTarget:
    Type: AWS::ApplicationAutoScaling::ScalableTarget
    Properties:
      MinCapacity: !Ref MinContainers
      MaxCapacity: !Ref MaxContainers
      ResourceId: !Join ["/", [service, !Ref ClusterName, !GetAtt Service.Name]]
      ScalableDimension: ecs:service:DesiredCount
      ServiceNamespace: ecs
      # "The Amazon Resource Name (ARN) of an AWS Identity and Access Management (IAM) role that allows Application Auto Scaling to modify your scalable target."
      RoleARN: !GetAtt AutoScalingRole.Arn

  # Create scaling policies that describe how to scale the service up and down.
  CPUScalingPolicy:
    Type: AWS::ApplicationAutoScaling::ScalingPolicy
    DependsOn: AutoScalingTarget
    Properties:
      PolicyName: !Sub "${ServiceName}-cpu-target-tracking-policy"
      PolicyType: TargetTrackingScaling
      ResourceId: !Join ["/", [service, !Ref ClusterName, !GetAtt Service.Name]]
      ScalableDimension: "ecs:service:DesiredCount"
      ServiceNamespace: "ecs"
      TargetTrackingScalingPolicyConfiguration:
        PredefinedMetricSpecification:
          PredefinedMetricType: ECSServiceAverageCPUUtilization
        TargetValue: !Ref TargetCPUUtilization
        ScaleInCooldown: 60
        ScaleOutCooldown: 60
  MemoryScalingPolicy:
    Type: AWS::ApplicationAutoScaling::ScalingPolicy
    DependsOn: AutoScalingTarget
    Properties:
      PolicyName: !Sub "${ServiceName}-memory-target-tracking-policy"
      PolicyType: TargetTrackingScaling
      ResourceId: !Join ["/", [service, !Ref ClusterName, !GetAtt Service.Name]]
      ScalableDimension: "ecs:service:DesiredCount"
      ServiceNamespace: "ecs"
      TargetTrackingScalingPolicyConfiguration:
        PredefinedMetricSpecification:
          PredefinedMetricType: ECSServiceAverageMemoryUtilization
        TargetValue: !Ref TargetMemoryUtilization
        ScaleInCooldown: 60
        ScaleOutCooldown: 60

Outputs:
  ServiceURL:
    Description: URL of the load balancer
    Value: !Sub http://${LoadBalancer.DNSName}
    Export:
      Name: !Sub ${AWS::StackName}-ServiceURL
Enter fullscreen mode Exit fullscreen mode

Let me upload above template into my S3 bucket created in the step0 using command*:*

aws s3 cp --region us-east-1 service.yaml s3://2048-game-templates-bucket
Enter fullscreen mode Exit fullscreen mode

This stack includes parameter variables such as VPC, Subnets for application tasks containers, Subnets for LoadBalancer and Container Registry. Other variables such as CPU, Memory configuration and Auto Scaling target percentages are optional. (already set default values for those)

So, we would need to note down the Outputs section of the first stack regarding VPC ID, Public Subnet IDs for LoadBalancer and Private Subnet IDs for ECS service.

Then, we will create a service-parameters.json file with appropriate values for subnets, VPC and ContainerRegistry.

[
  {
    "ParameterKey": "ClusterName",
    "ParameterValue": "test-ecs-cluster"
  },
  {
    "ParameterKey": "ContainerPort",
    "ParameterValue": "80"
  },
  {
    "ParameterKey": "ContainerRegistery",
    "ParameterValue": "app-repository:latest"
  },
  {
    "ParameterKey": "Environment",
    "ParameterValue": "dev"
  },
  {
    "ParameterKey": "ServiceName",
    "ParameterValue": "2048-ecs"
  },
  {
    "ParameterKey": "VPC",
    "ParameterValue": ""
  },
  {
    "ParameterKey": "AppSubnetA",
    "ParameterValue": ""
  },
  {
    "ParameterKey": "AppSubnetB",
    "ParameterValue": ""
  },
  {
    "ParameterKey": "LBSubnetA",
    "ParameterValue": ""
  },
  {
    "ParameterKey": "LBSubnetB",
    "ParameterValue": ""
  }
]
Enter fullscreen mode Exit fullscreen mode

Please Don’t forget to substitute the ParameterValue section of above file with outputs from the base stack.

And we can create the service stack with following command:

aws cloudformation create-stack --region us-east-1 --stack-name game-2048-svc --template-url https://2048-game-templates-bucket.s3.us-east-1.amazonaws.com/service.yaml --parameters file://service-parameters.json --capabilities CAPABILITY_NAMED_IAM
Enter fullscreen mode Exit fullscreen mode

Please note to replace the —template-url with your s3 file URL uploaded and relative path of your service-parameters.json file

After the stack is successfully created, you will get the Domain URL endpoint of LoadBalancer in the Outputs section of the template.
Service URL

Step 4: Domain Record Creation Step

In this example, I’ve used my custom domain URL and certificate from AWS Certificate Manager (ACM) as default value in SSLCertificateARN parameter value. If you don’t have custom domain available, you can just set IsEnableHTTPS parameter value to false and the template will only create HTTP listener.

To create a CNAME record for your custom domain, go into your DNS provider and create a CNAME type record with custom domain name and value is set to the ServiceURL output returned from above step.

In my case, I use CloudFlare as my DNS provider, so I logged into my CloudFlare account and create a simple DNS record as follows:

DNS Record

Step 5: Verify the application is working

Finally, you can verify your application is up and running by going to your domain URL from browser:

2048 Game Application

Congratulations! If you see that page, that means you have successfully deployed a 2048 game in ECS with CloudFormation as IaC tool.


Key Takeaways

  • Infrastructure-as-Code: CloudFormation ensures reproducibility and versioning of your infrastruture.

  • Scalability: ECS Fargate handles load without managing servers. (Serverless computing)

  • Cost Control: Tear down and bring up resources easily whenever you need, wherever you need.


Troubleshooting Tips

  • Task Failures: Check ECS task logs in CloudWatch for any failures of tasks and not in Active status.

  • ALB Issues: Verify security group inbound rules are set correctly if you face timeouts when accessing the endpoint URL.

  • Image Pull Errors: Ensure ECR repository permissions are correct if you get any permissions error when starting ECS tasks.


Code Reference

All the code we used in this tutorial blog can be found here: https://github.com/Heinux-Training/Real-World-AWS-Case-Studies/tree/main/CloudFormation/2048-ECS-ALB

NOTE: I’ve used Generative AI assistant like Amazon Q as VS Code extension and produced some of the codes used in this blog.


Conclusion

Deploying the 2048 game on AWS ECS with CloudFormation demonstrates the power of Infrastructure-as-Code (IaC) and managed container services to streamline application deployment. By automating infrastructure provisioning, we ensure consistency, reduce human error, and enable rapid scaling—principles critical to modern DevOps practices. This tutorial highlights how tools like ECS Fargate eliminate server management overhead, while CloudFormation templates provide reusable, version-controlled blueprints for environments. Beyond the technical steps, this project teaches the value of modular design (decoupling infrastructure from code), cost optimization (leveraging serverless compute), and resilience (via ALB and multi-task deployments). These skills translate directly to real-world scenarios, empowering teams to deploy and iterate on applications faster.

Top comments (0)