Back to all patterns

About

Development Tool

aws-sam-cli

Development Tool

cloudformation

Type

pattern

Using Amazon ECS Fargate with AWS SAM CLI

How to deploy a simple public facing application on AWS Fargate using AWS SAM CLI

Jessica Deen
Jessica Deen
Principal Developer Advocate at AWS

Dependencies

This pattern uses the AWS SAM CLI for deploying CloudFormation stacks on your AWS account. You should follow the appropriate steps for installing SAM CLI.

About

AWS SAM CLI streamlines the deployment of your containerized applications and necessary infrastructure resources efficiently. An extension of AWS CloudFormation, SAM CLI simplifies serverless application deployment and management by allowing you to define infrastructure as code. This facilitates version control and reproducibility, simplifying the packaging and deployment of your application code, dependencies, and configurations.

This pattern will show how to deploy a simple nodeJS application to AWS Fargate using AWS SAM CLI. The following resources will be created as part of the provided templates:

  • Fargate Cluster
  • Amazon Elastic Container Registry
  • Fargate Service
  • Fargate Task Definition
  • Amazon VPC
  • Internet Gateway
  • 2 Public Subnets
  • 2 Private Subnets
  • Application Load Balancer

Architecture

The following diagram shows the architecture that will be deployed:

Define your Infrastructure

The following AWS SAM CLI template.yml creates a simple Amazon ECS cluster using AWS Fargate.

As part of the template.yml, the following resources will be created:

  • Amazon ECS Cluster
  • ECR Repo
  • Log Group
  • All IAM related roles/policies

In addition to the template.yml, you will also need a vpc.yml, where the necessary network resources are defined.

Finally, AWS SAM CLI will also look for a samconfig file, which contains default parameters for your Infrastructure as Code.

File: template.yml Language: yaml
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: AWS Fargate with VPC, ALB, and ECR

Parameters:
  ImageTag:
    Description: tag name for image
    Type: String
    Default: latest

Globals:
  Function:
    Timeout: 3
    MemorySize: 128

Resources:  
  VPC:
    Type: AWS::Serverless::Application
    Properties:
      Location: ./vpc.yml

  ECRRepo:
    Type: AWS::ECR::Repository
    Properties:
      EmptyOnDelete: true

  Cluster:
    Type: AWS::ECS::Cluster
    Properties: 
      CapacityProviders: 
        - FARGATE
  
  Service:
    Type: AWS::ECS::Service
    Properties:
      ServiceName: "hello-world"
      Cluster: !Ref Cluster
      LaunchType: FARGATE
      EnableExecuteCommand: true
      HealthCheckGracePeriodSeconds: 5
      NetworkConfiguration:
        AwsvpcConfiguration:
          AssignPublicIp: ENABLED
          Subnets: [!GetAtt "VPC.Outputs.PublicSubnet1", !GetAtt VPC.Outputs.PublicSubnet2]
          SecurityGroups: [!GetAtt VPC.Outputs.SG]
      DeploymentConfiguration:
        MaximumPercent: 200
        MinimumHealthyPercent: 50
      DesiredCount: 1
      TaskDefinition: !Ref "TaskDefinition"
      LoadBalancers:
        - ContainerName: "hello-world"
          ContainerPort: 3000
          TargetGroupArn: !GetAtt VPC.Outputs.LB

  TaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: HelloWorld
      Cpu: 1024
      Memory: 8192
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn
      TaskRoleArn: !Ref ECSTaskRole
      RuntimePlatform:
        CpuArchitecture: X86_64
      ContainerDefinitions:
        - Name: hello-world
          Cpu: 1024
          Memory: 8192
          Image: !Sub
            - ${RepoUrl}:${ImageTag}
            - RepoUrl: !GetAtt ECRRepo.RepositoryUri
          PortMappings:
            - ContainerPort: 3000
          LogConfiguration:
            LogDriver: awslogs
            Options:
              mode: non-blocking
              max-buffer-size: 25m
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: containerlog
  
  LogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: /fargatelogs

  ECSTaskExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
        - Effect: Allow
          Principal:
            Service: [ecs-tasks.amazonaws.com]
          Action: ['sts:AssumeRole']
          Condition:
            ArnLike:
              aws:SourceArn: !Sub arn:aws:ecs:${AWS::Region}:${AWS::AccountId}:*
            StringEquals:
              aws:SourceAccount: !Ref AWS::AccountId
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

  ECSTaskRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
        - Effect: Allow
          Principal:
            Service: [ecs-tasks.amazonaws.com]
          Action: ['sts:AssumeRole']
          Condition:
            ArnLike:
              aws:SourceArn: !Sub arn:aws:ecs:${AWS::Region}:${AWS::AccountId}:*
            StringEquals:
              aws:SourceAccount: !Ref AWS::AccountId
      Path: /

Outputs:
  ClusterName:
    Description: Amazon ECS Cluster Name
    Value: !Ref Cluster
  ServiceName:
    Description: Amazon ECS Service Name
    Value: !GetAtt Service.Name
  FQDN:
    Description: URL for your application
    Value: !GetAtt VPC.Outputs.PublicLBFQDN
  RepositoryUrl:
    Description: URL of the repo
    Value: !GetAtt ECRRepo.RepositoryUri
