Deploying a Node.js app to Kubernetes for the first time trips a lot of people up not because Kubernetes itself is hard, but because of everything around it — building a proper image, wiring up config and secrets, getting health checks right, and connecting it to the outside world. In this guide, I’ll walk through the entire journey end-to-end: containerizing a Node.js app, deploying it to AWS EKS, exposing it with a Service and Ingress, and setting it up for CI/CD.
Step 1: Writing a Production-Ready Dockerfile
Before Kubernetes even enters the picture, you need a solid image. A common mistake is shipping a fat, single-stage image with the entire node_modules dev dependency tree. Use a multi-stage build instead:
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
USER nodejs
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD node dist/healthcheck.js
CMD ["node", "dist/server.js"]
Note the explicit non-root USER nodejs — this matters a lot once you start enforcing Pod Security Standards (restricted requires non-root containers, as covered in the companion PodSecurityPolicies article).
Build and push to Amazon ECR:
aws ecr create-repository --repository-name my-node-app --region us-east-1
aws ecr get-login-password --region us-east-1 | \
docker login --username AWS --password-stdin 123456789012.dkr.ecr.us-east-1.amazonaws.com
docker build -t 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-node-app:v1.0.0 .
docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-node-app:v1.0.0
Step 2: Health Check Endpoints
Kubernetes needs a way to know if your app is alive and ready to serve traffic. Add dedicated endpoints:
// health.js
const express = require('express');
const router = express.Router();
let isReady = false;
setTimeout(() => { isReady = true; }, 5000); // simulate startup work
router.get('/healthz', (req, res) => res.status(200).send('OK'));
router.get('/readyz', (req, res) => {
if (isReady) return res.status(200).send('READY');
res.status(503).send('NOT READY');
});
module.exports = router;
Step 3: The Deployment Manifest
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-node-app
namespace: production
labels:
app: my-node-app
spec:
replicas: 3
selector:
matchLabels:
app: my-node-app
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: my-node-app
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1001
seccompProfile:
type: RuntimeDefault
containers:
- name: my-node-app
image: 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-node-app:v1.0.0
ports:
- containerPort: 8080
envFrom:
- configMapRef:
name: my-node-app-config
- secretRef:
name: my-node-app-secrets
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /readyz
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
The readOnlyRootFilesystem: true combined with a mounted emptyDir at /tmp is a common pattern — it hardens the container while still giving Node.js somewhere writable if it (or a dependency) needs scratch space.
Step 4: ConfigMap and Secrets
apiVersion: v1
kind: ConfigMap
metadata:
name: my-node-app-config
namespace: production
data:
NODE_ENV: "production"
LOG_LEVEL: "info"
PORT: "8080"
API_TIMEOUT_MS: "5000"
For secrets, avoid plain Secret manifests checked into Git. Use External Secrets Operator pulling from AWS Secrets Manager:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: my-node-app-secrets
namespace: production
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: my-node-app-secrets
data:
- secretKey: DATABASE_URL
remoteRef:
key: prod/my-node-app/database-url
- secretKey: JWT_SECRET
remoteRef:
key: prod/my-node-app/jwt-secret
Step 5: Exposing the App with a Service and Ingress
apiVersion: v1
kind: Service
metadata:
name: my-node-app
namespace: production
spec:
selector:
app: my-node-app
ports:
- port: 80
targetPort: 8080
type: ClusterIP
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-node-app
namespace: production
annotations:
kubernetes.io/ingress.class: alb
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:123456789012:certificate/xxxx
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80},{"HTTPS":443}]'
alb.ingress.kubernetes.io/actions.ssl-redirect: '{"Type":"redirect","RedirectConfig":{"Protocol":"HTTPS","Port":"443","StatusCode":"HTTP_301"}}'
spec:
rules:
- host: api.company.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ssl-redirect
port:
name: use-annotation
- path: /
pathType: Prefix
backend:
service:
name: my-node-app
port:
number: 80
kubectl apply -f configmap.yaml -f secrets.yaml -f deployment.yaml -f service.yaml -f ingress.yaml
kubectl get ingress my-node-app -n production
Step 6: Autoscaling
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: my-node-app
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: my-node-app
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleDown:
stabilizationWindowSeconds: 300
kubectl apply -f hpa.yaml
kubectl get hpa my-node-app -n production
Step 7: CI/CD Pipeline
A realistic GitHub Actions pipeline building, pushing, and deploying via kubectl (or you could swap the last step for an ArgoCD sync):
name: deploy-node-app
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
- name: Login to ECR
id: ecr-login
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push image
env:
REGISTRY: ${{ steps.ecr-login.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $REGISTRY/my-node-app:$IMAGE_TAG .
docker push $REGISTRY/my-node-app:$IMAGE_TAG
- name: Update kubeconfig
run: aws eks update-kubeconfig --name production-cluster --region us-east-1
- name: Deploy
env:
REGISTRY: ${{ steps.ecr-login.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
kubectl set image deployment/my-node-app \
my-node-app=$REGISTRY/my-node-app:$IMAGE_TAG \
-n production
kubectl rollout status deployment/my-node-app -n production --timeout=180s
For teams already using GitOps (see the Canary Release article), replace the final kubectl set image step with a Git commit updating the image tag in a manifests repo, letting ArgoCD/Flux reconcile the change instead.
Step 8: Graceful Shutdown
Node.js apps need to handle SIGTERM properly, since Kubernetes sends it before force-killing a pod during rollouts or scale-downs:
const server = app.listen(8080);
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(() => {
console.log('HTTP server closed');
// close DB connections, flush logs, etc.
process.exit(0);
});
// force exit if graceful shutdown takes too long
setTimeout(() => process.exit(1), 10000);
});
Pair this with terminationGracePeriodSeconds in the pod spec (default 30s is usually fine, but tune it to match your shutdown logic):
spec:
terminationGracePeriodSeconds: 30
Structured Logging for Kubernetes Environments
Console logging that’s fine on a laptop becomes a problem in production — you need structured, parseable logs that a log aggregator (Fluent Bit, as covered in the DaemonSets article) can ship and index properly. Plain console.log output loses a lot of value compared to structured JSON:
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
formatters: {
level: (label) => ({ level: label }),
},
base: {
pid: process.pid,
hostname: process.env.HOSTNAME, // pod name, injected via downward API
},
});
logger.info({ requestId: 'abc-123', userId: 42 }, 'Order processed successfully');
Inject the pod name via the downward API so every log line is traceable back to its source pod:
env:
- name: HOSTNAME
valueFrom:
fieldRef:
fieldPath: metadata.name
Connecting to a Database Securely
A realistic Node.js app usually needs a database connection, and doing this right on EKS means combining what’s covered in the companion ConfigMaps, Secrets, and Persistent Volume Claims articles:
env:
- name: DB_HOST
valueFrom:
configMapKeyRef:
name: my-node-app-config
key: DB_HOST
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: my-node-app-secrets
key: DATABASE_PASSWORD
const { Pool } = require('pg');
const pool = new Pool({
host: process.env.DB_HOST,
password: process.env.DB_PASSWORD,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
pool.on('error', (err) => {
logger.error({ err }, 'Unexpected database pool error');
});
Keep the connection pool’s max size sane relative to your database’s total connection limit multiplied by your maximum replica count — a Node.js app that scales to 20 pods with a pool size of 10 each can exhaust a database’s connection limit surprisingly fast, a very common production incident on first-time Kubernetes migrations.
Troubleshooting
# Pod not starting
kubectl describe pod -l app=my-node-app -n production
# App crashing on startup
kubectl logs -l app=my-node-app -n production --previous
# Confirm environment variables actually landed correctly
kubectl exec -it deploy/my-node-app -n production -- env | grep NODE_ENV
# Test connectivity from inside the cluster
kubectl run -it --rm debug --image=curlimages/curl --restart=Never -- curl http://my-node-app.production.svc/healthz
A very common Node.js-specific gotcha: forgetting to set --max-old-space-size relative to the container’s memory limit. Node’s default heap sizing assumes it owns the whole host’s memory, which in a resource-limited container can cause OOMKills before Node’s own garbage collector even kicks in:
env:
- name: NODE_OPTIONS
value: "--max-old-space-size=400"
(set to roughly 80% of your container’s memory limit in MB).
Best Practices
- Multi-stage Docker builds to keep images small and free of dev dependencies.
- Separate liveness and readiness probes — liveness restarts a hung process, readiness controls traffic admission during startup or transient issues.
- Non-root user, read-only root filesystem, dropped capabilities — align with Pod Security Standards from day one.
- Explicit
NODE_OPTIONS --max-old-space-sizetuned to your container’s memory limit. - Handle
SIGTERMfor graceful connection draining during rollouts.
Summary
Deploying Node.js on Kubernetes is mostly about doing the fundamentals well: a lean multi-stage image, correct health check wiring, sane resource requests/limits tuned to Node’s memory model, and a CI/CD pipeline that builds, pushes to ECR, and rolls out via kubectl or GitOps. Layer in the HPA for autoscaling and the AWS Load Balancer Controller for ingress, and you have a genuinely production-grade setup on EKS — the same patterns scale from a single microservice to dozens of them.