Deploy sometimes can be a hard task, mainly if you do it directly in AWS. These days we have the facilitation to make the infrastructure versioned using tools like Terraform, a tool that transforms API commands in a thing like a program code with a better nice syntax. Now with a tool to create the resources, we need to choose what kind of technology to use and you maybe have already heard about Docker a way to run applications in a litter portion of your machine in an isolated way with no worries about different versions or types of applications.
Goal
Setup a Rails application on AWS using ECS controlled by Terraform.
Dependencies
And now you can install Terraform:
brew install tfenv
tfenv install 1.1.5 # use the last possible version
tfenv use 1.1.5
Create the Terraform Project
Let’s start creating a Git project, inside this project we can have as many Terraform projects as we want.
mkdir terraform
cd terraform
echo "# AWS ECS With Terraform" >> README.md
git init
git commit -A
git commit -m "first commit"
git branch -M main
git remote add origin [email protected]:wbotelhos/aws-ecs-with-terraform.git
git push -u origin main
Initiate Terraform Workspace
You can isolate the configurations, like branches, to keep different states to different stages.
terraform init # initiate the project
terraform workspace new production # creates a production workspace
Create Terraform Credentials
Since you can have more than one account on AWS is a good thing to make sure all commands will apply the change on the correct AWS account.
# aws.tf
provider "aws" {
profile = "blog"
region = "us-east-1"
}
Now put your credentials inside the ~/.aws/credentials
:
[blog]
aws_access_key_id=...
aws_secret_access_key=...
Here I said to Terraform always use the profile blog
at us-east-1
and the credentials are located at ~/.aws/credentials
.
Terraform Syntax
The syntax of Terraform is composed of a “method” with two arguments, the first one is the configured object and the second one is the name we give to this block of configuration. Most of the time it is irrelevant, we’ll use the value default
.
resource "aws_some_service" "variable_name" {
}
Here we’re configuring the some service
and the result of this block can be referred to in other resources through the name variable_name
like aws_some_service.variable_name.id
, getting the resulted ID of this block. Each resource can return different outputs.
Create VPC
Our application is hosted on the internet but we can create our own “internet” inside AWS called VPC (Virtual Private Cloud). Everything inside it is protected from the internet (private) and we can make our own rule and decide who will have access to it.
# vpc.tf
resource "aws_vpc" "default" {
cidr_block = "10.0.0.0/16"
tags = {
Env = "production"
Name = "vpc"
}
}
Here we have created a network /16
that gives us IPs from 10.0.0.1
to 10.0.255.254
totalizing 65534
IPs. The tags
are important to identify our resource and the Name
is often presented on the AWS Panel, so at least provide it.
Create Subnet
Inside our private network, we can separate groups of IP, this group can run isolated applications and in our case, we want two groups, one group that will have no access to the Internet (the world) and another one that will. Each of these groups will be divided into two parts. Let’s create the public Subnet:
# subnet.public.tf
resource "aws_subnet" "public__a" {
availability_zone = "us-east-1a"
cidr_block = "10.0.0.0/24"
map_public_ip_on_launch = true
tags = {
Env = "production"
Name = "public-us-east-1a"
}
vpc_id = aws_vpc.default.id
}
resource "aws_subnet" "public__b" {
availability_zone = "us-east-1b"
cidr_block = "10.0.1.0/24"
map_public_ip_on_launch = true
tags = {
Env = "production"
Name = "public-us-east-1b"
}
vpc_id = aws_vpc.default.id
}
We set a public subnet at us-east-1a
and another at us-east-1b
, both will expose a public IP and both belong to the same VPC we created earlier. Since our VPC is /16
we’ll separate the IP 10.0.0.(0..255)
for Subnet Public A and 10.0.1.(0..255)
for Subnet Public B.
The Private Subnet will be very similar, but won’t have a public IP:
# subnet.private.tf
resource "aws_subnet" "private__a" {
availability_zone = "us-east-1a"
cidr_block = "10.0.2.0/24"
map_public_ip_on_launch = false
tags = {
Env = "production"
Name = "private-us-east-1a"
}
vpc_id= aws_vpc.default.id
}
resource "aws_subnet" "private__b" {
availability_zone = "us-east-1b"
cidr_block = "10.0.3.0/24"
map_public_ip_on_launch = false
tags = {
Env = "production"
Name = "private-us-east-1b"
}
vpc_id= aws_vpc.default.id
}
This Subnet will be used, at 10.0.2.(0..255)
for Subnet Private A and 10.0.3.(0..255)
for Subnet Private B, to keep things like Database that does not need an Internet connection.
Internet Gateway
Our Public Subnet will be public, so we need to have access to the Internet. To do it we create an Internet Gateway:
# internet_gateway.tf
resource "aws_internet_gateway" "default" {
vpc_id = aws_vpc.default.id
tags = {
Env = "production"
Name = "internet-gateway"
}
}
Create Route Table
The Subnets are created, now we should create a route to these Subnets tracing a path for anyone that needs to reach it. We’ll have one Public and one Private route:
# route_table.public.tf
resource "aws_route_table" "public" {
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.default.id
}
tags = {
Env = "production"
Name = "route-table-public"
}
vpc_id = aws_vpc.default.id
}
This Public Route binds all possible IPs to the Internet Gateway, so this route has a free path to the Internet and can be used to any resource that needs to be exposed to our final user.
# route_table.private.tf
resource "aws_route_table" "private" {
tags = {
Env = "production"
Name = "route-table-private"
}
vpc_id = aws_vpc.default.id
}
Our Private Route does not provide access to the Internet and is good to be used with internal resources like Database and so…
Create Route Table Association
Now we have to connect the Route Table to the Subnets, finally tracing this communication path.
# route_table_association.public.tf
resource "aws_route_table_association" "public__a" {
route_table_id = aws_route_table.public.id
subnet_id = aws_subnet.public__a.id
}
resource "aws_route_table_association" "public__b" {
route_table_id = aws_route_table.public.id
subnet_id = aws_subnet.public__b.id
}
Both Public Subnets will use the Public Route Table.
# route_table_association.private.tf
resource "aws_route_table_association" "private__a" {
route_table_id = aws_route_table.private.id
subnet_id = aws_subnet.private__a.id
}
resource "aws_route_table_association" "private__b" {
route_table_id = aws_route_table.private.id
subnet_id = aws_subnet.private__b.id
}
And both Private Subnets will use the Private Route Table.
Create Main Route Table Association
Our VPC comes with a default Main Route Table that will be in charge of be used by subnets without Route Table. Let’s associate this default route to our Public Route Table:
# main_route_table_association.tf
resource "aws_main_route_table_association" "default" {
route_table_id = aws_route_table.public.id
vpc_id = aws_vpc.default.id
}
For now, we could create everything about our VPC and the way communication works inside it. Good job! (:
Create Database Instance on RDS
AWS already gives us a complete database via the RDS service, although you’ll pay one more EC2 for it. Before the DB let’s create a Security Group for it:
# security_group.db_instance.tf
resource "aws_security_group" "db_instance" {
description = "security-group--db-instance"
egress {
cidr_blocks = ["0.0.0.0/0"]
from_port = 0
protocol = "-1"
to_port = 0
}
ingress {
cidr_blocks = ["0.0.0.0/0"]
from_port = 5432
protocol = "tcp"
to_port = 5432
}
name = "security-group--db-instance"
tags = {
Env = "production"
Name = "security-group--db-instance"
}
vpc_id = aws_vpc.default.id
}
This Security Group opens ingress for the port 5432
and all traffic to outside.
Create DB Subnet Group
Our Database needs a Subnet to run and in this case, since we do not have external access, it will be on the Private Subnet.
# db_subnet_group.tf
resource "aws_db_subnet_group" "default" {
name = "db-subnet-group"
subnet_ids = [
aws_subnet.private__a.id,
aws_subnet.private__b.id
]
tags = {
Env = "production"
Name = "db-subnet-group"
}
}
Create DB Instance
And finally, we can create our Database.
resource "aws_db_instance" "default" {
backup_window = "03:00-04:00"
ca_cert_identifier = "rds-ca-2019"
db_subnet_group_name = "db-subnet-group"
engine_version = "13.4"
engine = "postgres"
final_snapshot_identifier = "final-snapshot"
identifier = "production"
instance_class = "db.t3.micro"
maintenance_window = "sun:08:00-sun:09:00"
name = "blog_production"
parameter_group_name = "default.postgres13"
password = "YOUR-MASTER-PASSWORD"
username = "postgres"
}
Here is used the name of the Subnet Group instead of an ID. We need to choose the default Certificate and the other attributes are simple to understand. For Database, it is enough and now we enter in some more complex configuration about ALB and EC2.
Create Security Group ALB
We’ll accept only HTTP connections from the internet and can have all traffic out.
# security_group.alb.tf
resource "aws_security_group" "alb" {
description = "security-group--alb"
egress {
cidr_blocks = ["0.0.0.0/0"]
from_port = 0
protocol = "-1"
to_port = 0
}
ingress {
cidr_blocks = ["0.0.0.0/0"]
from_port = 80
protocol = "tcp"
to_port = 80
}
name = "security-group--alb"
tags = {
Env = "production"
Name = "security-group--alb"
}
vpc_id = aws_vpc.default.id
}
Create ALB
ALB is very important because with it we can scale up or down the application and have one single point of access.
# alb.tf
resource "aws_alb" "default" {
name = "alb"
security_groups = [aws_security_group.alb.id]
subnets = [
aws_subnet.public__a.id,
aws_subnet.public__b.id,
]
}
The ALB stays at Public Subnet since it needs to be exposed to the internet and it’ll use the created security group allowing the access.
Create ALB Target Group
Target Group is the way we control the traffic from the ALB to the application, soon we’ll route the connection to this group.
# alb_target_group.tf
resource "aws_alb_target_group" "default" {
health_check {
path = "/"
}
name = "alb-target-group"
port = 80
protocol = "HTTP"
stickiness {
type = "lb_cookie"
}
vpc_id = aws_vpc.default.id
}
The Load Balance will act when the route /
does not return ok.
If you’re using Socket, for example, you need to make sure all connections go to the same group, in this case, use the
stickiness { type = "lb_cookie" }
to stick the session and avoid Round Robin that will normally send the connection to some instance with less process.
Create Security Group EC2
Our EC2 Instance should be reached only via the ALB connection, so we can just allow all connections coming from it to refer to the ALB Security Group. All output traffic is allowed.
# security_group.ecs.tf
resource "aws_security_group" "ec2" {
description = "security-group--ec2"
egress {
cidr_blocks = ["0.0.0.0/0"]
from_port = 0
protocol = "-1"
to_port = 0
}
ingress {
from_port = 0
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
to_port = 65535
}
name = "security-group--ec2"
tags = {
Env = "production"
Name = "security-group--ec2"
}
vpc_id = aws_vpc.default.id
}
Create IAM Policy Document
Here we start to create the rules to be applied to the EC2 machine to deal with ECS and we start with a policy:
# iam_policy_document.ecs.tf
data "aws_iam_policy_document" "ecs" {
statement {
actions = ["sts:AssumeRole"]
principals {
identifiers = ["ec2.amazonaws.com"]
type = "Service"
}
}
}
Create IAM Role
This previous policy allows an EC2 to assume, temporally, a role as the following:
# iam_role.ecs.tf
resource "aws_iam_role" "ecs" {
assume_role_policy = data.aws_iam_policy_document.ecs.json
name = "ecsInstanceRole"
}
Now we have a Role called ecsInstanceRole
which an EC2 can use to receive some powers.
Create IAM Role Policy Attachment
The Role previously created has no permission yet, so now we’ll attach some:
# iam_role_policy_attachment.ecs.tf
resource "aws_iam_role_policy_attachment" "ecs" {
role = aws_iam_role.ecs.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role"
}
The Policy AmazonEC2ContainerServiceforEC2Role
already exists on AWS, so we don’t need to create it from the scratch. It’ll give access to resources that ECS needs to deal with to run the application.
IAM Instance Profile
Now we have the Role with the necessary Policies and this Role can be used by the Instance Profile. The Instance Profile identifies the EC2, so it assumes the given role.
# iam_instance_profile.ecs.tf
resource "aws_iam_instance_profile" "ecs" {
name = "ecsInstanceProfile"
role = aws_iam_role.ecs.name
}
Heads up! If you create the Role via AWS Panel, the Role and the Instance Role will be created at the same time with the same name, but since we’re creating it separated, we can name it differently.
Create AMI
All EC2 comes from an image called AMI (Amazon Machine Images). In our case, we need an image compatible with ECS/Docker so we need to choose the right image and if possible, always the latest updated image.
Some people used to use the AMI (ami-23fd8d1g23faz...
) name directly on the Terraform code, it works, but if that AMI updates, you won’t get the last version.
# ami.tf
data "aws_ami" "default" {
filter {
name = "name"
values = ["amzn2-ami-ecs-hvm-2.0.202*-x86_64-ebs"]
}
most_recent = true
owners = ["amazon"]
}
We use filter
to find the AMI we want and with wildcard, we can get all Amazon ECS images 2.0
from the year 202...
(2022-01-01
). Then we say we always want the most recent version from all results returned for us. :)
Create User Data
Every time an EC2 instance starts we can run a custom code, this code is called User Data. A trick thing to do here is to make sure that our EC2 is shown up on the right ECS cluster we’ll create, for this we need to indicate it as an ENV variable. Really, many people including me lost a lot of hair to discover it, so, it won’t happen to you.
# user_data.sh
#!/bin/bash
echo ECS_CLUSTER=production >> /etc/ecs/ecs.config
In the future, our cluster will call production
.
Create Key Pair
When we launch an EC2 we need to provide a Key Pair as authentication to have access to the instance, let’s create it:
KEY_PATH=~/.ssh/blog
EMAIL=[email protected]
ssh-keygen -t rsa -b 4096 -f $KEY_PATH -C $EMAIL
# Enter passphrase (empty for no passphrase): press Enter
# Enter same passphrase again: press Enter
chmod 600 $KEY_PATH
ssh-add $KEY_PATH
cat ${KEY_PATH}.pub
Copy the public key output and paste it on the following public_key
attribute:
# key_pair.tf
resource "aws_key_pair" "default" {
key_name = "blog"
public_key = "ssh-rsa AAA...agw== [email protected]"
tags = {
"Name" = "[email protected]"
}
}
Unfortunately, you can’t use the file
function to read a file content, so it needed to be inline.
Create Launch Configuration
This configuration will indicate the EC2 config and it is used by the Auto Scale to boot up new machines.
# launch_configuration.tf
resource "aws_launch_configuration" "default" {
associate_public_ip_address = true
iam_instance_profile = aws_iam_instance_profile.ecs.name
image_id = data.aws_ami.default.id
instance_type = "t3.micro"
key_name = "blog"
lifecycle {
create_before_destroy = true
}
name_prefix = "lauch-configuration-"
root_block_device {
volume_size = 30
volume_type = "gp2"
}
security_groups = [aws_security_group.ec2.id]
user_data = file("user_data.sh")
- We want a public IP to be possible login to this instance;
- This machine will self identify with the
ecsInstanceProfile
we’ve created; - The AMI used is that one we’ve filtered;
- You need to provide the name of the Key Pair created, only this key can access the EC2;
- The
lifecycle
is important to be provided together with thename_prefix
, in this way the Auto Scale can create a new Launch Configuration without conflict when you need to do some update and will remove the current resource only when creating the new one, making the changes smooth; - The HD will have
30G
and use anSSD
GP2
; - Here we attach the Security Group created previously;
And finally, the trick part, you should provide user data, a code that will be triggered when the instance starts:
#!/bin/bash
echo ECS_CLUSTER=production >> /etc/ecs/ecs.config
It’ll indicate which ECS Cluster this machine will be available, without it, the container won’t find an instance and your task will fail. We’ll see it soon.
Create ALB Listener
Our Load Balancer is ready, but still not hearing anything, here will make it happen.
# alb_listener.tf
resource "aws_alb_listener" "default" {
default_action {
target_group_arn = aws_alb_target_group.default.arn
type = "forward"
}
load_balancer_arn = aws_alb.default.arn
port = 80
protocol = "HTTP"
}
Here we’re forwarding all traffic from the ALB to the Target Group. If you access the Load Balance’s DNS you should see a 5xx
code.
Heads up! A good practice is to use an SSL certificate on ALB, but I’ll use HTTP to avoid a bigger article, but soon I’ll explain how to configure HTTPS with ACM and Cloudflare.
Create Autoscaling Group
Now with everything configured we can decide how we’ll scale the things.
# autoscaling_group.tf
resource "aws_autoscaling_group" "default" {
desired_capacity = 1
health_check_type = "EC2"
launch_configuration = aws_launch_configuration.default.name
max_size = 2
min_size = 1
name = "auto-scaling-group"
tag {
key = "Env"
propagate_at_launch = true
value = "production"
}
tag {
key = "Name"
propagate_at_launch = true
value = "blog"
}
target_group_arns = [aws_alb_target_group.default.arn]
termination_policies = ["OldestInstance"]
vpc_zone_identifier = [
aws_subnet.public__a.id,
aws_subnet.public__b.id
]
}
- We want a minimum of 1 instance running and a maximum of 2 but the
desired_capacity
will dictate how many instances will be run. This value can be changed by the Load Balance and stay betweenmin_size
andmax_size
; - Our Health Check is done on EC2;
- This Group will use our previous Launch Configuration to know about the EC2 config;
- The tags will propagate their value to the EC2 instance, so we can check who is in charge to run which one;
- Remember we need to traffic the connections to a Target Group, well it’s here;
- Every time we scale down we’ll terminate the oldest instance since AWS charges the hour on the first seconds and we want to take the advantage of the most time we have;
- This traffic will pass through the Public Subnet.
Create ECR Repository
Our Docker image can be hosted in the AWS ECR, so during the deploy will fetch it.
# ecr_repository.tf
resource "aws_ecr_repository" "default" {
name = "blog"
image_scanning_configuration {
scan_on_push = true
}
}
output "repository_url" {
value = aws_ecr_repository.default.repository_url
}
We configured an auto-scan on push. It helps us discover a vulnerability in the image.
The output
command will expose the repository URL, we want to save the account id and the region for later.
Create App
Let’s create a simple Sinatra Application to be used as an Image using Sinatra:
# Gemfile
source 'https://rubygems.org'
gem 'sinatra'
It responds to the root URL returning a message.
# app.rb
class App < Sinatra::Base
get "/" do
ENV.fetch("MESSAGE", "Message ENV missing.")
end
end
Now we need to create a Rack Configuration File:
# config.ru
require "bundler"
Bundler.require
require_relative "app.rb"
run App
It runs bundler and then runs the App file with the route. It’s necessary a Dockerfile build the app image:
# Dockerfile
FROM ruby:2.7.4-alpine
WORKDIR /var/www/app
COPY Gemfile* ./
RUN gem install bundler
RUN bundle install
COPY . /var/www/app
CMD ["bundle", "exec", "rackup", "-p", "8080", "-E", "production"]
This Dockerfile installs Ruby, puts the files into /var/www/app
, and runs Rack as production.
Now we’ll build the image and upload it to ECR, but let’s test the app:
docker build . -t blog
docker run -it -p 8080:8080 blog
open http://localhost:8080 # in a new tab
Create ECR Release File
Here we’ll create a file responsible to make the releases, it’ll receive ENV variables.
# release.sh
#!/bin/bash
ECR_URL=${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com
aws ecr get-login-password --region ${REGION} | docker login --username AWS --password-stdin ${ECR_URL}
REPOSITORY=${REPOSITORY}
docker build . -t ${REPOSITORY}:${TAG} \
--build-arg MESSAGE=${MESSAGE}
docker tag ${REPOSITORY}:${TAG} ${ECR_URL}/${REPOSITORY}:${TAG}
docker push ${ECR_URL}/${REPOSITORY}:${TAG}
We build the ECR URL and execute the login on it, after we build the image, set the tag, and push it to the repository.
Now make this file executable with: chmod +x release.sh
and then run it passing some variables including that extracted from the ECR output.
ACCOUNT_ID=... \
AWS_PROFILE=blog \
REGION=us-east-1 \
REPOSITORY=blog \
TAG=v0.1.0 \
../terraform/release.sh
Heads up! The ENV
AWS_PROFILE
is important if you have more than one AWS profile configured.
Create ECS Cluster
Finally, let’s create our ECS Cluster.
# ecs_cluster.tf
resource "aws_ecs_cluster" "production" {
lifecycle {
create_before_destroy = true
}
name = "production"
tags = {
Env = "production"
Name = "production"
}
}
The Cluster groups a couple of Service and we can separate Stages by Clusters, for example.
Create Container Definitions
It’s a JSON file containing the definitions of our container and you can consider it as our boot-up config, like the Dockerfile.
# container_definitions.json
[{
"command": ["bundle", "exec", "rackup", "-p", "8080", "-E", "production"],
"cpu": 1024,
"environment": [
{ "name": "MESSAGE", "value": "Hello World!" }
],
"image": "ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/blog:v0.1.0",
"name": "app",
"portMappings": [
{ "containerPort": 8080, "protocol": "tcp" }
]
}]
- We have the same Dockerfile command to run the application;
- We’re using
1024
CPU units to run this task; - The ENVs are provided here and exported;
- The image is the repository URL output from ECR Resource plus the tag version you want;
- The app name will be
app
; - Our app will listen on container port
8080
.
Create Task Definition
It creates the Task Definition using the Container Definition we’ve just created. Each time you need a deploy you’ll need a new Task Definition version where it can change the ECR image or just configurations like ENV or container settings.
# ecs_task_definition.tf
resource "aws_ecs_task_definition" "default" {
container_definitions = file("container_definitions.json")
family = "blog"
memory = 500
network_mode = "host"
requires_compatibilities = ["EC2"]
}
- The
family
is the name of the Task Definition; - The configuration of the container is kept as a JSON;
- The memory will be 500 MiB (1 MiB = 2^20 bytes = 1,048,576 bytes);
- The network is
host
for better performance. If you need to run multiple tasks from the same app into the same container, usebridge
instead; - We’re a launch type as EC2 over Fargate.
Create Task Definition Data
For deployment purposes, we can create a data resource just to fetch the latest active task definition revision.
# ecs_task_definition.data.tf
data "aws_ecs_task_definition" "default" {
task_definition = aws_ecs_task_definition.default.family
}
Create ECS Service
The Service could be represented as your application, so it can be your Rails app or your database. In our case, it’s the Sinatra app running.
# ecs_service.tf
resource "aws_ecs_service" "default" {
cluster = aws_ecs_cluster.production.id
depends_on = [aws_iam_role_policy_attachment.ecs]
desired_count = 1
enable_ecs_managed_tags = true
force_new_deployment = true
load_balancer {
target_group_arn = aws_alb_target_group.default.arn
container_name = "app"
container_port = 8080
}
name = "blog"
task_definition = "${aws_ecs_task_definition.default.family}:${data.aws_ecs_task_definition.default.revision}"
}
- This service will run on cluster production;
- The
depends_on
is a recommendation to avoid the IAM policy be removed before the service, in update/destroy cases, and then this action hangs forever; - We desire to have 1 instance of the Task (clone of your app) running;
- We want AWS to create tags on the Tasks, these tags indicate the Cluster and the Service and are very helpful;
- The deployment will be forced, but you can configure the strategy to replace containers during the deployment;
- We attach the Load Balance to the Service, so Load Balance can distribute the requests between the Tasks. It’ll be bound on the
containerPort
defined previously; - Our app service will be called
blog
; - And the trick is to refer to the task definition getting the last revision.
Accessing The App
Congratulations! You made all the necessary setup, now just run terraform apply
and wait until everything is done.
To access the app, go to the ALB page and get the DNS name to access via browser.
Using Variables
We can use a variable to facilitate our life the syntax is ${variable}
and before we use it we need to declare it.
For .tf
files you just call var.variable
, but for JSON files you need to use a template. For a complete example let’s pass the MESSAGE
ENV from container_definition.json
as a variable, so let’s create a file to declare this variable.
# container_definitions.json.variables.tf
variable "container_definitions__message" { default = "Hello World!" }
I like to create a variable file for each resource since I used to create a file per resource. I like the name of the variable composed of resource_name
+ __
+ variable_name
, but feel free to use in your way.
The variable can have a default and if you want other attributes like description
.
Change the container_definition.json
to use a key over the message value:
# container_definitions.json
{ "name": "MESSAGE", "value": "${message}" }
And now we need to create a template file responsible to merge the variables to the JSON treated as a template:
# container_definitions.json.template.tf
data "template_file" "container_definitions" {
template = file("container_definitions.json")
vars = {
message = var.container_definitions__message
}
}
Here we loaded the JSON file and merged the vars
composed by the key used inside the template and the value fetched from the variable declared previously.
Using Variables Per Stage
The variable message
is a default value and maybe works very well for static values, but the most of time we want to declare different values based on the stage, for that we can create a var file and use it on the apply
command.
# production.tfvars
container_definitions__message="World, Hello!"
Now we just refer it:
terraform apply -var-file="stages/production.tfvars"
Here you can have as many files as your stages, but if your data is sensitive data?
Using Parameter Store
Sensitive data shouldn’t be committed in the repository and AWS has a good place to keep it, the SSM Parameter. To fetch it, first we need to set these values there, you can do it manually or via Terraform, let’s use the last one option.
# ssm_parameter.tf
resource "aws_ssm_parameter" "container_definitions__account_id" {
name = "/terraform/${terraform.workspace}/CONTAINER_DEFINITIONS__ACCOUNT_ID"
type = "String"
value = "YOUR-ACCOUNT-ID"
}
resource "aws_ssm_parameter" "db_instance__password" {
name = "/terraform/${terraform.workspace}/DB_INSTANCE__PASSWORD"
type = "SecureString"
value = "YOUR-PASSWORD"
}
resource "aws_ssm_parameter" "key_pair__public_key" {
name = "/terraform/${terraform.workspace}/KEY_PAIR__PUBLIC_KEY"
type = "SecureString"
value = "YOUR-PUBLIC-KEY-PAIR"
}
Here we used a variable from Terraform called workspace
. It was set at the beginning of this article when we create the workspace.
This file is just to help you to create the entries, but it won’t be committed, so add it to the .gitignore
:
echo 'ssm_parameter.tf' >> .gitignore
Now we’ll use the values as data:
# ssm_parameter.data.tf
data "aws_ssm_parameter" "container_definitions__account_id" {
name = "/terraform/${terraform.workspace}/CONTAINER_DEFINITIONS__ACCOUNT_ID"
}
data "aws_ssm_parameter" "db_instance__password" {
name = "/terraform/${terraform.workspace}/DB_INSTANCE__PASSWORD"
}
data "aws_ssm_parameter" "key_pair__public_key" {
name = "/terraform/${terraform.workspace}/KEY_PAIR__PUBLIC_KEY"
}
Just replace the static values with the dynamic values from SSM:
# container_definitions.json.template.tf
account_id = data.aws_ssm_parameter.container_definitions__account_id.value
# container_definitions.tf
"image": "${account_id}.dkr.ecr.us-east-1.amazonaws.com/blog:v0.1.0",
# db_instance.tf
password = data.aws_ssm_parameter.db_instance__password.value
# key_pair.tf
public_key = data.aws_ssm_parameter.key_pair__public_key.value
# launch_configuration.tf
iam_instance_profile = "arn:aws:iam::${data.aws_ssm_parameter.container_definitions__account_id.value}:instance-profile/ecsInstanceProfile"
Now everything is on SSM Parameter and you can create an SH script to update this and then run the terraform apply or if your CI/CD is not so modern like this, make the change directly on AWS using the AWS CLI.
Conclusion
Although we can improve this code using modules, that’s it! I’m not an expert in Infrastructure so feel free to help me to improve this code, I’ll be very happy to learn new things. Leave your comment and let me know what you think about this subject.
Repository link: https://github.com/wbotelhos/aws-ecs-with-terraform
Thank you! (: