Terraform for ECS: Provisioning S3, DynamoDB, and Fargate Cluster (Part 2)
Docker-and-ECR
ByOlaniyi Oladimeji
Terraform for ECS: Provisioning S3, DynamoDB, and Fargate Cluster (Part 2)

Part 1 ended with two Docker images sitting in ECR: the Flask application and its Nginx reverse proxy, and nothing deployed to AWS yet. This post picks up exactly there, using Terraform to provision the infrastructure those containers will eventually run inside: an S3 bucket, a DynamoDB table, and an ECS cluster configured for Fargate.

The interesting part of this post isn’t creating new resources from scratch; it’s adapting the existing kloud-gov-infrastructure Terraform module from the EC2-based series to fit a fundamentally different compute model. No EC2 instance, no per-instance IAM role, no security group governing SSH and application ports. Just the storage layer the application needs, plus a cluster ready to run containers instead of virtual machines.

A Cost Warning Worth Taking Seriously

Before touching any code: AWS Fargate has no free tier. Every task that runs on it, however briefly, generates a small charge, typically a few cents for a lab-scale exercise like this one, but a real cost nonetheless, unlike EC2’s free-tier-eligible t2.micro/t3.micro instances used throughout the earlier series.

Practically, that means: don’t start this post unless you also have time to get through Part 3 in the same sitting. Standing up the ECS cluster and then leaving it running for days between sessions racks up cost for no benefit, and when you’re genuinely done not just done for today delete the cluster. This isn’t a cost to resent; it’s a small, deliberate investment in hands-on experience that’s worth far more than the few cents it costs to run.

Step 1: Checking the Terraform Remote State

Before changing anything, confirm what Terraform currently believes exists:

cd /home/ec2-user/kloud-gov-infrastructure/terraform

terraform show
# This command can take a moment against the S3 backend, that's expected

If the earlier series’ resources were fully destroyed at the end of their respective posts, this should come back essentially empty, with no resources currently represented in state. That’s the clean starting point this post assumes. If terraform show surfaces leftover resources from the EC2-based series, it’s worth reconciling that before moving forward, since the module changes in the next step remove several resource blocks entirely, and Terraform needs to destroy anything still tracked against the old configuration first.

Step 2: Adapting the Terraform Module for a Containerized Architecture

This is the core work of this post. The project’s premise has fundamentally shifted: KloudGov no longer runs on EC2 instances it provisions directly. Compute now belongs to ECS and Fargate, but the application still needs the same storage backend it always has: a DynamoDB table for records and an S3 bucket for files, regardless of what runs the application code.

Three files need updating: the module’s main.tf, the module’s output.tf, and the root outputs.tf.

modules/aws_kloudgov_infrastructure/main.tf

Comment out rather than delete every resource specific to the EC2-based architecture: the security group, the EC2 instance itself (including its local-exec provisioners from the Ansible series), and the IAM role, policy attachments, and instance profile that granted that EC2 instance access to S3 and DynamoDB.

 

/*
resource "aws_security_group" "state_ec2_sg" {
  name        = "kloudgov-${var.state_name}-ec2-sg"
  description = "Allow traffic on ports 22 and 80"

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 5000
    to_port     = 5000
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port       = 0
    to_port         = 0
    protocol        = "-1"
    security_groups = ["sg-027f57abd3fefda49"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "kloudgov-${var.state_name}"
  }
}

resource "aws_instance" "state_ec2" {
  ami                    = "ami-007855ac798b5175e"
  instance_type          = "t3.micro"
  key_name               = "kloudgov-ec2-key"
  vpc_security_group_ids = [aws_security_group.state_ec2_sg.id]
  iam_instance_profile   = aws_iam_instance_profile.s3_dynamodb_full_access_instance_profile.name

  provisioner "local-exec" {
    command = "sleep 30; ssh-keyscan ${self.private_ip} >> ~/.ssh/known_hosts"
  }

  provisioner "local-exec" {
    command = "echo ${var.state_name} id=${self.id} ansible_host=${self.private_ip} ansible_user=ubuntu us_state=${var.state_name} aws_region=${var.region} aws_s3_bucket=${aws_s3_bucket.state_s3.bucket} aws_dynamodb_table=${aws_dynamodb_table.state_dynamodb.name} >> /etc/ansible/hosts"
  }

  provisioner "local-exec" {
    command = "sed -i '/${self.id}/d' /etc/ansible/hosts"
    when    = destroy
  }

  tags = {
    Name = "kloudgov-${var.state_name}"
  }
}
*/

