Collecting Container Output on EC2 With Cloud Logging Pipelines

This post is a full record of the issues and implementation details I hit while configuring container output collection on EC2 in a real environment.

Why central remote logging is worth it

Before this setup, checking container output always meant SSH into EC2 and running docker logs. Once environments grow (staging + prod), that becomes hard to manage. A centralized stream lets you inspect output in AWS Console or CLI directly, without SSH on every check.

End-to-end topology

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Docker containers on EC2
  โ”œโ”€โ”€ nginx
  โ”œโ”€โ”€ api
  โ””โ”€โ”€ postgres
        โ”‚
        โ”‚  each container's stdout/stderr
        โ”‚  sent via Docker's awslogs driver
        โ”‚  directly to AWS CloudWatch Logs
        โ–ผ
CloudWatch Logs
  โ”œโ”€โ”€ /side-project/dev/nginx
  โ”œโ”€โ”€ /side-project/dev/api
  โ””โ”€โ”€ /side-project/dev/postgres

To make this work, you need three things:

  1. CloudWatch has log groups ready to receive data (created by Terraform)
  2. EC2 has permission to write output streams (role + instance profile)
  3. Containers know the destination (awslogs driver configured in docker-compose)

How to inspect output quickly

1
2
3
4
5
6
7
# CLI (requires AWS profile)
aws logs tail /side-project/dev/api --follow --profile side-project
aws logs tail /side-project/dev/postgres --follow --profile side-project
aws logs tail /side-project/dev/nginx --follow --profile side-project

# Console:
# CloudWatch > Log groups > /side-project/dev/*

Local development does not use remote log shipping; use docker compose logs directly.

Credential and Trust Model Concepts

Access card object model

A role is like an access card. It defines:

  • who can wear this card (Trust Policy / Assume Role Policy)
  • which rooms this card can open (Permission Policy)
1
2
3
aws_iam_role "ec2"
โ”œโ”€โ”€ Trust Policy:      only EC2 service can assume this role
โ””โ”€โ”€ Permission Policy: allows writing to CloudWatch Logs

Badge holder object model

EC2 cannot attach a role directly. It must attach through an Instance Profile as an intermediate container. This is an AWS platform constraint:

1
IAM Role (access card) โ”€โ”€โ–ถ Instance Profile (badge holder) โ”€โ”€โ–ถ EC2 Instance (person)

Terraform example:

1
2
3
4
5
6
7
8
9
resource "aws_iam_role" "ec2" { ... }

resource "aws_iam_instance_profile" "ec2" {
  role = aws_iam_role.ec2.name
}

resource "aws_instance" "api" {
  iam_instance_profile = aws_iam_instance_profile.ec2.name
}

Note: adding iam_instance_profile to an existing EC2 instance triggers recreation (AWS limitation).

Delegation right for handing over the card

GitHub Actions uses Terraform to attach a role to EC2. AWS requires the executor to have permission to hand over that card: iam:PassRole.

Without this limit, anyone with broad IAM rights could attach an admin role to any EC2 instance.

1
2
3
4
5
6
7
8
9
{
  Action   = ["iam:PassRole"]
  Resource = "arn:aws:iam::*:role/side-project-ec2-*"
  Condition = {
    StringEquals = {
      "iam:PassedToService" = "ec2.amazonaws.com"
    }
  }
}

Best practice is to isolate PassRole in its own statement, separate from other IAM operations (CreateRole and so on).

Permission Statement Anatomy

Each IAM statement has three parts:

1
2
3
4
5
{
  Action   = "some operation"
  Resource = "which resources"
  Condition = { ... }
}

Why Some Operations Need Wildcard Scope

In this category, the pattern is Resource = "*", and operations like logs:DeleteLogGroup target specific log groups, so you can scope resources:

1
2
3
4
{
  Action   = "logs:DeleteLogGroup"
  Resource = "arn:aws:logs:us-east-1:*:log-group:/side-project/*"
}

But logs:DescribeLogGroups is a list operation. AWS does not evaluate your resource pattern for this call, so it must use *. Since this is read-only, the risk is acceptable.

Permission type Can Resource be scoped? Reason
Create/update/delete operations (CreateLogGroup, DeleteLogGroup, etc.) Yes, scope to /side-project/* Operation targets a concrete resource
List operation (DescribeLogGroups) No, must use * AWS does not support resource scoping for list APIs

Legacy and Current Endpoint Names

AWS sometimes renames APIs. These two pairs are functionally equivalent:

Function Legacy API Current API
Add tags on a log group logs:TagLogGroup logs:TagResource
List tags on a log group logs:ListTagsLogGroup logs:ListTagsForResource

Terraform AWS provider now uses the newer names, but for compatibility I keep both sets.

Foundational Stack and Delivery Stack

Bootstrap Main Terraform
Where it runs local (manual) GitHub Actions (CI/CD)
What it manages IAM permissions for GitHub Actions, S3 state bucket EC2, Security Group, EIP, IAM Role, CloudWatch
Why split it CI/CD cannot grant its own permissions (chicken-and-egg) permissions must exist before apply
Run frequency rarely, only when CI/CD permission changes every push to main

Flow:

1
2
3
4
5
6
1. run bootstrap terraform apply locally
   โ†’ GitHub Actions gets new permissions

2. push to main
   โ†’ infra.yml: terraform apply (creates IAM role, log groups, EC2)
   โ†’ deploy.yml: SSH into EC2, deploy containers

Container Output Shipping Settings

1
2
3
4
5
6
7
8
9
# docker-compose.prod.yml
services:
  api:
    logging:
      driver: awslogs
      options:
        awslogs-group: /side-project/${APP_ENV}/api
        awslogs-region: ${AWS_REGION}
        tag: api
  • driver: awslogs: Docker does not keep output locally, it sends to CloudWatch
  • ${APP_ENV} / ${AWS_REGION}: loaded from .env
  • tag: identifies the log stream in CloudWatch

Final Takeaways

After centralizing container output into CloudWatch, troubleshooting speed improved a lot, and multi-environment operations became cleaner. This pattern is not just for side projects; it can be reused for any EC2 + Docker deployment.