This post walks through a CloudFormation template that sets up two EC2 instances sharing an EFS filesystem — one in a public subnet, one in a private subnet. The goal is a working shared mount across both instances, with encryption in transit enforced and access controlled through IAM.
Full template on GitLab: https://git.naxslabs.com/cf/efs
Architecture
| Resource | Purpose |
|---|---|
| USEASTA (10.10.1.0/24) | Public subnet — internet facing, hosts InstanceEastA |
| USEASTB (10.10.2.0/24) | Private subnet — no public IP, hosts InstanceEastB |
| NAT Gateway | Outbound internet access for the private subnet |
| EFS FileSystem | Shared NFS filesystem mounted on both instances |
Networking
MyIGW:
Type: AWS::EC2::InternetGateway
MyIGWAttachment:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref MyVPC
InternetGatewayId: !Ref MyIGW
MyEIP:
Type: AWS::EC2::EIP
Properties:
Domain: vpc
MyNatGW:
Type: AWS::EC2::NatGateway
DependsOn: MyIGWAttachment
Properties:
SubnetId: !Ref USEASTA
AllocationId: !GetAtt MyEIP.AllocationId
The internet gateway handles inbound and outbound traffic for the public subnet. The NAT gateway sits in USEASTA with an Elastic IP and handles outbound-only traffic for USEASTB — the private instance can reach the internet to install packages but nothing from the internet can reach it directly.
MyRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref MyVPC
MyRoute:
Type: AWS::EC2::Route
DependsOn: MyIGWAttachment
Properties:
RouteTableId: !Ref MyRouteTable
DestinationCidrBlock: "0.0.0.0/0"
GatewayId: !Ref MyIGW
MyRTAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref USEASTA
RouteTableId: !Ref MyRouteTable
PrivateRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref MyVPC
PrivateRoute:
Type: AWS::EC2::Route
Properties:
RouteTableId: !Ref PrivateRouteTable
DestinationCidrBlock: "0.0.0.0/0"
NatGatewayId: !Ref MyNatGW
PrivateRTAssociationB:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref USEASTB
RouteTableId: !Ref PrivateRouteTable
Two route tables — one public pointing to the IGW, one private pointing to the NAT gateway. USEASTA and USEASTB are explicitly associated with their respective tables. The private subnet’s default route goes to the NAT gateway rather than the IGW, which is what makes it private.
Security Groups
MySecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Allow SSH Access
GroupName: naxs-ssh-sg
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 22
ToPort: 22
CidrIp: 0.0.0.0/0
VpcId: !Ref MyVPC
WebSG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Web tier - internet facing
GroupName: naxs-web-sg
VpcId: !Ref MyVPC
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 443
ToPort: 443
CidrIp: 0.0.0.0/0
- IpProtocol: tcp
FromPort: 80
ToPort: 80
CidrIp: 0.0.0.0/0
AppSG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: App tier - web tier access only
GroupName: naxs-app-sg
VpcId: !Ref MyVPC
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 8080
ToPort: 8080
SourceSecurityGroupId: !Ref WebSG
- IpProtocol: tcp
FromPort: 22
ToPort: 22
SourceSecurityGroupId: !Ref WebSG
EfsSG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: EFS access
GroupName: naxs-efs-sg
VpcId: !Ref MyVPC
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 2049
ToPort: 2049
SourceSecurityGroupId: !Ref WebSG
- IpProtocol: tcp
FromPort: 2049
ToPort: 2049
SourceSecurityGroupId: !Ref AppSG
EfsSG controls access to the EFS mount targets on port 2049 (NFS). Only instances holding WebSG or AppSG can reach it. The EfsSG is attached to the mount targets, not the instances — the instances initiate outbound to port 2049 and the mount target’s security group decides whether to allow it.
SSH open to the world on MySecurityGroup is intentional for demo purposes. In production restrict this to a known CIDR or use SSM Session Manager.
IAM Role
NXSEfsRole:
Type: AWS::IAM::Role
Properties:
RoleName: NXSEfsRole
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: ec2.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: EfsAccess
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- elasticfilesystem:ClientMount
- elasticfilesystem:ClientWrite
- elasticfilesystem:ClientRootAccess
Resource: "*"
NXSEfsInstanceProfile:
Type: AWS::IAM::InstanceProfile
Properties:
Roles:
- !Ref NXSEfsRole
The EFS filesystem is encrypted, which means the mount helper needs IAM credentials to authenticate. The role grants ClientMount, ClientWrite, and ClientRootAccess on EFS. The instance profile wraps the role so it can be attached to EC2 instances — you can’t attach a role directly to an instance, only through a profile.
The trust policy’s Principal: ec2.amazonaws.com is what allows EC2 to assume this role. Without it the role exists but no service can use it.
EFS Filesystem and Mount Targets
MyEFS:
Type: AWS::EFS::FileSystem
Properties:
Encrypted: true
EfsMountTargetA:
Type: AWS::EFS::MountTarget
Properties:
FileSystemId: !Ref MyEFS
SubnetId: !Ref USEASTA
SecurityGroups:
- !Ref EfsSG
EfsMountTargetB:
Type: AWS::EFS::MountTarget
Properties:
FileSystemId: !Ref MyEFS
SubnetId: !Ref USEASTB
SecurityGroups:
- !Ref EfsSG
The EFS filesystem itself is a single resource. Mount targets are the network endpoints that instances use to connect to it — one per subnet. Each mount target gets an IP address in its subnet and has EfsSG attached to control access. Instances connect to the mount target in their own subnet rather than crossing subnet boundaries.
EC2 Instances
InstanceEastA:
Type: AWS::EC2::Instance
Properties:
SubnetId: !Ref USEASTA
InstanceType: t2.micro
ImageId: "ami-0ea87431b78a82070"
IamInstanceProfile: !Ref NXSEfsInstanceProfile
KeyName: "myectwos"
SecurityGroupIds:
- !Ref MySecurityGroup
- !Ref WebSG
MetadataOptions:
HttpTokens: required
HttpEndpoint: enabled
BlockDeviceMappings:
- DeviceName: /dev/xvda
Ebs:
VolumeSize: 8
VolumeType: gp3
Encrypted: true
InstanceEastB:
Type: AWS::EC2::Instance
Properties:
SubnetId: !Ref USEASTB
InstanceType: t2.micro
ImageId: "ami-0ea87431b78a82070"
IamInstanceProfile: !Ref NXSEfsInstanceProfile
KeyName: "myectwos"
SecurityGroupIds:
- !Ref AppSG
MetadataOptions:
HttpTokens: required
HttpEndpoint: enabled
BlockDeviceMappings:
- DeviceName: /dev/xvda
Ebs:
VolumeSize: 8
VolumeType: gp3
Encrypted: true
Both instances get the EFS instance profile so the mount helper can authenticate. InstanceEastA is the public entry point — it gets both MySecurityGroup for SSH and WebSG for HTTP/HTTPS. InstanceEastB sits in the private subnet with AppSG only, reachable by SSH from the web tier and on port 8080.
IMDSv2 is enforced on both instances via HttpTokens: required and EBS volumes are encrypted at rest.
Mounting EFS
Once the stack is deployed, install the EFS mount helper on each instance and mount the filesystem:
sudo yum install -y amazon-efs-utils
sudo mkdir /mnt/efs
sudo mount -t efs -o tls fs-xxxxxxxxx:/ /mnt/efs
The -o tls flag enforces encryption in transit. The mount helper picks up the IAM credentials from the instance profile automatically — no credentials file needed.
To persist the mount across reboots add it to /etc/fstab:
fs-xxxxxxxxx:/ /mnt/efs efs _netdev,tls 0 0
The NAT gateway is the main cost driver here — it charges hourly plus per GB of data processed. Delete the stack when you’re done with the lab. EFS charges per GB stored so an empty filesystem costs essentially nothing.
Full template: https://git.naxslabs.com/cf/efs
