Kubernetes
EKS Storage Setup

Published: October 29, 2026 · 9 min read

EKS Storage Setup — EBS CSI Driver and StorageClass

A Pod is thrown away and recreated all the time. For an app that is fine.

For a database it is a disaster. The data goes with it.

A database needs a real disk that outlives the Pod. On EKS that means an AWS EBS volume. Three pieces have to be in place before Kubernetes can ask for one.

Here is the short version:

  • EKS gives you a cluster. It does not give you storage.
  • Three pieces: IAM role → EBS CSI driver → StorageClass.
  • CSI is a standard. Kubernetes never talks to AWS directly.
  • The IAM permission goes to one pod, not to the whole node.
  • A default StorageClass means your PVC files stay clean.
  • Then a StatefulSet gets a real disk, and keeps it when the Pod dies.

Kubernetes EKS storage setup in three pieces, an IAM role then the EBS CSI driver then a StorageClass

Why Any of This Is Needed

Your cluster has no idea what an EBS volume is.

Kubernetes was built to work on any cloud, so its core contains no AWS code at all. Something has to stand between the two and translate. That something is a CSI driver, and it does not come installed.

⚠️

Skip this setup and your first PersistentVolumeClaim sits in Pending forever. Nothing is broken. Nobody is listening.

Step 1 — Associate an OIDC Provider

terminal — OIDC
# one time, per cluster

eksctl utils associate-iam-oidc-provider \
  --region ap-south-1 \
  --cluster srinu-desetti-eks \
  --approve

# [i]  will create IAM Open ID Connect provider for cluster "srinu-desetti-eks"
# [v]  created IAM Open ID Connect provider for cluster "srinu-desetti-eks"

Why this exists: in a moment we want to give a pod permission to create AWS disks. But AWS IAM has never heard of your pods. It needs proof that the thing asking really is a pod in your cluster.

The OIDC provider is that proof. Your cluster signs a token. IAM checks the signature, and the pod gets the role. This mechanism is called IRSA — IAM Roles for Service Accounts.

Run this once per cluster. A new cluster tomorrow needs its own.

Step 2 — Create the IAM Role

terminal — IAM role
# an IAM role that ONE service account may use

eksctl create iamserviceaccount \
  --cluster srinu-desetti-eks \
  --region ap-south-1 \
  --namespace kube-system \
  --name ebs-csi-controller-sa \
  --attach-policy-arn arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy \
  --approve \
  --role-only \
  --role-name AmazonEKS_EBS_CSI_DriverRole

# --role-only  = create the role, do not create the ServiceAccount
#                the add-on in the next step creates that part

Read the important part slowly, because this is where people assume the wrong thing.

Kubernetes EKS storage permission goes to one service account, not to the whole worker node

The permission is not given to the worker node. It is given to one ServiceAccount, ebs-csi-controller-sa, and only the pod running under that account can use it.

On the same nodeCan it create EBS volumes?
ebs-csi-controller podyes
coredns, kube-proxyno
your mongo and app podsno
🚫

If this were a node level permission, every pod on that node could create and delete AWS disks. One compromised app container could wipe your volumes. IRSA is what keeps that from being true.

--role-only is deliberate. It creates the IAM role and stops there. The add-on in the next step creates the matching ServiceAccount and wires the two together.

Step 3 — Install the EBS CSI Driver

terminal — add-on
# install the driver, wired to the role above

eksctl create addon \
  --cluster srinu-desetti-eks \
  --region ap-south-1 \
  --name aws-ebs-csi-driver \
  --service-account-role-arn arn:aws:iam::$(aws sts get-caller-identity \
      --query Account --output text):role/AmazonEKS_EBS_CSI_DriverRole \
  --force

What CSI Actually Means

CSI is short for Container Storage Interface. It is a standard, not a piece of software.

CSI is the Kubernetes storage standard that each cloud implements with its own driver

Kubernetes defines one way to ask for storage. Every cloud then writes its own driver that answers in that language.

