Terraform Lesson 66 of 89

Terraform on AWS EKS: the EFS CSI Driver & Shared ReadWriteMany Storage

Every Kubernetes storage tutorial starts with a PersistentVolumeClaim and an EBS volume, and for a database or a single-writer cache that is exactly right. Then the requirement arrives that EBS simply cannot satisfy: three pods, possibly in three Availability Zones, all writing to the same directory at once. A CMS where any web replica must serve — and receive — the same user uploads. An ML training job whose workers all read one dataset and one checkpoint directory. A fleet of workers sharing a config or a prompt-template folder that an admin edits live. The instinct is to slap replicas: 3 on the Deployment and reuse the EBS PVC, and Kubernetes stops you cold: an EBS volume is ReadWriteOnce, attachable to exactly one node, and it lives in exactly one AZ. The second pod schedules onto a different node, the attach fails with a Multi-Attach error, and the pod hangs forever.

The answer is not a bigger EBS volume; it is a different kind of storage. Amazon EFS is a managed, elastic NFS filesystem: it presents one namespace reachable from every subnet in your VPC, so thousands of pods across every AZ can mount it ReadWriteMany and read each other’s writes in real time. This lesson builds that capability the way a platform team ships it — entirely in Terraform, on an existing EKS cluster. You will create an encrypted EFS filesystem, put a mount target in every private subnet, lock its security group to NFS port 2049 from your nodes only, install the aws-efs-csi-driver as an EKS add-on running under an IRSA role, define an efs-ap StorageClass that dynamically carves an access point per claim, and prove the whole thing with a two-replica Deployment that shares one PVC — one pod writes, the other reads, across zones.

This is a provider-integration lesson as much as a storage one, because shared storage on EKS is where four Terraform providers meet: aws builds the filesystem and the IAM, aws_eks_addon installs the driver, and the kubernetes provider lays down the StorageClass, the claim, and the workload. It assumes you already have a cluster from Provisioning an EKS Cluster: VPC & Managed Node Groups and an OIDC provider from EKS OIDC & IRSA: IAM Roles for Service Accounts. It is the RWX companion to the block-storage lesson EKS EBS CSI Driver & StorageClasses — read them side by side and the “which volume for which workload” decision becomes automatic.

What you’ll build

The scenario is the one that forces every team off EBS eventually: a workload whose replicas must share a filesystem. Concretely, one terraform apply (against an existing cluster) produces an encrypted EFS filesystem with Elastic throughput, a mount target in each of your private subnets so every AZ can reach it, a dedicated security group that permits only inbound NFS (TCP 2049) from the EKS node security group, an IAM role wired to the cluster’s OIDC provider (IRSA) and granted AmazonEFSCSIDriverPolicy, the aws-efs-csi-driver add-on bound to that role, an efs-sc StorageClass in access-point dynamic-provisioning mode, and a demo namespace holding one RWX PersistentVolumeClaim and a two-replica Deployment mounting it. Then you exec into each pod and watch them read the same file — proof that RWX is real.

Why Terraform rather than the console, eksctl, or a pile of kubectl apply? Because this stack spans IAM, VPC, EFS, an EKS add-on, and Kubernetes objects, and getting the ordering and the wiring right by hand — the OIDC trust, the 2049 rule sourced from the right SG, the add-on’s service-account role ARN — is exactly the tedious, error-prone glue that declarative IaC exists to make repeatable. The comparison:

Approach Repeatable? Wires IAM + VPC + K8s together Drift visible? Best for
Console click-ops No You click each piece separately No A one-off you will delete
eksctl / shell Scriptable, imperative Partly; you sequence it No — script has no state Quick experiments
kubectl apply YAML only The K8s half only No — IAM/EFS are out of band No The workload, not the platform
Terraform (aws + kubernetes) Yes, declarative Yes — one graph, correct order plan shows drift Platform teams, one workflow everywhere

Terraform’s edge is that the dependency graph is explicit: the mount targets wait for the SG, the add-on waits for the IRSA role, the StorageClass waits for both the filesystem and the driver, and destroy unwinds it in reverse. You author the intent once and the tool computes the order. This lesson assumes the core workflow from Terraform Fundamentals and an S3 + DynamoDB backend per Getting Started on AWS.

Here is the whole build as a resource inventory, so you can see the moving parts before the code:

Resource Terraform type Role
KMS key (optional) aws_kms_key Customer-managed key for EFS encryption at rest
EFS filesystem aws_efs_file_system The elastic NFS namespace, encrypted, with IA lifecycle
EFS SG aws_security_group Admits NFS 2049 from the node SG only
Mount targets aws_efs_mount_target (for_each) One ENI per private subnet/AZ
IRSA role aws_iam_role + assume policy OIDC-federated role for the driver’s SA
Policy attach aws_iam_role_policy_attachment AmazonEFSCSIDriverPolicy on the role
EFS CSI add-on aws_eks_addon Installs aws-efs-csi-driver, bound to the IRSA role
StorageClass kubernetes_storage_class efs.csi.aws.com, provisioningMode: efs-ap
Namespace + PVC kubernetes_namespace, kubernetes_persistent_volume_claim The RWX claim
Deployment kubernetes_deployment Two replicas sharing the PVC

Left-to-right EKS shared-storage architecture: Terraform provisions an encrypted Amazon EFS filesystem, a mount target in every private subnet with a security group admitting NFS 2049 only from the node security group, the aws-efs-csi-driver EKS add-on running under an IRSA role with AmazonEFSCSIDriverPolicy, and an efs-ap StorageClass that dynamically provisions an access point per PersistentVolumeClaim, which a two-replica Deployment mounts ReadWriteMany across Availability Zones

Read the diagram left to right: Terraform provisions the EFS filesystem (badge 1 — chosen over EBS precisely because it does RWX) guarded by a security group that opens NFS 2049 to the nodes only (badge 2), exposed through a mount target in every AZ (badge 3); the EFS CSI driver runs under an IRSA role (badge 4), an efs-ap StorageClass provisions an access point per claim (badge 5), and a single PVC is mounted read-write by pods in different AZs (badge 6). The six legend entries are the six decisions you make in the code below.

Why EBS can’t do this: access modes and the RWX problem

Before any HCL, internalise why you are reaching for a second storage class at all. It comes down to Kubernetes access modes — the contract a volume advertises about how many nodes may mount it and how:

Access mode Short Meaning EBS EFS
ReadWriteOnce RWO Mounted read-write by pods on one node ✅ (native)
ReadWriteOncePod RWOP Read-write by exactly one pod cluster-wide ✅ (1.22+)
ReadOnlyMany ROX Mounted read-only by many nodes ⚠️ snapshot clones only
ReadWriteMany RWX Mounted read-write by many nodes at once impossible ✅ (native)