# Adding the new ECS 

resource "aws_dynamodb_table" "state_dynamodb" {
  name         = "kloudgov-${var.state_name}-dynamodb"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "id"

  attribute {
    name = "id"
    type = "S"
  }

  tags = {
    Name = "kloudgov-${var.state_name}"
  }
}

resource "random_string" "bucket_suffix" {
  length  = 4
  special = false
  upper   = false
}

resource "aws_s3_bucket" "state_s3" {
  bucket = "kloudgov-${var.state_name}-s3-${random_string.bucket_suffix.result}"

  tags = {
    Name = "kloudgov-${var.state_name}"
  }
}



/*

resource "aws_iam_role" "s3_dynamodb_full_access_role" {
  name = "kloudgov-${var.state_name}-s3_dynamodb_full_access_role"

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "ec2.amazonaws.com"
      },
      "Effect": "Allow",
      "Sid": ""
    }
  ]
}
EOF

  tags = {
    Name = "kloudgov-${var.state_name}"
  }
}

resource "aws_iam_role_policy_attachment" "s3_full_access_role_policy_attachment" {
  role       = aws_iam_role.s3_dynamodb_full_access_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
}

resource "aws_iam_role_policy_attachment" "dynamodb_full_access_role_policy_attachment" {
  role       = aws_iam_role.s3_dynamodb_full_access_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess"
}

resource "aws_iam_instance_profile" "s3_dynamodb_full_access_instance_profile" {
  name = "kloudgov-${var.state_name}-s3_dynamodb_full_access_instance_profile"
  role = aws_iam_role.s3_dynamodb_full_access_role.name

  tags = {
    Name = "kloudgov-${var.state_name}"
  }
}
*/

Why comment out instead of delete entirely? This module now serves double duty as a record of both architectures: the EC2-based deployment this project started with, and the containerized one it’s moving toward. Comments preserve that history directly in the code and make it trivial to reference or restore later, rather than digging through Git history to remember exactly how the EC2 version worked.

Notice what survives untouched: aws_dynamodb_table.state_dynamodb, random_string.bucket_suffix, and aws_s3_bucket.state_s3. These three resources have nothing to do with compute; they’re the application’s persistent storage layer, and that layer doesn’t care whether the code reading and writing to it runs on an EC2 instance or inside an ECS task. That’s exactly the kind of clean separation good infrastructure design aims for.

Also worth noting: since the EC2 instance resource is now commented out, its IAM role and instance profile, which existed specifically to grant that EC2 instance permission to reach S3 and DynamoDB, are no longer meaningful either. ECS tasks get their AWS permissions through a different mechanism entirely (the KloudGovECSExecutionRole created in Part 1’s prerequisite), which is why that role exists as a standalone piece rather than being wired into this module.

modules/aws_kloudgov_infrastructure/output.tf

Comment out the EC2-specific output, since there’s no longer an EC2 public DNS to expose:

# modules/aws_kloudgov_infrastructure/output.tf

# output "state_ec2_public_dns" {
#   value = aws_instance.state_ec2.public_dns
# }

output "state_dynamodb_table" {
  value = aws_dynamodb_table.state_dynamodb.name
}

output "state_s3_bucket" {
  value = aws_s3_bucket.state_s3.bucket
}

VS Code tip: select the lines you want to comment out and press Ctrl+/ (or Cmd+/ on Mac) to toggle line comments quickly, rather than manually typing # at the start of each line.

terraform/outputs.tf

The root-level output that aggregates per-state values needs the same adjustment: drop the EC2 DNS reference; keep everything else:

# terraform/outputs.tf

output "state_infrastructure_outputs" {
  value = {
    for state, infrastructure in module.aws_kloudgov_infrastructure :
    state => {
      # ec2_public_dns = infrastructure.state_ec2_public_dns
      dynamodb_table = infrastructure.state_dynamodb_table
      s3_bucket      = infrastructure.state_s3_bucket
    }
  }
}

Scoping Back to a Single State for Testing

Consistent with the pattern from earlier posts in this project, confirm terraform/variables.tf is scoped to just one tenant while validating this new configuration:

# terraform/variables.tf

variable "states" {
  description = "A list of state names"
  default     = ["michigan"]
}