CloudIts driver
AWSaws-ebs-csi-driver
Google Cloudgcp-pd-csi-driver
Azureazuredisk-csi-driver

That is the whole point of a standard. Move the same PVC file to another cloud and it still works, because only the driver underneath changed.

The driver runs as ordinary pods. A controller calls the AWS API. A node pod on every worker attaches and mounts the disk.

terminal — verify
# give it a minute or two

aws eks describe-addon \
  --cluster-name srinu-desetti-eks \
  --addon-name aws-ebs-csi-driver \
  --region ap-south-1 --query 'addon.status'
# "ACTIVE"

kubectl get pods -n kube-system | grep ebs-csi
# ebs-csi-controller-7d4b9c6f85-2kq9x   6/6   Running   0   62s
# ebs-csi-controller-7d4b9c6f85-8vlmt   6/6   Running   0   62s
# ebs-csi-node-4xk2p                    3/3   Running   0   62s
# ebs-csi-node-9mt7r                    3/3   Running   0   62s
⚠️

Install the driver after the role exists. Do it the other way round and the controller pods start without permission, and every volume request fails.

Step 4 — Create a Default StorageClass

gp3-default-sc.yaml
# gp3-default-sc.yaml

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: gp3
  annotations:
    storageclass.kubernetes.io/is-default-class: "true"
provisioner: ebs.csi.aws.com        # the driver you just installed
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Delete
parameters:
  type: gp3

Four fields, four decisions:

FieldWhat it decides
provisionerwho creates the disk — the driver you just installed
parameters.typewhat kind — gp3 is the current general purpose SSD
reclaimPolicywhat happens when the PVC is deleted — Delete removes the EBS volume too
volumeBindingModewhen to create it — WaitForFirstConsumer waits for a Pod

WaitForFirstConsumer matters on AWS. An EBS volume lives in one availability zone and can only attach to a node in that same zone. Waiting lets Kubernetes choose the node first, then create the disk in the right place.

terminal — apply
kubectl apply -f gp3-default-sc.yaml
# storageclass.storage.k8s.io/gp3 created

kubectl get storageclass
# NAME            PROVISIONER       RECLAIMPOLICY   VOLUMEBINDINGMODE
# gp3 (default)   ebs.csi.aws.com   Delete          WaitForFirstConsumer

# the word "(default)" is the part that matters

That annotation is what prints (default). A PVC that names no StorageClass now gets this one automatically. Your YAML files stay free of cloud specific names.

Step 5 — Deploy a Database on It

The setup is done. Now use it.

A database is the reason you did all of this, so deploy one and watch the chain work.

Create a namespace

terminal — namespace
kubectl create namespace mongo-db
# namespace/mongo-db created

The StatefulSet file

Two objects go in one file: a headless Service and a StatefulSet.

mongo-statefulset.yaml
# mongo-statefulset.yaml  -  two objects in one file

apiVersion: v1
kind: Service
metadata:
  name: mongo
  namespace: mongo-db
spec:
  clusterIP: None              # headless - every Pod gets its own DNS name
  selector:
    app: mongo
  ports:
    - port: 27017

---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mongo
  namespace: mongo-db
spec:
  serviceName: mongo           # the headless Service above
  replicas: 1
  selector:
    matchLabels:
      app: mongo
  template:
    metadata:
      labels:
        app: mongo
    spec:
      containers:
        - name: mongo
          image: mongo:7
          ports:
            - containerPort: 27017
          volumeMounts:
            - name: mongo-data
              mountPath: /data/db      # where MongoDB writes its files

  volumeClaimTemplates:        # one PVC per replica, created for you
    - metadata:
        name: mongo-data
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: 2Gi

Four lines carry the weight here:

LineWhy it is there
clusterIP: Nonemakes the Service headless, so each Pod gets a name like mongo-0.mongo
kind: StatefulSetthe Pod comes back as mongo-0 every time, with the same disk
volumeClaimTemplatesone PVC per replica, created automatically
mountPath: /data/dbwhere MongoDB writes its files by default
⚠️

