MeteorOps
ECS to EKS migration guide

ECS to EKS migration guide

Migrate ECS to EKS with a step-by-step guide for prerequisites, workloads, networking, secrets, rollout checks, rollback, and validation.

Arthur Azrieli

0 min read

ECS to EKS migration guide

Teams usually move from Amazon ECS to Amazon EKS when their workloads need Kubernetes-native deployment patterns, sidecars, custom controllers, shared Helm charts, or a platform standard that already runs on Kubernetes. The practical trigger is usually concrete: an ECS service now needs 3 or more replicas with consistent rollout behavior, OpenTelemetry or service-mesh sidecars, separate IAM roles per workload, and the same deployment model across several teams.

If you need source or target platform detail before planning, review the MeteorOps pages on AWS ECS and AWS EKS.

When an ECS to EKS migration is worth doing

An ECS to EKS migration is worth doing when Kubernetes solves a specific operating problem that ECS is not solving cleanly for your team. Good reasons include standardized Helm or Kustomize releases, GitOps with Argo CD or Flux, sidecar-heavy workloads, Kubernetes-native autoscaling, shared admission policies, and platform teams that already support EKS clusters.

The migration is also worth considering when you have 10 or more services and each service already carries platform-specific exceptions in ECS task definitions, load balancer settings, IAM task roles, and deployment scripts. At that point, a consistent Kubernetes deployment contract can reduce repeated service-by-service work.

The migration is usually not worth it for 1 to 5 simple stateless services that run well on ECS Fargate, deploy safely, and do not need Kubernetes APIs. EKS adds cluster upgrades, node or Fargate profile management, CNI capacity planning, controller upgrades, RBAC, admission control, and Kubernetes troubleshooting. If no one owns those tasks, ECS is often the safer choice.

Cost can move in either direction. EKS can improve bin packing on EC2 nodes, but it also adds a cluster control plane, baseline controllers, DaemonSets, load balancers, NAT traffic, and logging volume. Before migrating, compare the current ECS cost for each service against a projected EKS cost using the same replica count, peak CPU, memory, load balancers, CloudWatch logs, and data transfer.

Prerequisites and pre-migration checklist

As of August 15, 2026, the examples below use Kubernetes APIs available in Kubernetes 1.29 and newer, AWS CLI v2, kubectl compatible with your cluster minor version, eksctl command syntax available in v0.180.0 and newer, and Helm 3. Replace the Kubernetes version with an EKS-supported version approved by your platform team before creating production clusters.

Inventory the ECS service

  • Record the ECS cluster name, service name, task definition revision, desired count, deployment minimum healthy percent, and deployment maximum percent.
  • Record each container image, port, command, environment variable, secret, CPU unit, memory limit, health check path, and log configuration.
  • Record the ECS task role and execution role. Separate application permissions from image-pull and logging permissions.
  • Record the current load balancer, target group health check, listener rules, DNS records, security groups, and allowed source CIDRs.
  • Record autoscaling rules, including CPU target, memory target, request count target, minimum tasks, and maximum tasks.
  • Record any persistent storage, scheduled tasks, service discovery names, and dependency startup order.

Prepare the migration window

  • Set the production DNS TTL to 60 seconds at least 24 hours before cutover if the current TTL is higher.
  • Use immutable image tags, such as 2026-08-15.1 or a Git SHA. Do not migrate with latest.
  • Freeze ECS task definition changes during the cutover window.
  • Define rollback ownership before the change starts. One person should own DNS, one person should own Kubernetes rollout, and one person should watch metrics and logs.
  • Confirm that database migrations are backward compatible. The ECS version and EKS version must both run against the same schema during rollback.

Step-by-step ECS to EKS migration path

1. Export the ECS task definition

Start by exporting the current ECS task definition so you can map it into Kubernetes manifests without guessing. This command gives you the exact container image, CPU, memory, ports, environment variables, secrets, and log settings currently running in ECS.

