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.
- Freeze deployments to both ECS and EKS.
- Change weighted DNS back to 100 percent ECS and 0 percent EKS.
- Watch ALB 5xx rate, p95 latency, application errors, and queue depth for at least 15 minutes.
- 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.
- 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.




