Skip to main content

Command Palette

Search for a command to run...

Making My EC2 Visible: CloudWatch Logging with Terraform and IAM

From invisible to observable: IAM, CloudWatch Agent, and SSM wired together with Terraform

Updated
7 min read
Making My EC2 Visible: CloudWatch Logging with Terraform and IAM
L
Software Engineer at Bayer transitioning into Cloud and DevOps engineering, with 8+ years of software development experience across mobile and web. Currently working hands-on with AWS infrastructure, Terraform, and Kubernetes in a production environment. ⚙️ Technical Foundation AWS Certified Solutions Architect Associate and Cloud Practitioner, currently pursuing HashiCorp Terraform Associate, with hands-on focus on: - Infrastructure as Code (Terraform, Helm) - CI/CD pipelines (GitHub Actions, OIDC-based AWS authentication) - Container orchestration (AWS EKS, Kubernetes) - Serverless architecture (Lambda, S3, SNS, SQS, API Gateway) At Bayer I work alongside senior infrastructure engineers on production AWS EKS deployments, writing Terraform for real infrastructure, and building automated pipelines. Open to connecting with cloud professionals and exploring Cloud Engineer, DevOps Engineer, and Platform Engineer opportunities in Ottawa and remote. 🤝

In my last post I built a three tier AWS architecture from scratch using Terraform. VPC, subnets, security groups, bastion host, app server, RDS. It worked. But after I SSHed in and confirmed everything was running, I realized I had a problem.

My bastion host was completely blind. If something went wrong, I had no logs. No visibility. I would have to SSH in and manually grep through /var/log/messages hoping to find something useful.

That is not how production systems work. So the next step was obvious: wire up CloudWatch logging.

Here is what I added and why each piece exists.


What You Need to Wire Together

Getting CloudWatch logs flowing from EC2 requires three things working together:

IAM Role (permission to send logs)
    +
CloudWatch Agent (collects and ships logs)
    +
Agent Config (tells the agent what to collect)

Miss any one of these and nothing works. No error either, just silence.


Step 1: IAM Role and Instance Profile

The EC2 instance needs permission to write logs to CloudWatch. You give it that permission through an IAM role attached as an instance profile.

resource "aws_iam_role" "ec2_role" {
  name = "ec2-cloudwatch-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action    = "sts:AssumeRole"
        Effect    = "Allow"
        Principal = {
          Service = "ec2.amazonaws.com"
        }
      }
    ]
  })

  tags = {
    Name = "ec2_role"
  }
}

The assume_role_policy is the trust policy. It answers the question: who is allowed to use this role? In this case, the answer is the EC2 service itself.

Next, attach the AWS managed policy that grants the CloudWatch agent the permissions it needs:

resource "aws_iam_role_policy_attachment" "cloudwatch_attach" {
  role       = aws_iam_role.ec2_role.name
  policy_arn = "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy"
}

CloudWatchAgentServerPolicy gives the agent permission to create log groups, create log streams, and put log events. You do not need to write a custom policy for this.

Then wrap the role in an instance profile. An instance profile is the container that lets an EC2 instance actually use an IAM role. The role itself is not enough:

resource "aws_iam_instance_profile" "ec2_instance_profile" {
  name = "ec2_instance_profile"
  role = aws_iam_role.ec2_role.name
}

Step 2: Attach the Instance Profile to the Bastion

In my three tier setup I added the instance profile to the bastion host only. The bastion is internet facing and the machine I actually SSH into. That is the one worth monitoring right now. The app server in the private subnet has nothing running on it yet.

The change to the bastion resource is two lines:

resource "aws_instance" "bastion_host" {
  ami                         = "ami-0278a2977150e13fc"
  instance_type               = "t3.micro"
  subnet_id                   = aws_subnet.main_subnet_public_1.id
  vpc_security_group_ids      = [aws_security_group.bastion_sg.id]
  key_name                    = aws_key_pair.bastion_key.key_name
  associate_public_ip_address = true
  iam_instance_profile        = aws_iam_instance_profile.ec2_instance_profile.name  # added
  user_data                   = local.cloudwatch_user_data                            # added

  tags = {
    Name = "bastion_host"
  }
}

Step 3: Install and Configure the CloudWatch Agent via user_data

user_data is a script that runs once when the EC2 instance first boots. I used it to install the CloudWatch agent, write its config, and start it.