aws ecs describe-task-definition \
  --task-definition payments-api \
  --query 'taskDefinition' \
  --output json > ecs-task-definition.json

aws ecs describe-services \
  --cluster prod-ecs \
  --services payments-api \
  --output json > ecs-service.json

For a typical ECS task with 512 CPU units and 1024 MiB memory, start the Kubernetes container with a CPU request of 250m, a memory request of 512Mi, and a memory limit of 1024Mi. Avoid setting a CPU limit unless you have measured throttling behavior, because Linux CPU quotas can add latency to bursty web services.

2. Create the EKS cluster

Create the EKS cluster in the same VPC as the ECS service when the workload must reach the same private databases, caches, queues, or internal APIs. The example below creates a managed node group with 3 nodes across private subnets. Use at least 3 nodes for production so one node can drain without taking the service below 2 replicas.

cat > cluster.yaml <<'EOF'
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig

metadata:
  name: prod-eks
  region: us-east-1
  version: "1.29"

vpc:
  subnets:
    private:
      us-east-1a:
        id: subnet-0aaa1111
      us-east-1b:
        id: subnet-0bbb2222
      us-east-1c:
        id: subnet-0ccc3333

iam:
  withOIDC: true

managedNodeGroups:
  - name: app-m6i
    instanceType: m6i.large
    desiredCapacity: 3
    minSize: 3
    maxSize: 8
    privateNetworking: true
    labels:
      workload: app
EOF

eksctl create cluster -f cluster.yaml

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

kubectl get nodes -o wide

If you use Terraform for production infrastructure, create the same cluster, node groups, IAM roles, and add-ons in Terraform before migrating live traffic. Do not let a manually created test cluster become the long-term production control plane unless your team has agreed to manage it that way.

3. Install the AWS Load Balancer Controller

ECS services commonly sit behind an Application Load Balancer. On EKS, the AWS Load Balancer Controller creates and updates AWS ALBs from Kubernetes Ingress resources. The controller needs IAM permissions through an annotated Kubernetes service account.

eksctl utils associate-iam-oidc-provider \
  --region us-east-1 \
  --cluster prod-eks \
  --approve

curl -o iam_policy.json \
  https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v2.8.1/docs/install/iam_policy.json

aws iam create-policy \
  --policy-name AWSLoadBalancerControllerIAMPolicy \
  --policy-document file://iam_policy.json

eksctl create iamserviceaccount \
  --cluster prod-eks \
  --region us-east-1 \
  --namespace kube-system \
  --name aws-load-balancer-controller \
  --attach-policy-arn arn:aws:iam::123456789012:policy/AWSLoadBalancerControllerIAMPolicy \
  --override-existing-serviceaccounts \
  --approve

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 \
  --set clusterName=prod-eks \
  --set serviceAccount.create=false \
  --set serviceAccount.name=aws-load-balancer-controller

For public ALBs, tag the public subnets with kubernetes.io/role/elb=1. For private ALBs, tag the private subnets with kubernetes.io/role/internal-elb=1. The controller also needs at least 2 usable subnets in different Availability Zones.

4. Create the namespace and service account

Each migrated ECS service should get a namespace or a clear namespace convention. For IAM access, map the old ECS task role permissions to an EKS workload role by using IAM Roles for Service Accounts.

kubectl create namespace payments

cat > trust-policy.json <<'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3041E"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3041E:sub": "system:serviceaccount:payments:payments-api",
          "oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3041E:aud": "sts.amazonaws.com"
        }
      }
    }
  ]
}
EOF

aws iam create-role \
  --role-name eks-payments-api \
  --assume-role-policy-document file://trust-policy.json

aws iam attach-role-policy \
  --role-name eks-payments-api \
  --policy-arn arn:aws:iam::123456789012:policy/payments-api-runtime-policy

kubectl create serviceaccount payments-api \
  --namespace payments

kubectl annotate serviceaccount payments-api \
  --namespace payments \
  eks.amazonaws.com/role-arn=arn:aws:iam::123456789012:role/eks-payments-api

