Deploy your AWS ECS cluster with Pulumi

Etienne Callies
3 min readNov 17, 2023

--

This post assumes you have working knowledge of AWS ECS and Pulumi Framework.

ECS is great to deploy an application that has been dockerized. It’s even better if you can access your deployed app with an HTTPS url on your custom sub-domain. You will find in this article a configuration for Pulumi (only in Python though).

Requirements:

  • A Dockerfile to deploy. We assume the relative path is “..” in the code (replace it!). We also assume that the dockerized app is a server expecting calls on port 80.
  • A registered domain name with Route53 as the DNS service. We assume it’s “example.com” in the code (replace it!).
  1. Create role

We’ll need two authorisations :

  • First one is for logging on CloudWatch (optional but convenient to debug)
  • Second one is for pulling docker image from Elastic Container Registry (ECR)
import pulumi_aws as aws


example_role = aws.iam.role.Role(
"example-role",
assume_role_policy="""{
"Version": "2012-10-17",
"Statement": [{
"Action": "sts:AssumeRole",
"Principal": {
"Service": "ecs-tasks.amazonaws.com"
},
"Effect": "Allow",
"Sid": ""
}]
}""")

# Add logging authorization
example_logging_policy = aws.iam.Policy(
"example-logging-policy",
path="/",
description="IAM policy for logging",
policy="""{
"Version":"2012-10-17",
"Statement":[{
"Action":[
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"],
"Resource":"arn:aws:logs:*:*:*",
"Effect":"Allow"}]
}""")
example_logging_policy_attachment = aws.iam.RolePolicyAttachment(
"example-logging-policy-attachment",
role=example_role.name,
policy_arn=example_logging_policy.arn
)

# Add ECR authorization
example_ecr_policy = aws.iam.Policy(
"example-ecr-policy",
path="/",
description="IAM policy for ECR",
policy="""{
"Version":"2012-10-17",
"Statement":[{
"Action":[
"ecr:GetAuthorizationToken",
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer"],
"Resource":"*",
"Effect":"Allow"}]
}""")
example_ecr_policy_attachment = aws.iam.RolePolicyAttachment(
"example-ecr-policy-attachment",
role=example_role.name,
policy_arn=example_ecr_policy.arn
)

2. Build and push docker image

import pulumi_awsx as awsx


# Build and push docker image
example_ecr_repository = awsx.ecr.Repository(
"example-ecr-repository",
)

example_docker_image = awsx.ecr.Image(
"example-docker-image",
path="..", # the path to the Dockerfile
args={'--platform': 'linux/amd64'},
repository_url=example_ecr_repository.url,
)

3. Create your cluster and service(s)

We first create a Cluster, an Application Load Balancer and a Log Group. Then we create as many services as we want. You just have to configure the RAM you’d like.

from pulumi_awsx.awsx import DefaultRoleWithPolicyArgs, ExistingLogGroupArgs, DefaultLogGroupArgs


example_cluster = aws.ecs.Cluster("example-cluster")
example_load_balancer = awsx.lb.ApplicationLoadBalancer("example-load-balancer")
example_log_group = aws.cloudwatch.LogGroup("example-log-group")

example_service = awsx.ecs.FargateService(
"example-service",
cluster=example_cluster.arn,
assign_public_ip=True,
task_definition_args=awsx.ecs.FargateServiceTaskDefinitionArgs(
container=awsx.ecs.TaskDefinitionContainerDefinitionArgs(
name='example-container',
image=example_docker_image.image_uri,
memory=8192, # Use the memory you'd like
port_mappings=[awsx.ecs.TaskDefinitionPortMappingArgs(
target_group=example_load_balancer.default_target_group,
)],
environment=[
# Here is the way to add environment variable
# awsx.ecs.TaskDefinitionKeyValuePairArgs(name="ENV_VAR_EXAMPLE", value='some value'),
],
log_configuration=awsx.ecs.TaskDefinitionLogConfigurationArgs(
log_driver="awslogs",
options={
"awslogs-region": aws.get_region().name,
"awslogs-group": example_log_group.name,
"awslogs-stream-prefix": "container",
},
)
),
task_role=DefaultRoleWithPolicyArgs(role_arn=example_role.arn),
log_group=DefaultLogGroupArgs(
existing=ExistingLogGroupArgs(
arn=example_log_group.arn
)
)
),
)

# We print load balancer DNS name
pulumi.export("url", example_load_balancer.load_balancer.dns_name)

If you want to add another service in the same cluster (the load balancer will distribute HTTP calls equally), just copy-paste and add a suffixe like this :

example_service2 = awsx.ecs.FargateService(
"example-service2",
cluster=example_cluster.arn,
assign_public_ip=True,
task_definition_args=awsx.ecs.FargateServiceTaskDefinitionArgs(
container=awsx.ecs.TaskDefinitionContainerDefinitionArgs(
name='example-container2',
image=example_docker_image.image_uri,
memory=8192, # Use the memory you'd like
port_mappings=[awsx.ecs.TaskDefinitionPortMappingArgs(
target_group=example_load_balancer.default_target_group,
)],
environment=[
# Here is the way to add environment variable
# awsx.ecs.TaskDefinitionKeyValuePairArgs(name="ENV_VAR_EXAMPLE", value='some value'),
],
log_configuration=awsx.ecs.TaskDefinitionLogConfigurationArgs(
log_driver="awslogs",
options={
"awslogs-region": aws.get_region().name,
"awslogs-group": example_log_group.name,
"awslogs-stream-prefix": "container2",
},
)
),
task_role=DefaultRoleWithPolicyArgs(role_arn=example_role.arn),
log_group=DefaultLogGroupArgs(
existing=ExistingLogGroupArgs(
arn=example_log_group.arn
)
)
),
)

4. Create SSL certificate and add HTTPS (optional)

If you want to access your app with HTTPS, you have to create an SSL certificate and to add a specific listener to the load balancer. Otherwise you will still be able to access it with HTTP.

# We are goind to create a subdomain
# To be replaced by any subdomain you'd like
example_domain_name = "subdomain.example.com"

rnd_zone = aws.route53.get_zone(
name="example.com", # Replace with actual registered domain
private_zone=False
)
example_certificate = aws.acm.Certificate(
"example-ssl-certificate",
domain_name=example_domain_name,
validation_method="DNS",
)
example_cert_validation_record = aws.route53.Record(
"example-cert-validation-record",
name=example_certificate.domain_validation_options[0].resource_record_name,
records=[example_certificate.domain_validation_options[0].resource_record_value],
ttl=60,
type=example_certificate.domain_validation_options[0].resource_record_type,
zone_id=rnd_zone.zone_id)
example_certificate_validation = aws.acm.CertificateValidation(
"example-cert-validation",
certificate_arn=example_certificate.arn,
validation_record_fqdns=[example_cert_validation_record.fqdn],
)
# Add HTTPS listener
example_listener = aws.lb.Listener(
"example-listener",
protocol='HTTPS',
port=443,
ssl_policy="ELBSecurityPolicy-TLS13-1-2-2021-06",
certificate_arn=example_certificate.arn,
default_actions=[aws.lb.ListenerDefaultActionArgs(
type="forward",
target_group_arn=example_load_balancer.default_target_group.arn,
)],
load_balancer_arn=example_load_balancer.load_balancer.apply(lambda lb: lb.arn),
)

That’s it! Just “pulumi up” and enjoy!

--

--

Etienne Callies
0 Followers

Research Engineer @ Ouihelp 🇫🇷