How to Deploy a Node.js Application on Kubernetes

How to Deploy a Node.js Application on Kubernetes

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

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.

References

Exit mobile version