Use the same AWS permissions as the ECS task role for the first controlled test. After the service is stable on EKS, reduce permissions to the minimum actions and resources the workload actually uses.

5. Move configuration and secrets

Move non-secret ECS environment variables into a ConfigMap. For the first migration pass, create Kubernetes Secrets from your current secret values without committing them to Git. If your team already uses AWS Secrets Manager, External Secrets Operator, or Secrets Store CSI Driver, use that standard instead of copying secret values into Kubernetes.

kubectl create configmap payments-config \
  --namespace payments \
  --from-literal=NODE_ENV=production \
  --from-literal=LOG_LEVEL=info \
  --from-literal=PORT=8080

kubectl create secret generic payments-secrets \
  --namespace payments \
  --from-literal=DATABASE_URL='postgres://user:password@db.example.internal:5432/payments' \
  --dry-run=client \
  -o yaml | kubectl apply -f -

6. Deploy the workload to EKS

The Deployment below maps one ECS service to one Kubernetes Deployment. It starts with 3 replicas, allows one extra pod during rollout, keeps all existing pods available during rollout, and uses readiness checks so the load balancer sends traffic only to ready pods.

cat > payments-api.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payments-api
  namespace: payments
  labels:
    app: payments-api
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: payments-api
  template:
    metadata:
      labels:
        app: payments-api
    spec:
      serviceAccountName: payments-api
      terminationGracePeriodSeconds: 60
      containers:
        - name: app
          image: 123456789012.dkr.ecr.us-east-1.amazonaws.com/payments-api:2026-08-15.1
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              containerPort: 8080
          envFrom:
            - configMapRef:
                name: payments-config
            - secretRef:
                name: payments-secrets
          resources:
            requests:
              cpu: 250m
              memory: 512Mi
            limits:
              memory: 1024Mi
          startupProbe:
            httpGet:
              path: /live
              port: http
            periodSeconds: 2
            failureThreshold: 30
          readinessProbe:
            httpGet:
              path: /ready
              port: http
            initialDelaySeconds: 5
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /live
              port: http
            initialDelaySeconds: 30
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 10"]
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
  name: payments-api
  namespace: payments
spec:
  type: ClusterIP
  selector:
    app: payments-api
  ports:
    - name: http
      port: 80
      targetPort: http
EOF

kubectl apply -f payments-api.yaml
kubectl -n payments rollout status deployment/payments-api --timeout=5m
kubectl -n payments get pods -o wide

If your application writes outside /tmp, remove readOnlyRootFilesystem: true for the first migration and fix the write paths later. Do not block the migration on a security hardening setting that the application cannot yet support.

7. Add ingress and test without production traffic

Create an Ingress that provisions an ALB for the EKS service. Use a canary hostname first, such as payments-canary.example.com, so you can test TLS, routing, health checks, and application behavior before changing production DNS.

cat > payments-ingress.yaml <<'EOF'
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: payments-api
  namespace: payments
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/healthcheck-path: /ready
    alb.ingress.kubernetes.io/success-codes: "200"
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80},{"HTTPS":443}]'
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:123456789012:certificate/11111111-2222-3333-4444-555555555555
spec:
  ingressClassName: alb
  rules:
    - host: payments-canary.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: payments-api
                port:
                  number: 80
EOF

kubectl apply -f payments-ingress.yaml
kubectl -n payments get ingress payments-api

After DNS for the canary hostname points to the ALB, run smoke tests against the canary endpoint.

curl -fsS https://payments-canary.example.com/ready
curl -fsS https://payments-canary.example.com/live

If the service is private, use an internal ALB and test from a bastion host, VPN, or a temporary pod in the VPC. For a quick cluster-local test, use port forwarding.

kubectl -n payments port-forward svc/payments-api 8080:80

curl -i http://127.0.0.1:8080/ready

8. Add autoscaling