Use volumeClaimTemplates, not volumes. A plain volumes block would share one disk across every replica, which is exactly wrong for a database.

Apply it

terminal — apply
kubectl apply -f mongo-statefulset.yaml
# service/mongo created
# statefulset.apps/mongo created

kubectl get pods -n mongo-db -w
# NAME      READY   STATUS              RESTARTS   AGE
# mongo-0   0/1     Pending             0          2s    <- PVC being provisioned
# mongo-0   0/1     ContainerCreating   0          18s   <- EBS volume attaching
# mongo-0   1/1     Running             0          34s   <- done

Those three statuses are the whole setup paying off. Pending is the disk being created, ContainerCreating is it being attached, Running means it is mounted.

What happens behind one kubectl apply on Kubernetes EKS storage, from StatefulSet to a running Pod

Six things happened from one command:

  1. The StatefulSet stamped a PVC out of volumeClaimTemplates.
  2. The default StorageClass picked it up, because the claim named none.
  3. The CSI driver called the AWS EBS API.
  4. AWS created the volume and attached it to the node.
  5. A PV appeared and bound to the claim.
  6. The Pod mounted it and reached Running.

Verify

terminal — verify
kubectl get pvc -n mongo-db
# NAME                 STATUS   VOLUME                  CAPACITY   STORAGECLASS
# mongo-data-mongo-0   Bound    pvc-9f2c1a4b-77e1-...   2Gi        gp3

kubectl get pv
# NAME                    CAPACITY   RECLAIM POLICY   STATUS   CLAIM
# pvc-9f2c1a4b-77e1-...   2Gi        Delete           Bound    mongo-db/mongo-data-mongo-0

kubectl get all -n mongo-db
# pod/mongo-0          1/1   Running
# service/mongo        ClusterIP   None    <none>   27017/TCP
# statefulset.apps/mongo   1/1

Note the claim name: mongo-data-mongo-0. The pattern is <template name>-<pod name>, so you can always tell which disk belongs to which Pod.

Step 6 — Prove the Data Survives

Put something in the database first.

terminal — mongosh
kubectl exec -it mongo-0 -n mongo-db -- mongosh

# inside the shell
show dbs

use USERS
db.USERS.insertOne({ name: "srinu" })
db.USERS.find()
# [ { _id: ObjectId('...'), name: 'srinu' } ]

exit

Now delete the Pod and see what comes back.

Deleting a Kubernetes Pod on EKS storage keeps the claim and the data intact

terminal — delete and check
# delete the Pod on purpose
kubectl delete pod mongo-0 -n mongo-db
# pod "mongo-0" deleted

kubectl get pods -n mongo-db -w
# mongo-0   0/1   ContainerCreating   0   3s
# mongo-0   1/1   Running             0   21s     <- SAME name

# is the data still there?
kubectl exec -it mongo-0 -n mongo-db -- mongosh --eval "db.getSiblingDB('USERS').USERS.find()"
# [ { _id: ObjectId('...'), name: 'srinu' } ]

# the claim never moved
kubectl get pvc -n mongo-db
# mongo-data-mongo-0   Bound   pvc-9f2c1a4b-77e1-...   2Gi   gp3

Same name. Same claim. Same row.

The Pod was replaced. The PVC, the PV and the EBS volume were never touched. That is the entire point of everything above.

When a PVC Stays Pending

If mongo-0 never leaves Pending, run kubectl describe pvc mongo-data-mongo-0 -n mongo-db and read the events.

Event saysWhat it means
waiting for first consumernormal, the Pod has not been scheduled yet
no persistent volumes availableno default StorageClass
failed to provision volumethe driver is missing, or has no IAM role
AccessDeniedthe role is not attached to the service account

EKS Storage Quick Reference

commands
# the storage setup, once per cluster
eksctl utils associate-iam-oidc-provider --cluster <name> --region <r> --approve
eksctl create iamserviceaccount --cluster <name> --name ebs-csi-controller-sa ... --role-only
eksctl create addon --cluster <name> --name aws-ebs-csi-driver --service-account-role-arn <arn>
kubectl apply -f gp3-default-sc.yaml

