MeteorOps
self-managed Kubernetes to EKS migration guide

self-managed Kubernetes to EKS migration guide

Move from self-managed Kubernetes to EKS with a practical step-by-step plan for IAM, cluster setup, workload cutover, rollback, and checks.

Michael Zion

0 min read

Who makes this move

Teams move from self-managed Kubernetes to EKS when control-plane upgrades, node replacement, IAM wiring, security reviews, or AWS networking work consume more engineering time than the workloads justify.

Do not plan to move etcd. Build a new EKS cluster, recreate the platform layer, deploy workloads from source-controlled manifests, and shift traffic with a rollback path.

When the migration is worth doing

This migration is worth doing when your workloads already run in AWS, your team wants AWS-managed Kubernetes control-plane operations, and your platform depends on AWS-native primitives such as IAM roles for service accounts, EBS volumes, ALB or NLB ingress, CloudWatch, VPC networking, and managed node groups.

It is also worth doing when your current cluster has weak upgrade discipline. If the control plane is more than two Kubernetes minor versions behind your workloads, treat the move as both a migration and a platform refresh. As of August 15, 2026, the examples in this guide assume Kubernetes 1.30 APIs, Terraform 1.8.x, AWS provider 5.x, Helm 3.14 or newer, AWS CLI v2, and kubectl within one minor version of the target cluster.

This migration is not worth doing if your workloads must stay outside AWS, if your current cluster uses custom control-plane behavior that EKS will not expose, or if your main problem is poor application ownership rather than cluster operations. EKS will not fix missing readiness probes, unsafe database migrations, oversized containers, or a CI pipeline that deploys unreviewed YAML.

If you are still defining the target platform, review the AWS EKS implementation details before you commit to subnet layout, IAM boundaries, node strategy, and ingress design.

Prerequisites and pre-migration checklist

Before you create the EKS cluster, freeze the current cluster shape in writing. You need an inventory that covers API versions, workloads, persistent storage, ingress, IAM access, DNS, observability, and rollback ownership.

kubectl version -o yaml
kubectl get nodes -o wide
kubectl get ns
kubectl get deploy,statefulset,daemonset -A -o wide
kubectl get service,ingress -A
kubectl get hpa,pdb -A
kubectl get storageclass
kubectl get pv
kubectl get pvc -A
kubectl get crd -o name
kubectl get validatingwebhookconfiguration,mutatingwebhookconfiguration
kubectl get events -A --sort-by=.lastTimestamp

Use Git, Helm charts, Kustomize overlays, or your existing deployment system as the source of truth. Do not treat a raw export from the old cluster as the migration artifact, because exported objects contain fields such as resourceVersion, uid, clusterIP, managedFields, and status that do not belong in the target cluster.

  • You should choose an EKS Kubernetes minor version that AWS supports in your target region on cutover day.
  • You should confirm that every manifest uses stable APIs such as apps/v1 for Deployments and networking.k8s.io/v1 for Ingress.
  • You should list every controller that must exist before applications deploy, including ingress controllers, ExternalDNS, cert-manager, External Secrets, metrics-server, and autoscaling components.
  • You should identify every persistent volume and decide whether the data moves by snapshot, replication, backup restore, or application-level export.
  • You should reduce public DNS TTLs to 60 seconds at least 24 hours before cutover if DNS will control traffic shifting.
  • You should define a rollback owner, rollback command, and rollback deadline before the first production request reaches EKS.

Step-by-step migration path

1. Build the EKS cluster as new infrastructure

Create a new cluster instead of modifying the old one in place. A new EKS cluster gives you a clean control plane, a repeatable Terraform plan, and a safe place to test controllers before production traffic moves.

The Terraform example below creates an EKS 1.30 cluster with managed node groups, core EKS add-ons, EBS CSI support, and three on-demand worker nodes. Use three private subnets in three Availability Zones when the application must tolerate a zone failure. Use two zones only when cost or IPv4 address capacity matters more than zone-failure tolerance.

terraform {
  required_version = ">= 1.8.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 20.0"

  cluster_name    = "prod-eks"
  cluster_version = "1.30"

  vpc_id     = var.vpc_id
  subnet_ids = var.private_subnet_ids

  cluster_endpoint_public_access = true
  enable_irsa                    = true

  cluster_addons = {
    coredns = {
      most_recent = true
    }
    kube-proxy = {
      most_recent = true
    }
    vpc-cni = {
      most_recent = true
    }
    aws-ebs-csi-driver = {
      most_recent = true
    }
    eks-pod-identity-agent = {
      most_recent = true
    }
  }

  eks_managed_node_groups = {
    general = {
      min_size     = 3
      desired_size = 3
      max_size     = 6

      instance_types = ["m6i.large"]
      capacity_type  = "ON_DEMAND"

      labels = {
        role = "general"
      }
    }
  }
}

