
How to Build a Secure Container Platform on AWS ECS Fargate with Terraform and GitHub Actions
A step-by-step guide to building a secure three-tier app on AWS ECS Fargate using Terraform, GitHub Actions, least-privilege IAM, Secrets Manager, and GuardDuty runtime monitoring.
Deploying containers is easy. Deploying them securely is where most teams struggle.
A common mistake is treating security as something to add after the application is running. The focus is usually on getting features into production as quickly as possible, while security is pushed to a later phase. By then, the environment often contains publicly accessible resources, secrets stored in plain text, overly permissive IAM roles, and workloads running with unnecessary privileges.
Fixing these issues after deployment is always more difficult than designing the platform securely from the beginning.
In this guide, we'll build a secure three-tier application on AWS ECS Fargate where security is part of every stage of the deployment process, not an afterthought. We'll provision the infrastructure with Terraform, automate security checks with GitHub Actions, use least-privilege IAM roles, inject secrets securely with AWS Secrets Manager, and monitor running containers with Amazon GuardDuty Runtime Monitoring.
More importantly, this isn't just a list of implementation steps. Throughout the guide, I'll explain why each security decision matters, the risks it addresses, and what can happen if it's ignored. The goal is to help you understand the reasoning behind the architecture so you can apply the same approach to your own environments.
Both the application repository and the infrastructure repository are available at the end of this article.
Common Security Mistakes in ECS Deployments
When container platforms are built under tight deadlines, the same security mistakes appear again and again. Individually they may not seem critical, but together they create an environment that's much easier to compromise.
Hardcoded credentials
Database passwords, API keys or connection strings are stored directly in task definitions or environment variables. Anyone with permission to view the task configuration can potentially access sensitive information.
Over-permissioned IAM roles
A single IAM role is often used for both the ECS service and the application itself, with broad permissions such as AdministratorAccess or PowerUserAccess. This breaks the principle of least privilege and increases the impact of a compromised container.
Containers running as root
Many applications run with root privileges simply because the default container image allows it. If an attacker gains access to the container, those privileges can make further compromise much easier.
Mutable image tags
Using the
latest tag in Amazon ECR makes it difficult to know exactly which application version is running. It also complicates troubleshooting and makes reliable rollbacks nearly impossible.Publicly accessible workloads
Backend services and databases are sometimes deployed in public subnets or assigned public IP addresses, exposing internal services that should never be reachable from the internet.
No runtime monitoring
Many teams secure the deployment pipeline but have little visibility into what happens after a container starts. Without runtime monitoring, suspicious behavior may go unnoticed until significant damage has already occurred.
None of these issues is catastrophic on its own. However, when several of them exist together, a single application vulnerability can allow an attacker to gain access to a container, retrieve sensitive credentials, move laterally across the environment, and remain undetected.
Architecture Overview
The platform consists of a secure three-tier application running on AWS ECS Fargate. It includes a Node.js Express frontend, a Node.js REST API backend, and an Amazon RDS PostgreSQL 15 database.

The application runs inside a single Amazon VPC that is divided into three isolated network tiers.
Public Subnets
Only the Application Load Balancer (ALB) is deployed in the public subnets. It has a public IP address and accepts incoming HTTP traffic on port 80.
Private Application Subnets
The frontend and backend ECS Fargate services run in private subnets without public IP addresses. They can communicate with each other inside the VPC but cannot be accessed directly from the internet.
Isolated Database Subnets
The PostgreSQL database runs in dedicated private subnets with no Internet Gateway, no NAT Gateway, and no public access. It can only be reached by the backend service inside the VPC.
When a user accesses the application, the request flows like this:
- The request reaches the Application Load Balancer (ALB).
- The ALB forwards it to the Frontend running on ECS Fargate.
- The frontend calls the Backend using AWS Cloud Map private DNS.
- The backend connects securely to Amazon RDS PostgreSQL on port 5432.
- The response is returned to the user through the same path.
With this design, only the ALB is exposed to the internet. The backend and database stay private, reducing the attack surface and making the application more secure.
Step 1: Shift-Left Security in GitHub Actions
Security shouldn't begin after deployment. It should start as soon as code is pushed to the repository.
In this project, the GitHub Actions pipeline runs three security checks before deploying the application. If any check fails, the workflow stops immediately, preventing insecure code or infrastructure from reaching AWS.

