I Tried Configuring readonlyRootFilesystem + tmpfs in a Custom Task Definition for ECS Express Mode with CloudFormation
This page has been translated by machine translation. View original
Introduction
In July 2026, ECS Express Mode added support for custom task definitions.
In the article above, I created an Express Mode service using the CLI. This time, I confirmed that CloudFormation also supports custom task definitions, so I'll walk through how to declaratively configure readonlyRootFilesystem and tmpfs.
Verification Details
Verification Environment
| Item | Value |
|---|---|
| Region | us-west-2 (Oregon) |
| Stack name | express-nginx-readonly-v2 |
| Resource type | AWS::ECS::ExpressGatewayService |
| Task definition | AWS::ECS::TaskDefinition (custom) |
| CPU / Memory | 0.25 vCPU / 512 MiB |
| Architecture | ARM64 |
| Container | Custom image based on nginx:1.27.5-alpine (ECR, listens on 8080, responds on /health) |
CloudFormation Template
readonlyRootFilesystem + tmpfs
Set ReadonlyRootFilesystem: true in the container definition, and mount tmpfs on the paths that require write access.
ContainerDefinitions:
- Name: Main
ReadonlyRootFilesystem: true
LinuxParameters:
Tmpfs:
- ContainerPath: /tmp
Size: 128
MountOptions:
- noexec
- nosuid
- ContainerPath: /var/lib/amazon/ssm
Size: 16
MountOptions:
- nosuid
- ContainerPath: /var/log/amazon/ssm
Size: 16
MountOptions:
- nosuid
In this verification, since I used ECS Exec for checking, I mounted tmpfs on /var/lib/amazon/ssm and /var/log/amazon/ssm as write destinations for the SSM core agent. Note that the official documentation states that combining ECS Exec with readonlyRootFilesystem is not supported. If you don't use ECS Exec, the tmpfs for these two paths is unnecessary. For details on Fargate's tmpfs support, see the following article.
ARM64
To use an image built on Apple silicon as is, I specified ARM64 with RuntimePlatform.
RuntimePlatform:
CpuArchitecture: ARM64
OperatingSystemFamily: LINUX
The three IAM roles (Execution / Task / Infrastructure) are defined within the stack. See the full template for details.
Linking the Custom Task Definition
Specify !Ref CustomTaskDefinition for TaskDefinitionArn of AWS::ECS::ExpressGatewayService.
ExpressModeService:
Type: AWS::ECS::ExpressGatewayService
Properties:
ServiceName: !Sub '${ServiceName}-service'
Cluster: 'default'
InfrastructureRoleArn: !GetAtt InfrastructureRole.Arn
TaskDefinitionArn: !Ref CustomTaskDefinition
HealthCheckPath: '/health'
Full template (click to expand)
AWSTemplateFormatVersion: '2010-09-09'
Description: 'ECS Express Mode + readonlyRootFilesystem + tmpfs - 完全自己完結型 ARM64 版'
Parameters:
ServiceName:
Type: String
Default: 'express-nginx-readonly'
Resources:
# ===== IAM Roles =====
# ExecutionRole: ECSがイメージpull・ログ送信に使用
ExecutionRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub '${ServiceName}-execution-role'
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: ecs-tasks.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
# TaskRole: コンテナ内アプリが使用(ECS Exec用)
TaskRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub '${ServiceName}-task-role'
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: ecs-tasks.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: ECSExecPolicy
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- ssmmessages:CreateControlChannel
- ssmmessages:CreateDataChannel
- ssmmessages:OpenControlChannel
- ssmmessages:OpenDataChannel
Resource: '*'
# InfrastructureRole: ECS Express Gatewayサービス管理用
InfrastructureRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub '${ServiceName}-infra-role'
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: AllowAccessInfrastructureForECSExpressServices
Effect: Allow
Principal:
Service: ecs.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices
# ===== ECS Resources =====
ECSLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub '/aws/ecs/default/${ServiceName}-service'
RetentionInDays: 7
CustomTaskDefinition:
Type: AWS::ECS::TaskDefinition
Properties:
Family: !Sub '${ServiceName}'
RequiresCompatibilities:
- FARGATE
NetworkMode: awsvpc
Cpu: '256'
Memory: '512'
ExecutionRoleArn: !GetAtt ExecutionRole.Arn
TaskRoleArn: !GetAtt TaskRole.Arn
RuntimePlatform:
CpuArchitecture: ARM64
OperatingSystemFamily: LINUX
ContainerDefinitions:
- Name: Main
Image: !Sub '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/express-mode-nginx-tmpfs:latest'
Essential: true
ReadonlyRootFilesystem: true
PortMappings:
- ContainerPort: 8080
Protocol: tcp
Name: http
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: !Sub '/aws/ecs/default/${ServiceName}-service'
awslogs-region: !Ref 'AWS::Region'
awslogs-stream-prefix: ecs
HealthCheck:
Command:
- CMD-SHELL
- 'wget -qO- http://localhost:8080/health || exit 1'
Interval: 15
Timeout: 5
Retries: 3
StartPeriod: 10
LinuxParameters:
Tmpfs:
- ContainerPath: /tmp
Size: 128
MountOptions:
- noexec
- nosuid
- ContainerPath: /var/lib/amazon/ssm
Size: 16
MountOptions:
- nosuid
- ContainerPath: /var/log/amazon/ssm
Size: 16
MountOptions:
- nosuid
ExpressModeService:
Type: AWS::ECS::ExpressGatewayService
DependsOn: ECSLogGroup
Properties:
ServiceName: !Sub '${ServiceName}-service'
Cluster: 'default'
InfrastructureRoleArn: !GetAtt InfrastructureRole.Arn
TaskDefinitionArn: !Ref CustomTaskDefinition
HealthCheckPath: '/health'
NetworkConfiguration:
Subnets:
- subnet-xxxxxxxxxxxxxxxxx
- subnet-xxxxxxxxxxxxxxxxx
- subnet-xxxxxxxxxxxxxxxxx
ScalingTarget:
MinTaskCount: 1
MaxTaskCount: 1
AutoScalingMetric: 'AVERAGE_CPU'
AutoScalingTargetValue: 70
Outputs:
ServiceEndpoint:
Value: !GetAtt ExpressModeService.Endpoint
TaskDefinitionArn:
Value: !Ref CustomTaskDefinition
Deployment
aws cloudformation deploy \
--stack-name express-nginx-readonly-v2 \
--template-file express-nginx-readonly-standalone.yaml \
--parameter-overrides ServiceName=express-nginx-readonly-v2 \
--capabilities CAPABILITY_NAMED_IAM \
--region us-west-2
When the stack status becomes CREATE_COMPLETE, the deployment is complete.
Preparation for Checking the Configuration (CLI)
To check the deployed configuration from the inside, I enabled ECS Exec and shortened the bake time. Neither affects the deployed settings.
Enabling ECS Exec + Shortening the Bake Time
I enabled ECS Exec so I could check the configuration from inside the container using mount and uname. I also changed the bake time to shorten the wait during verification. By default, Express Mode sets a Canary bake time of 3 minutes plus a deployment bake time of 3 minutes, so I set both to 0. In production, I recommend operating with appropriate bake times.
aws ecs update-service \
--cluster default \
--service express-nginx-readonly-v2-service \
--enable-execute-command \
--deployment-configuration '{
"strategy": "CANARY",
"bakeTimeInMinutes": 0,
"canaryConfiguration": {
"canaryPercent": 5.0,
"canaryBakeTimeInMinutes": 0
}
}' \
--region us-west-2
This update-service triggers a deployment, and a new task is started. Because ECS Exec is not applied to existing tasks, run execute-command after the new task reaches RUNNING.
Operation Check (ECS Exec)
Health Check
$ curl -s "https://ex-xxxxxxxx.ecs.us-west-2.on.aws/health"
ok
Confirming the Root Filesystem Is Read-Only
$ aws ecs execute-command --cluster default \
--task <task-id> --container Main \
--command "touch /test.txt" --interactive
touch: /test.txt: Read-only file system
Confirming Writes to tmpfs
$ aws ecs execute-command --cluster default \
--task <task-id> --container Main \
--command "sh -c 'echo test > /tmp/test.txt && cat /tmp/test.txt'" --interactive
test
tmpfs Size and Mount Options
$ aws ecs execute-command --cluster default \
--task <task-id> --container Main \
--command "df -h /tmp" --interactive
Filesystem Size Used Available Use% Mounted on
tmpfs 128.0M 280.0K 127.7M 0% /tmp
$ aws ecs execute-command --cluster default \
--task <task-id> --container Main \
--command "mount | grep tmpfs" --interactive
tmpfs on /tmp type tmpfs (rw,nosuid,noexec,relatime,size=131072k)
tmpfs on /var/lib/amazon/ssm type tmpfs (rw,nosuid,relatime,size=16384k)
tmpfs on /var/log/amazon/ssm type tmpfs (rw,nosuid,relatime,size=16384k)
The three tmpfs mounts are each configured with the specified size. The mount options are noexec,nosuid for /tmp and nosuid for the two SSM paths.
Architecture Check
$ aws ecs execute-command --cluster default \
--task <task-id> --container Main \
--command "uname -m" --interactive
aarch64
uname -m returns aarch64, confirming that the container is running on an ARM64 environment as specified in RuntimePlatform.
Summary of Results
| Check item | Result |
|---|---|
| Health check (/health) | ok |
| Root write denied | Read-only file system |
| tmpfs write (/tmp) | Success |
| tmpfs size | 128MB / 16MB / 16MB |
| tmpfs mount options | /tmp: noexec,nosuid / for SSM: nosuid |
| Architecture | aarch64 (ARM64) |
Handling Security Hub Findings
ECS.5 (readonlyRootFilesystem)
The Security Hub ECS.5 control evaluates whether readonlyRootFilesystem is set to true in the task definition. In this configuration, ReadonlyRootFilesystem: true is declared in the custom task definition, so the task definition satisfies the ECS.5 evaluation criteria.
$ aws ecs describe-task-definition \
--task-definition express-nginx-readonly-v2 \
--query 'taskDefinition.containerDefinitions[?name==`Main`].readonlyRootFilesystem' \
--region us-west-2
[true]
When enabling readonlyRootFilesystem, I recommend evaluating in advance the paths and resources the application needs to write to.
ACM.1 (Certificate Expiration Management)
When a service is created, Express Mode automatically issues a domain in the format ex-XXX.ecs.<region>.on.aws and an ACM certificate. Before DNS validation is complete (PENDING_VALIDATION), data such as the expiration date cannot be retrieved for this certificate. As a result, Security Hub's ACM.1 may detect it as FAILED with Certificate data could not be retrieved.
If you repeatedly create and delete clusters in a verification environment, short-lived certificates are likely to be evaluated in this state, causing findings to occur. This certificate is an AWS-managed certificate tagged with AmazonECSManaged: true and cannot be renewed or controlled by the user. After the resource is deleted, it transitions to NOT_AVAILABLE and is excluded from Security Hub's scope, but if you have a detection-to-notification mechanism, consider suppressing ACM.1.
Conclusion
With ECS Express Mode's support for custom task definitions, an AWS::ECS::TaskDefinition defined in CloudFormation can now be referenced from AWS::ECS::ExpressGatewayService.
This lets you declare task definition settings such as readonlyRootFilesystem, tmpfs, and ARM64 as IaC and apply them from the moment the service is created. There is no longer any need to swap the task definition after creating the service, as was previously required. Procedures for making changes outside of IaC management are also unnecessary.
Note that items without a CloudFormation resource property, such as enabling ECS Exec, currently still need to be configured via the CLI, but I hope for support in the future.