Restrict the public API endpoint with allowed CIDR ranges or use a private endpoint if your CI runners, VPN, or bastion model supports it. Do not leave administrative access dependent on a single engineer鈥檚 local AWS credentials.

terraform init
terraform plan -out=tfplan
terraform apply tfplan

aws eks update-kubeconfig \
  --region us-east-1 \
  --name prod-eks

kubectl get nodes
kubectl get pods -n kube-system

2. Install the platform controllers before applications

Install controllers in dependency order. Network, DNS, certificates, secrets, metrics, autoscaling, and storage should work before the first application rollout.

If you use AWS Load Balancer Controller, create its IAM role first and bind it to a Kubernetes service account with IRSA or EKS Pod Identity. Do not give broad load-balancer permissions to the node IAM role.

helm repo add eks https://aws.github.io/eks-charts
helm repo update

helm upgrade --install aws-load-balancer-controller eks/aws-load-balancer-controller \
  --namespace kube-system \
  --version 1.8.1 \
  --set clusterName=prod-eks \
  --set serviceAccount.create=false \
  --set serviceAccount.name=aws-load-balancer-controller

Use the same rule for ExternalDNS, cert-manager, External Secrets, and observability agents. Each controller should have the minimum IAM permissions required for its AWS API calls.

3. Define storage classes and data movement

Do not copy PersistentVolume objects directly from the old cluster. Persistent volumes bind to specific provisioners, zones, handles, and reclaim policies. For EBS-backed workloads, create a StorageClass for the EBS CSI driver and move data with an EBS snapshot, backup restore, or application-level replication.

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: gp3-retain
provisioner: ebs.csi.aws.com
parameters:
  type: gp3
  fsType: ext4
reclaimPolicy: Retain
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer

For databases, prefer database-native replication or a tested backup restore over volume copying. A PostgreSQL cutover, for example, should use streaming replication, logical replication, or a verified pg_dump and pg_restore process, depending on downtime tolerance and database size.

4. Prepare manifests for EKS-specific differences

Run a server-side dry run against EKS before applying production workloads. This catches removed API versions, invalid fields, missing CRDs, and admission webhook problems before the cutover window.

kubectl apply --server-side --dry-run=server -k environments/prod-eks
kubectl diff -k environments/prod-eks

Every production Deployment should define replicas, resource requests, readiness probes, liveness probes, rollout behavior, and a disruption budget. The example below runs three replicas, requires at least two available pods during voluntary disruption, and blocks rollout progress if the new pods do not become ready within 10 minutes.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: prod
spec:
  replicas: 3
  progressDeadlineSeconds: 600
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: api
          image: 123456789012.dkr.ecr.us-east-1.amazonaws.com/api:2026-08-15-1
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: "250m"
              memory: "512Mi"
            limits:
              memory: "1Gi"
          readinessProbe:
            httpGet:
              path: /ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 20
            timeoutSeconds: 2
            failureThreshold: 3
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api
  namespace: prod
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: api

5. Deploy workloads without sending production traffic

Deploy namespaces, CRDs, shared controllers, secrets, ConfigMaps, and workloads before you change DNS or load-balancer routing. If you use sealed secrets or External Secrets, install the operator first and verify that each secret materializes in the target namespace.

kubectl apply -f k8s/namespaces/
kubectl apply -f k8s/crds/
kubectl apply -k environments/prod-eks

kubectl rollout status deployment/api -n prod --timeout=10m
kubectl get pods -n prod -o wide
kubectl logs -n prod deployment/api --tail=100

Update CI to deploy to the EKS cluster through AWS authentication rather than a static kubeconfig. The GitHub Actions example below assumes OIDC-based AWS access and a deployment role with scoped EKS permissions.

name: deploy-prod-eks

on:
  workflow_dispatch:

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read

    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-prod-eks-deploy
          aws-region: us-east-1

      - name: Configure kubeconfig
        run: aws eks update-kubeconfig --name prod-eks --region us-east-1

      - name: Apply manifests
        run: |
          kubectl apply -k environments/prod-eks
          kubectl rollout status deployment/api -n prod --timeout=10m

6. Shift traffic in controlled increments

Use a blue-green cutover when the old cluster and EKS can serve the same version of the application against compatible data. Shift 1 percent first, then 10 percent, 50 percent, and 100 percent only after the error rate, latency, saturation, and logs match your baseline.

If Route 53 controls your service DNS, use weighted records with a 60-second TTL. The example below assumes you have already removed any conflicting non-weighted CNAME for the same name and type. Use alias A or AAAA records instead of CNAME records for a zone apex.