Secret Scanning with TruffleHog
One of the easiest ways to expose sensitive data is by accidentally committing secrets such as AWS access keys or API tokens. Even if those secrets are removed in a later commit, they still exist in the Git history.
TruffleHog scans the entire repository history and verifies whether a detected secret is still active. If it finds a valid secret, the pipeline fails immediately.
1
2
3
4
5
- name: TruffleHog Secret Scan
uses: trufflesecurity/trufflehog@main
with:
extra_args: --only-verified
continue-on-error: falseWhy these settings?
fetch-depth: 0scans the complete Git history instead of only the latest commit.--only-verifiedreports only active secrets, reducing false positives.continue-on-error: falsestops the pipeline if a valid secret is detected.
Container Image Scanning with Trivy
Before pushing a Docker image to Amazon ECR, Trivy scans the image for known vulnerabilities in both the operating system packages and application dependencies.
1
2
3
4
5
6
7
- name: Trivy Scan - Backend Image
uses: aquasecurity/trivy-action@master
with:
image-ref: 'backend-test:latest'
exit-code: '1'
ignore-unfixed: true
severity: 'CRITICAL'Why these settings?
exit-code: '1'fails the workflow when a critical vulnerability is found.ignore-unfixed: trueskips vulnerabilities that don't have an available fix yet.-
severity: 'CRITICAL'blocks deployments only for the most severe issues.
Infrastructure Scanning with Checkov
Application code isn't the only thing that needs protection. Infrastructure can also introduce security risks if it's misconfigured.
Before running
terraform apply, Checkov scans every Terraform file and identifies common security issues.1
2
3
4
5
6
- name: Checkov IaC Security Scan
uses: bridgecrewio/checkov-action@master
with:
directory: infra/
framework: terraform
soft_fail: trueCheckov helps detect issues such as:
- Public S3 buckets
- Security Groups exposing SSH to the internet
- Unencrypted databases
- Other Terraform security misconfigurations

Step 2: Hardening the Container Registry with Amazon ECR
Before a container is deployed, it should be stored in a secure registry. In this project, Amazon Elastic Container Registry (ECR) is configured with security controls that help protect container images from being modified or replaced.

Why Use Immutable Image Tags?
By default, Docker image tags are mutable. This means the same tag, such as
latest, can point to different images over time. While this may seem convenient, it creates several security and operational challenges.- You can't easily identify which version is running in production.
- - Rolling back to a previous release becomes unreliable.
- - A production image can be overwritten accidentally or by a compromised CI/CD pipeline.
To avoid these issues, the ECR repository is configured with immutable image tags.
1
2
3
4
5
6
7
8
9
10
11
12
13
resource "aws_ecr_repository" "backend" {
name = "${var.app_name}-backend-${var.environment}"
image_tag_mutability = "IMMUTABLE"
image_scanning_configuration {
scan_on_push = true
}
encryption_configuration {
encryption_type = "KMS"
kms_key = aws_kms_key.ecr.arn
}
}Instead of using the
latest tag, every image is tagged with its Git commit SHA. This creates a direct link between the deployed container and the source code.1
2
3
IMAGE_TAG=${{ github.sha }}
docker build -t $ECR_REGISTRY/$BACKEND_REPO:$IMAGE_TAG ./backend
docker push $ECR_REGISTRY/$BACKEND_REPO:$IMAGE_TAGIf someone tries to push an image with the same tag again, Amazon ECR rejects the request with a
TagAlreadyExistsException. This ensures that every image remains unchanged after it's pushed.Additional Security Controls
Along with immutable tags, the repository is configured with:
- Scan on Push, which automatically scans every new image for vulnerabilities using Amazon Inspector.
- - AWS KMS encryption, which encrypts container images at rest using a customer-managed KMS key with automatic key rotation enabled.
These controls help ensure that every image stored in the registry is traceable, protected and scanned before it's deployed.
Step 3: Network Isolation with Security Group Chaining
A secure application starts with a secure network. Instead of relying on firewalls inside containers, this architecture controls traffic using Security Groups. Each layer accepts traffic only from the layer directly in front of it, creating a simple and secure communication path.

