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
|
|
To make this work, you need three things:
- CloudWatch has log groups ready to receive data (created by Terraform)
- EC2 has permission to write output streams (role + instance profile)
- Containers know the destination (awslogs driver configured in docker-compose)
How to inspect output quickly
|
|
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)
|
|
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:
|
|
Terraform example:
|
|
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.
|
|
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:
|
|
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:
|
|
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:
|
|
Container Output Shipping Settings
|
|
driver: awslogs: Docker does not keep output locally, it sends to CloudWatch${APP_ENV}/${AWS_REGION}: loaded from.envtag: 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.