Container Security on Fargate: Inspector, ECR Scanning, and Runtime Defence
Fargate is often marketed as "serverless containers" — and that framing can create a false sense of security. Yes, AWS manages the underlying hosts. But the container images you run, the IAM permissions you grant, and the network access you allow are entirely your responsibility. A misconfigured task role or an unpatched base image is just as dangerous on Fargate as it would be on self-managed EC2.
This post covers a container security stack for ECS Fargate — the tools, the configurations, and the reasoning behind each layer.
Layer 1: Base Image Selection
Every container security conversation starts with the base image. A minimal base image has a smaller attack surface — fewer packages means fewer CVEs.
A sensible preference order:
Distroless images (gcr.io/distroless): No shell, no package manager, only the runtime. The attack surface is minimal. The tradeoff: debugging is hard because you can't exec into the container and run commands.
Alpine-based images: Small (~5MB base), includes a shell and
apkpackage manager. Good balance of size and debuggability.Slim variants (node:20-slim, python:3.12-slim): Debian-based but with most packages removed. Larger than Alpine but better library compatibility.
For Node.js applications, node:20-alpine is a solid base:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:20-alpine AS runtime
RUN addgroup -S appuser && adduser -S appuser -G appuser
USER appuser
WORKDIR /app
COPY --from=builder --chown=appuser:appuser /app/node_modules ./node_modules
COPY --chown=appuser:appuser . .
EXPOSE 3000
CMD ["node", "app.js"]
Key practices here:
- Multi-stage build: the builder stage installs dependencies; the runtime stage copies only what's needed. Build tools don't end up in production.
- Non-root user: the container process runs as
appuser, not root. Even if an attacker gets code execution, they can't read root-owned files or modify system configuration. npm ci --only=production: dev dependencies (nodemon, jest, eslint) are never installed in the production image.
Layer 2: ECR Enhanced Scanning with Inspector2
ECR has two scanning modes: Basic (open-source Clair scanner, daily or on-push) and Enhanced (Amazon Inspector2, continuous, deeper CVE database).
Enhanced scanning is the right choice. The configuration in Pulumi:
new aws.ecr.RegistryScanningConfiguration('scanning', {
scanType: 'ENHANCED',
rules: [
{
repositoryFilters: [{ filter: '*', filterType: 'WILDCARD' }],
scanFrequency: 'CONTINUOUS_SCAN',
},
],
});
// Inspector2 enablement
new aws.inspector2.Enabler('inspector', {
accountIds: [currentAccountId],
resourceTypes: ['ECR'],
});
With Enhanced scanning enabled, Inspector2 does several things Basic scanning doesn't:
Continuous re-scanning: Images are re-scanned when new CVEs are published, not just when you push. A vulnerability discovered three months after you built your image will surface without needing a new push.
OS and package-level CVEs: Inspector2 scans both OS packages and application packages (npm, pip, gem dependencies). Basic scanning only covers OS packages.
Exploitability scoring: Inspector2 includes EPSS (Exploit Prediction Scoring System) scores alongside CVSS, helping you prioritise which findings to fix first.
Acting on Scan Findings
Scan results appear in the ECR console and in Inspector2. For pipeline enforcement, we add a post-push check to our CI/CD pipeline:
# Wait for scan completion and check for critical/high findings
FINDINGS=$(aws ecr describe-image-scan-findings \
--repository-name website \
--image-id imageTag=$IMAGE_TAG \
--region eu-west-2 \
--query 'imageScanFindings.findingSeverityCounts' \
--output json)
CRITICAL=$(echo $FINDINGS | jq '.CRITICAL // 0')
HIGH=$(echo $FINDINGS | jq '.HIGH // 0')
if [ "$CRITICAL" -gt 0 ]; then
echo "Pipeline blocked: $CRITICAL critical CVEs found in image"
exit 1
fi
Block on CRITICAL findings. HIGH findings generate a warning but don't block — base image updates sometimes introduce HIGH findings that are mitigated by other controls or that don't apply to the runtime context.
Layer 3: Least-Privilege Task IAM Roles
Fargate tasks authenticate to AWS services via IAM task roles. The task role is assumed by the task at startup and provides credentials through the ECS metadata endpoint.
The failure mode to avoid: a broad task role. Teams sometimes reach for AdministratorAccess because it's convenient. This means any code running in that container — including a compromised dependency — has full AWS admin access.
The principle: each service gets a task role with exactly the permissions it needs to function.
For a website service with no AWS dependencies:
const websiteTaskRole = new aws.iam.Role('website-task-role', {
assumeRolePolicy: JSON.stringify({
Version: '2012-10-17',
Statement: [{
Effect: 'Allow',
Principal: { Service: 'ecs-tasks.amazonaws.com' },
Action: 'sts:AssumeRole',
}],
}),
tags: { managedBy: 'pulumi', service: 'website' },
});
// No DynamoDB or S3 access needed — task role gets no additional AWS permissions
// Only the execution role (for ECR pull and CloudWatch logs) is attached
For the IDP service, which does need DynamoDB:
new aws.iam.RolePolicy('idp-task-policy', {
role: idpTaskRole.id,
policy: JSON.stringify({
Version: '2012-10-17',
Statement: [{
Effect: 'Allow',
Action: [
'dynamodb:PutItem',
'dynamodb:GetItem',
'dynamodb:UpdateItem',
'dynamodb:Query',
],
Resource: [
`arn:aws:dynamodb:eu-west-2:${accountId}:table/gabaltech-idp-deployments`,
`arn:aws:dynamodb:eu-west-2:${accountId}:table/gabaltech-idp-deployments/index/*`,
],
}],
}),
});
Note the explicit resource ARNs — not *. If the DynamoDB permissions are ever abused, the blast radius is limited to that specific table.
Layer 4: Network Controls
Fargate tasks run in VPC subnets. Place application tasks in private subnets — no public IP, no direct internet ingress. All inbound traffic comes through the Application Load Balancer in the public subnet.
Task security groups follow the same least-privilege principle:
const taskSg = new aws.ec2.SecurityGroup('website-task', {
vpcId: vpc.id,
ingress: [{
fromPort: 3000,
toPort: 3000,
protocol: 'tcp',
securityGroups: [albSg.id], // Only ALB can reach the task
}],
egress: [{
fromPort: 443,
toPort: 443,
protocol: 'tcp',
cidrBlocks: ['0.0.0.0/0'], // HTTPS out only (ECR, CloudWatch, external APIs)
}],
});
The task can only receive traffic from the ALB and can only initiate HTTPS outbound connections. No SSH, no arbitrary outbound.
Layer 5: ECS Exec for Secure Debugging
Traditionally, debugging a running container required SSH access to the underlying host. Fargate removes the host — so how do you debug?
ECS Exec uses AWS Systems Manager Session Manager to open an interactive shell session directly into a running Fargate task. No SSH, no open ports, no credentials to manage — the session goes through the SSM control plane.
To enable it:
const service = new aws.ecs.Service('website', {
// ...
enableExecuteCommand: true, // Enable ECS Exec
});
The task role needs additional SSM permissions:
{
"Effect": "Allow",
"Action": [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel"
],
"Resource": "*"
}
Usage:
aws ecs execute-command \
--cluster gabaltech-cluster \
--task TASK_ID \
--container website \
--interactive \
--command "/bin/sh"
All ECS Exec sessions are logged to CloudTrail — you have an audit trail of who connected to which task, when.
Important: Restrict who can use ECS Exec via IAM. The ecs:ExecuteCommand action should be granted only to engineers who need it, not all developers.
Putting It Together
This container security model is defence in depth:
| Layer | Control | Protection |
|---|---|---|
| Image | Minimal base, non-root user, multi-stage build | Smaller attack surface, privilege escalation prevention |
| Registry | ECR Enhanced scanning, Inspector2 | Known CVE detection before deployment |
| IAM | Least-privilege task roles | Limit blast radius of compromise |
| Network | Private subnets, restrictive SGs | Prevent direct external access |
| Debug access | ECS Exec via SSM | Audited access, no SSH ports |
None of these controls is individually sufficient. An attacker who bypasses the network controls still faces least-privilege IAM. An attacker who gets code execution in a non-root container has limited impact. The combination makes a real difference.
Fargate's managed host model handles a lot — patch management, kernel updates, hypervisor security. But the application layer is still yours. Treat it accordingly.