The traffic flows through the application in this order: Internet, then the Application Load Balancer (port 80/443), then the Frontend ECS Task (port 3000), then the Backend ECS Task (port 8080), then Amazon RDS PostgreSQL (port 5432).
Instead of allowing traffic from IP addresses, each Security Group references the Security Group of the previous layer. This means only trusted resources can communicate with each other.
For example, even if the frontend container is compromised, it still can't connect directly to the database because the database only accepts connections from the backend Security Group.
Keeping AWS Traffic Private
The ECS tasks also need to communicate with AWS services such as Amazon ECR, Secrets Manager, and CloudWatch Logs. Rather than sending this traffic through a NAT Gateway, the VPC uses VPC Endpoints, keeping communication entirely within the AWS network.
The following endpoints are configured:
- ECR API — Image authentication
- - ECR DKR — Image layer downloads
- - Secrets Manager — Retrieve application secrets
- - CloudWatch Logs — Send container logs
- - Amazon S3 Gateway Endpoint — Access ECR image layers stored in S3

Using VPC Endpoints reduces the attack surface, keeps AWS service traffic private, and avoids unnecessary internet access from the application.
Step 4: Understanding Task Role vs Execution Role
One of the most common mistakes in Amazon ECS is confusing the Task Execution Role with the Task Role. Although both are attached to an ECS task, they serve completely different purposes.

Task Execution Role
The Task Execution Role is used by the ECS agent, not by your application. It allows ECS to start the container and perform tasks such as:
- Pulling container images from Amazon ECR
- - Sending logs to Amazon CloudWatch
- - Retrieving secrets from AWS Secrets Manager
These permissions are required only during container startup.
Task Role
The Task Role is used by the application running inside the container. It defines which AWS services your application can access after it starts.
In this project, the backend communicates directly with PostgreSQL over the network, so it doesn't need permissions to access services such as S3, IAM, or Secrets Manager. The Task Role follows the principle of least privilege and only includes the permissions required by the application.
Why Separate These Roles?
Keeping these roles separate improves security. If the application is ever compromised, the attacker only inherits the Task Role, not the Task Execution Role. Since the Task Role has minimal permissions, the potential impact is greatly reduced.
Step 5: Secure Secret Management with AWS Secrets Manager
Hardcoding passwords or API keys inside container environment variables is a common security mistake. Anyone with permission to view the ECS task definition can potentially see those values.
Instead, this project uses AWS Secrets Manager with ECS native secret injection. The ECS agent retrieves the secret when the container starts and makes it available to the application without exposing it in the task definition.