File: vpc.yml Language: yaml
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
AWSTemplateFormatVersion: '2010-09-09'
Description: VPC

Mappings:
  SubnetConfig:
    VPC:
      CIDR: "10.0.0.0/16"
    Public1:
      CIDR: "10.0.0.0/19"
    Public2:
      CIDR: "10.0.32.0/19"
    Private1:
      CIDR: "10.0.64.0/19"
    Private2:
      CIDR: "10.0.96.0/19"

Resources:
# VPC
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      EnableDnsSupport: true
      EnableDnsHostnames: true
      CidrBlock: !FindInMap ["SubnetConfig", "VPC", "CIDR"]
      Tags:
        - Key: Name
          Value: VPC

# VPC Internet Gateway and Attachment
  VPCInternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Name
          Value: VPC Internet Gateway

  VPCInternetGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref "VPC"
      InternetGatewayId: !Ref VPCInternetGateway

# Public Subnet
  PublicSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone:
        Fn::Select:
          - 0
          - Fn::GetAZs: { Ref: "AWS::Region" }
      VpcId: !Ref "VPC"
      CidrBlock: !FindInMap ["SubnetConfig", "Public1", "CIDR"]
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: Public Subnet 1

  PublicSubnet1RouteTable:
      Type: AWS::EC2::RouteTable
      Properties:
        VpcId: !Ref VPC
        Tags:
          - Key: Name
            Value: Public Subnet 1 Route Table
  
  PublicSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PublicSubnet1RouteTable
      SubnetId: !Ref PublicSubnet1

  PublicSubnet1DefaultRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId:
        Ref: PublicSubnet1RouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref VPCInternetGateway
    DependsOn:
      - VPC

  PublicSubnet1EIP:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc

  PublicSubnet1NatGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt PublicSubnet1EIP.AllocationId
      SubnetId:
        Ref: PublicSubnet1
      Tags:
        - Key: Name
          Value: Public Subnet 1 Nat Gateway

  PublicSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone:
        Fn::Select:
          - 1
          - Fn::GetAZs: { Ref: "AWS::Region" }
      VpcId: !Ref "VPC"
      CidrBlock: !FindInMap ["SubnetConfig", "Public2", "CIDR"]
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: Public Subnet 2

  PublicSubnet2RouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId:
        Ref: "VPC"
      Tags:
        - Key: Name
          Value: Public Subnet 2 Route Table

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

  PublicSubnet2DefaultRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId:
        Ref: PublicSubnet2RouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref VPCInternetGateway
    DependsOn:
      - VPC

  PublicSubnet2EIP:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc

  PublicSubnet2NatGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt PublicSubnet2EIP.AllocationId
      SubnetId: !Ref PublicSubnet2
      Tags:
        - Key: Name
          Value: Public Subnet 2 Nat Gateway

# Private Subnets
  PrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone:
         Fn::Select:
         - 0
         - Fn::GetAZs: {Ref: 'AWS::Region'}
      VpcId: !Ref 'VPC'
      CidrBlock: !FindInMap ["SubnetConfig", "Private1", "CIDR"]
      Tags:
        - Key: Name
          Value: Subnet-One

  Private1RouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: rt-1

  Private1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref Private1RouteTable
      SubnetId: !Ref PrivateSubnet1

  PrivateSubnet1DefaultRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref Private1RouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref PublicSubnet1NatGateway

  PrivateSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone:
         Fn::Select:
         - 1
         - Fn::GetAZs: {Ref: 'AWS::Region'}
      VpcId: !Ref 'VPC'
      CidrBlock: !FindInMap ["SubnetConfig", "Private2", "CIDR"]
      Tags:
        - Key: Name
          Value: Subnet-Two

  Private2RouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: rt-2

  Private2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref Private2RouteTable
      SubnetId: !Ref PrivateSubnet2
  
  PrivateSubnet2DefaultRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref Private2RouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref PublicSubnet2NatGateway
      
# Load Balancer Configuration
  PublicLoadBalancer:
    DependsOn: VPCInternetGateway
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: public-http-lb
      Scheme: internet-facing
      SecurityGroups:
        - !Ref PublicHTTPSecurityGroup
      Subnets:
        - !Ref PublicSubnet1
        - !Ref PublicSubnet2
      Type: application

  PublicLBTargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      #  Check if your application is healthy within 20 = 10*2 seconds, compared to 2.5 mins = 30*5 seconds.
      HealthCheckIntervalSeconds: 10 # Default is 30.
      HealthyThresholdCount: 2       # Default is 5.
      HealthCheckTimeoutSeconds: 5
      Name: public-tg
      VpcId: !Ref VPC
      Protocol: HTTP
      TargetGroupAttributes:
        - Key: deregistration_delay.timeout_seconds
          Value: 0
      Port: 3000
      TargetType: ip
      IpAddressType: ipv4

  PublicLBListener:
    Type: "AWS::ElasticLoadBalancingV2::Listener"
    Properties:
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref PublicLBTargetGroup
      LoadBalancerArn: !Ref PublicLoadBalancer
      Port: 80
      Protocol: HTTP

  PublicHTTPSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: HTTP access to the public facing load balancer
      SecurityGroupEgress:
        - CidrIp: "0.0.0.0/0"
          Description: Allow all outbound traffic by default
          IpProtocol: "-1"
      SecurityGroupIngress:
        # Allow traffic from services on port 3000
        - CidrIp: 0.0.0.0/0
          Description: Allow traffic from services on port 3000
          FromPort: 3000
          IpProtocol: tcp
          ToPort: 3000
      VpcId: !Ref VPC

  ApplicationLBSecurityGroupIngress:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      Description: Allow from anyone on port 80
      IpProtocol: tcp
      CidrIp: 0.0.0.0/0
      FromPort: 80
      ToPort: 80
      GroupId: !Ref PublicHTTPSecurityGroup

