IaC AWS

Terraform Module: AWS EFS — encrypted, multi-AZ shared file storage in one call

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:

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 configlive/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 configlive/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

TerraformAWSEFSModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading