Building a Production Grade AWS Infrastructure Part 3: IAM Roles
In my previous post, I containerized the frontend and backend using Docker Compose with Postgres. Now that the app runs locally, it’s time to start building the AWS infrastructure.
But where do you even begin? AWS has a deny-by-default permissions model, meaning nothing works until you explicitly give it permission. That makes IAM the logical starting point, before you can spin up a VPC, launch an ECS task, or write to S3, you need roles and policies in place, otherwise all your services will fail from the start.
The IAM Mindset: Roles, not Users
When I first started learning IAM, my mind automatically went to users, creating someone a username, password, access keys. I had to remind myself: this is infrastructure, not a team. In production, your services don’t log in. They assume roles.
The difference is important:
- IAM User : person with a password and/or access keys
- IAM Role : a set of permissions that a service can assume temporarily
The two are backed by the same core concept: an IAM identity. It’s just that roles are designed for machines and services, not people. Since our app runs on AWS infrastructure, roles are the way to go.
trust_policy_permissions: what?
When you look at the role block in Terraform, the first thing you’ll see is the trust policy:
trust_policy_permissions = {
TrustRoleAndServiceToAssume = {
actions = [
"sts:AssumeRole",
"sts:TagSession",
]
principals = [{
type = "Service"
identifiers = [
"ecs-tasks.amazonaws.com",
]
}]
}
}
Enter fullscreen mode Exit fullscreen mode
TrustRoleAndServiceToAssume is just a friendly label given by the terraform module, it could be labeled anything. Like TrustThisServicePlease. The important part is the actions:
- sts:AssumeRole : This tells AWS to issue a temporary security credential (valid for about an hour by default) to the service specified in the principals block. It’s how AWS allows one identity to borrow another identity’s permissions temporarily.
- sts:TagSession : More of an auditing tool. This attaches metadata tags to that session so you can trace which service, container, or user requested the credentials. Very useful for debugging later.
Now here’s something beginners often get wrong — the principals block. I initially had:
type = "AWS" → changed to "Service"
identifiers = ["ec2.amazonaws.com"] → changed to "ecs-tasks.amazonaws.com"
Enter fullscreen mode Exit fullscreen mode
Use type = "Service" when the entity that needs the role is a service like EC2, ECS, or Lambda. Use type = "AWS" when it’s a user or account.
I also removed the condition block:
condition = [{
test = "StringEquals"
variable = "sts:ExternalId"
values = ["some-secret-id"]
}]
Enter fullscreen mode Exit fullscreen mode
This block is specifically for external services, for example, if a third-party SaaS needs to access your AWS account. The ExternalId ensures that even if someone steals your role ARN, they can’t use it without that secret ID. Since our application runs on ECS, which is an internal service, we don’t need this.
Why two roles? The ECS role architecture
A common beginner mistake is to create a single role for everything. But if you’re using ECS, there is a much better split:
1. Task Execution Role (app-execution-role)
This role belongs to the ECS agent, it’s the AWS service itself. Its job is to bridge the gap between the container specification and the services ECS needs to start your app.
Policies attached:
policies = {
getSecretsPostgres = "arn:aws:iam::aws:policy/AWSSecretsManagerClientReadOnlyAccess"
pullImageFromECS = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
Enter fullscreen mode Exit fullscreen mode
- AmazonECSTaskExecutionRolePolicy : Allows the ECS agent to pull images from ECR and write logs to CloudWatch
- AWSSecretsManagerClientReadOnlyAccess : Allows the ECS agent to read secrets from Secrets Manager
Think of this as the valet parking guy: he parks your car (the container) in the right spot, but he doesn’t drive it anywhere.
2. Task Role (app-task-role)
This role belongs to the application itself, the code running inside the container. If your Node.js app needs to read an S3 bucket, send an email via SES, or access DynamoDB, this is where you attach those permissions.
But here’s the thing: right now, our application only talks to PostgreSQL via standard network drivers. It doesn’t need IAM permissions.
policies = {
}
Enter fullscreen mode Exit fullscreen mode
Notice the empty policies block. I intentionally left it that way.
If I didn’t set a separate task role, the application would default to the host’s IAM role (the EC2 instance’s role), which could accidentally grant broad permissions. By explicitly setting an empty task role, we lock down the container: even if someone exploits a vulnerability in the Node application, there are zero permissions to leverage.
Later, when the app grows and it needs to upload user avatars to S3, or send welcome emails via SES, I’ll attach the appropriate policies.
How to decide on policies
Settling on the right policies requires two questions:
1. What AWS service does my app touch?
My app is a Node.js application backed by PostgreSQL:
- Compute: ECS with Fargate (needs to pull images from ECR)
- Database: Amazon RDS (connection goes through the network, not IAM)
- Security: AWS Secrets Manager (to retrieve db credentials)
2. What actions does my app need for each service?
- Pulling images from ECR? Read-only ->
AmazonEC2ContainerRegistryReadOnly - Writing logs? Write ->
CloudWatchAgentServerPolicy
The golden rule: the fewer permissions, the better. Start with nothing and add as you go. AWS provides plenty of managed policies, but if none fits perfectly, you can always write a custom one.
Custom policies with data sources
If you ever need a policy that doesn’t exist in AWS’s managed offerings, Terraform provides the aws_iam_policy_document data source:
data "aws_iam_policy_document" "example" {
statement {
actions = [""]
resources = [""]
effect = "Allow"
}
}
Enter fullscreen mode Exit fullscreen mode
You can then reference module.iam_policy.arn or build inline policies. The key thing is: write exactly what your app needs, nothing more.
The outputs file: exposing your roles
Once the IAM roles are defined, your other modules can’t see them without an outputs file. Think of it as an export statement.
output "execution_role_arn" {
description = "The ARN of the ECS execution role"
value = module.iam_role.arn
}
output "task_role_arn" {
description = "The ARN of the ECS task role"
value = module.iam_role_task.arn
}
Enter fullscreen mode Exit fullscreen mode
- .arn tells Terraform to fetch the resource name string generated by AWS once the role is created
- The outputs will be referenced in the ECS module later
-
Never output plaintext secrets like database passwords. If you absolutely must, use
sensitive = true
Now our VPC module (which I’ll cover in the next post) and the ECS module will be able to link to these IAM role ARNs programmatically, without copy-pasting random string IDs.
What I learnt
- IAM roles are for services, IAM users are for people — two sides of the same coin
- ECS needs two distinct roles: one for the ECS agent and one for the app
- Empty task policies are a feature: they keep the app in the most secure posture possible
- Export via outputs, never hardcode ARNs
Next up: The VPC module
With IAM out of the way, the next logical step is setting up the VPC. All the networking, subnets, route tables, internet gateways. Needs to be defined before we can plug in RDS, ALB, or ECS.
If you’re learning IAM roles for the first time like me, what AWS service tripped you up the most? Drop a comment below!
Want to follow along? The full code is available here:
CloudNotes – Production Grade AWS Infrastructure
A full-stack Note Taker application designed as a sandbox for building production-grade AWS infrastructure with Terraform. The app is fully containerized with Docker and orchestrated with Docker Compose, ready to be deployed across a high-availability AWS architecture.
Architecture
┌──────────────────────────────────┐
│ AWS Cloud (Target) │
│ │
│ Route53 ─── CloudFront ─── S3 │
│ │ │
│ ▼ │
│ ┌─────── ALB ───────┐ │
│ │ │ │
│ ┌────▼────┐ ┌────▼────┐ │
│ │ EC2 ASG │ │ EC2 ASG │ │
│ │(Backend)│ │(Backend)│ │
│ └────┬────┘ └────┬────┘ │
│ │ │ │
│ └────────┬─────────┘ │
│ ▼ │
│ ┌──── RDS ────┐ │
│ │ PostgreSQL │ │
│ └─────────────┘ │
└──────────────────────────────────┘
Local (Docker Compose):
┌──────────┐ ┌────────────┐ ┌──────────────┐
│ Frontend │───▶│ Backend │───▶│ PostgreSQL │
│ (Nginx) │ │ (Node.js) │ │ (15-alpine) │
│ :3000 │ │ :5001 │ │ :5432…
답글 남기기