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

  • 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-size tuned to your container’s memory limit.
  • Handle SIGTERM for 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.

References

Total
1
Shares

Leave a Reply

Previous Post
How to Use StatefulSets in Kubernetes

How to Use StatefulSets in Kubernetes

Next Post
How to Set Up Prometheus Monitoring for Kubernetes

How to Set Up Prometheus Monitoring for Kubernetes

Related Posts