Outputs:
  PublicLBFQDN:
    Value: !Join
      - ""
      - - "http://"
        - !GetAtt PublicLoadBalancer.DNSName
        - "/"
  VPCID:
    Value: !Ref VPC
  PublicSubnet1:
    Value: !Ref PublicSubnet1
  PublicSubnet2:
    Value: !Ref PublicSubnet2
  PrivateSubnet1:
    Value: !Ref PrivateSubnet1
  PrivateSubnet2:
    Value: !Ref PrivateSubnet2
  LB:
    Value: !Ref PublicLBTargetGroup
  SG:
    Value: !Ref PublicHTTPSecurityGroup
File: samconfig.toml Language: toml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# More information about the configuration file can be found here:
# https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html
version = 0.1

[default]
[default.global.parameters]
stack_name = "nodejs-sam"

[default.build.parameters]
cached = true
parallel = true

[default.validate.parameters]
lint = true

[default.deploy.parameters]
capabilities = "CAPABILITY_IAM"
confirm_changeset = false
resolve_s3 = true
s3_prefix = "nodejs-sam"
region = "us-east-1"
parameter_overrides = "Tag=\"latest\""
image_repositories = []

[default.package.parameters]
resolve_s3 = true

[default.sync.parameters]
watch = true

[default.local_start_api.parameters]
warm_containers = "EAGER"

[default.local_start_lambda.parameters]
warm_containers = "EAGER"

You will also need an application you can deploy to your infrastructure. For the purpose of this demo, a simple Hello World nodeJS application is provided for you.

File: index.js Language: Javascript
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const express = require('express')
const app = express()
const port = 3000

app.get('/', (req, res) => {
  res.send('Hello World!')
  res.status(200).json
})

app.listen(port, () => {
  console.log(`Access your application at`, `http://localhost:${port}`)
})

module.exports = app
File: package.json Language: JSON
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{
    "name": "hello-world",
    "version": "1.0.0",
    "description": "A simple hello-world application",
    "main": "index.js",
    "scripts": {
      "start": "node index.js",
      "test": "jest --forceExit --ci --reporters=default --reporters=jest-junit",
      "lint": "eslint . --ext .js"
    },
    "jest-junit": {
      "outputDirectory": "reports",
      "outputName": "jest-junit.xml",
      "ancestorSeparator": " › ",
      "uniqueOutputName": "false",
      "suiteNameTemplate": "{filepath}",
      "classNameTemplate": "{classname}",
      "titleTemplate": "{title}"
    },
    "author": "Jessica Deen",
    "license": "MIT",
    "dependencies": {
      "express": "^4.18.2"
    },
    "devDependencies": {
      "eslint": "^8.51.0",
      "jest": "^29.7.0",
      "jest-junit": "^16.0.0",
      "supertest": "^6.3.3"
    }
  }
File: Dockerfile Language: Dockerfile
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
FROM node:lts-alpine

ENV NODE_ENV=production

WORKDIR /usr/src/app

COPY ["package.json", "package-lock.json*", "./"]

RUN npm install --production --silent && mv node_modules ../

COPY . .

EXPOSE 3000

RUN chown -R node /usr/src/app

USER node

CMD ["npm", "start"]

Once you have the files downloaded, you can deploy your infrastructure using the following commands:

sam build && sam deploy

Test it Out

After the provided templates are deployed, you can find the the following AWS CloudFormation outputs using the following commands:

RepositoryUrl=$(aws cloudformation --region us-east-1 describe-stacks --stack-name nodejs-sam --query "Stacks[0].Outputs[?OutputKey=='RepositoryUrl'].OutputValue" --output text) && echo $RepositoryUrl

FQDN=$(aws cloudformation --region us-east-1 describe-stacks --stack-name nodejs-sam --query "Stacks[0].Outputs[?OutputKey=='FQDN'].OutputValue" --output text) && echo $FQDN

⚠️ Warning: You will want to build and push your container image prior to deploying this application to Fargate.

The default template will look for the image in your provisioned Amazon Elastic Container Registry with the tag “latest”. Once the image exists, you will be able to test your application using the output retrieved from the FQDN command.

💡 Tip: In Production, it’s not best practice to use the latest tag for your containerized application images. Instead, you’ll want to tag your images per release as part of your CI/CD workflow or pipeline.