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 also be referenced from CloudFormation, making it easier to declaratively configure readonlyRootFilesystem and tmpfs as IaC. We will introduce verification steps that involve actually deploying in an ARM64 environment and using ECS Exec for confirmation purposes.
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, we created an Express Mode service using the CLI. This time, having confirmed that CloudFormation also supports custom task definitions, we will walk through the steps to declaratively configure readonlyRootFilesystem and tmpfs.

What We Verified

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, listening on port 8080, responds to /health)

CloudFormation Template

readonlyRootFilesystem + tmpfs

Set ReadonlyRootFilesystem: true in the container definition and mount tmpfs at 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 we use ECS Exec for confirmation purposes, we mounted tmpfs at /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 are not using ECS Exec, the tmpfs mounts for these two paths are unnecessary. For more details on Fargate's tmpfs support, please refer to the following article.

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

ARM64

To use images built on Apple Silicon as-is, we specified ARM64 in RuntimePlatform.

      RuntimePlatform:
        CpuArchitecture: ARM64
        OperatingSystemFamily: LINUX

The three IAM roles (Execution / Task / Infrastructure) are defined within the stack. For details, please refer to the full template.

Linking the Custom Task Definition

Specify !Ref CustomTaskDefinition in the 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

Deployment is complete once the stack status becomes CREATE_COMPLETE.

Preparation for Configuration Verification (CLI)

To verify the deployed configuration from inside the container, we enabled ECS Exec and shortened the bake time. Neither of these affects the already deployed configuration.

Enabling ECS Exec + Reducing Bake Time

We enable ECS Exec to verify configuration from inside the container using mount and uname. We also changed the bake time to reduce waiting time during verification. Since Express Mode defaults to a 3-minute canary bake time + 3-minute deployment bake time, we set both to 0. We recommend operating with appropriate bake times in production environments.

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 starts a new task. Since ECS Exec is not applied to existing tasks, run execute-command after the new task becomes RUNNING.

Verification (ECS Exec)

Health Check

$ curl -s "https://ex-xxxxxxxx.ecs.us-west-2.on.aws/health"
ok

Verifying 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

Verifying Write Access 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 sizes. The mount options are noexec,nosuid for /tmp, and nosuid for the two SSM paths.

Architecture Verification

$ aws ecs execute-command --cluster default \
  --task <task-id> --container Main \
  --command "uname -m" --interactive

aarch64

With uname -m returning aarch64, we confirmed that the environment is running on ARM64 as specified in RuntimePlatform.

Summary of Results

Verification Item Result
Health check (/health) ok
Root write rejection Read-only file system
tmpfs write (/tmp) Successful
tmpfs size 128MB / 16MB / 16MB
tmpfs mount options /tmp: noexec,nosuid / SSM: nosuid
Architecture aarch64 (ARM64)

Responding to Security Hub Findings

ECS.5 (readonlyRootFilesystem)

The Security Hub ECS.5 control evaluates whether readonlyRootFilesystem is set to true in a task definition. In this configuration, we declare ReadonlyRootFilesystem: true in the custom task definition, satisfying the evaluation criteria for ECS.5 at the task definition level.

$ 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, we recommend evaluating in advance which paths your application requires write access to and what resources are needed.

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 along with an ACM certificate. Before DNS validation is complete (PENDING_VALIDATION), data such as the certificate's expiration date cannot be retrieved. As a result, Security Hub's ACM.1 may trigger a FAILED finding reported as Certificate data could not be retrieved.

In a verification environment where the cluster is repeatedly created and deleted, short-lived certificates are more likely to be evaluated in this state, causing findings to occur. These certificates are AWS-managed certificates tagged with AmazonECSManaged: true and cannot be renewed or controlled by the user. After the resource is deleted, they transition to NOT_AVAILABLE and fall out of Security Hub's scope. However, if you have a finding-and-notification mechanism in place, consider suppressing ACM.1.

Summary

With ECS Express Mode's support for custom task definitions, it is now possible to reference an AWS::ECS::TaskDefinition defined in CloudFormation from AWS::ECS::ExpressGatewayService.

This allows task definition settings such as readonlyRootFilesystem, tmpfs, and ARM64 to be declared as IaC and applied from the time the service is created. There is no longer a need to swap out the task definition after service creation, as was previously necessary. Steps to make changes outside of IaC management are also no longer required.

Note that for items without a corresponding property in CloudFormation resources, such as enabling ECS Exec, CLI-based configuration is still required at this time, but we look forward to future support.

Share this article

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