When you start working with AWS, the first point of interaction is always the web console. You get an intuitive web UI to interact with different AWS services and smart default configuration options that lower the learning curve for developers who are new to the services.
However, when you start running your production systems on AWS, provisioning infrastructure via the web UI is error prone and not scalable. That’s when Infrastructure as Code (IaC) tools come into the picture. IaC tools allow you to declaratively provision the infrastructure and apply DevOps practices to your infrastructure code. They also enable you to provision immutable components, ensuring repeatability of your deployments and minimizing drift in different environments.
There are several IaC frameworks available to provision infrastructure in AWS: AWS CloudFormation, AWS Cloud Development Kit (CDK), HashiCorp Cloud Development Kit for Terraform (CDKTF), and HashiCorp Terraform. This post discusses the salient features of each of these frameworks, with a special focus on how to use Terraform to provision immutable infrastructure in AWS.
Let’s first go into the various IaC frameworks you can use to provision infrastructure on AWS in more detail.
CloudFormation is an AWS service that allows you to use JSON/YAML files to create infrastructure templates. You can apply these templates, and AWS will provision the resources as defined in them. CloudFormation is only available to use with AWS, not other cloud providers.
AWS CDK is an open-source offering that allows you to define your infrastructure components using familiar programming languages. It provides libraries for TypeScript, Python, Java, .NET, and Go. Each library comes with proven defaults, which lowers the entry barrier for new users. AWS CDK allows developers to leverage the existing tooling of their preferred programming language, such as IDEs, build tools, testing frameworks, and development workflows.
HashiCorp Terraform is another open-source framework for IaC. It allows you to write your infrastructure configuration in human-readable and declarative files called HashiCorp Configuration Language (HCL). Terraform’s plugin-based architecture helps you manage infrastructure on multiple cloud platforms, and its state management allows you to track changes throughout the deployments.
CDKTF is an amalgamation of AWS CDK and Terraform. Like AWS CDK, it allows you to programmatically provision the infrastructure, but it works with all of the cloud providers that Terraform supports. CDKTF converts your code (written in TypeScript, Python, Java, .NET, or Go) into Terraform modules to leverage the existing ecosystem of Terraform.
In this post, you will learn how to use Terraform to provision immutable infrastructure in AWS and how to deploy a container on AWS Fargate. AWS Fargate is a serverless compute offering that frees you from having to spin up the VMs yourself. It is compatible with both Amazon Elastic Container Service (ECS) and Amazon Elastic Kubernetes Service (EKS).
Before getting started, you’ll need an AWS account and aws-cli and terraform tools.
To install aws-cli, follow the instructions in the official documentation. Also, set up your access key.
Next, authenticate using the command below:
aws configure
You’ll be prompted to enter the aws_access_key_id and aws_secret_access_key, which you got in the previous step.
Next, follow these instructions to install Terraform based on your operating system. This demo uses Terraform v1.1.5.
Now, create a folder for all your Terraform source files. Let’s call it aws-terraform-demo.
Start by setting up the plugins.tf file to initialize the AWS provider.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 4.4"
}
}
}
provider "aws" {
profile = "default"
region = "eu-central-1"
}
Here, you are defining the dependency on the AWS provider and initializing it. Once you set the region at the provider level, you don’t need to specify it for individual components.
Next, create a file state-bucket.tf and an S3 bucket to store the state:
resource "aws_s3_bucket" "state_bucket" {
bucket = "cs-state-bucket"
}
resource "aws_s3_bucket_acl" "state_bucket_acl" {
bucket = aws_s3_bucket.state_bucket.id
acl = "private"
}
Note: The bucket name should be globally unique, so you won’t be able to use the same one in your demo.
Now you are ready to initialize the code.
Run the following command to initialize Terraform:
terraform init
This will download the plugins and initialize the state.
Terraform has been successfully initialized!
You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.
If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
Next, run the terraform plan to see what components Terraform will create. This is a dry run, where Terraform checks the current state from the state file and figures out which changes it needs to make in order to achieve the new desired state.
terraform plan -out planfile
Plan: 2 to add, 0 to change, 0 to destroy.
If you apply the changes, the aws_s3_bucket and aws_s3_bucket_acl resources will be created.
To apply the changes, use this command:
terraform apply planfile
This will apply the planfile by creating the resources.
aws_s3_bucket.state_bucket: Creating...
aws_s3_bucket.state_bucket: Creation complete after 1s [id=cs-state-bucket]
aws_s3_bucket_acl.state_bucket_acl: Creating...
aws_s3_bucket_acl.state_bucket_acl: Creation complete after 0s [id=cs-state-bucket,private]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Terraform has now created a state file terraform.tfstate in your local directory. However, storing state on a local machine is not recommended. If your state is stored locally and you run your code from some other machine, Terraform will try to recreate all the existing components, which will result in an error.
That’s why it's recommended to use Terraform’s remote state. This will store the state in the S3 bucket, which will allow it to be available, even if you run your code from another machine in the future.
Let’s make the changes to configure the remote backend for your state. Use the bucket you created in the previous step to store the state.
First, create a state.tf file:
terraform {
backend "s3" {
bucket = "cs-state-bucket"
key = "demo/state"
region = "eu-central-1"
}
}
Make sure the region in state.tf and plugins.tf is consistent.
Next, run terraform init again. You will be asked to upload the local state to the bucket as well.
Initializing the backend...
Do you want to copy existing state to the new backend?
Pre-existing state was found while migrating the previous "local" backend to the
newly configured "s3" backend. No existing state was found in the newly
configured "s3" backend. Do you want to copy this state to the new "s3"
backend? Enter "yes" to copy and "no" to start with an empty state.
Enter a value: yes
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Using previously-installed hashicorp/aws v4.10.0
Terraform has been successfully initialized!
Now, you are ready to deploy your container to AWS Fargate.
First, define some variables in the variables.tf file.
variable "az_count" {
type = number
description = "Number of AZs to deploy the resources"
default = 2
}
variable "app_port" {
type = number
description = "Application Port on which to run"
default = 80
}
You’ll use these variables while defining other infrastructure components.
Now, configure the network components in the network.tf file.
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
data "aws_availability_zones" "available" {
}
resource "aws_subnet" "private" {
count = var.az_count
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
}
resource "aws_subnet" "public" {
count = var.az_count
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, var.az_count + count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
vpc_id = aws_vpc.main.id
map_public_ip_on_launch = true
}
resource "aws_internet_gateway" "gw" {
vpc_id = aws_vpc.main.id
}
resource "aws_route" "internet_access" {
route_table_id = aws_vpc.main.main_route_table_id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.gw.id
}
resource "aws_eip" "gw" {
count = var.az_count
vpc = true
depends_on = [aws_internet_gateway.gw]
}
resource "aws_nat_gateway" "gw" {
count = var.az_count
subnet_id = element(aws_subnet.public.*.id, count.index)
allocation_id = element(aws_eip.gw.*.id, count.index)
}
resource "aws_route_table" "private" {
count = var.az_count
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = element(aws_nat_gateway.gw.*.id, count.index)
}
}
resource "aws_route_table_association" "private" {
count = var.az_count
subnet_id = element(aws_subnet.private.*.id, count.index)
route_table_id = element(aws_route_table.private.*.id, count.index)
}
Next, you'll create some security groups to allow traffic from the Internet to your containers. You will use these security groups while configuring an Application Load Balancer (ALB) in subsequent steps.
Create the security_groups.tf file.
resource "aws_security_group" "lb" {
name = "nginx-load-balancer-security-group"
description = "controls access to the ALB from internet"
vpc_id = aws_vpc.main.id
ingress {
protocol = "tcp"
from_port = var.app_port
to_port = var.app_port
cidr_blocks = ["0.0.0.0/0"]
}
egress {
protocol = "-1"
from_port = 0
to_port = 0
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group" "ecs_tasks" {
name = "nginx-task-security-group"
description = "Allow traffic from ALB to backend"
vpc_id = aws_vpc.main.id
ingress {
protocol = "tcp"
from_port = var.app_port
to_port = var.app_port
security_groups = [aws_security_group.lb.id]
}
egress {
protocol = "-1"
from_port = 0
to_port = 0
cidr_blocks = ["0.0.0.0/0"]
}
}
These settings allow anyone from the Internet to connect to your ALB. This is okay for this demo, but for production environments, you should only allow access from certain IP addresses.
Next, configure the ALB, its target backend, and a listener that forwards the traffic from the ALB to the backend in the alb.tf file.
resource "aws_alb" "main" {
name = "nginx-load-balancer"
subnets = aws_subnet.public.*.id
security_groups = [aws_security_group.lb.id]
}
resource "aws_alb_target_group" "app" {
name = "nginx-target-group"
port = var.app_port
protocol = "HTTP"
vpc_id = aws_vpc.main.id
target_type = "ip"
}
resource "aws_alb_listener" "front_end" {
load_balancer_arn = aws_alb.main.id
port = var.app_port
protocol = "HTTP"
default_action {
target_group_arn = aws_alb_target_group.app.id
type = "forward"
}
}
You can also configure health_check in the aws_alb_target_group resource to ensure that traffic is only forwarded to healthy instances in the backend.
You’ve now finished the setup you need for creating your Amazon ECS cluster and deploying your container onto it.
To create the Amazon ECS cluster, first create a file called elastic_cluster.tf.
resource "aws_ecs_cluster" "demo-cluster" {
name = "demo-cluster"
}
resource "aws_ecs_cluster_capacity_providers" "demo_cluster_capacity_provider" {
cluster_name = aws_ecs_cluster.demo-cluster.name
capacity_providers = ["FARGATE"]
default_capacity_provider_strategy {
base = 1
weight = 100
capacity_provider = "FARGATE"
}
}
resource "aws_ecs_task_definition" "service" {
family = "service"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = 1024
memory = 2048
container_definitions = jsonencode([
{
name = "nginx-container"
image = "nginx"
cpu = 2
memory = 256
essential = true
portMappings = [
{
containerPort = var.app_port
hostPort = var.app_port
}
]
}
])
}
resource "aws_ecs_service" "demo-service" {
name = "demo-service"
cluster = aws_ecs_cluster.demo-cluster.id
task_definition = aws_ecs_task_definition.service.arn
desired_count = 1
network_configuration {
security_groups = [aws_security_group.ecs_tasks.id]
subnets = aws_subnet.private.*.id
assign_public_ip = true
}
load_balancer {
target_group_arn = aws_alb_target_group.app.id
container_name = "nginx-container"
container_port = var.app_port
}
depends_on = [aws_alb_listener.front_end]
}
In this demo, you are deploying NGINX, but you can replace it with any of your web applications and adjust the app_port variable in variables.tf.
You can also configure autoscaling for your cluster and set up some logging, but for the sake of brevity, we are omitting those steps.
Use the following resources to configure autoscaling and logs:
aws_autoscaling_policy
resource.
Once all the resources are configured, try a dry run using plan. Then apply the plan file.
terraform plan -out planfile
terraform apply planfile
If you have followed the steps correctly, your changes should be applied without any errors.
Once changes are applied, go to the AWS Management Console. Navigate to EC2 > Load Balancing > Load Balancers. This will show your newly created nginx-load-balancer resource.

If there aren’t any resources there, make sure you have chosen the correct region for the console. You can change the region in the top right corner.
Now, select your load balancer resource. This will open up details in the bottom section of the console.
Copy the DNS and paste it into the browser. You should see the NGINX home page.

Let’s say that you already have a lot of resources manually deployed in your AWS account. Now, you want to use Terraform to provision any future resources—and you want to follow IaC principles. In such a case, you can import your existing cloud resources into Terraform’s purview. Use tools like Terraformer to create the tf resource files for existing infrastructure resources and to import their state.
Utilizing Terraform to provision your AWS resources allows you to use human-readable HCL to define the desired state of your infrastructure declaratively. With IaC principles in place, you can use the same DevOps practices as you do in your software development workflows, ensuring maintainability and testability in your setup.
Terraform, or IaC tools in general, also have advantages like idempotence and repeatability, which minimize the drift between different environments. This enables teams to quickly provision and tear down production-like environments and test their codes early in the development cycle, leading to higher quality software.