locals {
  cloudwatch_user_data = <<-EOF
    #!/bin/bash

    yum update -y
    yum install -y amazon-cloudwatch-agent

    cat <<CONFIG > /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json
    {
      "logs": {
        "logs_collected": {
          "files": {
            "collect_list": [
              {
                "file_path": "/var/log/messages",
                "log_group_name": "ec2-logs",
                "log_stream_name": "{instance_id}"
              }
            ]
          }
        }
      }
    }
    CONFIG

    /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
      -a fetch-config -m ec2 \
      -c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json \
      -s
  EOF
}

Breaking down what this does:

Install the agent. amazon-cloudwatch-agent is in the Amazon Linux repos. One yum command.

Write the config. The JSON config tells the agent what to collect. In this case /var/log/messages, send it to a log group called ec2-logs, and use the EC2 instance ID as the stream name so you can tell instances apart if you have more than one.

Start the agent. The amazon-cloudwatch-agent-ctl command fetches the config and starts the agent as a service. The -s flag means start immediately.

I used a locals block to keep the user_data script out of the resource block. It keeps things readable.


Step 4: Verify It Is Working

After terraform apply, wait a few minutes for the instance to boot and the agent to start. Then go to CloudWatch in the AWS console, navigate to Log Groups, and look for ec2-logs.

If you see a log stream named after your instance ID with entries from /var/log/messages, everything is wired up correctly.

You can also SSH into the bastion and check the agent status directly:

sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
  -a status

A healthy response looks like:

{
  "status": "running",
  "starttime": "...",
  "version": "..."
}

Also, To confirm everything was working I ran a quick test. I SSHed into the bastion and appended a test message directly to the log file:

echo "TEST-CLOUDWATCH-123" | sudo tee -a /var/log/messages

Within a minute I could see TEST-CLOUDWATCH-123 appear in the CloudWatch log stream. That confirmed the whole chain was working, agent running, IAM permissions correct, logs flowing.


The Problem With This Approach

This works. But it has a flaw that I only understood after the fact.

The agent config is baked into user_data. That script runs once at boot. If I want to add a new log file to collect, or change the log group name, I have to destroy and recreate the EC2 instance to pick up the change.

That is not acceptable in a real environment. You do not want to terminate an instance just to change a logging config.


The Better Way: Store Config in SSM Parameter Store

The fix is to store the agent config in SSM Parameter Store and have the agent fetch it from there instead of a local file.

First, store the config in SSM:

resource "aws_ssm_parameter" "cloudwatch_config" {
  name  = "/cloudwatch-agent/config"
  type  = "String"
  value = jsonencode({
    logs = {
      logs_collected = {
        files = {
          collect_list = [
            {
              file_path        = "/var/log/messages"
              log_group_name   = "ec2-logs"
              log_stream_name  = "{instance_id}"
            }
          ]
        }
      }
    }
  })
}

Then update the IAM role to allow SSM reads. Add a second policy attachment:

resource "aws_iam_role_policy_attachment" "ssm_attach" {
  role       = aws_iam_role.ec2_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

Then update user_data to fetch from SSM instead of a local file:

locals {
  cloudwatch_user_data = <<-EOF
    #!/bin/bash

    yum update -y
    yum install -y amazon-cloudwatch-agent

    /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
      -a fetch-config -m ec2 \
      -c ssm:/cloudwatch-agent/config \
      -s
  EOF
}

Now if you want to change what logs you collect, you update the SSM parameter and restart the agent. No instance termination required.

sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
  -a fetch-config -m ec2 \
  -c ssm:/cloudwatch-agent/config \
  -s

What I Learned

Three things stood out:

IAM roles and instance profiles are not the same thing. The role holds the permissions. The instance profile is the wrapper that lets EC2 actually assume the role. You need both.

user_data only runs once. If your config lives inside user_data, changing it means recreating the instance. For anything you might need to update later, SSM is the right place.

Visibility should not be optional. I built the three tier architecture and it worked, but I had no idea what was happening inside it. Adding CloudWatch took less than an hour. There is no reason to run EC2 instances without it.


What Is Next

The next step is enabling RDS IAM Authentication, replacing the hardcoded database password in my Terraform config with token-based access. I felt the pain of hardcoded credentials firsthand recently and there is a better way to handle it.

#aws #terraform #devops #cloudwatch #infrastructure-as-code