I Tried Configuring readonlyRootFilesystem + tmpfs in a Custom Task Definition for ECS Express Mode with CloudFormation

I Tried Configuring readonlyRootFilesystem + tmpfs in a Custom Task Definition for ECS Express Mode with CloudFormation

ECS Express Mode custom task definitions can now be referenced from CloudFormation, making it easier to declare readonlyRootFilesystem and tmpfs as IaC. This post walks through deploying on ARM64 and verifying with ECS Exec.
2026.07.12

This page has been translated by machine translation. View original

Introduction

In July 2026, ECS Express Mode added support for custom task definitions.

https://dev.classmethod.jp/articles/ecs-express-mode-custom-task-definition/

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.

https://dev.classmethod.jp/articles/ecs-fargate-tmpfs-support/

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.

https://dev.classmethod.jp/articles/ecs-experss-reduce-deployment-time/

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.

Share this article

AWSのお困り事はクラスメソッドへ