{
  "Comment": "Shift 10 percent of api traffic to EKS",
  "Changes": [
    {
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "api.example.com.",
        "Type": "CNAME",
        "SetIdentifier": "old-cluster",
        "Weight": 90,
        "TTL": 60,
        "ResourceRecords": [
          {
            "Value": "old-api-lb.example.net"
          }
        ]
      }
    },
    {
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "api.example.com.",
        "Type": "CNAME",
        "SetIdentifier": "eks",
        "Weight": 10,
        "TTL": 60,
        "ResourceRecords": [
          {
            "Value": "k8s-prod-api-1234567890.us-east-1.elb.amazonaws.com"
          }
        ]
      }
    }
  ]
}
aws route53 change-resource-record-sets \
  --hosted-zone-id Z1234567890 \
  --change-batch file://route53-weighted-10.json

Common failure modes and how to recover

Pods stay Pending because the VPC has too few IP addresses

The Amazon VPC CNI assigns pod IPs from subnet address space. A /24 AWS subnet has 251 usable private IP addresses after AWS reservations, so it cannot support 500 pods even if your nodes have CPU and memory available. Recover by adding larger private subnets, adding node groups in subnets with free IPs, or reducing pod density.

kubectl describe pod -n prod api-abc123
kubectl -n kube-system logs daemonset/aws-node --tail=100

Ingress creates no load balancer

An Ingress can remain inert when the AWS Load Balancer Controller is missing, has the wrong IAM permissions, or does not match the IngressClass. Check the controller logs and confirm that the service account uses the intended IAM role.

kubectl get ingressclass
kubectl describe ingress -n prod api
kubectl logs -n kube-system deployment/aws-load-balancer-controller --tail=100

Admission webhooks block deployments

A missing webhook backend can block all object creation for a matching resource. Install webhook-owning controllers before dependent resources. If a non-critical webhook blocks recovery, set its failurePolicy to Ignore only as a temporary repair, record the change, and restore the original policy after the controller is healthy.

Stateful workloads start in the wrong zone

EBS volumes attach within one Availability Zone. Use WaitForFirstConsumer on EBS storage classes so Kubernetes schedules the pod and provisions the volume in a compatible zone. For restored volumes, pin the workload to the volume鈥檚 zone or restore the snapshot into the target zone.

Readiness probes pass too early

A readiness endpoint should verify that the process can serve real traffic, including required downstream dependencies. If the endpoint only returns process liveness, EKS may send traffic to pods before caches, database pools, or migrations are ready.

Rollback plan

Keep the self-managed cluster running and deployable for 7 calendar days after full traffic cutover. Use 14 calendar days for systems with weekly jobs, monthly billing previews, or batch processes that do not run every day.

  1. You should keep the old production image, manifests, and CI path available until the rollback window closes.
  2. You should block destructive database schema changes until EKS serves 100 percent of traffic and the rollback deadline has passed.
  3. You should keep DNS TTL at 60 seconds during the migration window.
  4. You should roll traffic back by restoring the old Route 53 weight to 100 and the EKS weight to 0.
  5. You should roll back application code only after you confirm that the old cluster can read the current database schema and data.

DNS rollback is safe only when both clusters use the same durable data source or when the old cluster has received all writes. If the migration moved the database or changed write ownership, rollback must follow the data plan, not the load-balancer plan.

How to validate that the migration succeeded

The migration is done when EKS serves 100 percent of production traffic, the old cluster receives no ingress traffic for at least 24 hours, and the application meets the same service targets it met before the cutover.

kubectl get deploy,statefulset,daemonset -A
kubectl get pods -A
kubectl get hpa,pdb -A
kubectl get events -A --sort-by=.lastTimestamp | tail -50
kubectl top pods -A
  • You should see zero production pods in CrashLoopBackOff or repeated ImagePullBackOff.
  • You should see all production Deployments complete rollout within the configured progressDeadlineSeconds.
  • You should verify that p95 latency stays within 10 percent of the pre-migration baseline for 24 hours, unless your own SLO sets a stricter limit.
  • You should verify that 5xx rate, queue depth, CPU throttling, memory usage, and pod restarts do not increase against the previous 24-hour baseline.
  • You should confirm that HPA scales under load and that PodDisruptionBudgets do not block planned node replacement.
  • You should restore at least one backup in a non-production environment before decommissioning the old cluster.
  • You should remove unused IAM permissions, stale kubeconfigs, old CI secrets, and old load balancers after the rollback window closes.

Getting expert help with the move

A careful self-managed Kubernetes to EKS migration is mostly sequencing, IAM, networking, and rollback discipline. MeteorOps can provide senior DevOps and platform engineers to work inside your Slack, repo, Terraform, CI, and Kubernetes workflow when you want experienced hands on the migration.

Relevant examples include a high-scale Kubernetes-to-Pulumi import and an AWS and Kubernetes infrastructure simplification project.

Want a senior engineer on this?

We put vetted senior DevOps engineers in your Slack within a week, billed by the hour. No retainer, no lock-in.