The line that matters is the last one. An EBS volume is a block device: the AWS control plane attaches it to a single EC2 instance’s block layer, and a block device with a normal filesystem (ext4/xfs) cannot be mounted read-write by two kernels simultaneously without corrupting itself. So EBS tops out at RWO — read-write from the pods on one node. Worse for a multi-AZ cluster, an EBS volume is created in one Availability Zone and cannot leave it; a pod that lands on a node in another AZ cannot attach it at all. EFS sidesteps both limits by not being a block device: it is a network filesystem (NFSv4.1), and NFS was designed from day one for many clients writing concurrently, with the server arbitrating. Because it is reached over the network, it is regional — every AZ can mount the same filesystem.

That difference cascades into a decision table you should be able to recite:

Dimension Amazon EBS (gp3) Amazon EFS
Storage type Block device Managed NFS filesystem
Access mode RWO (one node) RWX (many nodes)
AZ scope Single AZ (pinned) Regional — all AZs
Provisioned capacity Fixed size you set/grow Elastic — grows/shrinks automatically
Latency Sub-millisecond, consistent ~ms, NFS round-trips
Throughput ceiling Per-volume (up to 1,000 MB/s) Elastic/Provisioned; scales huge
Price model Per GB provisioned Per GB stored + throughput + IA tiers
Rough cost ~₹8/GB-mo (gp3) ~₹25/GB-mo Standard, far less on IA
CSI driver ebs.csi.aws.com efs.csi.aws.com
Ideal workload Databases, single-writer caches Shared uploads, ML datasets, config
Snapshot/restore EBS snapshots AWS Backup / EFS-to-EFS

The reading of this table is not “EFS is better.” It is “they solve different problems and cost differently.” EBS is faster and cheaper per GB and is the correct home for a Postgres data directory or an Elasticsearch node — anything single-writer and latency-sensitive. EFS costs more per GB and adds network latency, and you use it precisely when sharing is the requirement. The workloads that pull you to EFS share a shape:

Workload Why RWX Why not EBS
CMS / WordPress media uploads Any web replica must serve and receive uploads Uploads on one pod’s EBS are invisible to the others
ML training dataset + checkpoints All workers read one dataset, share a checkpoint dir Copying the dataset per-pod wastes hours and TB
Shared config / prompt templates Admin edits once; every pod sees it live An EBS copy per pod drifts and needs redeploys
CI/CD artifact or build cache Parallel runners read/write one cache RWO serialises the runners onto one node
Legacy app expecting a shared mount Lift-and-shift assumed NFS Re-architecting to object storage is a project

If your “shared” need is actually read-mostly and can tolerate eventual consistency, object storage (S3) is cheaper still — but the moment the workload needs POSIX semantics (open, flock, in-place edits, a real directory tree) and concurrent writers, EFS is the answer.

Modelling EFS in Terraform

EFS decomposes into three resources you will always create together: the filesystem (the durable data), the mount targets (the per-AZ network entry points), and a security group that gates them. Miss any one and pods will not mount.

The filesystem — aws_efs_file_system

The filesystem is the durable object; everything else references its ID. The arguments that matter:

resource "aws_efs_file_system" "this" {
  creation_token = "kv-eks-efs-${var.env}" # idempotency token — dedupes retries
  encrypted      = true                    # encryption at rest (always on)
  kms_key_id     = aws_kms_key.efs.arn     # omit → AWS-managed aws/elasticfilesystem key

  performance_mode = "generalPurpose"      # or "maxIO" (legacy, higher per-op latency)
  throughput_mode  = "elastic"             # "bursting" | "provisioned" | "elastic"

  # Tier cold data to Infrequent Access to cut cost ~90% per GB.
  lifecycle_policy {
    transition_to_ia                    = "AFTER_30_DAYS"
    transition_to_primary_storage_class = "AFTER_1_ACCESS"
  }

  tags = { Name = "kv-eks-efs-${var.env}" }
}
Argument Values What it controls Note
creation_token string Idempotency key for the create call Stops a retried apply making two filesystems
encrypted bool Encryption at rest Set true — always; free
kms_key_id ARN CMK for encryption Omit for the AWS-managed key; a CMK gives you key policy + rotation control
performance_mode generalPurpose | maxIO Latency vs parallelism generalPurpose for ~all; maxIO is legacy, adds latency, and is incompatible with Elastic
throughput_mode bursting | provisioned | elastic How throughput scales See table below
provisioned_throughput_in_mibps number Fixed MiB/s Only with throughput_mode = "provisioned"
lifecycle_policy block IA / Archive tiering Slashes cost for cold data
availability_zone_name string One-Zone EFS Cheaper, not multi-AZ — defeats RWX-across-AZs

Two footguns hide in that table. First, throughput_mode = "elastic" requires generalPurpose and forbids provisioned_throughput_in_mibps; combine them and the apply errors. Second, setting availability_zone_name creates a One-Zone filesystem — cheaper, but single-AZ, which throws away the very cross-AZ sharing you came for. Leave it unset for a regional filesystem.

Performance mode is a smaller decision and, in 2026, almost always the default:

Performance mode Latency Parallel ops Use when
generalPurpose Lowest per-op High (tens of thousands) Almost everything — the only mode that supports Elastic throughput
maxIO Higher per-op Highest aggregate Legacy, huge highly-parallel jobs; incompatible with Elastic — avoid for new filesystems

Throughput mode is the lever people misjudge, because Bursting — the old default — throttles surprisingly:

Mode How it scales Cost Use when
bursting Baseline scales with size (50 KB/s per GB), burst credits for spikes Cheapest at rest Small/cheap filesystems with bursty, low sustained I/O
provisioned You fix a MiB/s floor regardless of size Pay for provisioned MiB/s Small filesystem needing steady high throughput
elastic Auto-scales up and down to demand, pay-per-use Pay for what you drive Default for most — no credits to exhaust, no guessing

For anything with unpredictable load — the exact profile of a shared media store or an ML cache — Elastic is the modern default: there are no burst credits to run dry (the classic “our EFS got slow at 2 a.m.” incident), and you are not paying a provisioned floor 24/7. Reserve Provisioned for a small filesystem that nonetheless needs a guaranteed high floor.

The lifecycle policy is free money. EFS Standard is ~₹25/GB-mo; Infrequent Access (IA) is roughly a tenth of that. transition_to_ia = "AFTER_30_DAYS" moves files not touched in 30 days to IA; transition_to_primary_storage_class = "AFTER_1_ACCESS" pulls them back to Standard the moment they are read again. For a media library with a long tail of rarely-viewed assets, this quietly cuts the bill by most of it:

Storage class Relative price/GB Populated by
Standard 1× (baseline) New and recently accessed files
Standard-IA ~0.1× transition_to_ia after N days idle
Archive ~0.03× transition_to_archive (≥90 days), rarely-read data

Mount targets — one per AZ — aws_efs_mount_target

A filesystem you cannot reach is useless. A mount target is an elastic network interface (ENI) EFS places inside a subnet, giving that subnet’s AZ an IP to NFS-mount. The rule that trips everyone: one mount target per Availability Zone, and a pod can only reach EFS through the mount target in its own node’s AZ. So you create one mount target per private subnet where nodes run, and the idiomatic way is for_each over the subnet IDs — the pattern from Meta-Arguments: count & for_each:

resource "aws_efs_mount_target" "this" {
  for_each        = toset(var.private_subnet_ids) # one per private subnet/AZ
  file_system_id  = aws_efs_file_system.this.id
  subnet_id       = each.value
  security_groups = [aws_security_group.efs.id]
}
Rule Detail Failure if broken
One per AZ At most one mount target per AZ per filesystem Duplicate-AZ mount target → apply error
Cover every node AZ A node whose AZ has no mount target cannot mount Pods on that node hang in ContainerCreating
Put them in the private subnets Nodes live there; keeps EFS off the internet Public mount targets widen the attack surface
Attach the EFS SG The SG gates who may reach 2049 No SG rule → mount times out
Subnets must be in the filesystem’s region Mount target follows the subnet’s AZ

The subtle production bug is an AZ mismatch: your node group spans three AZs but you only created mount targets in two subnets. Everything works until the autoscaler places a pod in the third AZ, and only that pod fails to mount. for_each over the full private-subnet list is the guard — the same list your node groups use, so they can never diverge.

The security group — NFS 2049 from the nodes only

The mount target’s ENI needs a security group, and this is a place to be deliberately narrow. EFS speaks NFS on TCP 2049, and the only thing that should reach it is your EKS nodes. So the rule sources port 2049 from the node security group, not from a CIDR — reference the SG, and the rule follows the nodes wherever they scale:

resource "aws_security_group" "efs" {
  name_prefix = "kv-eks-efs-"
  description = "EFS mount targets: allow NFS 2049 from EKS nodes only"
  vpc_id      = var.vpc_id

  ingress {
    description     = "NFS from EKS node group"
    from_port       = 2049
    to_port         = 2049
    protocol        = "tcp"
    security_groups = [var.node_security_group_id] # source = the node/cluster SG
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = { Name = "kv-eks-efs-sg" }
}
Setting Value Why
Ingress port 2049 (TCP) NFSv4.1 — the only port EFS needs
Ingress source security_groups = [node SG] Follows the nodes; never a broad CIDR
vpc_id the cluster VPC Mount targets live in this VPC
Egress all (or 2049 back to nodes) NFS is stateful; return traffic

For a managed node group, the security group to reference is the cluster’s primary security groupdata.aws_eks_cluster.this.vpc_config[0].cluster_security_group_id — which EKS attaches to every managed node, so var.node_security_group_id should point at it. The reason to source from the SG rather than the subnet CIDR is drift-resistance and least privilege: scale the node group, add a subnet, replace the nodes — the rule keeps working and nothing else in the VPC can touch 2049.

On the diagram this control is a shield/network element labelled “NFS SG”, never a firewall — a security group is a stateful allow-list on an ENI, not a network firewall appliance, and drawing it as a firewall misleads readers about what it is.

The EFS CSI driver: add-on, IRSA & the StorageClass

Terraform has built the AWS-side storage. For Kubernetes to use it, three things must be true: the EFS CSI driver must be running in the cluster, it must hold AWS permissions to create access points, and a StorageClass must tell it how to provision volumes.

Installing the driver — add-on vs Helm

The driver is a controller + node DaemonSet that translates PVCs into EFS mounts. Two supported install paths:

Path Terraform Pros Cons
EKS managed add-on aws_eks_addon (aws-efs-csi-driver) AWS lifecycles it; version pinning; wires IRSA via one arg Slightly behind upstream releases
Helm chart helm_release (aws-efs-csi-driver) Latest versions, full values control You own upgrades and the SA/IRSA annotation

For a Terraform shop the managed add-on is the better default — it takes a service_account_role_arn, so the IRSA wiring is one argument and AWS handles the upgrade path. Discover versions with aws eks describe-addon-versions --addon-name aws-efs-csi-driver --kubernetes-version 1.30:

resource "aws_eks_addon" "efs_csi" {
  cluster_name  = var.cluster_name
  addon_name    = "aws-efs-csi-driver"
  addon_version = var.efs_csi_addon_version # e.g. "v2.1.0-eksbuild.1"

  service_account_role_arn    = aws_iam_role.efs_csi.arn # IRSA — the key line
  resolve_conflicts_on_create = "OVERWRITE"
  resolve_conflicts_on_update = "OVERWRITE"

  tags = { Name = "aws-efs-csi-driver" }
}

The Helm equivalent, when you want the bleeding edge, annotates the service account with the same role ARN yourself:

resource "helm_release" "efs_csi" {
  name       = "aws-efs-csi-driver"
  repository = "https://kubernetes-sigs.github.io/aws-efs-csi-driver/"
  chart      = "aws-efs-csi-driver"
  namespace  = "kube-system"
  version    = "3.1.0"

  set {
    name  = "controller.serviceAccount.annotations.eks\\.amazonaws\\.com/role-arn"
    value = aws_iam_role.efs_csi.arn
  }
}

IRSA — giving the driver AWS permissions

Under dynamic provisioning the driver calls the EFS API to create an access point for every PVC, so it needs elasticfilesystem:CreateAccessPoint, DeleteAccessPoint, and DescribeAccessPoints — exactly what the AWS-managed AmazonEFSCSIDriverPolicy grants. IRSA (IAM Roles for Service Accounts) is how a specific Kubernetes service account assumes an IAM role via the cluster’s OIDC provider — no node-wide keys, no secrets. (The full mechanism is the subject of EKS OIDC & IRSA; here we apply it.)

data "aws_eks_cluster" "this" { name = var.cluster_name }

# The IAM OIDC provider you created in the IRSA lesson.
data "aws_iam_openid_connect_provider" "this" {
  url = data.aws_eks_cluster.this.identity[0].oidc[0].issuer
}

locals {
  oidc_url = replace(data.aws_eks_cluster.this.identity[0].oidc[0].issuer, "https://", "")
}

data "aws_iam_policy_document" "efs_csi_assume" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [data.aws_iam_openid_connect_provider.this.arn]
    }
    # Only the driver's controller SA in kube-system may assume this role.
    condition {
      test     = "StringEquals"
      variable = "${local.oidc_url}:sub"
      values   = ["system:serviceaccount:kube-system:efs-csi-controller-sa"]
    }
    condition {
      test     = "StringEquals"
      variable = "${local.oidc_url}:aud"
      values   = ["sts.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "efs_csi" {
  name               = "kv-eks-efs-csi-irsa-${var.env}"
  assume_role_policy = data.aws_iam_policy_document.efs_csi_assume.json
}

resource "aws_iam_role_policy_attachment" "efs_csi" {
  role       = aws_iam_role.efs_csi.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEFSCSIDriverPolicy"
}
IRSA piece Resource / value Job
OIDC provider data.aws_iam_openid_connect_provider The trust anchor federating K8s SAs to IAM
Trust policy sub system:serviceaccount:kube-system:efs-csi-controller-sa Only this SA may assume the role
Trust policy aud sts.amazonaws.com Audience check — blocks token confusion
Managed policy AmazonEFSCSIDriverPolicy CreateAccessPoint / DeleteAccessPoint / describe
Wiring to driver add-on service_account_role_arn Binds the role to the SA

The sub condition is the security crux: it scopes the role to precisely the efs-csi-controller-sa service account in kube-system, so no other pod — even a compromised one — can assume it. Get the SA name wrong and the driver’s calls fail with AccessDenied and PVCs sit Pending. (AWS is also rolling out EKS Pod Identity as a newer alternative to IRSA — an aws_eks_pod_identity_association instead of an OIDC trust policy; it is simpler but IRSA remains the broadly supported path and is what the add-on’s service_account_role_arn expects.)

The StorageClass — access-point dynamic provisioning

The StorageClass tells Kubernetes how to turn a PVC into a volume. For EFS you almost always want provisioningMode: efs-ap, where the driver creates one EFS access point per claim. An access point is an application-specific entry into the filesystem: it pins a root directory and enforces a POSIX uid/gid, so each PVC gets an isolated, permission-scoped subtree of the one filesystem — tenants cannot see each other’s data even though they share the filesystem.

An access point is worth understanding as its own object, because it is what makes one shared filesystem safe for many tenants:

Access-point property Set from (StorageClass param) Effect
Root directory path basePath + subPathPattern The subtree this claim is confined to — it cannot escape upward
Directory permissions directoryPerms POSIX mode bits on the root dir (e.g. 700)
POSIX user (uid) uid or auto The uid all I/O through the access point runs as
POSIX group (gid) gid / gidRangeStartgidRangeEnd The gid, or a pool the driver assigns from
Uniqueness ensureUniqueDirectory Appends a suffix so two claims never share a dir
resource "kubernetes_storage_class" "efs" {
  metadata { name = "efs-sc" }
  storage_provisioner = "efs.csi.aws.com"
  reclaim_policy      = "Delete" # delete the access point when the PVC is deleted

  parameters = {
    provisioningMode = "efs-ap"
    fileSystemId     = aws_efs_file_system.this.id
    directoryPerms   = "700"
    gidRangeStart    = "1000" # POSIX GID pool for per-PVC access points
    gidRangeEnd      = "2000"
    basePath         = "/dynamic_provisioning"
    # One isolated subdir per claim; $$ escapes Terraform interpolation.
    subPathPattern        = "$${.PVC.namespace}/$${.PVC.name}"
    ensureUniqueDirectory = "true"
  }

  mount_options = ["tls"] # encryption in transit (NFS over TLS)
}
Parameter Example Meaning
provisioningMode efs-ap Create an access point per PVC (the dynamic mode)
fileSystemId fs-0abc… Which EFS filesystem to carve access points in
directoryPerms 700 POSIX perms on the access point’s root dir
gidRangeStart / gidRangeEnd 1000 / 2000 GID pool the driver assigns per access point
uid / gid fixed number Pin a single uid/gid instead of a range (optional)
basePath /dynamic_provisioning Parent dir under which access-point dirs are made
subPathPattern $${.PVC.namespace}/$${.PVC.name} Template for each claim’s subdirectory
ensureUniqueDirectory true Append a suffix so two PVCs never collide
reclaimPolicy (block arg) Delete / Retain Delete or keep the access point + data on PVC delete
mount_options ["tls"] NFS over TLS — encryption in transit

Two things about that HCL. First, $${...} is an escape: in Terraform, ${ starts an interpolation, so to write the literal ${.PVC.name} that the driver’s subPathPattern expects, you double the dollar sign. Forget it and Terraform tries to evaluate .PVC.name and errors. Second, reclaim_policy = "Delete" means deleting the PVC deletes the access point and its data — right for ephemeral, dynamically-provisioned claims, but set it to Retain for anything precious, and know that Retained access points linger and can block the filesystem from being destroyed later.

Static vs dynamic provisioning

There are two ways to hand EFS to a pod, and knowing when to use each avoids a lot of confusion:

Dynamic (efs-ap) Static (pre-provisioned PV)
Who makes the volume The driver, per PVC You, up front
Kubernetes object StorageClass → PVC A PersistentVolume you author
Isolation One access point per claim Whatever volumeHandle you point at
volumeHandle Managed for you fs-id or fs-id::fsap-id (you set it)
StorageClass efs-sc storageClassName: "" (none)
Best for Self-service, many teams, per-PVC dirs A shared, pre-existing directory everyone mounts
IAM need Driver needs CreateAccessPoint None at provision time

Dynamic is the self-service default this lesson uses: developers create a PVC against efs-sc and get an isolated, permission-scoped slice automatically. Static is for when a single, known directory (perhaps pre-populated with a dataset) must be mounted by everyone — you write a PersistentVolume whose volumeHandle is the filesystem (or fs-xxxx::fsap-yyyy to pin an access point), give it accessModes: [ReadWriteMany], and PVCs bind to it directly:

# Static PV — no StorageClass provisioner; you point at the filesystem/access point.
apiVersion: v1
kind: PersistentVolume
metadata: { name: efs-shared-dataset }
spec:
  capacity: { storage: 100Gi } # advisory only — EFS is elastic
  accessModes: ["ReadWriteMany"]
  persistentVolumeReclaimPolicy: Retain
  storageClassName: ""
  csi:
    driver: efs.csi.aws.com
    volumeHandle: fs-0abc123def456::fsap-0aabbcc # filesystem :: access point

Hands-on: build it with Terraform

Time to run it. This is a complete configuration you paste into a directory (say eks-efs/) and apply against an existing EKS cluster. ⚠️ This provisions real, billable resources — EFS storage, throughput, an add-on. Do the destroy at the end.

Because the cluster already exists, we configure the kubernetes provider from a data source — plan-time API access is available, so the StorageClass/PVC/Deployment can live in the same config as the AWS resources. (If you were building the cluster in the same apply, you would split into two states, exactly as the EKS cluster lesson explains.)

Step 1 — versions.tf (providers + backend)

terraform {
  required_version = ">= 1.6"

  required_providers {
    aws        = { source = "hashicorp/aws", version = "~> 5.60" }
    kubernetes = { source = "hashicorp/kubernetes", version = "~> 2.31" }
  }

  # Remote state — S3 with DynamoDB locking (per the AWS getting-started lesson).
  backend "s3" {
    bucket         = "kv-tfstate-demo"
    key            = "eks/efs-csi/dev.tfstate"
    region         = "ap-south-1"
    dynamodb_table = "kv-tfstate-lock"
    encrypt        = true
  }
}

provider "aws" {
  region = var.region
}

# Read the existing cluster — endpoint, CA, OIDC issuer.
data "aws_eks_cluster" "this" { name = var.cluster_name }

# Authenticate via the exec plugin (fresh, short-lived token each run) rather
# than a data-source token that would be baked into state at plan time.
provider "kubernetes" {
  host                   = data.aws_eks_cluster.this.endpoint
  cluster_ca_certificate = base64decode(data.aws_eks_cluster.this.certificate_authority[0].data)
  exec {
    api_version = "client.authentication.k8s.io/v1beta1"
    command     = "aws"
    args        = ["eks", "get-token", "--cluster-name", var.cluster_name]
  }
}

Step 2 — variables.tf

variable "region" {
  type    = string
  default = "ap-south-1"
}

variable "env" {
  type    = string
  default = "dev"
}

variable "cluster_name" {
  description = "Name of the existing EKS cluster"
  type        = string
}

variable "vpc_id" {
  description = "VPC the cluster runs in"
  type        = string
}

variable "private_subnet_ids" {
  description = "Private subnets (one per AZ) to place EFS mount targets in"
  type        = list(string)
}

variable "node_security_group_id" {
  description = "The EKS node/cluster primary SG allowed to reach EFS on 2049"
  type        = string
}

variable "efs_csi_addon_version" {
  description = "aws-efs-csi-driver add-on version (aws eks describe-addon-versions)"
  type        = string
  default     = "v2.1.0-eksbuild.1"
}

Step 3 — main.tf (the resources)

# ---- KMS + EFS filesystem ---------------------------------------------------
resource "aws_kms_key" "efs" {
  description             = "CMK for EFS at-rest encryption (${var.env})"
  deletion_window_in_days = 7
  enable_key_rotation     = true
}

resource "aws_efs_file_system" "this" {
  creation_token   = "kv-eks-efs-${var.env}"
  encrypted        = true
  kms_key_id       = aws_kms_key.efs.arn
  performance_mode = "generalPurpose"
  throughput_mode  = "elastic"

  lifecycle_policy {
    transition_to_ia                    = "AFTER_30_DAYS"
    transition_to_primary_storage_class = "AFTER_1_ACCESS"
  }

  tags = { Name = "kv-eks-efs-${var.env}" }
}

# ---- Security group: NFS 2049 from the nodes only ---------------------------
resource "aws_security_group" "efs" {
  name_prefix = "kv-eks-efs-"
  description = "EFS mount targets: allow NFS 2049 from EKS nodes only"
  vpc_id      = var.vpc_id

  ingress {
    description     = "NFS from EKS node group"
    from_port       = 2049
    to_port         = 2049
    protocol        = "tcp"
    security_groups = [var.node_security_group_id]
  }
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
  tags = { Name = "kv-eks-efs-sg" }
}

# ---- One mount target per private subnet / AZ -------------------------------
resource "aws_efs_mount_target" "this" {
  for_each        = toset(var.private_subnet_ids)
  file_system_id  = aws_efs_file_system.this.id
  subnet_id       = each.value
  security_groups = [aws_security_group.efs.id]
}

# ---- IRSA role for the EFS CSI driver ---------------------------------------
data "aws_iam_openid_connect_provider" "this" {
  url = data.aws_eks_cluster.this.identity[0].oidc[0].issuer
}

locals {
  oidc_url = replace(data.aws_eks_cluster.this.identity[0].oidc[0].issuer, "https://", "")
}

data "aws_iam_policy_document" "efs_csi_assume" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [data.aws_iam_openid_connect_provider.this.arn]
    }
    condition {
      test     = "StringEquals"
      variable = "${local.oidc_url}:sub"
      values   = ["system:serviceaccount:kube-system:efs-csi-controller-sa"]
    }
    condition {
      test     = "StringEquals"
      variable = "${local.oidc_url}:aud"
      values   = ["sts.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "efs_csi" {
  name               = "kv-eks-efs-csi-irsa-${var.env}"
  assume_role_policy = data.aws_iam_policy_document.efs_csi_assume.json
}

resource "aws_iam_role_policy_attachment" "efs_csi" {
  role       = aws_iam_role.efs_csi.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEFSCSIDriverPolicy"
}

# ---- The EFS CSI driver as an EKS add-on, bound to the IRSA role ------------
resource "aws_eks_addon" "efs_csi" {
  cluster_name                = var.cluster_name
  addon_name                  = "aws-efs-csi-driver"
  addon_version               = var.efs_csi_addon_version
  service_account_role_arn    = aws_iam_role.efs_csi.arn
  resolve_conflicts_on_create = "OVERWRITE"
  resolve_conflicts_on_update = "OVERWRITE"

  depends_on = [aws_iam_role_policy_attachment.efs_csi]
  tags       = { Name = "aws-efs-csi-driver" }
}

# ---- StorageClass: access-point dynamic provisioning ------------------------
resource "kubernetes_storage_class" "efs" {
  metadata { name = "efs-sc" }
  storage_provisioner = "efs.csi.aws.com"
  reclaim_policy      = "Delete"

  parameters = {
    provisioningMode      = "efs-ap"
    fileSystemId          = aws_efs_file_system.this.id
    directoryPerms        = "700"
    gidRangeStart         = "1000"
    gidRangeEnd           = "2000"
    basePath              = "/dynamic_provisioning"
    subPathPattern        = "$${.PVC.namespace}/$${.PVC.name}"
    ensureUniqueDirectory = "true"
  }

  mount_options = ["tls"]
  depends_on    = [aws_eks_addon.efs_csi]
}

# ---- Demo: a namespace, an RWX PVC, and a 2-replica Deployment sharing it ---
resource "kubernetes_namespace" "demo" {
  metadata { name = "efs-demo" }
}

resource "kubernetes_persistent_volume_claim" "shared" {
  metadata {
    name      = "shared-rwx"
    namespace = kubernetes_namespace.demo.metadata[0].name
  }
  spec {
    access_modes       = ["ReadWriteMany"]
    storage_class_name = kubernetes_storage_class.efs.metadata[0].name
    resources {
      requests = { storage = "5Gi" } # advisory — EFS is elastic
    }
  }
}

resource "kubernetes_deployment" "writer_reader" {
  metadata {
    name      = "shared-demo"
    namespace = kubernetes_namespace.demo.metadata[0].name
  }
  spec {
    replicas = 2
    selector { match_labels = { app = "shared-demo" } }
    template {
      metadata { labels = { app = "shared-demo" } }
      spec {
        # Spread the two replicas across AZs to prove cross-AZ RWX.
        topology_spread_constraint {
          max_skew           = 1
          topology_key       = "topology.kubernetes.io/zone"
          when_unsatisfiable = "ScheduleAnyway"
          label_selector { match_labels = { app = "shared-demo" } }
        }
        container {
          name    = "app"
          image   = "public.ecr.aws/docker/library/busybox:1.36"
          command = ["/bin/sh", "-c"]
          # Each pod appends its identity every 5s to the SHARED file.
          args = [
            "while true; do echo \"$(date -Iseconds) from $(hostname)\" >> /data/shared.log; sleep 5; done"
          ]
          volume_mount {
            name       = "shared"
            mount_path = "/data"
          }
        }
        volume {
          name = "shared"
          persistent_volume_claim {
            claim_name = kubernetes_persistent_volume_claim.shared.metadata[0].name
          }
        }
      }
    }
  }
}

Step 4 — outputs.tf

output "efs_id" {
  value = aws_efs_file_system.this.id
}

output "efs_dns_name" {
  value = aws_efs_file_system.this.dns_name
}

output "mount_target_azs" {
  value = { for k, mt in aws_efs_mount_target.this : k => mt.availability_zone_name }
}

output "efs_csi_role_arn" {
  value = aws_iam_role.efs_csi.arn
}

output "storage_class" {
  value = kubernetes_storage_class.efs.metadata[0].name
}

Step 5 — init, plan, apply

Point Terraform at the existing cluster’s details (from the cluster lesson’s outputs or terraform output in that config), then init:

export AWS_REGION=ap-south-1
terraform init
Initializing the backend...
Successfully configured the backend "s3"!
Initializing provider plugins...
- Installing hashicorp/aws v5.60.x...
- Installing hashicorp/kubernetes v2.31.x...
Terraform has been successfully initialized!

Plan it, supplying the cluster wiring (a terraform.tfvars is cleanest):

terraform plan -out=efs.plan \
  -var 'cluster_name=kv-eks-dev' \
  -var 'vpc_id=vpc-0abc123' \
  -var 'private_subnet_ids=["subnet-0a1","subnet-0b2","subnet-0c3"]' \
  -var 'node_security_group_id=sg-0nodes123'
Terraform will perform the following actions:

  # aws_efs_file_system.this will be created
  + resource "aws_efs_file_system" "this" {
      + encrypted        = true
      + performance_mode = "generalPurpose"
      + throughput_mode  = "elastic"
      + dns_name         = (known after apply)
    }
  # aws_efs_mount_target.this["subnet-0a1"] will be created  (×3 subnets)
  # aws_security_group.efs will be created
  # aws_iam_role.efs_csi will be created
  # aws_eks_addon.efs_csi will be created
  # kubernetes_storage_class.efs will be created
  # kubernetes_persistent_volume_claim.shared will be created
  # kubernetes_deployment.shared_demo will be created
  # ... (KMS key, policy attachment, namespace)

Plan: 13 to add, 0 to change, 0 to destroy.

Apply (EFS and mount targets create in seconds; the add-on takes a minute or two):

terraform apply efs.plan
aws_efs_file_system.this: Creation complete after 6s [id=fs-0abc123def456]
aws_efs_mount_target.this["subnet-0a1"]: Creation complete after 48s
aws_efs_mount_target.this["subnet-0b2"]: Creation complete after 51s
aws_efs_mount_target.this["subnet-0c3"]: Creation complete after 49s
aws_eks_addon.efs_csi: Creation complete after 1m20s
kubernetes_storage_class.efs: Creation complete after 1s
kubernetes_persistent_volume_claim.shared: Creation complete after 9s
kubernetes_deployment.shared_demo: Creation complete after 12s

Apply complete! Resources: 13 added, 0 changed, 0 destroyed.

Outputs:
efs_id        = "fs-0abc123def456"
storage_class = "efs-sc"

Step 6 — verify RWX sharing (the whole point)

First confirm the plumbing landed, then prove two pods share one volume:

kubectl get sc efs-sc
kubectl -n efs-demo get pvc          # STATUS should be Bound
kubectl -n efs-demo get pods -o wide # two pods, ideally different NODE + ZONE
NAME                          STATUS   VOLUME        CAPACITY   ACCESS MODES   STORAGECLASS
shared-rwx                    Bound    pvc-9f0e…     5Gi        RWX            efs-sc

NAME                          READY   NODE                    ...
shared-demo-6c8…-abcde        1/1     ip-10-0-1-11 (az-a)     ...
shared-demo-6c8…-fghij        1/1     ip-10-0-2-22 (az-b)     ...

The two pods are on different nodes in different AZs — an EBS RWO volume could never do this. Now read the shared file from one pod and confirm it contains lines written by both:

POD_A=$(kubectl -n efs-demo get pod -l app=shared-demo -o jsonpath='{.items[0].metadata.name}')
POD_B=$(kubectl -n efs-demo get pod -l app=shared-demo -o jsonpath='{.items[1].metadata.name}')

kubectl -n efs-demo exec "$POD_A" -- sh -c 'tail -4 /data/shared.log'
2026-07-09T10:31:05+00:00 from shared-demo-6c8…-abcde
2026-07-09T10:31:06+00:00 from shared-demo-6c8…-fghij   <-- written by POD_B, read by POD_A
2026-07-09T10:31:10+00:00 from shared-demo-6c8…-abcde
2026-07-09T10:31:11+00:00 from shared-demo-6c8…-fghij

Both hostnames appear in the file read from a single pod: the write from POD_B in AZ-b is visible to POD_A in AZ-a, live, over NFS. Write a file from B and read it from A to be fully convinced:

kubectl -n efs-demo exec "$POD_B" -- sh -c 'echo "hello from B" > /data/from-b.txt'
kubectl -n efs-demo exec "$POD_A" -- cat /data/from-b.txt   # -> hello from B

Confirm the driver dynamically created an access point for the claim, and the smoke-test checklist:

aws efs describe-access-points --file-system-id "$(terraform output -raw efs_id)" \
  --query 'AccessPoints[].RootDirectory.Path'
# [ "/dynamic_provisioning/efs-demo/shared-rwx-<suffix>" ]
Check Command Expect
StorageClass present kubectl get sc efs-sc efs.csi.aws.com
Driver pods running kubectl -n kube-system get pods | grep efs-csi efs-csi-controller + efs-csi-node Ready
PVC bound kubectl -n efs-demo get pvc Bound, RWX
Pods across AZs kubectl -n efs-demo get pods -o wide 2 pods, different NODE/zone
Access point created aws efs describe-access-points --file-system-id <id> one per PVC
Cross-pod read works exec POD_A -- cat /data/from-b.txt content written by POD_B

Step 7 — destroy & clean up

⚠️ Tear it all down so you stop paying for storage and throughput:

terraform destroy -auto-approve \
  -var 'cluster_name=kv-eks-dev' -var 'vpc_id=vpc-0abc123' \
  -var 'private_subnet_ids=["subnet-0a1","subnet-0b2","subnet-0c3"]' \
  -var 'node_security_group_id=sg-0nodes123'
kubernetes_deployment.shared_demo: Destroying...
kubernetes_persistent_volume_claim.shared: Destroying...   # reclaim=Delete → access point removed
aws_eks_addon.efs_csi: Destroying...
aws_efs_mount_target.this["subnet-0a1"]: Destroying...
aws_efs_file_system.this: Destroying...

Destroy complete! Resources: 13 destroyed.

Because reclaim_policy = "Delete", deleting the PVC deletes the dynamically-provisioned access point before the filesystem is torn down — Terraform sequences it. Watch for one gotcha: if you had set Retain, or created access points out of band, they linger and EFS refuses to delete (FileSystemInUse). List and delete stragglers with aws efs describe-access-points --file-system-id <id> then delete-access-point.

Variables, outputs & making it reusable

The demo already parameterises the cluster wiring; a platform team goes one step further and wraps the whole thing in a module with clean inputs — cluster name, subnets, node SG, throughput mode, lifecycle days — and outputs the filesystem ID and StorageClass name for other stacks to consume. The for_each over subnets is already the reusable core; the natural next move is supporting multiple StorageClasses (e.g. a Retain class for precious data and a Delete class for scratch) from a map:

variable "storage_classes" {
  description = "EFS StorageClasses to create, keyed by name"
  type = map(object({
    reclaim_policy = optional(string, "Delete")
    directory_perms = optional(string, "700")
    base_path      = optional(string, "/dynamic_provisioning")
  }))
  default = {
    "efs-sc"        = { reclaim_policy = "Delete" }
    "efs-sc-retain" = { reclaim_policy = "Retain", base_path = "/retained" }
  }
}

resource "kubernetes_storage_class" "efs" {
  for_each            = var.storage_classes
  metadata { name = each.key }
  storage_provisioner = "efs.csi.aws.com"
  reclaim_policy      = each.value.reclaim_policy
  parameters = {
    provisioningMode = "efs-ap"
    fileSystemId     = aws_efs_file_system.this.id
    directoryPerms   = each.value.directory_perms
    basePath         = each.value.base_path
    gidRangeStart    = "1000"
    gidRangeEnd      = "2000"
  }
  mount_options = ["tls"]
}

Should you roll your own or use registry modules? Two community modules cover this ground, and for a big fleet they are the fast path:

Consideration Roll your own Registry modules
EFS filesystem + mount targets Your aws_efs_* (this lesson) terraform-aws-modules/efs/aws
IRSA role for the driver Your OIDC trust policy terraform-aws-modules/iam//modules/iam-role-for-service-accounts-eks (has an attach_efs_csi_policy toggle)
The add-on Your aws_eks_addon terraform-aws-modules/eks cluster_addons
Control / learning Total — you see every wire Abstracted; fast to standardise
Best for Understanding, opinionated platforms Large fleets, many clusters

The honest recommendation matches the modules lesson: build it once by hand so the OIDC trust, the 2049 rule, and the access-point mechanics are not magic, then decide whether the registry module’s convenience is worth the abstraction. You cannot debug the module until you know what it hides.

Common mistakes and troubleshooting

These are the failures that actually page people. Scan the table, then read the prose on the five nastiest.

Symptom Likely cause Fix
Pod stuck ContainerCreating, then mount timeout SG doesn’t allow 2049 from the node SG Add ingress 2049 sourced from the node/cluster SG
Mount timeout on some pods only No mount target in that pod’s AZ for_each a mount target over every private subnet
PVC stuck Pending Driver lacks IAM (AccessDenied on CreateAccessPoint) Attach AmazonEFSCSIDriverPolicy to the IRSA role; check the sub
PVC Pending, events show wrong/blank fileSystemId StorageClass fileSystemId typo/empty Set it to aws_efs_file_system.this.id
AccessDenied even with the policy IRSA subefs-csi-controller-sa in kube-system Fix the trust-policy sub; re-check aud=sts.amazonaws.com
Files created but pods get permission denied Access-point uid/gid vs container runAsUser mismatch Align directoryPerms/gid with the pod’s security context
Error: interpolation on subPathPattern ${...} not escaped in HCL Write $${.PVC.namespace}/$${.PVC.name}
Throughput throttled / slow at load bursting mode ran out of burst credits Switch throughput_mode = "elastic" (or provisioned)
throughput_mode elastic + provisioned MiB/s Invalid combination Drop provisioned_throughput_in_mibps under Elastic
destroy fails FileSystemInUse Retained/orphan access points or mount targets Delete access points, then the filesystem
Second pod won’t schedule on the old EBS PVC You’re still on RWO EBS Use the EFS efs-sc RWX class, not the EBS class
Add-on create conflict Driver already present (Helm/older add-on) resolve_conflicts_on_create = "OVERWRITE"

Mount timeout is the number-one first-day failure and it is always one of three things: the security group does not admit 2049 from the nodes, there is no mount target in the pod’s AZ, or the mount target is in the wrong subnet. Diagnose with kubectl describe pod (you will see failed to mount ... connection timed out), then check, in order: does the EFS SG have an ingress rule for 2049 sourced from the node SG; is there an aws_efs_mount_target in the subnet matching the pod’s node AZ; and are the mount targets in the private subnets the nodes actually use. The for_each-over-subnets pattern eliminates the second and third by construction.

PVC Pending with dynamic provisioning is nearly always IRSA. The driver’s controller tries to call CreateAccessPoint and gets AccessDenied. Two culprits: the AmazonEFSCSIDriverPolicy is not attached to the role, or — more subtly — the role’s trust policy sub does not exactly equal system:serviceaccount:kube-system:efs-csi-controller-sa, so the SA can’t even assume the role. Read the controller logs (kubectl -n kube-system logs deploy/efs-csi-controller -c csi-provisioner) and the error names which. The other Pending cause is a bad fileSystemId in the StorageClass — a typo or an unresolved reference leaves it blank and the driver has nothing to provision into.

Access-point permissions bite when the container runs as a specific non-root user. The access point pins a POSIX uid/gid and directoryPerms; if your pod’s securityContext.runAsUser differs and the directory perms are 700, the process gets Permission denied writing to its own volume. Align them: either let the access point’s gid own the directory (fsGroup on the pod matching the gidRange), or set directoryPerms to something the pod’s user can write. This is the price of the isolation access points give you — worth understanding before it surprises you at 2 a.m.

Throughput throttling is the classic “EFS got slow” incident, and it is a mode problem, not a size problem. In Bursting mode, baseline throughput scales with stored size (50 KB/s per GB) and you spend burst credits for anything above it; a small-but-busy filesystem drains its credits and then crawls at baseline. The BurstCreditBalance CloudWatch metric hitting zero is the tell. The modern fix is Elastic throughput — it auto-scales to demand with no credits to exhaust — which is why this lesson defaults to it. Provisioned is the alternative when you want a guaranteed floor on a small filesystem.

Dynamic vs static confusion produces phantom failures. If you author a static PersistentVolume but also set storageClassName: efs-sc, Kubernetes tries to dynamically provision instead of binding your PV — set storageClassName: "" on both the PV and PVC for static binding. Conversely, a dynamic PVC needs the efs-sc class name; leave it blank and it never binds. Decide which mode you want and be consistent across the PV and the PVC.

Cost, cleanup & production notes

EFS bills on three axes — storage stored, throughput, and the IA/Archive discount — with no per-hour charge for the filesystem itself. Rough Mumbai (ap-south-1) figures for this demo, to make “destroy it” concrete:

Component Rate (approx) This demo (~1 GB, light I/O)
EFS Standard storage ~₹25/GB-mo ~₹25/mo per GB
EFS IA storage ~₹2.5/GB-mo pennies (cold data)
Elastic throughput (read) ~₹0.5/GB transferred ~₹0 idle, pay per use
Elastic throughput (write) ~₹2.4/GB transferred ~₹0 idle
Mount targets / ENIs no charge ₹0
EKS add-on no charge ₹0
Idle demo left a day ~₹1–2 (it stores almost nothing)

EFS is cheap when nearly empty, which the demo is — the real cost lever in production is total stored GB and how much of it you let cool into IA. The single biggest saving is the lifecycle policy: a media library with a long cold tail can land 80–90% of its bytes in IA at a tenth the price. The second lever is not over-provisioning throughput: Elastic bills per GB transferred, so an idle filesystem costs almost nothing, whereas a Provisioned floor bills 24/7 whether you use it or not.

Five production-hardening notes beyond the demo:

  1. Encrypt at rest with a CMK, and in transit with TLS. encrypted = true plus a customer-managed kms_key_id gives you key rotation and a key policy; mount_options = ["tls"] (or the driver’s TLS default) encrypts NFS on the wire. Both are effectively free.
  2. Back it up. EFS has no snapshots like EBS; use AWS Backup (an aws_backup_plan + selection on the filesystem ARN) for point-in-time recovery. RWX data is often the most shared and least individually owned — back it up deliberately.
  3. Tier aggressively with lifecycle policies. transition_to_ia and transition_to_archive are the cheapest cost control you will ever configure; set them from day one.
  4. Least-privilege the IRSA role and scope access points. The driver gets AmazonEFSCSIDriverPolicy and nothing more; per-PVC access points with distinct uid/gid keep tenants isolated inside the one filesystem. Never grant the node role blanket EFS access.
  5. Right-size throughput and watch the metrics. Prefer Elastic; if you must use Bursting, alarm on BurstCreditBalance and PercentIOLimit so you catch throttling before users do. Keep state remote, encrypted, and locked (S3 + DynamoDB), and run terraform plan in CI for drift.

The Azure equivalent of this pattern is Azure Files (SMB/NFS) mounted via the Azure Files CSI driver on AKS with a ReadWriteMany class — same access-mode story, different provider; the shape rhymes. On GKE it is Filestore. The lesson generalises: block storage is RWO/single-AZ; when you need many-writer, multi-AZ sharing, you reach for a managed NFS/SMB filesystem and its CSI driver.

Cheat-sheet

Task HCL / command
Filesystem resource "aws_efs_file_system" "this" { encrypted=true throughput_mode="elastic" }
IA lifecycle lifecycle_policy { transition_to_ia="AFTER_30_DAYS" }
Mount target/AZ aws_efs_mount_target with for_each = toset(var.private_subnet_ids)
NFS SG rule ingress { from_port=2049 to_port=2049 protocol="tcp" security_groups=[node_sg] }
IRSA trust sub system:serviceaccount:kube-system:efs-csi-controller-sa
Driver policy arn:aws:iam::aws:policy/service-role/AmazonEFSCSIDriverPolicy
Add-on aws_eks_addon { addon_name="aws-efs-csi-driver" service_account_role_arn=... }
StorageClass storage_provisioner="efs.csi.aws.com" provisioningMode="efs-ap"
Escape in subPathPattern $${.PVC.namespace}/$${.PVC.name}
TLS in transit mount_options = ["tls"]
RWX PVC access_modes=["ReadWriteMany"] storage_class_name="efs-sc"
K8s provider (exec) exec { command="aws" args=["eks","get-token","--cluster-name",...] }
List access points aws efs describe-access-points --file-system-id <id>
Driver logs kubectl -n kube-system logs deploy/efs-csi-controller -c csi-provisioner
Addon versions aws eks describe-addon-versions --addon-name aws-efs-csi-driver
Prove sharing exec POD_A -- cat /data/from-b.txt (written by POD_B)

Interview and exam questions

1. Why can’t an EBS volume be shared ReadWriteMany across pods on different nodes? EBS is a block device attached to a single EC2 instance, and a normal filesystem cannot be mounted read-write by two kernels at once without corruption — so EBS is RWO. It is also pinned to one AZ, so a pod in another AZ cannot attach it at all. EFS is an NFS filesystem reached over the network, designed for concurrent multi-client writes and reachable from every AZ, so it supports RWX.

2. What is an EFS mount target and how many do you need? A mount target is an ENI EFS places inside a subnet, giving that AZ an IP to NFS-mount. You need one per Availability Zone your nodes run in — a pod reaches EFS only through the mount target in its own AZ. Idiomatic Terraform: for_each a mount target over the private subnet IDs.

3. Which port and source does the EFS security group allow? Inbound TCP 2049 (NFSv4.1), sourced from the EKS node security group (reference the SG, not a CIDR) so the rule follows the nodes and nothing else in the VPC can reach the filesystem.

4. How does the EFS CSI driver get AWS permissions, and which policy? Via IRSA: an IAM role trusts the cluster’s OIDC provider with a sub condition scoping it to system:serviceaccount:kube-system:efs-csi-controller-sa, and the role has AmazonEFSCSIDriverPolicy attached (CreateAccessPoint/DeleteAccessPoint/describe). The add-on’s service_account_role_arn binds the role to the SA.

5. What does provisioningMode: efs-ap do? It tells the driver to dynamically create one EFS access point per PVC — an application-specific entry pinning a root directory and a POSIX uid/gid — so each claim gets an isolated, permission-scoped slice of the shared filesystem.

6. Static vs dynamic EFS provisioning — when each? Dynamic (efs-sc StorageClass, access point per PVC) for self-service where each claim wants its own isolated directory. Static (a hand-authored PersistentVolume with a volumeHandle of fs-id or fs-id::fsap-id, storageClassName: "") for a single, known, often pre-populated directory that everyone mounts.

7. A PVC against efs-sc is stuck Pending. First things to check? The driver’s IAM: is AmazonEFSCSIDriverPolicy attached, and does the IRSA trust-policy sub exactly match efs-csi-controller-sa in kube-system? Then the StorageClass fileSystemId — a blank or wrong ID leaves the driver with nothing to provision into. Read the csi-provisioner logs.

8. Pods hang in ContainerCreating with a mount timeout. Causes? The SG doesn’t allow 2049 from the node SG; or there’s no mount target in the pod’s AZ; or the mount targets are in the wrong subnets. Diagnose with kubectl describe pod and verify the SG rule and one mount target per node AZ.

9. EFS throughput is throttling under load. What happened and how do you fix it? The filesystem is in Bursting mode and exhausted its burst credits (BurstCreditBalance at zero), so it fell back to baseline. Switch throughput_mode to Elastic (auto-scales, no credits) or Provisioned (guaranteed floor).

10. Why is $${.PVC.name} written with two dollar signs in Terraform? ${ starts a Terraform interpolation, so to emit the literal ${.PVC.name} the driver’s subPathPattern expects, you escape it as $${...}. Without the escape, Terraform tries to evaluate .PVC.name and errors.

11. (Terraform Associate) You configure the kubernetes provider from an existing cluster’s data source and put the StorageClass in the same config. Why is that OK here but not when creating the cluster? Because the cluster already exists, its endpoint/CA are known at plan time and the provider can reach the API. When you create the cluster in the same apply, the provider would be configured from not-yet-known attributes and kubernetes_manifest needs a live API at plan — so you split into two states.

12. (Terraform Associate) terraform destroy fails with FileSystemInUse. Why, and how do you resolve it? Access points or mount targets still reference the filesystem — commonly access points created with reclaimPolicy: Retain or out of band. List them (aws efs describe-access-points), delete them, then destroy. Using reclaim_policy = "Delete" lets Terraform remove dynamically-provisioned access points automatically.

Key takeaways

TerraformawsEKSEFSCSIIRSAReadWriteManyStorageClassKubernetesNFSaccess-pointIaC
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