Step 3: Running Terraform

With the module adapted and scoped to a single state, apply the changes:

pwd
# /home/ec2-user/kloud-gov-infrastructure/terraform

terraform plan      # 3 to add
terraform apply -auto-approve

That 3 to add is worth pausing on as a sanity check. The original EC2-based module produced 9 resources per state (security group, EC2 instance, DynamoDB table, S3 bucket, random string, IAM role, two policy attachments, instance profile). Removing the six EC2/IAM-specific resources leaves exactly three: the DynamoDB table, the random string suffix, and the S3 bucket. If your plan shows a different number, stop and check which resource block didn’t get commented out correctly before applying.

Once applied, record the resulting resource names; these become environment variables the ECS task definition needs in Part 3, the same role they played in the systemd service file back in the EC2/Ansible series:

US_STATE=michigan
AWS_REGION=us-east-1
AWS_DYNAMODB_TABLE=kloudgov-michigan-dynamodb
AWS_BUCKET=kloudgov-michigan-s3-****

Step 4: Creating the ECS Cluster

Create the cluster directly through the AWS Console:

ECS → Clusters → Create Cluster
Cluster name: kloudgov-ecs-cluster
Infrastructure: AWS Fargate (Serverless)

This is worth being explicit about because it’s easy to get wrong in a way that quietly undoes this entire post. If you’re following along from earlier notes on this project, you may see “Amazon EC2 instances” listed as the infrastructure option here that’s inconsistent with everything else in this post: the Fargate cost warning above, the whole premise of re-platforming away from EC2, and Part 3’s task definitions, which are built to launch as Fargate tasks with no EC2 capacity provider involved. Selecting EC2 instances as the cluster’s infrastructure type would mean provisioning and managing EC2 capacity again, the exact thing this three-part series exists to move away from. Choose Fargate (Serverless), not EC2 instances.

Why This Split Matters

It would have been possible to fold the ECS cluster’s creation into Terraform alongside the S3 bucket and DynamoDB table. In a more mature version of this project, that’s exactly where it should end up, but creating it manually here, through the console, mirrors the same deliberate teaching choice made back in the EC2 series. Part 2: doing something by hand once, before automating it, makes the automation that follows genuinely legible rather than a black box you’re copying without understanding. Part 3 is where this cluster gets used: creating a task definition and service manually, the same way the EC2 series was deployed before introducing Ansible.

Key Takeaways

Commenting out architecture-specific resources, rather than deleting them, preserves a working record of both the old and new approaches directly in the codebase, genuinely useful when a module is transitioning between fundamentally different compute models.
Storage resources (S3, DynamoDB) are correctly decoupled from compute resources (EC2, ECS) in this module’s design; the application’s data layer doesn’t need to know or care what’s actually running the application code.
IAM for ECS works differently than IAM for EC2 there’s no per-instance role or instance profile in this model; instead, a single execution role (created in Part 1) is referenced directly by the ECS task definition in Part 3.
Always double-check the Terraform plan’s resource count against what you expect. “3 to add” here is a direct, verifiable signal that exactly the right resources survived the module edit.

AWS Fargate carries real, if small, cost with no free tier; plan your working sessions accordingly, and tear down the cluster promptly once you’re genuinely done rather than leaving it running between sessions.

With the storage layer provisioned and the ECS cluster standing by, Part 3 brings everything together: creating a task definition that references both ECR images from Part 1 and launching it as a service on this cluster to get KloudGov actually running on Fargate.

{{ reviewsTotal }}{{ options.labels.singularReviewCountLabel }}
{{ reviewsTotal }}{{ options.labels.pluralReviewCountLabel }}
{{ options.labels.noReviewsLabel }}
{{ options.labels.newReviewButton }}
{{ userData.canReview.message }}

Related Posts

EKS-KloudGov-Cluster
AWS EKS Cluster Setup: Provisioning Kubernetes with eksctl and Terraform (Part 1)
This new series adds a third architecture: Kubernetes, via Amazon EKS (Elastic Kubernetes Service), fronted...
Docker-and-ECR
Terraform ECS Automation: Fully Automated, HTTPS-Secured Fargate Services (Part 4)
Everything built by hand in Part 3 the task definition, the target group, the ALB,...
Docker-and-ECR
ECS Task Definitions and Fargate Services: Deploying KloudGov (Part 3)
creating the task definition through the console, launching the service, and watching it come up...