How to Set Up Fargate Execution Roles and IAM Policies in Terraform

Terraform needs AWS permissions to run, and many services that Terraform creates, such as ECS, Lambda and EC2, need IAM roles of their own. Most IAM errors come from the same few mistakes: the wrong role for the job, a permissions policy pasted where a trust policy belongs, or a policy that is much wider than it needs to be. ECS on Fargate is a good example, because a task uses two roles that look alike and do different things.
This post starts with the Fargate execution role and task role, then answers the general IAM questions that come with them: how to attach several policies to one role, how to fix MalformedPolicyDocument: Has prohibited field Resource, and how to find the minimum permissions for the role that runs Terraform.
TLDR
| Question | Short answer |
|---|---|
| What is the execution role for? | ECS uses it to start the task: pull the image, write logs, read secrets for the container |
| What is the task role for? | Your application code uses it to call AWS APIs |
| How do I attach several policies to one role? | One aws_iam_role_policy_attachment per policy, usually with for_each |
| "Has prohibited field Resource"? | A trust policy (assume_role_policy) must not contain Resource. Move the permissions to a separate policy |
| Minimum permissions for Terraform? | Start in a sandbox, generate a policy from CloudTrail with IAM Access Analyzer, and give plans a read-only role |
Prerequisites
- Terraform 1.5 or later and the AWS provider 6.x.
- An AWS account where you can create IAM roles. Test IAM changes in a sandbox account first.
How Do I Configure an ECS Fargate Task Execution Role With Terraform?
Create a role that ecs-tasks.amazonaws.com can assume, attach the AWS managed policy AmazonECSTaskExecutionRolePolicy, and set the role's ARN as execution_role_arn in the task definition. Create a second role for your application and set it as task_role_arn.
Task start (ECS agent, execution role) Task running (your code, task role)
pull the image from ECR read and write S3 objects
create log streams, send logs query DynamoDB
read secrets for the container's env call any other AWS API
The Execution Role
data "aws_caller_identity" "current" {}
data "aws_iam_policy_document" "ecs_tasks_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
resource "aws_iam_role" "ecs_execution" {
name = "app-ecs-execution"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
}
resource "aws_iam_role_policy_attachment" "ecs_execution" {
role = aws_iam_role.ecs_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
AmazonECSTaskExecutionRolePolicy allows four ECR actions (GetAuthorizationToken, BatchCheckLayerAvailability, GetDownloadUrlForLayer, BatchGetImage) and two CloudWatch Logs actions (CreateLogStream, PutLogEvents). It does not allow reading secrets, and it does not allow logs:CreateLogGroup, so create the log group with Terraform. For the task role, the ECS documentation also shows aws:SourceAccount and aws:SourceArn conditions in the trust policy, which stop other accounts' tasks from using the role.
The Task Role and the Task Definition
resource "aws_iam_role" "ecs_task" {
name = "app-ecs-task"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
}
data "aws_iam_policy_document" "app" {
statement {
actions = ["s3:GetObject", "s3:PutObject"]
resources = ["${aws_s3_bucket.app_data.arn}/*"]
}
}
resource "aws_iam_role_policy" "app" {
name = "app-s3"
role = aws_iam_role.ecs_task.id
policy = data.aws_iam_policy_document.app.json
}
resource "aws_cloudwatch_log_group" "app" {
name = "/ecs/app"
retention_in_days = 30
}
resource "aws_ecs_task_definition" "app" {
family = "app"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.ecs_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn
container_definitions = jsonencode([{
name = "app"
image = "${aws_ecr_repository.app.repository_url}:${var.image_tag}"
essential = true
portMappings = [{ containerPort = 8080, protocol = "tcp" }]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.app.name
"awslogs-region" = var.region
"awslogs-stream-prefix" = "app"
}
}
}])
}
The application never sees the execution role. If your code gets AccessDenied from S3, change the task role. If the task does not start, look at the execution role.
Secrets From Secrets Manager or Parameter Store
The container definition's secrets list makes ECS read a value at task start and pass it as an environment variable. ECS reads it with the execution role, so that role needs access:
data "aws_iam_policy_document" "execution_secrets" {
statement {
actions = ["secretsmanager:GetSecretValue"]
resources = [aws_secretsmanager_secret.db.arn]
}
statement {
actions = ["ssm:GetParameters"]
resources = ["arn:aws:ssm:${var.region}:${data.aws_caller_identity.current.account_id}:parameter/app/*"]
}
}
resource "aws_iam_role_policy" "execution_secrets" {
name = "read-app-secrets"
role = aws_iam_role.ecs_execution.id
policy = data.aws_iam_policy_document.execution_secrets.json
}
# in the container definition
secrets = [
{ name = "DB_PASSWORD", valueFrom = "${aws_secretsmanager_secret.db.arn}:password::" },
{ name = "REDIS_URL", valueFrom = "arn:aws:ssm:${var.region}:${data.aws_caller_identity.current.account_id}:parameter/app/redis-url" },
]
The :password:: suffix picks the password key from a JSON secret. Leave it out to pass the whole secret string. The execution role also needs kms:Decrypt on the key, but only when the secret or a SecureString parameter uses a customer managed KMS key. The default AWS managed keys need no extra statement.
Images From ECR in Another Account
Two policies must agree. The repository policy in the account that owns the images allows your execution role:
# in the account that owns the repository
data "aws_iam_policy_document" "cross_account_pull" {
statement {
actions = [
"ecr:BatchCheckLayerAvailability",
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer",
]
principals {
type = "AWS"
identifiers = ["arn:aws:iam::${var.ecs_account_id}:role/app-ecs-execution"]
}
}
}
resource "aws_ecr_repository_policy" "cross_account_pull" {
repository = aws_ecr_repository.app.name
policy = data.aws_iam_policy_document.cross_account_pull.json
}
In the ECS account, the managed policy already allows those actions on *, so the execution role needs nothing more. If the repository uses a customer managed KMS key, the key policy must also allow the role.
Errors When a Task Does Not Start
The task's Stopped reason in the ECS console, or aws ecs describe-tasks, shows the error:
| Message starts with | Usual cause |
|---|---|
ResourceInitializationError: unable to pull secrets or registry auth |
Execution role cannot read a secret or parameter, a KMS key denies it, or the task has no network path to the service (private subnet without NAT or VPC endpoints) |
CannotPullContainerError |
Wrong image tag, missing ECR permissions, or no network path to ECR |
ResourceInitializationError: failed to validate logger args |
The log group does not exist, or the role cannot write to it |
If you set awslogs-create-group = "true" in the log options instead of creating the group with Terraform, add logs:CreateLogGroup to the execution role.
How Do I Attach Multiple IAM Policies to an IAM Role?
Use one aws_iam_role_policy_attachment per policy. With for_each over the policy ARNs, adding or removing a policy changes only that attachment:
locals {
ci_role_policies = {
ecr_read = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
ssm_read = "arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess"
deploy = aws_iam_policy.deploy.arn
}
}
resource "aws_iam_role_policy_attachment" "ci" {
for_each = local.ci_role_policies
role = aws_iam_role.ci.name
policy_arn = each.value
}
Use static keys such as ecr_read, not the ARNs themselves. The ARN of a policy that Terraform creates in the same run is unknown at plan time, and for_each keys must be known.
Rules that prevent surprises:
- Do not use
aws_iam_policy_attachment(without "role" in the name) for this. It manages every attachment of a policy exclusively and removes the policy from users, groups and roles that Terraform does not know about. - Do not combine the
managed_policy_arnsorinline_policyarguments ofaws_iam_rolewith attachment resources. Both arguments are deprecated, and the two ways fight each other on every apply. - Use
aws_iam_role_policyfor inline policies that belong to one role only, such as the S3 policy above. - Use
aws_iam_role_policy_attachments_exclusiveif Terraform should also remove policies that someone attached by hand. - A role can have 10 managed policies by default. The quota can be raised to 20. If you need more, combine related statements into fewer customer managed policies.
How Do I Fix "MalformedPolicyDocument: Has prohibited field Resource"?
The error comes from CreateRole or UpdateAssumeRolePolicy when the trust policy of a role has a Resource element. A trust policy (assume_role_policy in Terraform) says who may assume the role. It has a Principal and no Resource. The permissions that the role grants go in a separate policy.
This is the mistake, a permissions statement inside the trust policy:
resource "aws_iam_role" "app" {
name = "app"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["sts:AssumeRole", "s3:GetObject"]
Principal = { Service = "ec2.amazonaws.com" }
Resource = "arn:aws:s3:::app-data/*" # not allowed in a trust policy
}]
})
}
Split it into a trust policy and a permissions policy:
resource "aws_iam_role" "app" {
name = "app"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "ec2.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy" "app_s3" {
name = "read-app-data"
role = aws_iam_role.app.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "s3:GetObject"
Resource = "arn:aws:s3:::app-data/*"
}]
})
}
The opposite mistake, a Principal inside a permissions policy (aws_iam_policy or aws_iam_role_policy), fails with MalformedPolicyDocument: Policy document should not specify a principal. The rule for both: identity policies say what, trust and resource policies say who.
terraform validate cannot find these errors, because it checks HCL and does not call AWS. To check a policy before the apply, save the JSON to a file such as trust.json and run IAM Access Analyzer on it:
aws accessanalyzer validate-policy \
--policy-type RESOURCE_POLICY \
--validate-policy-resource-type AWS::IAM::AssumeRolePolicyDocument \
--policy-document file://trust.json
The aws_iam_policy_document data source also helps: it has separate principals and resources blocks, so the structure is harder to get wrong than hand-written JSON.
What Are the Minimum AWS Permissions for Terraform?
No command prints them, because they depend on what your configuration manages and on the provider version. A workable process:
- Run the configuration in a sandbox account with a broad role, and let CloudTrail record the calls.
- Generate a policy from that activity with IAM Access Analyzer's policy generation, which reads up to 90 days of CloudTrail events for a role and lists the actions it used.
- Replace the broad role with the generated policy, narrow the resource ARNs, and run
plan,applyanddestroyagain. Add what is still missing from theAccessDeniedmessages, which name the exact action.
Expect more actions than the resource names suggest. Terraform reads a resource back after it creates it, and at every plan. For one aws_s3_bucket, the AWS provider reads the bucket's ACL, CORS, website, versioning, logging, lifecycle, replication, encryption, object lock, tags and policy settings, so a policy with only s3:CreateBucket fails on the first refresh. The exact list changes between provider versions, which is one more reason to generate the policy from real activity and to test provider upgrades in the sandbox first.
Split the permissions by job:
- A plan role with read access only, plus read and lock access to the state. Pull request pipelines use this role, so code from a branch cannot change infrastructure.
- An apply role with write access to the services that this root folder manages, used only on the main branch after review.
- State access for both:
s3:GetObjectands3:PutObjecton the state key,s3:GetObject,s3:PutObjectands3:DeleteObjecton the.tflockkey next to it (withuse_lockfile), ands3:ListBucketon the bucket.
A smaller blast radius helps more than a perfect policy: one role per environment and per root folder, so a mistake in dev/app cannot reach prod/network. Write Narrow Policies, and Lint Them in our Security Principles guide shows how to lint policies for actions that are wider than they need to be.
Related Resources
- IAM and security in our AWS guide, including how to find what is used and how to debug Access Denied.
- AWS Security Checklist to review an account.
- How to Apply SQL Scripts to RDS With Terraform for secrets that a database needs.
- Terraform Best Practices for separate plan and apply roles in CI.
Try it hands-on
Run the commands from this article in the browser. Nothing to install.
Terraform Basics Simulator
Learn Terraform basics in an interactive browser lab. Read and edit the HCL config, run init, validate, plan, apply, and destroy, and watch state react to your edits. A free, hands-on way to learn infrastructure as code with no AWS account.
AWS VPC Networking Simulator
Learn AWS networking fundamentals with an interactive VPC simulator. Visualize how traffic flows through public and private subnets, understand NAT Gateways, Internet Gateways, and route tables.
We earn commissions when you shop through the links below.
Bitrise
Managed runners for GitHub Actions
Bitrise Build Hub runs your GitHub Actions jobs on M4 Macs and AMD Linux machines. Change runs-on and keep your workflows, secrets and logs on GitHub.
Atomsized
AWS platform engineering and GitOps
Design and automation for reliable AWS and Kubernetes platforms, safer delivery workflows, and preview and UAT environments your engineers can understand and own.
DigitalOcean
Cloud infrastructure for developers
Simple, reliable cloud computing designed for developers
DevDojo
Developer community & tools
Join a community of developers sharing knowledge and tools
SMTPfast
Developer-first email API
Send transactional and marketing email through a clean REST API. Detailed logs, webhooks, and embeddable signup forms in one dashboard.
QuizAPI
Developer-first quiz platform
Build, generate, and embed quizzes with a powerful REST API. AI-powered question generation and live multiplayer.
Want to support DevOps Daily and reach thousands of developers?
Become a SponsorFound an issue?
Related Posts
Also worth your time on this topic
AWS VPC with Terraform
Build a complete AWS VPC infrastructure using Terraform with public/private subnets, NAT gateway, and security groups.
120 minutes
AWS Security Checklist
Essential security configuration checklist for AWS cloud environments.
45-60 minutes
Cloud IAM Basics
What is IAM in cloud computing? Explain the concepts of users, roles, and policies.
junior