Instead of storing secrets like
DB_PASSWORD="MySecretPassword123", the task definition references the secret by its ARN.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
container_definitions = jsonencode([
{
name = "backend"
image = var.backend_image
essential = true
# Native Secret Injection from Secrets Manager
secrets = [
{
name = "DB_CREDENTIALS"
valueFrom = var.db_secret_arn
}
]
environment = [
{ name = "NODE_ENV", value = "production" },
{ name = "DB_HOST", value = var.db_address },
{ name = "DB_PORT", value = "5432" }
]
}
])When the container starts, the ECS agent injects the secret into the
DB_CREDENTIALS environment variable as a JSON object. The Node.js application simply reads and parses it.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let dbConfig = {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME || 'appdb',
user: process.env.DB_USER || 'dbadmin',
password: process.env.DB_PASSWORD,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
};
// Parse native secret injection if present
if (process.env.DB_CREDENTIALS) {
try {
const creds = JSON.parse(process.env.DB_CREDENTIALS);
dbConfig.user = creds.username || dbConfig.user;
dbConfig.password = creds.password || dbConfig.password;
dbConfig.database = creds.dbname || dbConfig.database;
} catch (err) {
console.warn('Could not parse DB_CREDENTIALS JSON');
}
}With this approach, sensitive credentials stay in AWS Secrets Manager instead of your source code. They aren't hardcoded in the application, committed to Git, or stored directly in the ECS task definition, making secret management much more secure.
Step 6: Container Hardening
Protecting the AWS infrastructure is only part of the job. The container should also be secured to reduce the impact of a compromised application.
Both the frontend and backend use a multi-stage Docker build based on
node:20-alpine. This keeps the runtime image smaller by excluding build tools and other unnecessary files.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Stage 1: Build Dependencies
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
# Stage 2: Hardened Runtime Image
FROM node:20-alpine
ENV NODE_ENV=production
WORKDIR /app
# Copy built dependencies and source code
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
COPY server.js ./
# Security Hardening: Run as unprivileged non-root user
USER node
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1
CMD ["node", "server.js"]Why These Settings?
USER noderuns the application as a non-root user instead of root, limiting what an attacker can do if the container is compromised.- -
readonlyRootFilesystem = trueprevents the container from modifying files at runtime, making many post-exploitation techniques much harder. - -
HEALTHCHECKallows Amazon ECS to detect unhealthy containers and replace them automatically.

Step 7: Database Isolation with Amazon RDS
The application uses Amazon RDS PostgreSQL 15.7 for the database. It runs in private database subnets, so it isn't directly accessible from the internet. Only the backend service can connect to it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
resource "aws_db_instance" "this" {
identifier = "${var.app_name}-postgres-${var.environment}"
engine = "postgres"
engine_version = "15.7"
instance_class = "db.t4g.micro"
allocated_storage = 20
storage_type = "gp3"
storage_encrypted = true
publicly_accessible = false
skip_final_snapshot = true
db_subnet_group_name = aws_db_subnet_group.this.name
vpc_security_group_ids = [var.db_security_group_id]
}This configuration helps secure the database in a few ways:
publicly_accessible = falsekeeps the database private by preventing a public IP from being assigned.- -
storage_encrypted = trueencrypts the database, backups, and snapshots at rest. - - Database Subnet Group places the database in dedicated private subnets across multiple Availability Zones for better isolation and availability.
Step 8: Runtime Threat Detection with Amazon GuardDuty
Security checks in the CI/CD pipeline help catch known issues before deployment, but they can't detect threats that happen after the application is running. That's where Amazon GuardDuty comes in.
In this project, GuardDuty Runtime Monitoring is enabled for ECS Fargate to continuously monitor running containers for suspicious activity.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# GuardDuty Detector
resource "aws_guardduty_detector" "this" {
count = var.enable_guardduty ? 1 : 0
enable = true
finding_publishing_frequency = "FIFTEEN_MINUTES"
}
# ECS Fargate Runtime Monitoring
resource "aws_guardduty_detector_feature" "runtime_monitoring" {
count = var.enable_guardduty ? 1 : 0
detector_id = aws_guardduty_detector.this[0].id
name = "RUNTIME_MONITORING"
status = "ENABLED"
additional_configuration {
name = "ECS_FARGATE_AGENT_MANAGEMENT"
status = "ENABLED"
}
}
Once Runtime Monitoring is enabled, AWS continuously watches your running containers for suspicious activity, including:
- Malicious processes running inside a container.
- - Unexpected outbound connections to known malicious servers.
- - Suspicious AWS API activity detected through CloudTrail.
This provides continuous visibility after deployment and helps identify threats that traditional vulnerability scans can't detect.
Step 9: Security Alerts with EventBridge and SNS
Sending an alert for every security event quickly becomes overwhelming. In this project, notifications are sent only for High and Critical GuardDuty findings (severity 7.0 and above).

