Quick take — A production-ready Terraform module for AWS EFS: KMS encryption, per-AZ mount targets, lifecycle policies for IA/Archive tiering, backups, and a least-privilege file system policy. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "aws" {
region = "us-east-1"
}
module "efs" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-efs?ref=v1.0.0"
name = "..." # Name / creation token for the file system; used as tag …
vpc_id = "..." # VPC in which the mount target security group is created.
subnet_ids = ["...", "..."] # Subnets (one per AZ) that receive a mount target.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Amazon EFS (Elastic File System) is a fully managed, elastic NFSv4.1 file system that multiple EC2 instances, ECS/Fargate tasks, and Lambda functions can mount at the same time, across Availability Zones, with no capacity to provision. It is the answer when several compute nodes need to share the same files — CMS uploads, ML training datasets, shared application state, CI artifacts — and an object store like S3 doesn’t fit because the workload expects POSIX semantics and a real file path.
The catch is that a correct EFS deployment is never just one resource. To be usable and safe you also need a mount target in every AZ your subnets span (the resource clients actually connect to), a security group that allows NFS (TCP 2049) only from your application tier, encryption at rest with a customer-managed KMS key, lifecycle policies to drop cold data into Infrequent Access and Archive storage classes, and usually a file system policy that enforces encryption in transit and denies anonymous access. Wiring all of that by hand in every project is exactly the kind of repetitive, easy-to-get-subtly-wrong work that belongs in a reusable module. This module turns the whole bundle — aws_efs_file_system, its mount targets, the NFS security group, lifecycle tiering, backup policy, and an optional access point — into a few well-validated variables.
When to use it
Reach for this module when:
- Multiple compute instances need a shared POSIX filesystem — for example a horizontally scaled web tier where every node must read and write the same
/var/www/uploads, or several ECS tasks sharing a config volume. - You are running containers on ECS/Fargate or EKS and want durable, AZ-resilient storage that survives task restarts (EFS is the standard persistent-volume backend for Fargate).
- You need elastic, pay-per-use capacity and don’t want to size or grow EBS volumes — EFS grows and shrinks automatically and you pay only for what you store.
- Lambda functions require more than 512 MB of
/tmpor need to share large dependencies/models across invocations via an access point.
Skip EFS (and this module) when a single instance owns the data and low-latency block storage is the priority — use EBS. If the access pattern is object/blob with no POSIX requirement, use S3. EFS is for shared, file-semantic, multi-writer workloads.
Module structure
terraform-module-aws-efs/
├── versions.tf # provider + Terraform version pins
├── main.tf # aws_efs_file_system + mount targets + SG + policy + access point
├── variables.tf # var-driven inputs with validations
└── outputs.tf # id/arn/dns + mount target + access point outputs
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
# One mount target per subnet the caller passes in. EFS allows exactly one
# mount target per AZ, so callers must pass at most one subnet per AZ.
mount_target_subnets = toset(var.subnet_ids)
name_tag = { Name = var.name }
}
# Security group for the NFS mount targets. Ingress on 2049 is restricted to
# the caller-supplied client security groups and/or CIDRs — never 0.0.0.0/0.
resource "aws_security_group" "this" {
name_prefix = "${var.name}-efs-"
description = "NFS access to EFS file system ${var.name}"
vpc_id = var.vpc_id
tags = merge(var.tags, local.name_tag)
lifecycle {
create_before_destroy = true
}
}
resource "aws_security_group_rule" "ingress_sg" {
for_each = toset(var.allowed_security_group_ids)
type = "ingress"
description = "NFS from client security group ${each.value}"
from_port = 2049
to_port = 2049
protocol = "tcp"
source_security_group_id = each.value
security_group_id = aws_security_group.this.id
}
resource "aws_security_group_rule" "ingress_cidr" {
count = length(var.allowed_cidr_blocks) > 0 ? 1 : 0
type = "ingress"
description = "NFS from allowed CIDR blocks"
from_port = 2049
to_port = 2049
protocol = "tcp"
cidr_blocks = var.allowed_cidr_blocks
security_group_id = aws_security_group.this.id
}
# The file system itself: encrypted at rest, with a performance/throughput
# profile and a configurable lifecycle policy for IA/Archive tiering.
resource "aws_efs_file_system" "this" {
creation_token = var.name
encrypted = true
kms_key_id = var.kms_key_arn
performance_mode = var.performance_mode
throughput_mode = var.throughput_mode
provisioned_throughput_in_mibps = (
var.throughput_mode == "provisioned" ? var.provisioned_throughput_in_mibps : null
)
# Tier rarely-touched data down to save up to ~92% on storage cost, and
# bring it back to Standard on first access when requested.
dynamic "lifecycle_policy" {
for_each = var.transition_to_ia != null ? [1] : []
content {
transition_to_ia = var.transition_to_ia
}
}
dynamic "lifecycle_policy" {
for_each = var.transition_to_archive != null ? [1] : []
content {
transition_to_archive = var.transition_to_archive
}
}
dynamic "lifecycle_policy" {
for_each = var.transition_to_primary_storage_on_access ? [1] : []
content {
transition_to_primary_storage_class = "AFTER_1_ACCESS"
}
}
# Built-in automatic backups via AWS Backup default plan.
dynamic "protection" {
for_each = var.replication_overwrite_protection_enabled ? [1] : []
content {
replication_overwrite = "ENABLED"
}
}
tags = merge(var.tags, local.name_tag)
}
# Toggle AWS Backup automatic backups (separate from any custom backup plan).
resource "aws_efs_backup_policy" "this" {
file_system_id = aws_efs_file_system.this.id
backup_policy {
status = var.backup_enabled ? "ENABLED" : "DISABLED"
}
}
# One mount target per supplied subnet. Clients in that AZ connect here.
resource "aws_efs_mount_target" "this" {
for_each = local.mount_target_subnets
file_system_id = aws_efs_file_system.this.id
subnet_id = each.value
security_groups = [aws_security_group.this.id]
}
# Enforce encryption in transit and deny anonymous access. Optionally lock the
# policy down so the root account cannot accidentally be the only principal.
resource "aws_efs_file_system_policy" "this" {
count = var.enforce_in_transit_encryption ? 1 : 0
file_system_id = aws_efs_file_system.this.id
bypass_policy_lockout_safety_check = false
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyUnencryptedTransport"
Effect = "Deny"
Principal = { AWS = "*" }
Action = "*"
Resource = aws_efs_file_system.this.arn
Condition = {
Bool = { "aws:SecureTransport" = "false" }
}
}
]
})
}
# Optional POSIX access point — the standard way to give containers/Lambda a
# scoped entry directory with an enforced UID/GID, no root on the host needed.
resource "aws_efs_access_point" "this" {
for_each = { for ap in var.access_points : ap.name => ap }
file_system_id = aws_efs_file_system.this.id
posix_user {
uid = each.value.uid
gid = each.value.gid
}
root_directory {
path = each.value.path
creation_info {
owner_uid = each.value.uid
owner_gid = each.value.gid
permissions = each.value.permissions
}
}
tags = merge(var.tags, { Name = "${var.name}-${each.value.name}" })
}
variables.tf
variable "name" {
description = "Name / creation token for the EFS file system; used as a tag and resource prefix."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9][a-zA-Z0-9-_]{1,63}$", var.name))
error_message = "name must be 2-64 chars, start alphanumeric, and contain only letters, digits, hyphens, or underscores."
}
}
variable "vpc_id" {
description = "VPC in which the mount target security group is created."
type = string
}
variable "subnet_ids" {
description = "Subnets (one per AZ) that receive a mount target. EFS allows only one mount target per AZ."
type = list(string)
validation {
condition = length(var.subnet_ids) > 0
error_message = "Provide at least one subnet so the file system has a mount target."
}
}
variable "kms_key_arn" {
description = "ARN of a customer-managed KMS key for encryption at rest. Set to null to use the aws/elasticfilesystem AWS-managed key."
type = string
default = null
}
variable "performance_mode" {
description = "EFS performance mode: 'generalPurpose' (default, lowest latency) or 'maxIO'."
type = string
default = "generalPurpose"
validation {
condition = contains(["generalPurpose", "maxIO"], var.performance_mode)
error_message = "performance_mode must be 'generalPurpose' or 'maxIO'."
}
}
variable "throughput_mode" {
description = "Throughput mode: 'elastic' (recommended), 'bursting', or 'provisioned'."
type = string
default = "elastic"
validation {
condition = contains(["elastic", "bursting", "provisioned"], var.throughput_mode)
error_message = "throughput_mode must be 'elastic', 'bursting', or 'provisioned'."
}
}
variable "provisioned_throughput_in_mibps" {
description = "Throughput in MiB/s when throughput_mode is 'provisioned'. Ignored otherwise."
type = number
default = null
validation {
condition = var.provisioned_throughput_in_mibps == null || try(var.provisioned_throughput_in_mibps > 0, false)
error_message = "provisioned_throughput_in_mibps must be a positive number when set."
}
}
variable "transition_to_ia" {
description = "Move files to Infrequent Access after this idle period. One of AFTER_1_DAY, AFTER_7_DAYS, AFTER_14_DAYS, AFTER_30_DAYS, AFTER_60_DAYS, AFTER_90_DAYS, or null to disable."
type = string
default = "AFTER_30_DAYS"
validation {
condition = var.transition_to_ia == null || contains(
["AFTER_1_DAY", "AFTER_7_DAYS", "AFTER_14_DAYS", "AFTER_30_DAYS", "AFTER_60_DAYS", "AFTER_90_DAYS"],
coalesce(var.transition_to_ia, "AFTER_30_DAYS")
)
error_message = "transition_to_ia must be one of the allowed AFTER_* values or null."
}
}
variable "transition_to_archive" {
description = "Move IA files to the Archive storage class after this idle period (e.g. AFTER_90_DAYS, AFTER_180_DAYS), or null to disable."
type = string
default = null
validation {
condition = var.transition_to_archive == null || contains(
["AFTER_1_DAY", "AFTER_7_DAYS", "AFTER_14_DAYS", "AFTER_30_DAYS", "AFTER_60_DAYS", "AFTER_90_DAYS", "AFTER_180_DAYS", "AFTER_270_DAYS", "AFTER_365_DAYS"],
coalesce(var.transition_to_archive, "AFTER_90_DAYS")
)
error_message = "transition_to_archive must be one of the allowed AFTER_* values or null."
}
}
variable "transition_to_primary_storage_on_access" {
description = "If true, files in IA/Archive return to Standard on first access (AFTER_1_ACCESS)."
type = bool
default = true
}
variable "backup_enabled" {
description = "Enable AWS Backup automatic daily backups for the file system."
type = bool
default = true
}
variable "replication_overwrite_protection_enabled" {
description = "Enable replication overwrite protection (set to ENABLED for a non-destination file system)."
type = bool
default = true
}
variable "enforce_in_transit_encryption" {
description = "Attach a file system policy that denies any access not using TLS (aws:SecureTransport=false)."
type = bool
default = true
}
variable "allowed_security_group_ids" {
description = "Client security group IDs allowed to reach NFS (port 2049) on the mount targets."
type = list(string)
default = []
}
variable "allowed_cidr_blocks" {
description = "CIDR blocks allowed to reach NFS (port 2049). Prefer security groups over CIDRs where possible."
type = list(string)
default = []
validation {
condition = !contains(var.allowed_cidr_blocks, "0.0.0.0/0")
error_message = "Refusing to open NFS to 0.0.0.0/0. Restrict to your application subnets."
}
}
variable "access_points" {
description = "Optional POSIX access points to create (name, root path, enforced uid/gid, and dir permissions)."
type = list(object({
name = string
path = string
uid = number
gid = number
permissions = string
}))
default = []
}
variable "tags" {
description = "Tags applied to all resources created by the module."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "The ID of the EFS file system."
value = aws_efs_file_system.this.id
}
output "arn" {
description = "The ARN of the EFS file system."
value = aws_efs_file_system.this.arn
}
output "dns_name" {
description = "The DNS name (fs-xxxx.efs.<region>.amazonaws.com) used to mount the file system."
value = aws_efs_file_system.this.dns_name
}
output "name" {
description = "The creation token / name of the file system."
value = aws_efs_file_system.this.creation_token
}
output "security_group_id" {
description = "ID of the security group attached to the mount targets."
value = aws_security_group.this.id
}
output "mount_target_ids" {
description = "Map of subnet ID to mount target ID."
value = { for s, mt in aws_efs_mount_target.this : s => mt.id }
}
output "mount_target_dns_names" {
description = "Map of subnet ID to the AZ-specific mount target DNS name."
value = { for s, mt in aws_efs_mount_target.this : s => mt.mount_target_dns_name }
}
output "access_point_ids" {
description = "Map of access point name to its access point ID."
value = { for k, ap in aws_efs_access_point.this : k => ap.id }
}
output "access_point_arns" {
description = "Map of access point name to its access point ARN."
value = { for k, ap in aws_efs_access_point.this : k => ap.arn }
}
How to use it
module "efs" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-efs?ref=v1.0.0"
name = "platform-shared"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids # one per AZ
kms_key_arn = aws_kms_key.efs.arn
throughput_mode = "elastic"
performance_mode = "generalPurpose"
# Cost: cool down idle data, restore on access.
transition_to_ia = "AFTER_30_DAYS"
transition_to_archive = "AFTER_90_DAYS"
transition_to_primary_storage_on_access = true
backup_enabled = true
enforce_in_transit_encryption = true
# Only the app tier may mount it.
allowed_security_group_ids = [aws_security_group.app.id]
access_points = [{
name = "uploads"
path = "/uploads"
uid = 1000
gid = 1000
permissions = "0755"
}]
tags = {
Environment = "production"
Team = "platform"
}
}
# Downstream: mount the access point in an ECS task definition volume.
resource "aws_ecs_task_definition" "web" {
family = "web"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = 512
memory = 1024
container_definitions = jsonencode([/* ... */])
volume {
name = "uploads"
efs_volume_configuration {
file_system_id = module.efs.id
transit_encryption = "ENABLED"
authorization_config {
access_point_id = module.efs.access_point_ids["uploads"]
iam = "ENABLED"
}
}
}
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 state bucket/container + key per path...
}
}
2. Module config — live/prod/efs/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-efs?ref=v1.0.0"
}
inputs = {
name = "..."
vpc_id = "..."
subnet_ids = ["...", "..."]
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/efs && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | Name / creation token for the file system; used as tag and resource prefix. |
vpc_id |
string |
— | Yes | VPC in which the mount target security group is created. |
subnet_ids |
list(string) |
— | Yes | Subnets (one per AZ) that receive a mount target. |
kms_key_arn |
string |
null |
No | Customer-managed KMS key ARN for encryption at rest; null uses the AWS-managed key. |
performance_mode |
string |
"generalPurpose" |
No | generalPurpose or maxIO. |
throughput_mode |
string |
"elastic" |
No | elastic, bursting, or provisioned. |
provisioned_throughput_in_mibps |
number |
null |
No | Throughput in MiB/s when throughput_mode = "provisioned". |
transition_to_ia |
string |
"AFTER_30_DAYS" |
No | Idle period before moving files to Infrequent Access, or null to disable. |
transition_to_archive |
string |
null |
No | Idle period before moving IA files to Archive, or null to disable. |
transition_to_primary_storage_on_access |
bool |
true |
No | Return IA/Archive files to Standard on first access. |
backup_enabled |
bool |
true |
No | Enable AWS Backup automatic daily backups. |
replication_overwrite_protection_enabled |
bool |
true |
No | Enable replication overwrite protection for a non-destination file system. |
enforce_in_transit_encryption |
bool |
true |
No | Attach a policy denying access without TLS. |
allowed_security_group_ids |
list(string) |
[] |
No | Client SGs allowed to reach NFS (2049). |
allowed_cidr_blocks |
list(string) |
[] |
No | CIDRs allowed to reach NFS (2049); 0.0.0.0/0 is rejected. |
access_points |
list(object) |
[] |
No | POSIX access points (name, path, uid, gid, permissions). |
tags |
map(string) |
{} |
No | Tags applied to all created resources. |
Outputs
| Name | Description |
|---|---|
id |
The ID of the EFS file system. |
arn |
The ARN of the EFS file system. |
dns_name |
DNS name used to mount the file system. |
name |
Creation token / name of the file system. |
security_group_id |
ID of the security group on the mount targets. |
mount_target_ids |
Map of subnet ID to mount target ID. |
mount_target_dns_names |
Map of subnet ID to AZ-specific mount target DNS name. |
access_point_ids |
Map of access point name to access point ID. |
access_point_arns |
Map of access point name to access point ARN. |
Enterprise scenario
A media SaaS runs its image-processing tier as a horizontally scaled ECS Fargate service spread across three AZs, and every task must read and write the same library of original uploads and rendered derivatives. The platform team consumes this module once with subnet_ids covering all three private subnets and an uploads access point pinned to UID/GID 1000, so each task mounts the shared tree over TLS with IAM authorization and no host root access. Lifecycle tiering moves originals untouched for 30 days into Infrequent Access and then to Archive after 90, cutting storage spend on the long tail of old assets by over 80%, while transition_to_primary_storage_on_access keeps a re-rendered older asset fast again the moment it’s read.
Best practices
- Always encrypt at rest with a customer-managed KMS key (
kms_key_arn) rather than the AWS-managed key — it gives you key rotation, access policies, and CloudTrail visibility, and it’s required by most compliance baselines. - Enforce encryption in transit. Keep
enforce_in_transit_encryption = trueand mount withtransit_encryption = "ENABLED"(TLS) on every client; the attached policy hard-denies any non-TLS connection so a misconfigured client fails loudly instead of leaking traffic. - Lock the security group to client SGs, never CIDRs to the world. Pass
allowed_security_group_idsfor your app tier; the module refuses0.0.0.0/0on port 2049 outright. EFS exposed to the internet is a classic data-exfiltration finding. - Right-size cost with lifecycle tiering and elastic throughput. Use
throughput_mode = "elastic"so you pay per use instead of provisioning, and lettransition_to_ia/transition_to_archivepush cold data down — IA and Archive are dramatically cheaper than Standard for data you rarely touch. - One mount target per AZ, in private subnets. Provide exactly one subnet per AZ (EFS rejects two mount targets in the same AZ) and keep them private; clients always connect to the mount target in their own AZ to avoid cross-AZ data charges and latency.
- Use access points instead of raw root mounts for containers and Lambda — they enforce a fixed POSIX user and a scoped root directory, give you clean per-app isolation on a shared file system, and pair naturally with IAM authorization for least privilege.