ECS Service Auto Scaling maps most closely to a Kubernetes HorizontalPodAutoscaler. Kubernetes HPA needs metrics-server. The example below targets 60 percent average CPU utilization based on the pod CPU request, with a floor of 3 replicas and a ceiling of 10 replicas.

kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/download/v0.7.1/components.yaml

cat > payments-hpa.yaml <<'EOF'
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: payments-api
  namespace: payments
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: payments-api
  minReplicas: 3
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60
EOF

kubectl apply -f payments-hpa.yaml
kubectl -n payments get hpa payments-api

9. Deploy from CI

Keep the image build step as close as possible to the ECS pipeline during the first migration. Change only the deployment target. This GitHub Actions job builds an image, pushes it to ECR, applies Kubernetes manifests, and waits for rollout completion.

name: deploy-payments-api

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-actions-deploy
          aws-region: us-east-1

      - uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push image
        run: |
          IMAGE=123456789012.dkr.ecr.us-east-1.amazonaws.com/payments-api:${GITHUB_SHA}
          docker build -t $IMAGE .
          docker push $IMAGE
          echo "IMAGE=$IMAGE" >> $GITHUB_ENV

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

      - name: Deploy
        run: |
          kubectl apply -f k8s/
          kubectl -n payments set image deployment/payments-api app=$IMAGE
          kubectl -n payments rollout status deployment/payments-api --timeout=5m

10. Shift production traffic

Use weighted DNS when you can. Start with 5 percent of traffic to EKS, then move to 25 percent, 50 percent, and 100 percent if error rate, latency, saturation, and logs stay within your agreed limits for at least 15 minutes at each step.

For a non-apex Route 53 record, this weighted CNAME pattern is simple and reversible. Replace the old and new DNS names with your current ECS ALB and new EKS ALB names.

cat > weighted-eks-5.json <<'EOF'
{
  "Comment": "Send 5 percent of payments traffic to EKS",
  "Changes": [
    {
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "payments.example.com.",
        "Type": "CNAME",
        "SetIdentifier": "ecs",
        "Weight": 95,
        "TTL": 60,
        "ResourceRecords": [
          {
            "Value": "old-ecs-alb-123.us-east-1.elb.amazonaws.com"
          }
        ]
      }
    },
    {
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "payments.example.com.",
        "Type": "CNAME",
        "SetIdentifier": "eks",
        "Weight": 5,
        "TTL": 60,
        "ResourceRecords": [
          {
            "Value": "new-eks-alb-456.us-east-1.elb.amazonaws.com"
          }
        ]
      }
    }
  ]
}
EOF

aws route53 change-resource-record-sets \
  --hosted-zone-id Z1234567890 \
  --change-batch file://weighted-eks-5.json

Common failure modes and recovery

Pods start but receive no traffic

The most common cause is a readiness probe mismatch. ECS target group health checks and Kubernetes readiness probes may not use the same path, timeout, or success code. Compare the ECS target group health check with the Kubernetes readiness probe, then check endpoint readiness.

kubectl -n payments describe pod -l app=payments-api
kubectl -n payments get endpoints payments-api
kubectl -n payments describe ingress payments-api

The workload gets AccessDenied from AWS APIs

AccessDenied errors usually mean the service account annotation, IAM trust policy, or attached policy is wrong. Test the role from a temporary pod that uses the same service account.

cat > awscli-irsa-test.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: awscli-irsa-test
  namespace: payments
spec:
  serviceAccountName: payments-api
  restartPolicy: Never
  containers:
    - name: awscli
      image: amazon/aws-cli:2.15.0
      command: ["sleep", "3600"]
EOF

kubectl apply -f awscli-irsa-test.yaml
kubectl -n payments exec awscli-irsa-test -- aws sts get-caller-identity
kubectl -n payments delete pod awscli-irsa-test

Pods stay Pending

