Manage Secrets for ECS Fargate with Terraform

Store secrets in AWS Secrets Manager and inject them into ECS Fargate tasks.

Introduction

30 Second Summary

Every application needs credentials to talk to other services. Storing those credentials in plain text inside your code or container images is one of the most common security mistakes in cloud deployments.

In this project, you will use Terraform to store secrets in AWS Secrets Manager and inject them into running ECS Fargate containers using the native AWS secrets mechanism. No hardcoded values, no credentials baked into images.

What You'll Build

A Terraform configuration that provisions secrets in AWS Secrets Manager and an ECS Fargate cluster whose tasks consume those secrets at runtime via native injection.

By the end of this project, you'll have:

  • Secrets stored in AWS Secrets Manager with production best practices: path-based naming, structured JSON, tagging, and recovery windows.
  • A running ECS Fargate task that reads secrets as environment variables via native injection.
  • A validated IAM permission boundary proving that prefix-scoped least-privilege access works end-to-end.
  • Secret Mission: Configure automatic secret rotation with a Lambda function and verify the rotation lifecycle.

Are there any prerequisites?

This project assumes you have an AWS account with the AWS CLI configured, an existing VPC with private subnets and a NAT gateway, and an existing IAM execution role for Fargate with Secrets Manager read permissions scoped to a prefix.

If you do not have these resources yet, provision them with Terraform before starting this project.

Set Up Provider and Project Structure

Before you can create secrets or deploy containers, you need a solid foundation. Your Terraform configuration needs to know which AWS provider version to use, which region to target, and how to reference your existing infrastructure.

In this step, you will verify your tools work, scaffold a new project directory, and define all the variables that connect this module to your existing VPC, subnets, and IAM role.

In this step, get ready to:
  • Confirm Terraform and AWS CLI are working with your account.
  • Create the project directory and configure the AWS provider.
  • Define variables that reference your existing infrastructure.
Verify Terraform and AWS CLI are working

Your project requires Terraform version 1.5.0 or higher, and AWS CLI configured with valid credentials. A quick check now saves debugging time later.

  • Confirm your Terraform version by running this command:
terraform version

You should see a version number of 1.5.0 or higher in the output.

Seeing a version lower than 1.5.0?

Upgrade Terraform by downloading the latest release from the official HashiCorp downloads page at https://developer.hashicorp.com/terraform/install.

help me upgrade Terraform to the latest version

  • Confirm your AWS CLI is authenticated by running this command:
aws sts get-caller-identity

You should see a JSON response containing your account ID, user ARN, and user ID. This confirms your CLI can reach AWS with valid credentials.

Getting an error about credentials?

Make sure your AWS CLI is configured with valid access keys. Run aws configure to set your access key, secret key, and default region.

help me configure my AWS CLI credentials

Create the project directory and provider configuration

Your Terraform project needs its own directory with a provider configuration that pins the AWS provider to a stable major version. This prevents unexpected breaking changes when you run terraform init in the future.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir ecs-secrets-terraform
cd ecs-secrets-terraform
  • Confirm the folder exists by running:
ls

You should see the ecs-secrets-terraform folder listed in your Desktop output.

  • Create a file called versions.tf by running:
touch versions.tf
  • Open versions.tf in your text editor and paste the following code:
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

What does this code do?

The required_version block ensures anyone running this code uses Terraform 1.5.0 or higher. The required_providers block pins the AWS provider to major version 6, so minor updates arrive automatically but breaking changes do not.

The provider "aws" block sets the deployment region from a variable you will define next. This keeps the region configurable instead of hardcoded.

  • Save versions.tf.

Seeing a syntax error?

Make sure each opening brace { has a matching closing brace }. Check that the source and version values are wrapped in double quotes.

help me fix syntax errors in my versions.tf file

✔️ Awesome, I've got everything!

Great. Make sure you have saved versions.tf before moving on.

ⓧ I'd like to double check the full code

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}
Define variables for your existing infrastructure

Instead of hardcoding VPC IDs and subnet values, you will define them as variables. This lets your module compose cleanly with your existing Terraform code and makes it reusable across environments.

  • Create a file called variables.tf by running:
touch variables.tf
  • Open variables.tf and paste these configuration variables:
variable "aws_region" {
  description = "AWS region for all resources"
  type        = string
  default     = "us-east-1"
}

variable "app_name" {
  description = "Application name used as prefix for resource naming"
  type        = string
  default     = "myapp"
}

variable "vpc_id" {
  description = "ID of the existing VPC"
  type        = string
}