# checking the setup
kubectl get storageclass                      # look for "(default)"
kubectl get pods -n kube-system | grep ebs-csi
aws eks describe-addon --cluster-name <name> --addon-name aws-ebs-csi-driver --query 'addon.status'

# deploying a database
kubectl create namespace mongo-db
kubectl apply -f mongo-statefulset.yaml
kubectl get pods -n mongo-db -w

# checking the disk
kubectl get pvc -n mongo-db
kubectl get pv
kubectl describe pvc <name> -n mongo-db       # events say why it is Pending

# going inside
kubectl exec -it mongo-0 -n mongo-db -- mongosh
CommandWhat it tells you
kubectl get storageclassis there a (default) one
kubectl get pods -n kube-system | grep ebs-csiis the driver running
kubectl get pvc -n <ns>Pending or Bound
kubectl describe pvc <name>the events that explain why
kubectl get pvthe real volumes that exist
kubectl exec -it mongo-0 -n mongo-db -- mongoshgo inside the database

What's Next

Four commands, run once. After that every database just asks for a claim and gets a real disk. The official EBS CSI driver page (opens in a new tab) and the Kubernetes StorageClass page (opens in a new tab) go deeper.

← Back to Network Policy

Continue to StatefulSet & Storage →


Frequently Asked Questions

What is CSI in Kubernetes?

CSI stands for Container Storage Interface. It is a standard, not a product. Kubernetes core has never heard of EBS or any other disk. It only knows how to speak CSI, and each cloud provider writes a driver that understands it. AWS gives you aws-ebs-csi-driver, Google gives gcp-pd-csi-driver and Azure gives azuredisk-csi-driver.

Why does EKS need the EBS CSI driver?

Because nothing inside the cluster can create an AWS disk on its own. The EBS CSI driver runs as pods on your worker nodes and listens for storage requests. It then calls the AWS EBS API to create, attach and delete real volumes. Without it a PersistentVolumeClaim stays Pending forever, because nobody is listening.

What happens when you create a PVC in Kubernetes?

The PVC looks for a StorageClass. If you did not name one it uses the one marked default. That StorageClass names a provisioner, and the request goes to it. On EKS that provisioner is ebs.csi.aws.com. It calls AWS, creates a real EBS volume, wraps it in a PersistentVolume and binds that PV to your PVC.

Does the IAM role give permission to the whole worker node?

No. The role is attached to one Kubernetes ServiceAccount called ebs-csi-controller-sa, and only the pod using that account can assume it. Other pods on the same node, such as your database or your application, get nothing from it. This is called IRSA, which stands for IAM Roles for Service Accounts.

Why do you need an OIDC provider on an EKS cluster?

Because AWS IAM needs a way to trust a pod. The OIDC provider lets your cluster issue a signed token. That token proves a pod really belongs to this cluster, under this service account. IAM checks that token before handing over the role. Without the OIDC provider, IRSA cannot work and the driver has no permission.

What is a default StorageClass in Kubernetes?

It is the StorageClass used when a PVC does not name one. You mark it with the annotation storageclass.kubernetes.io/is-default-class set to true. kubectl get storageclass then shows the word default beside it. Every PVC written without a storageClassName quietly uses it. That keeps your YAML files portable.

Why is my PVC stuck in Pending on EKS?

There are two very different reasons. If your StorageClass uses WaitForFirstConsumer, Pending is normal and correct until a Pod actually mounts the claim. If no Pod is waiting, there are three usual causes. A missing default StorageClass, the EBS CSI driver not installed, or the driver lacking its IAM role. Run kubectl describe pvc and read the events at the bottom.

What does WaitForFirstConsumer mean in a StorageClass?

It tells Kubernetes not to create the disk until a Pod is actually scheduled. This matters on AWS. An EBS volume lives in one availability zone, and can only attach to a node in that same zone. Waiting lets Kubernetes pick the node first, then create the volume in the right zone. The other option, Immediate, creates the disk straight away and can strand it in the wrong zone.