What it actually takes to deploy your app on EKS
The checklist looks short: network, cluster, nodes, ECR, deploy. Each line hides a day of work. A field guide from taking a real app to an EKS cluster — nine subnets, Pod Identity, managed services, and the manifest that finally ships it.
· 13 min read
On my laptop, Kubernetes is cozy. minikube boots a single node, kubectl port-forward punches a hole to any pod, and the hardest problem is remembering which of the four nested networks my packet is in. Then you try to put the same app on AWS EKS and discover that the walls between those networks are no longer hidden Linux VMs — they're real AWS resources, each with a console page, an hourly price, and a way to be misconfigured.
This post is the field guide I wish I had. The checklist looks short:
- Network setup
- EKS cluster (control plane)
- EKS managed node group
- Accessing the cluster
- Build and push to ECR
- Deploy managed services
- Deploy the application
Each line hides a day of work. Let's take them in order.
Step 0 is always the network
Before a single Kubernetes object exists, you lay out a VPC. Mine follows the three-tier pattern, times three Availability Zones — nine subnets total:
- 3 public subnets — one per AZ. Home of the ALB and the NAT Gateway. Their route table has a route to the Internet Gateway.
- 3 private subnets — one per AZ. Home of the EKS worker nodes. Their route table sends
0.0.0.0/0to the NAT Gateway: pods can pull images and call AWS APIs, but nothing on the internet can initiate a connection in. - 3 protected subnets — one per AZ. Home of the data tier (RDS, ElastiCache). Their route table has no internet route at all. Not through NAT, not through anything. A database that cannot reach the internet cannot be exfiltrated to it.
Two things bit me here. First, tag the subnets — the AWS Load Balancer Controller discovers where to put load balancers via tags (kubernetes.io/role/elb on public, kubernetes.io/role/internal-elb on private). Second, the NAT Gateway decision: one per AZ is the HA answer, one total is the budget answer. NAT Gateways bill per hour and per GB processed, and three of them idling is real money. I started with one and accepted the cross-AZ risk; know which trade you're making.
If subnets and route tables feel fuzzy, I walked the fundamentals in AWS networking, from the IP up — this post stands on that one.
The control plane: what EKS actually gives you
An "EKS cluster" is just the control plane: API server, etcd, scheduler, controller manager — run by AWS, patched by AWS, billed by AWS at a flat hourly rate whether you deploy anything or not. It has no compute for your pods.
Creation is one form (or one eksctl command): name, Kubernetes version, the VPC and subnets it should attach to, and whether the API endpoint is public, private, or both. The control plane injects ENIs into your subnets so it can reach kubelets; you pick the private subnets for that.
The only decision with teeth is the version. EKS supports each Kubernetes minor for roughly 14 months, then drags you into extended support at a higher price. Pick the newest version your tooling supports and put the upgrade on the calendar now.
The node group: where pods actually run
A managed node group is an autoscaling group of EC2 instances that EKS bootstraps and joins to the cluster for you. You choose the instance type, min/desired/max counts, and — importantly — the private subnets, so nodes get no public IPs. Spread across all three AZs, which is exactly what the topologySpreadConstraints in the manifest below will exploit.
Taints, tolerations, and affinity — the scheduling knobs
Three concepts control which pods land on which nodes, and they're easy to mix up:
- Taints repel. A taint lives on a *node*: "don't schedule here unless you can tolerate me." A toleration lives on a *pod* and grants permission — but crucially, a toleration doesn't *attract* the pod to the tainted node. It only removes the barrier.
- Node affinity attracts. A pod declares "schedule me on nodes with these labels" — required (hard) or preferred (soft). Taint + toleration + node affinity together give you dedicated node pools: taint keeps everyone else out, affinity pulls the right workload in.
- Pod affinity and anti-affinity position pods relative to *other pods*: "put me near the cache" or "never put two replicas of me on the same node." Anti-affinity across zones is the classic HA pattern — though
topologySpreadConstraintsexpresses the same intent with finer control over the allowed skew.
Accessing the cluster
One command wires kubectl to the new control plane:
aws eks update-kubeconfig --region ap-southeast-3 --name myapp-cluster
kubectl get nodesUnder the hood, kubeconfig entries call aws eks get-token, so your kubectl identity is your IAM identity. Whoever created the cluster gets admin automatically; everyone else needs an access entry (the modern replacement for the old aws-auth ConfigMap) mapping their IAM principal to a Kubernetes permission set. If kubectl get nodes returns Unauthorized for a teammate, this is why.
Build and push to ECR
ECR is the registry the node group can pull from without any credential ceremony — the node role carries pull permissions. Push is three commands:
aws ecr get-login-password --region ap-southeast-3 \
| docker login --username AWS --password-stdin 123456789012.dkr.ecr.ap-southeast-3.amazonaws.com
docker build --platform linux/amd64 -t myapp-backend:v1.0.0 .
docker tag myapp-backend:v1.0.0 123456789012.dkr.ecr.ap-southeast-3.amazonaws.com/myapp-backend:v1.0.0
docker push 123456789012.dkr.ecr.ap-southeast-3.amazonaws.com/myapp-backend:v1.0.0The flag that matters: **--platform linux/amd64**. On an Apple Silicon Mac, a bare docker build produces an arm64 image, and if your node group runs amd64 instances every pod dies in exec format error crash loops. Build for the architecture your nodes actually run (or run Graviton nodes and flip the flag).
The managed services around the cluster
Resist the urge to run stateful infrastructure inside the cluster. Everything with data gravity went to managed services, in the protected subnets:
- RDS Postgres — first a DB subnet group spanning the three protected subnets, then the instance. Its security group accepts port 5432 only from the node security group. Security-group-to-security-group rules beat CIDR rules: they survive any node churn.
- ElastiCache — same drill: cache subnet group in protected subnets, SG chained to the nodes.
- SQS, S3 — regional services, no subnets involved; a queue and a bucket. Access is IAM, which is the next section.
- SES — reused the existing identity from another project. Email reputation is hard-won; don't reset it by re-verifying domains casually.
- Secrets Manager — holds the database URL and API keys, so nothing sensitive ever lands in a manifest or a repo.
Giving pods AWS permissions: Pod Identity
Here's the question that separates a toy cluster from a real one: when your pod calls SQS, whose credentials is it using? The wrong answers are "an access key in an env var" and "the node role" (every pod on the node inherits it). The right answer on EKS today is Pod Identity:
- Create a namespace and a ServiceAccount (
myapp-custom) in it — plain YAML, applied like anything else. - Create an IAM role: in the role wizard choose AWS service → EKS → Pod Identity as the trusted entity, then attach the permissions the app needs (SQS, S3, SES, Secrets Manager — scoped to the specific resources).
- In the EKS console: cluster → Access → Pod Identity Associations → Create. Pick the namespace, the service account, and the role.
Any pod that runs as that service account now gets temporary, auto-rotated credentials injected by the EKS Pod Identity Agent. The SDK picks them up with zero code changes. No keys to leak, no keys to rotate, and the blast radius of a compromised pod is exactly the role you scoped.
The in-cluster pieces
Not everything deserves a managed service. Alongside the app live:
- A ConfigMap (
myapp-config) with the non-secret config: queue URLs, bucket names, feature flags. - Redis — a small single-replica Deployment for ephemeral state where losing it means nothing worse than a cold cache.
- NATS — the internal message bus. There's no managed NATS on AWS, and it's light enough that running it in-cluster is the honest choice.
Each gets a ClusterIP Service, and — exactly like docker-compose, exactly like minikube — the app reaches them by DNS name: redis:6379, nats:4222. Same flat-network model from my laptop, now spanning three AZs.
Deploying the application
Everything above exists so this manifest can be boring:
apiVersion: v1
kind: Service
metadata:
name: myapp-api
namespace: myapp
labels:
app.kubernetes.io/name: api
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: api
ports:
- name: http
port: 8088
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp-api
namespace: myapp
labels:
app.kubernetes.io/name: api
spec:
revisionHistoryLimit: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app.kubernetes.io/name: api
template:
metadata:
labels:
app.kubernetes.io/name: api
spec:
serviceAccountName: myapp-custom
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: api
containers:
- name: api
image: 123456789012.dkr.ecr.ap-southeast-3.amazonaws.com/myapp-backend:v1.0.0
imagePullPolicy: IfNotPresent
command: ["myapp-server"]
ports:
- name: http
containerPort: 8088
envFrom:
- configMapRef:
name: myapp-config
- secretRef:
name: myapp-secrets
optional: true
readinessProbe:
httpGet:
path: /health
port: http
periodSeconds: 10
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /health
port: http
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 200m
memory: 192Mi
limits:
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]The lines that earn their place:
- **
maxUnavailable: 0+maxSurge: 1** — every rollout is zero-downtime: the new pod must pass its readiness probe before an old one is killed. This is the same promise as the blue-green post, expressed in four lines of strategy. - **
topologySpreadConstraints** — replicas spread across AZs (maxSkew: 1), so an AZ outage degrades capacity instead of deleting it.ScheduleAnywaykeeps it a preference, not a deadlock. - **
serviceAccountName: myapp-custom** — the single line that activates the whole Pod Identity chain. - **Probes on
/health** — readiness gates traffic and rollouts; liveness restarts a wedged process. Different jobs, different timings. - Requests, and a memory limit only — requests are what the scheduler bin-packs on; the memory limit caps a leak before it takes the node down. No CPU limit on purpose: CPU throttling hurts tail latency more than the fairness it buys.
- The security context — no privilege escalation, all capabilities dropped. Free hardening, two lines.
The last door: Ingress
A ClusterIP is only a hallucination that nodes agree on — nothing outside the VPC can reach it. The AWS Load Balancer Controller turns an Ingress object into a real ALB in the public subnets, with a TLS cert from ACM, forwarding straight to pod IPs (the VPC CNI gives every pod a routable VPC address, so the ALB targets pods directly — no NodePort hop). Annotations on the Ingress choose scheme, cert, and health check; the controller reconciles the rest.
What I'd tell past me
- The network is half the project. Get the three-tier subnet layout and route tables right before touching
eksctl, and everything after is assembly. - Identity is the other half. Pod Identity, access entries, SG-to-SG rules — none of it deploys your app, all of it decides whether the deployment is safe.
- Watch the meter. The control plane bills hourly, NAT Gateways bill twice, and an ALB idles at real cost. An EKS cluster is exactly the kind of environment awswaste exists to audit — run it against the account after the dust settles.
- Everything I learned about pods, Services, and DNS on minikube transferred unchanged. EKS didn't replace the model — it just made the walls real and the doors billable.