variable "private_subnet_ids" {
  description = "List of private subnet IDs for Fargate tasks"
  type        = list(string)
}

variable "execution_role_arn" {
  description = "ARN of the existing ECS task execution role with Secrets Manager permissions"
  type        = string
}

What does this code do?

These five variables connect your new module to infrastructure that already exists in your AWS account. The aws_region and app_name variables have defaults so you can override them only when needed.

The vpc_id, private_subnet_ids, and execution_role_arn variables have no defaults because they are unique to your account. Terraform will require you to supply them at apply time.

  • Below the existing variables in the same file, paste the sensitive credentials variable:
variable "api_credentials" {
  description = "API credentials to store in Secrets Manager"
  type = object({
    api_key    = string
    api_secret = string
    endpoint   = string
  })
  sensitive = true
}

Why is this variable marked sensitive?

Setting sensitive = true tells Terraform to redact this variable's value from all plan and apply output. Without it, your API keys would appear in plaintext every time you run terraform plan.

The object type enforces a specific shape. Terraform will reject any input missing api_key, api_secret, or endpoint.

  • Save variables.tf.

Seeing a type error?

Make sure object({ uses a curly brace, not a square bracket. Each key inside the object block must be followed by = string to declare its type.

help me fix my variables.tf type definitions

✔️ Awesome, I've got everything!

Great. Make sure you have saved variables.tf before moving on.

ⓧ I'd like to double check the full code

variable "aws_region" {
  description = "AWS region for all resources"
  type        = string
  default     = "us-east-1"
}

variable "app_name" {
  description = "Application name used as prefix for resource naming"
  type        = string
  default     = "myapp"
}

variable "vpc_id" {
  description = "ID of the existing VPC"
  type        = string
}

variable "private_subnet_ids" {
  description = "List of private subnet IDs for Fargate tasks"
  type        = list(string)
}

variable "execution_role_arn" {
  description = "ARN of the existing ECS task execution role with Secrets Manager permissions"
  type        = string
}

variable "api_credentials" {
  description = "API credentials to store in Secrets Manager"
  type = object({
    api_key    = string
    api_secret = string
    endpoint   = string
  })
  sensitive = true
}

Finally, create an example file that shows the expected variable format. This serves as a template for anyone using this module.

  • Create the example file by running:
touch terraform.tfvars.example
  • Open terraform.tfvars.example and paste the following template:
aws_region         = "us-east-1"
app_name           = "myapp"
vpc_id             = "vpc-xxxxxxxxxxxxxxxxx"
private_subnet_ids = ["subnet-xxxxxxxxxxxxxxxxx", "subnet-yyyyyyyyyyyyyyyyy"]
execution_role_arn = "arn:aws:iam::123456789012:role/myapp-ecs-execution-role"

api_credentials = {
  api_key    = "your-api-key-here"
  api_secret = "your-api-secret-here"
  endpoint   = "https://api.example.com/v1"
}

What is this file for?

The .tfvars.example file documents the expected inputs without containing real secrets. You commit this to version control as a reference for your team.

When you are ready to apply, copy it to terraform.tfvars and replace the placeholder values with your actual VPC ID, subnet IDs, execution role ARN, and credentials. Never commit the real terraform.tfvars to source control.

  • Save terraform.tfvars.example.

✔️ Awesome, I've got everything!

Great. Make sure you have saved all three files before continuing.

ⓧ I'd like to double check the full code

aws_region         = "us-east-1"
app_name           = "myapp"
vpc_id             = "vpc-xxxxxxxxxxxxxxxxx"
private_subnet_ids = ["subnet-xxxxxxxxxxxxxxxxx", "subnet-yyyyyyyyyyyyyyyyy"]
execution_role_arn = "arn:aws:iam::123456789012:role/myapp-ecs-execution-role"

api_credentials = {
  api_key    = "your-api-key-here"
  api_secret = "your-api-secret-here"
  endpoint   = "https://api.example.com/v1"
}
  • Initialize Terraform to download the AWS provider by running:
terraform init

You should see output confirming that the hashicorp/aws provider was successfully downloaded and installed. Look for a message indicating initialization was successful.

Terraform init failing?

If you see a provider download error, check your internet connection. If Terraform reports a version constraint issue, confirm that versions.tf has version = "~> 6.0" with the tilde-arrow operator.

help me troubleshoot terraform init errors

Your project foundation is solid. You have a pinned provider, well-typed variables, and a working initialization. Next up, you will create your first secret in AWS Secrets Manager using production best practices.

Create Secrets in Secrets Manager

Your Terraform project is initialized and your provider is ready. Now it's time to store your first secret.

Hardcoding credentials in application code or container images is one of the most common security mistakes in cloud deployments. AWS Secrets Manager solves this by storing secrets centrally and letting services retrieve them at runtime. In this step, you'll create a secret using Terraform with production best practices baked in from the start.

In this step, get ready to:
  • Create a secret with path-based naming, recovery window, and resource tags.
  • Store structured JSON credentials as a secret version using a sensitive variable.
  • Verify the secret exists in AWS without retrieving its value.
Create the secret resource with path-based naming

Path-based naming means your secret name includes a prefix (like myapp/) that matches your IAM policy prefix. This lets your IAM policy grant access to all secrets under that path without listing each one individually.

  • Create a new file called secrets.tf in your project directory.
  • Add the secret resource by pasting this code into secrets.tf:
resource "aws_secretsmanager_secret" "api_credentials" {
  name                    = "${var.app_name}/api-credentials"
  description             = "API credentials for ${var.app_name} ECS tasks"
  recovery_window_in_days = 7

  tags = {
    Application = var.app_name
    ManagedBy   = "terraform"
    Environment = "dev"
  }
}

What does this code do?

  • name uses the path format myapp/api-credentials so it matches your existing IAM policy prefix. Any role scoped to myapp/* can read this secret.
  • recovery_window_in_days = 7 means if you delete this secret, AWS keeps it recoverable for 7 days before permanent deletion. The default is 30 days. Seven is the shortest window that still gives you a safety net.
  • tags add metadata for cost tracking and ownership. In production, these help teams identify who created a resource and how it's managed.
  • Save secrets.tf.

You won't see a change yet. The next chunk adds the actual secret value, and then you'll apply both together.

Store structured JSON credentials as a secret version

A single Secrets Manager secret can hold multiple values when you store them as structured JSON. This avoids creating one secret per value. It also enables ECS to select individual keys from the secret at task launch time.

  • Add the secret version resource below the existing code in secrets.tf:
resource "aws_secretsmanager_secret_version" "api_credentials" {
  secret_id = aws_secretsmanager_secret.api_credentials.id
  secret_string = jsonencode({
    api_key    = var.api_credentials.api_key
    api_secret = var.api_credentials.api_secret
    endpoint   = var.api_credentials.endpoint
  })
}

What does this code do?

  • secret_id links this version to the secret you just defined above.
  • jsonencode() converts the object into a JSON string. Later, ECS can extract individual keys from this JSON using the arn:json-key:: selector syntax.
  • The values come from var.api_credentials, which you marked as sensitive = true in variables.tf. This prevents the actual credentials from ever appearing in terraform plan output.

Why use a sensitive variable instead of hardcoding?

When a variable is marked sensitive, Terraform redacts its value from all plan and apply output. Anyone watching your terminal or reading CI logs sees (sensitive value) instead of the actual credential.

Note that the value still gets stored in Terraform state. For production, you'd encrypt your state file or use a backend with encryption at rest (like S3 with server-side encryption).

  • Save secrets.tf.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here's the complete secrets.tf file for comparison:

resource "aws_secretsmanager_secret" "api_credentials" {
  name                    = "${var.app_name}/api-credentials"
  description             = "API credentials for ${var.app_name} ECS tasks"
  recovery_window_in_days = 7

  tags = {
    Application = var.app_name
    ManagedBy   = "terraform"
    Environment = "dev"
  }
}

resource "aws_secretsmanager_secret_version" "api_credentials" {
  secret_id = aws_secretsmanager_secret.api_credentials.id
  secret_string = jsonencode({
    api_key    = var.api_credentials.api_key
    api_secret = var.api_credentials.api_secret
    endpoint   = var.api_credentials.endpoint
  })
}
Apply and verify the secret in AWS

Now you'll deploy the secret to AWS and verify it was created correctly. The verification uses describe-secret (which returns metadata) instead of get-secret-value (which returns the actual credential). Avoiding unnecessary secret retrievals is a production best practice.

  • Apply your Terraform configuration by running:
terraform apply
  • Terraform will show you a plan of what it intends to create. Review the plan and type yes when prompted to confirm.

What should I see in the plan output?

You should see two resources being created: the secret and the secret version. Importantly, you should see (sensitive value) wherever the credentials would appear. This confirms your sensitive variable is working correctly.

  • After the apply completes successfully, verify the secret metadata by running:
aws secretsmanager describe-secret --secret-id myapp/api-credentials
  • Confirm the output shows the correct name (myapp/api-credentials), your tags (Application, ManagedBy, Environment), and a recovery window of 7 days.

Why describe-secret instead of get-secret-value?

The describe-secret command returns metadata only (name, ARN, tags, rotation config). It never exposes the actual secret value.

In production, every call to get-secret-value is logged in CloudTrail. Minimizing unnecessary retrievals reduces your audit surface and follows the principle of least privilege.

Seeing an access denied error?

  • Make sure your AWS CLI credentials have permissions to create Secrets Manager resources. Your IAM user or role needs secretsmanager:CreateSecret and secretsmanager:DescribeSecret permissions.
  • If you changed the app_name variable from the default myapp, replace myapp/api-credentials in the CLI command with your custom prefix.
  • If a secret with the same name already exists from a previous attempt, it might be in a pending deletion state. Wait for the recovery window to pass or use a different name.

Help me troubleshoot my Secrets Manager access denied error

Your secret is live in AWS Secrets Manager with proper naming, tagging, and a recovery safety net. Next, you'll build an ECS Fargate cluster with a task definition that pulls these credentials at launch time.

Build ECS Cluster and Task Definition

Your secrets are stored safely in AWS Secrets Manager. Now you need compute infrastructure that can actually consume them at runtime.

In this step, you will create an ECS Fargate cluster and a task definition that pulls secret values directly from Secrets Manager into the container as environment variables. The container never sees the secret ARN or touches the Secrets Manager API. ECS handles that through the execution role you already have.

In this step, get ready to:
  • Create an ECS cluster with Container Insights enabled.
  • Define a task definition that injects secrets as environment variables using the native ECS secrets mechanism.
  • Add a security group for your Fargate tasks with egress-only rules.
Create the ECS cluster with Container Insights

Container Insights gives you CPU, memory, and network metrics for every task in the cluster. Enabling it from day one means you get observability without extra setup later.

  • Create a new file called ecs.tf in your project directory.
  • Add the ECS cluster and capacity provider resources by pasting this code:
resource "aws_ecs_cluster" "main" {
  name = "${var.app_name}-cluster"

  setting {
    name  = "containerInsights"
    value = "enabled"
  }

  tags = {
    Application = var.app_name
    ManagedBy   = "terraform"
  }
}

resource "aws_ecs_cluster_capacity_providers" "main" {
  cluster_name = aws_ecs_cluster.main.name

  capacity_providers = ["FARGATE"]

  default_capacity_provider_strategy {
    capacity_provider = "FARGATE"
    weight            = 1
  }
}

What does this code do?

The aws_ecs_cluster resource creates a named cluster with Container Insights turned on via the setting block.

The aws_ecs_cluster_capacity_providers resource associates the FARGATE capacity provider with this cluster and makes it the default launch strategy. This means any service you create on this cluster uses Fargate automatically.

Why Fargate instead of EC2 instances?

Fargate is serverless compute for containers. You do not manage EC2 instances, patch operating systems, or handle scaling of the underlying hosts.

For a secrets validation workflow where you run a single task briefly, Fargate's per-second billing is far more efficient than keeping an EC2 instance running.

Define the task definition with secrets injection

The task definition tells ECS exactly how to run your container. The secrets array maps JSON keys from your Secrets Manager secret into container environment variables at launch time.

  • Add the task definition resource below the capacity providers block in ecs.tf:
resource "aws_ecs_task_definition" "app" {
  family                   = "${var.app_name}-task"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = "256"
  memory                   = "512"
  execution_role_arn       = var.execution_role_arn
  task_role_arn            = var.execution_role_arn

  container_definitions = jsonencode([
    {
      name      = "${var.app_name}-container"
      image     = "public.ecr.aws/amazonlinux/amazonlinux:2023"
      essential = true
      command   = ["sleep", "3600"]

      secrets = [
        {
          name      = "API_KEY"
          valueFrom = "${aws_secretsmanager_secret.api_credentials.arn}:api_key::"
        },
        {
          name      = "API_SECRET"
          valueFrom = "${aws_secretsmanager_secret.api_credentials.arn}:api_secret::"
        },
        {
          name      = "API_ENDPOINT"
          valueFrom = "${aws_secretsmanager_secret.api_credentials.arn}:endpoint::"
        }
      ]

      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = "/ecs/${var.app_name}"
          "awslogs-region"        = var.aws_region
          "awslogs-stream-prefix" = "ecs"
          "awslogs-create-group"  = "true"
        }
      }

      linuxParameters = {
        initProcessEnabled = true
      }
    }
  ])

  tags = {
    Application = var.app_name
    ManagedBy   = "terraform"
  }
}

What does this code do?

The family field groups revisions of this task definition together under one name.

The secrets array uses the valueFrom format arn:json-key:: to extract individual keys from your structured JSON secret. The two trailing colons mean "use the current version" and "use the current stage" (AWSCURRENT).

The linuxParameters.initProcessEnabled setting is required for ECS Exec, which you will use later to shell into the container and verify secrets injection.

Why use the execution role as the task role too?

Your existing execution role already has the permissions needed for ECS Exec (SSM Messages). In production, you would split the execution role (pulls images, reads secrets) from the task role (your application's permissions). For this project, reusing the same role keeps the configuration simple.

Create the security group for Fargate tasks

Fargate tasks run inside your VPC and need a security group. This task only needs outbound access to reach Secrets Manager and CloudWatch Logs.

  • Add the security group resource at the bottom of ecs.tf:
resource "aws_security_group" "ecs_tasks" {
  name        = "${var.app_name}-ecs-tasks-sg"
  description = "Security group for ${var.app_name} ECS Fargate tasks"
  vpc_id      = var.vpc_id

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow all outbound traffic"
  }

  tags = {
    Application = var.app_name
    ManagedBy   = "terraform"
  }
}

What does this code do?

This security group allows all outbound traffic (protocol -1 means all protocols) but defines no ingress rules. Your Fargate task can reach AWS services through your NAT gateway but nothing can initiate connections to the task.

  • Save ecs.tf.
  • Apply the configuration by running:
terraform apply
  • Type yes when prompted to confirm the changes.
  • Terraform will create the ECS cluster, task definition, and security group. You should see output showing 3 resources added.
  • Verify the task definition has correct secrets mapping by running:
aws ecs describe-task-definition --task-definition myapp-task
  • In the output, look for the secrets array inside containerDefinitions. You should see three entries mapping API_KEY, API_SECRET, and API_ENDPOINT to your secret ARN with JSON key selectors.

Seeing an error during apply?

If you see an error about the execution role ARN, confirm your terraform.tfvars has the correct ARN for your existing execution role.

If you see a jsonencode error, check for missing commas or mismatched braces in the container_definitions block.

help me debug my terraform apply error for the ECS task definition

✔️ Awesome, I've got everything!

Great. Double check you have saved ecs.tf and your terraform apply completed successfully.

ⓧ I'd like to double check the full code

Here is the complete ecs.tf file. Compare your file against this:

resource "aws_ecs_cluster" "main" {
  name = "${var.app_name}-cluster"

  setting {
    name  = "containerInsights"
    value = "enabled"
  }

  tags = {
    Application = var.app_name
    ManagedBy   = "terraform"
  }
}

resource "aws_ecs_cluster_capacity_providers" "main" {
  cluster_name = aws_ecs_cluster.main.name

  capacity_providers = ["FARGATE"]

  default_capacity_provider_strategy {
    capacity_provider = "FARGATE"
    weight            = 1
  }
}

resource "aws_ecs_task_definition" "app" {
  family                   = "${var.app_name}-task"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = "256"
  memory                   = "512"
  execution_role_arn       = var.execution_role_arn
  task_role_arn            = var.execution_role_arn

  container_definitions = jsonencode([
    {
      name      = "${var.app_name}-container"
      image     = "public.ecr.aws/amazonlinux/amazonlinux:2023"
      essential = true
      command   = ["sleep", "3600"]

      secrets = [
        {
          name      = "API_KEY"
          valueFrom = "${aws_secretsmanager_secret.api_credentials.arn}:api_key::"
        },
        {
          name      = "API_SECRET"
          valueFrom = "${aws_secretsmanager_secret.api_credentials.arn}:api_secret::"
        },
        {
          name      = "API_ENDPOINT"
          valueFrom = "${aws_secretsmanager_secret.api_credentials.arn}:endpoint::"
        }
      ]

      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = "/ecs/${var.app_name}"
          "awslogs-region"        = var.aws_region
          "awslogs-stream-prefix" = "ecs"
          "awslogs-create-group"  = "true"
        }
      }

      linuxParameters = {
        initProcessEnabled = true
      }
    }
  ])

  tags = {
    Application = var.app_name
    ManagedBy   = "terraform"
  }
}

resource "aws_security_group" "ecs_tasks" {
  name        = "${var.app_name}-ecs-tasks-sg"
  description = "Security group for ${var.app_name} ECS Fargate tasks"
  vpc_id      = var.vpc_id

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow all outbound traffic"
  }

  tags = {
    Application = var.app_name
    ManagedBy   = "terraform"
  }
}

Your ECS cluster and task definition are ready. Next up, you will deploy this task and verify that secrets appear as environment variables inside the running container.

Deploy and Validate Secrets Injection

In the previous step, you defined the ECS cluster, task definition, and security group. Your task definition knows which secrets to inject, but no container is actually running yet.

In this step, you will deploy a live Fargate task and prove that the secrets flow from Secrets Manager into the container as environment variables. You will also validate the IAM boundary to confirm your prefix-scoped policy restricts access correctly.

In this step, get ready to:
  • Deploy an ECS service that launches a Fargate task in your private subnets.
  • Shell into the running container to verify injected secret values.
  • Validate the IAM permission boundary by testing access to an out-of-prefix secret.
Create the ECS service and outputs

An ECS service manages the lifecycle of your Fargate tasks. It ensures at least one task is always running and enables ECS Exec so you can shell into the container for validation.

  • Add the ECS service resource to the bottom of ecs.tf by pasting the following code below the aws_security_group resource:
resource "aws_ecs_service" "app" {
  name            = "${var.app_name}-service"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = 1
  launch_type     = "FARGATE"

  # Enable ECS Exec so we can shell into running containers
  enable_execute_command = true

  # Place tasks in private subnets with the egress-only security group
  network_configuration {
    subnets         = var.private_subnet_ids
    security_groups = [aws_security_group.ecs_tasks.id]
  }

  tags = {
    Application = var.app_name
    ManagedBy   = "terraform"
  }
}

What does this code do?

The aws_ecs_service resource tells ECS to keep exactly one Fargate task running at all times.

  • enable_execute_command = true activates ECS Exec, which lets you open an interactive shell inside the container via the AWS CLI.
  • The network_configuration block places tasks in your private subnets. Outbound traffic goes through your NAT gateway so the task can pull the container image and reach Secrets Manager.
  • Now create a new file called outputs.tf in the same project directory and paste this code:
output "secret_arn" {
  description = "ARN of the Secrets Manager secret"
  value       = aws_secretsmanager_secret.api_credentials.arn
}

output "ecs_cluster_name" {
  description = "Name of the ECS cluster"
  value       = aws_ecs_cluster.main.name
}

output "ecs_service_name" {
  description = "Name of the ECS service"
  value       = aws_ecs_service.app.name
}

output "task_definition_arn" {
  description = "ARN of the task definition"
  value       = aws_ecs_task_definition.app.arn
}

Why create outputs?

Terraform outputs surface key resource identifiers after terraform apply. You will use the cluster name and service name in the next substeps to list tasks and run ECS Exec commands without hunting through the AWS console.

  • Save both ecs.tf and outputs.tf.
  • Deploy the service by running:
terraform apply
  • Type yes when prompted.
  • You should see the aws_ecs_service.app resource created, followed by the outputs listing your cluster name, service name, secret ARN, and task definition ARN.

Seeing an error about the service?

  • If you see a subnet or VPC error, confirm the private_subnet_ids in your terraform.tfvars reference subnets in the same VPC as vpc_id.
  • If the task fails to start (stays in PROVISIONING), check that your private subnets have a route to a NAT gateway. The container needs outbound internet access to pull the image from ECR Public and to reach the Secrets Manager endpoint.

help me troubleshoot my ECS Fargate service deployment failing with Terraform

Verify secrets injection via ECS Exec

The task should be running within a minute or two of terraform apply completing. You will now shell into it to confirm secrets are available as environment variables.

  • List the running task to get its ARN by running:
aws ecs list-tasks --cluster myapp-cluster --service-name myapp-service

What should I see?

You should see a JSON response with a taskArns array containing one ARN. Copy that full ARN for the next command.

  • Open an interactive shell in the container by running:
aws ecs execute-command \
    --cluster myapp-cluster \
    --task [[TASK_ARN="your task ARN from the previous command"]] \
    --container myapp-container \
    --interactive \
    --command "/bin/sh"

What does this command do?

This command uses ECS Exec to start an interactive /bin/sh session inside your running container. The --interactive flag is required because ECS only supports interactive sessions.

If the connection is successful, you will see a shell prompt. You can now run commands directly in the container environment.

  • Once you see the shell prompt, verify the injected secrets by running these commands inside the container:
echo $API_KEY
echo $API_SECRET
echo $API_ENDPOINT
  • You should see the exact values you set in your terraform.tfvars file printed to the terminal. These values were injected by ECS at task launch time from Secrets Manager.

Why is this powerful?

Your application code never imported a Secrets Manager SDK. The container has no idea where these values came from. ECS resolved the secrets at launch time via the execution role, so your code simply reads standard environment variables.

Not seeing your secret values?

  • If you see empty output, verify the task's execution role has secretsmanager:GetSecretValue permission for the myapp/ prefix.
  • If you get a TargetNotConnectedException error, the task might not be fully running yet. Wait 30 seconds and try the execute-command again.
  • Confirm that initProcessEnabled is set to true in your container definition. ECS Exec requires an init process.

help me debug ECS Exec not connecting to my Fargate container

Validate the IAM permission boundary

Your existing IAM execution role restricts Secrets Manager access to the myapp/ prefix. This final test proves that the boundary works by attempting to read a secret outside that prefix from inside the container.

  • While still inside the container shell, attempt to read a secret outside your prefix by running:
aws secretsmanager get-secret-value --secret-id other-prefix/test --region us-east-1
  • You should see an AccessDeniedException error. This confirms your IAM policy correctly denies access to secrets outside the myapp/ prefix.

Why does this test matter?

In production, multiple services share the same AWS account. Each service should only access its own secrets. The AccessDeniedException proves that even if an attacker compromises your container, they cannot pivot to read secrets belonging to other services.

  • Exit the container shell by typing:
exit

Don't see an AccessDeniedException?

  • If you see a ResourceNotFoundException instead, that means the secret other-prefix/test does not exist. The IAM boundary is still valid, but the error was thrown before the permission check. Try reading a secret that does exist under a different prefix to get a clear AccessDeniedException.
  • If you get the secret value back successfully, your task role's IAM policy is too broad. Review the policy to ensure it restricts secretsmanager:GetSecretValue to resources matching arn:aws:secretsmanager:*:*:secret:myapp/*.

help me debug why my ECS task can access secrets outside its IAM prefix scope

✔️ Awesome, I've got everything!

Great. Double check you have saved both ecs.tf and outputs.tf.

ⓧ I'd like to double check the full code

Here is the complete ecs.tf at this point:

resource "aws_ecs_cluster" "main" {
  name = "${var.app_name}-cluster"

  setting {
    name  = "containerInsights"
    value = "enabled"
  }

  tags = {
    Application = var.app_name
    ManagedBy   = "terraform"
  }
}

resource "aws_ecs_cluster_capacity_providers" "main" {
  cluster_name = aws_ecs_cluster.main.name

  capacity_providers = ["FARGATE"]

  default_capacity_provider_strategy {
    capacity_provider = "FARGATE"
    weight            = 1
  }
}

resource "aws_ecs_task_definition" "app" {
  family                   = "${var.app_name}-task"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = "256"
  memory                   = "512"
  execution_role_arn       = var.execution_role_arn
  task_role_arn            = var.execution_role_arn

  container_definitions = jsonencode([
    {
      name      = "${var.app_name}-container"
      image     = "public.ecr.aws/amazonlinux/amazonlinux:2023"
      essential = true
      command   = ["sleep", "3600"]

      secrets = [
        {
          name      = "API_KEY"
          valueFrom = "${aws_secretsmanager_secret.api_credentials.arn}:api_key::"
        },
        {
          name      = "API_SECRET"
          valueFrom = "${aws_secretsmanager_secret.api_credentials.arn}:api_secret::"
        },
        {
          name      = "API_ENDPOINT"
          valueFrom = "${aws_secretsmanager_secret.api_credentials.arn}:endpoint::"
        }
      ]

      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = "/ecs/${var.app_name}"
          "awslogs-region"        = var.aws_region
          "awslogs-stream-prefix" = "ecs"
          "awslogs-create-group"  = "true"
        }
      }

      linuxParameters = {
        initProcessEnabled = true
      }
    }
  ])

  tags = {
    Application = var.app_name
    ManagedBy   = "terraform"
  }
}

resource "aws_security_group" "ecs_tasks" {
  name        = "${var.app_name}-ecs-tasks-sg"
  description = "Security group for ${var.app_name} ECS Fargate tasks"
  vpc_id      = var.vpc_id

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow all outbound traffic"
  }

  tags = {
    Application = var.app_name
    ManagedBy   = "terraform"
  }
}

resource "aws_ecs_service" "app" {
  name            = "${var.app_name}-service"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = 1
  launch_type     = "FARGATE"

  enable_execute_command = true

  network_configuration {
    subnets         = var.private_subnet_ids
    security_groups = [aws_security_group.ecs_tasks.id]
  }

  tags = {
    Application = var.app_name
    ManagedBy   = "terraform"
  }
}

And here is the complete outputs.tf:

output "secret_arn" {
  description = "ARN of the Secrets Manager secret"
  value       = aws_secretsmanager_secret.api_credentials.arn
}

output "ecs_cluster_name" {
  description = "Name of the ECS cluster"
  value       = aws_ecs_cluster.main.name
}

output "ecs_service_name" {
  description = "Name of the ECS service"
  value       = aws_ecs_service.app.name
}

output "task_definition_arn" {
  description = "ARN of the task definition"
  value       = aws_ecs_task_definition.app.arn
}

You have now proven the full secrets pipeline end-to-end. Terraform provisions secrets, ECS injects them at runtime, and IAM enforces access boundaries. This is the exact pattern used in production AWS deployments. Next up, try the Secret Mission to add automatic rotation to this setup.

Secret mission

Configure Automatic Secret Rotation

Your secrets are stored securely and injected into containers at runtime. But credentials that never change are a ticking time bomb. In this mission, you'll configure automatic rotation using a Lambda function and the Secrets Manager rotation lifecycle.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Cost warning

This project uses AWS resources that incur ongoing charges. Secrets Manager costs $0.40 per secret per month (prorated). ECS Fargate charges per-second while tasks are running.

If you are not actively building on top of this infrastructure, choose Pause or Delete to avoid unexpected charges.

Resources you used:

  • Secrets Manager secret (myapp/api-credentials).
  • ECS cluster, service, task definition, and security group.
  • CloudWatch log group (/ecs/myapp).
  • Lambda rotation function and IAM role (Secret Mission only).
  • Local Terraform project files on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building on top of your secrets infrastructure or plan to add more services to your ECS cluster.

  • Your ECS Fargate task continues running and billing per-second.
  • Your Secrets Manager secret costs $0.40/month (prorated daily).
  • CloudWatch Container Insights incurs minimal log ingestion charges.
  • The Lambda rotation function (Secret Mission) is covered by the AWS free tier for invocations.

Pause - I'll come back to this later

Stop the ECS service to halt Fargate charges while keeping your infrastructure definition intact. Your secret still incurs its monthly cost.

  • Open ecs.tf and change desired_count = 1 to desired_count = 0 on the aws_ecs_service resource.
  • Run the following command to apply the change:
terraform apply
  • This stops all running Fargate tasks, eliminating per-second compute charges.
  • Your Secrets Manager secret ($0.40/month) and CloudWatch logs remain.
  • To resume later, change desired_count back to 1 and run terraform apply again.

Delete - I don't want to use this again

Remove all AWS resources and local project files to start completely fresh.

  • Destroy all Terraform-managed AWS resources by running:
terraform destroy
  • Type yes when prompted to confirm.
  • This removes your ECS cluster, service, task definition, security group, Lambda function, IAM roles, and the Secrets Manager secret.

What about the recovery window?

Your secret enters a 7-day recovery window before permanent deletion (the recovery_window_in_days = 7 you configured). During this window the secret is inactive but not yet gone.

If you want immediate deletion, change recovery_window_in_days to 0 in secrets.tf before running terraform destroy.

  • Delete your local project files by running:
cd ~/Desktop
rm -rf ecs-secrets-terraform

On Windows, run:

cd ~\Desktop
rmdir /s /q ecs-secrets-terraform
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop.

Nice Work!

Nice Work!

Nice work! You just built a production-grade secrets pipeline using Terraform and AWS Secrets Manager, with secrets flowing securely into running ECS Fargate containers.

You've learned how to:

  • Created secrets in AWS Secrets Manager using Terraform with production best practices: path-based naming, structured JSON, tagging, recovery windows, and sensitive variable handling.
  • Built an ECS Fargate cluster and task definition that consumes secrets via native injection with no hardcoded values in code or Terraform state.
  • Validated end-to-end that your prefix-scoped IAM policy correctly restricts access, and verified secrets appear as environment variables inside running containers using ECS Exec.
  • Secret Mission: Configured automatic rotation with a Lambda function and verified the rotation lifecycle.

Ready to quiz yourself?