Making Alerts Easy to Read
GuardDuty generates detailed JSON events, which aren't easy to understand during an incident. An EventBridge Input Transformer converts them into a simple email and sends it through Amazon SNS.
1
2
3
4
5
6
7
8
9
10
11
12
13
// Input Paths Map
{
"severity": "$.detail.severity",
"finding_type": "$.detail.type",
"title": "$.detail.title",
"attacker_ip": "$.detail.service.action.networkConnectionAction.remoteIpDetails.ipAddress",
"region": "$.region",
"account": "$.account",
"time": "$.time"
}
// Input Template (Plain Text Result)
"SECURITY ALERT | GuardDuty | Severity: <severity>/10 | Type: <finding_type> | Threat: <title> | Attacker IP: <attacker_ip> | Account: <account> | Region: <region> | Time: <time> | Console: https://console.aws.amazon.com/guardduty/home?region=<region>#/findings"
Instead of a large JSON payload, the team receives a clear email with the key details and a direct link to the GuardDuty finding, making it faster to investigate and respond.
Step 10: Automating ECS Deployments
When deploying a new version of an ECS service, the existing task definition can't be registered again as it is. The output from
aws ecs describe-task-definition includes read-only fields that cause the registration to fail.To automate this process, the deployment pipeline uses a small Python script.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import sys
import json
def update_task_definition(input_file, output_file, image_uri):
with open(input_file, 'r') as f:
data = json.load(f)
# 1. Update container image URI with new Commit SHA tag
data['containerDefinitions'][0]['image'] = image_uri
# 2. Ensure internal service discovery DNS remains correct
for env in data['containerDefinitions'][0].get('environment', []):
if env.get('name') == 'BACKEND_URL':
env['value'] = 'http://backend.devsecops.local:8080'
# 3. Whitelist ONLY valid registration keys (strip read-only metadata)
valid_keys = {
'family', 'taskRoleArn', 'executionRoleArn', 'networkMode',
'containerDefinitions', 'volumes', 'requiresCompatibilities',
'cpu', 'memory'
}
keys_to_remove = [k for k in data.keys() if k not in valid_keys]
for k in keys_to_remove:
data.pop(k, None)
with open(output_file, 'w') as f:
json.dump(data, f, indent=2)
if __name__ == '__main__':
update_task_definition(sys.argv[1], sys.argv[2], sys.argv[3])The script performs three tasks:
- Updates the container image with the latest Git commit SHA.
- - Removes read-only fields from the task definition.
- - Creates a clean JSON file that can be registered as a new ECS task definition.
This keeps deployments fully automated and avoids registration errors during the CI/CD pipeline.

Verifying the Deployment
After deployment, a few quick checks confirm that everything is working as expected.
- The Application Load Balancer reports healthy targets.
- - The frontend communicates with the backend using AWS Cloud Map.
- - Database credentials are loaded from AWS Secrets Manager.
- - The backend connects successfully to Amazon RDS PostgreSQL.

Project Structure
The infrastructure is organized into reusable Terraform modules, making it easier to manage and extend.
1
2
3
4
5
6
7
8
9
10
11
12
13
devsecops-ecs/
├── .github/
├── infra/
│ ├── environments/
│ └── modules/
│ ├── vpc/
│ ├── ecr/
│ ├── alb/
│ ├── iam/
│ ├── secrets_manager/
│ ├── rds/
│ ├── ecs_fargate/
│ └── guardduty/Lessons Learned
Building this project highlighted a few practical lessons:
- Avoid using the
latestimage tag. Git commit SHAs provide reliable deployments and rollbacks. - - Keep the Task Execution Role and Task Role separate to follow least-privilege access.
- - Use VPC Endpoints so ECS tasks can access AWS services without using the public internet.
- - Running containers as non-root with a read-only filesystem adds another layer of protection.
Source Code
Both repositories are available on GitHub:
- Infrastructure (Terraform): [link to be added]
- - Application: [link to be added]
Conclusion
Building a secure platform on Amazon ECS Fargate isn't about using one security service. It's about adding security at every layer of the deployment.
In this project, security starts in the CI/CD pipeline, continues through the network, IAM, secrets and database and extends to runtime monitoring with Amazon GuardDuty.
Because everything is defined in code, the environment can be deployed consistently while following the same security practices every time.
I hope this guide helps you build a more secure ECS environment and gives you a solid starting point for your own projects.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article