Pending pods usually mean insufficient CPU, insufficient memory, node selector mismatch, taint mismatch, or subnet IP exhaustion with the AWS VPC CNI. For a service that may run 50 pods per Availability Zone during peak plus rollout surge, keep at least 100 free private IPs per application subnet before cutover.

kubectl -n payments describe pod -l app=payments-api
kubectl describe nodes
aws ec2 describe-subnets \
  --subnet-ids subnet-0aaa1111 subnet-0bbb2222 subnet-0ccc3333 \
  --query 'Subnets[].{SubnetId:SubnetId,AvailableIpAddressCount:AvailableIpAddressCount}'

The application shuts down badly during rollout

ECS and Kubernetes handle shutdown differently. Kubernetes sends SIGTERM, waits for terminationGracePeriodSeconds, then sends SIGKILL. Keep the 10-second preStop delay and 60-second termination grace period until you confirm the app drains requests correctly.

Rollback plan

A safe rollback keeps ECS running at the original desired count until EKS has served 100 percent of production traffic for at least 24 hours and one normal peak traffic window. Do not scale ECS to zero during the first cutover unless the workload cannot safely run in both places.

  1. Freeze deployments to both ECS and EKS.
  2. Change weighted DNS back to 100 percent ECS and 0 percent EKS.
  3. Watch ALB 5xx rate, p95 latency, application errors, and queue depth for at least 15 minutes.
  4. If the failure came from the new image rather than EKS, roll back the Kubernetes Deployment with kubectl -n payments rollout undo deployment/payments-api.
  5. Keep the failed EKS pods and logs long enough to debug the cause. Do not delete the namespace before collecting events, pod descriptions, and application logs.
kubectl -n payments rollout history deployment/payments-api
kubectl -n payments rollout undo deployment/payments-api
kubectl -n payments rollout status deployment/payments-api --timeout=5m

kubectl -n payments get events --sort-by=.lastTimestamp
kubectl -n payments logs deploy/payments-api --tail=200

Database rollback is the part DNS cannot fix. Use expand-and-contract migrations, keep old columns until both ECS and EKS versions no longer need them, and avoid destructive schema changes in the same release as the traffic cutover.

How to validate that the migration succeeded

The migration is done when EKS serves 100 percent of production traffic, rollback remains possible, and the service meets the same or better operating targets it had on ECS. Use concrete checks rather than a general impression.

  • Rollout: kubectl -n payments rollout status deployment/payments-api completes within 5 minutes, and all 3 baseline replicas stay Ready.
  • Errors: HTTP 5xx rate is at or below the ECS baseline for the same traffic period. For many API services, that means below 0.1 percent, but use your real baseline.
  • Latency: p95 latency stays within 10 percent of the ECS baseline during normal and peak traffic.
  • Restarts: Pods show no unexplained restarts during the first 60 minutes after full cutover.
  • Autoscaling: HPA increases replicas when CPU exceeds the 60 percent target and scales down without dropping ready capacity below 3 replicas.
  • IAM: Application logs and CloudTrail show no new AccessDenied errors for required AWS API calls.
  • Networking: ALB targets are healthy, security groups allow only expected sources, and private dependencies resolve through the expected VPC paths.
  • Observability: Logs, metrics, traces, alerts, and dashboards use the EKS workload labels and can separate ECS traffic from EKS traffic during the transition.
  • Cost: After 24 to 72 hours, compare EKS nodes, ALBs, NAT gateways, CloudWatch logs, and data transfer against the ECS baseline.

After the service is stable, move manifests into your normal repository, pin controller chart versions, document the runbook, and schedule EKS upgrade ownership. Only then should you scale the ECS service to zero and remove old target groups, task definitions, security group rules, and alarms.

Getting help with the migration

MeteorOps helps teams plan and execute ECS to EKS migrations with senior DevOps and platform engineers who work directly in your Slack, repositories, AWS account, and delivery process. If you want experienced hands on cluster design, workload conversion, rollout safety, observability, and rollback planning, we can support the move without a retainer or long-term lock-in.

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.