Digitalocean

Kubernetes on DigitalOcean: Getting Started

A practical guide to deploying Node.js applications on DigitalOcean Kubernetes (DOKS), covering cluster setup, deployments, services, ingress, autoscaling, and CI/CD integration.

Kubernetes on DigitalOcean: Getting Started

DigitalOcean Kubernetes (DOKS) is a managed Kubernetes service that strips away the operational overhead of running your own control plane while keeping the price tag reasonable. If you have been running Node.js applications on single Droplets or App Platform and you have hit the ceiling on scaling, reliability, or deployment flexibility, DOKS is the next logical step. This guide covers everything from spinning up your first cluster to deploying a production-grade Node.js application with autoscaling, secrets management, and a full CI/CD pipeline.

Prerequisites

Before diving in, make sure you have the following ready:

  • A DigitalOcean account with billing enabled
  • doctl CLI installed and authenticated (doctl auth init)
  • kubectl installed (v1.28 or later)
  • Docker installed locally for building container images
  • A DigitalOcean Container Registry (DOCR) or Docker Hub account
  • Node.js v18+ installed locally
  • Basic familiarity with Docker containers and YAML

Install doctl if you have not already:

# macOS
brew install doctl

# Windows (via scoop)
scoop install doctl

# Linux (snap)
sudo snap install doctl

# Authenticate
doctl auth init
# Paste your API token when prompted

DOKS Overview and Pricing

DOKS is DigitalOcean's managed Kubernetes offering. You do not pay for the control plane — DigitalOcean runs the API server, etcd, scheduler, and controller manager for free. You only pay for the worker nodes (Droplets), load balancers, and block storage volumes you provision.

Here is what the pricing looks like in practice:

Resource Size Monthly Cost
Worker Node s-2vcpu-4gb $24/mo each
Worker Node s-4vcpu-8gb $48/mo each
Load Balancer Small $12/mo
Block Storage 10 GB $1/mo
Container Registry Starter (500 MB) Free
Container Registry Basic (5 GB) $5/mo

A minimal production cluster with three s-2vcpu-4gb nodes, a load balancer, and a small registry runs about $89/month. Compare that to AWS EKS at $73/month just for the control plane before you add any nodes, and the value proposition is clear.

DOKS supports Kubernetes versions 1.28 through 1.31 at the time of writing, with automatic patch upgrades and one-click minor version upgrades.

Creating a Cluster

Via the CLI (doctl)

The fastest way to spin up a cluster:

# List available Kubernetes versions
doctl kubernetes options versions

# List available regions
doctl kubernetes options regions

# List available node sizes
doctl kubernetes options sizes

# Create a cluster with 3 nodes in NYC1
doctl kubernetes cluster create my-app-cluster \
  --region nyc1 \
  --version 1.31.1-do.3 \
  --size s-2vcpu-4gb \
  --count 3 \
  --tag env:production

# Output:
# Notice: Cluster is provisioning, waiting for cluster to be running
# ..........................................................................
# Notice: Cluster created, fetching credentials
# Notice: Adding cluster credentials to kubeconfig file found in "/home/user/.kube/config"
# Notice: Setting current-context to do-nyc1-my-app-cluster
# ID                                      Name              Region    Version          Auto Upgrade    Status     Node Pools
# 8a4f2e1c-3b5d-4a6e-9c8d-7e2f1a0b3c4d    my-app-cluster    nyc1      1.31.1-do.3      false           running    my-app-cluster-default-pool

This command creates the cluster and automatically configures your local kubectl context. The whole process takes about four minutes.

Via the DigitalOcean UI

Navigate to the Kubernetes section in the DigitalOcean control panel, click "Create Cluster," and walk through the wizard. Choose your region, Kubernetes version, node pool configuration, and name. The UI is straightforward, but the CLI is faster for repeatable setups.

Connecting kubectl to Your Cluster

If you created the cluster via doctl, your kubeconfig is already configured. If you need to connect from another machine or re-fetch credentials:

# Save cluster credentials to kubeconfig
doctl kubernetes cluster kubeconfig save my-app-cluster

# Verify the connection
kubectl cluster-info

# Output:
# Kubernetes control plane is running at https://8a4f2e1c-3b5d-4a6e-9c8d-7e2f1a0b3c4d.k8s.ondigitalocean.com
# CoreDNS is running at https://8a4f2e1c-3b5d-4a6e-9c8d-7e2f1a0b3c4d.k8s.ondigitalocean.com/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

# List nodes
kubectl get nodes

# Output:
# NAME                                STATUS   ROLES    AGE   VERSION
# my-app-cluster-default-pool-cx4q2   Ready    <none>   5m    v1.31.1
# my-app-cluster-default-pool-cx4q3   Ready    <none>   5m    v1.31.1
# my-app-cluster-default-pool-cx4q4   Ready    <none>   5m    v1.31.1

You can also switch between multiple cluster contexts:

# List all contexts
kubectl config get-contexts

# Switch context
kubectl config use-context do-nyc1-my-app-cluster

Deploying a Node.js Application

The Application

Let us start with a real Node.js Express application. Here is the server code:

// server.js
var express = require("express");
var os = require("os");

var app = express();
var port = process.env.PORT || 3000;

app.use(express.json());

app.get("/health", function(req, res) {
  res.json({
    status: "healthy",
    hostname: os.hostname(),
    uptime: process.uptime(),
    timestamp: new Date().toISOString()
  });
});

app.get("/ready", function(req, res) {
  // Check dependencies here (database, cache, etc.)
  var isReady = true;
  if (isReady) {
    res.json({ status: "ready" });
  } else {
    res.status(503).json({ status: "not ready" });
  }
});

app.get("/", function(req, res) {
  res.json({
    message: "Hello from DOKS",
    version: process.env.APP_VERSION || "1.0.0",
    environment: process.env.NODE_ENV || "development",
    pod: os.hostname()
  });
});

app.get("/api/data", function(req, res) {
  var items = [
    { id: 1, name: "Widget A", price: 29.99 },
    { id: 2, name: "Widget B", price: 49.99 },
    { id: 3, name: "Widget C", price: 19.99 }
  ];
  res.json({ items: items, count: items.length });
});

app.listen(port, function() {
  console.log("Server running on port " + port);
});

The Dockerfile

Build a production-ready container image with a multi-stage Dockerfile:

# Build stage
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# Production stage
FROM node:20-alpine
WORKDIR /app

RUN addgroup -g 1001 -S appgroup && \
    adduser -S appuser -u 1001 -G appgroup

COPY --from=build /app/node_modules ./node_modules
COPY server.js ./
COPY package.json ./

USER appuser

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

CMD ["node", "server.js"]

Building and Pushing to DigitalOcean Container Registry

# Create a container registry (one-time)
doctl registry create my-registry --subscription-tier basic

# Log in to the registry
doctl registry login

# Build the image
docker build -t registry.digitalocean.com/my-registry/my-node-app:1.0.0 .
# Output:
# [+] Building 12.3s (14/14) FINISHED
# => [build 1/3] FROM node:20-alpine
# => [build 2/3] COPY package*.json ./
# => [build 3/3] RUN npm ci --only=production
# => [stage-1 5/6] COPY --from=build /app/node_modules ./node_modules
# => exporting to image
# => => writing image sha256:a3b4c5d6e7f8...
# => => naming to registry.digitalocean.com/my-registry/my-node-app:1.0.0

# Push to registry
docker push registry.digitalocean.com/my-registry/my-node-app:1.0.0
# Output:
# The push refers to repository [registry.digitalocean.com/my-registry/my-node-app]
# 1.0.0: digest: sha256:9f8e7d6c5b4a... size: 1576

# Integrate the registry with your cluster
doctl kubernetes cluster registry add my-app-cluster

Kubernetes Deployment Manifest

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-node-app
  namespace: default
  labels:
    app: my-node-app
    version: "1.0.0"
spec:
  replicas: 3
  selector:
    matchLabels:
      app: my-node-app
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1
      maxSurge: 1
  template:
    metadata:
      labels:
        app: my-node-app
        version: "1.0.0"
    spec:
      containers:
        - name: my-node-app
          image: registry.digitalocean.com/my-registry/my-node-app:1.0.0
          ports:
            - containerPort: 3000
              protocol: TCP
          envFrom:
            - configMapRef:
                name: my-node-app-config
            - secretRef:
                name: my-node-app-secrets
          resources:
            requests:
              memory: "128Mi"
              cpu: "100m"
            limits:
              memory: "256Mi"
              cpu: "500m"
          livenessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 10
            periodSeconds: 15
            timeoutSeconds: 3
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /ready
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10
            timeoutSeconds: 3
            failureThreshold: 3
      imagePullSecrets:
        - name: my-registry

Service

# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: my-node-app-service
  namespace: default
  labels:
    app: my-node-app
spec:
  type: ClusterIP
  selector:
    app: my-node-app
  ports:
    - port: 80
      targetPort: 3000
      protocol: TCP
      name: http

Ingress

For production traffic routing, install the NGINX Ingress Controller and create an Ingress resource:

# Install NGINX Ingress Controller via Helm
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
helm install ingress-nginx ingress-nginx/ingress-nginx \
  --set controller.publishService.enabled=true

# Wait for the load balancer IP
kubectl get svc ingress-nginx-controller -w
# Output:
# NAME                       TYPE           CLUSTER-IP      EXTERNAL-IP     PORT(S)
# ingress-nginx-controller   LoadBalancer   10.245.67.89    143.198.xx.xx   80:31234/TCP,443:31567/TCP
# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-node-app-ingress
  namespace: default
  annotations:
    kubernetes.io/ingress.class: nginx
    cert-manager.io/cluster-issuer: letsencrypt-prod
    nginx.ingress.kubernetes.io/rate-limit: "100"
    nginx.ingress.kubernetes.io/rate-limit-window: "1m"
spec:
  tls:
    - hosts:
        - api.example.com
      secretName: api-example-tls
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: my-node-app-service
                port:
                  number: 80

Configuring Environment Variables

ConfigMaps

Use ConfigMaps for non-sensitive configuration:

# k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: my-node-app-config
  namespace: default
data:
  NODE_ENV: "production"
  PORT: "3000"
  APP_VERSION: "1.0.0"
  LOG_LEVEL: "info"
  CACHE_TTL: "300"

Secrets

Use Secrets for sensitive data like database credentials and API keys:

# Create secrets from literal values
kubectl create secret generic my-node-app-secrets \
  --from-literal=DB_HOST=private-db-cluster-do-user-123456-0.db.ondigitalocean.com \
  --from-literal=DB_PASSWORD='s3cur3P@ssw0rd!' \
  --from-literal=API_KEY='sk-prod-abc123def456'

# Or from a YAML file
# k8s/secrets.yaml
apiVersion: v1
kind: Secret
metadata:
  name: my-node-app-secrets
  namespace: default
type: Opaque
stringData:
  DB_HOST: "private-db-cluster-do-user-123456-0.db.ondigitalocean.com"
  DB_PASSWORD: "s3cur3P@ssw0rd!"
  API_KEY: "sk-prod-abc123def456"

Access these in your Node.js code just like regular environment variables:

// db.js
var pg = require("pg");

var pool = new pg.Pool({
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT) || 5432,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  ssl: { rejectUnauthorized: false }
});

pool.on("error", function(err) {
  console.error("Unexpected database error:", err);
  process.exit(1);
});

module.exports = pool;

Persistent Storage with DigitalOcean Block Storage

DOKS integrates with DigitalOcean Volumes through the CSI driver, which is pre-installed. Create a PersistentVolumeClaim and it automatically provisions a DO Volume:

# k8s/pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: my-app-data
  namespace: default
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: do-block-storage
  resources:
    requests:
      storage: 10Gi

Mount it in your Deployment:

# Add to your deployment spec.template.spec
volumes:
  - name: app-data
    persistentVolumeClaim:
      claimName: my-app-data

# Add to spec.template.spec.containers[0]
volumeMounts:
  - name: app-data
    mountPath: /app/data

Verify the volume was provisioned:

kubectl get pvc
# Output:
# NAME           STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS       AGE
# my-app-data    Bound    pvc-3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d  10Gi       RWO            do-block-storage   30s

Note that do-block-storage volumes are ReadWriteOnce — they can only be attached to a single node at a time. If you need shared storage across multiple pods on different nodes, consider using DigitalOcean Spaces with an S3-compatible client instead.

Load Balancer Integration

When you create a Service with type: LoadBalancer, DOKS automatically provisions a DigitalOcean Load Balancer:

# k8s/service-lb.yaml
apiVersion: v1
kind: Service
metadata:
  name: my-node-app-lb
  namespace: default
  annotations:
    service.beta.kubernetes.io/do-loadbalancer-size-slug: "lb-small"
    service.beta.kubernetes.io/do-loadbalancer-protocol: "http"
    service.beta.kubernetes.io/do-loadbalancer-algorithm: "round_robin"
    service.beta.kubernetes.io/do-loadbalancer-healthcheck-path: "/health"
    service.beta.kubernetes.io/do-loadbalancer-healthcheck-port: "3000"
    service.beta.kubernetes.io/do-loadbalancer-healthcheck-protocol: "http"
    service.beta.kubernetes.io/do-loadbalancer-redirect-http-to-https: "true"
    service.beta.kubernetes.io/do-loadbalancer-certificate-id: "your-cert-id"
spec:
  type: LoadBalancer
  selector:
    app: my-node-app
  ports:
    - name: http
      port: 80
      targetPort: 3000
    - name: https
      port: 443
      targetPort: 3000

The load balancer takes about 60 seconds to provision. Check its status:

kubectl get svc my-node-app-lb -w
# Output:
# NAME             TYPE           CLUSTER-IP      EXTERNAL-IP    PORT(S)                      AGE
# my-node-app-lb   LoadBalancer   10.245.12.34    <pending>      80:30123/TCP,443:30456/TCP   10s
# my-node-app-lb   LoadBalancer   10.245.12.34    143.198.xx.xx  80:30123/TCP,443:30456/TCP   65s

In most production setups, I prefer using the NGINX Ingress Controller with a single LoadBalancer Service rather than creating one Load Balancer per Service. Each Load Balancer costs $12/month, and they add up quickly.

Scaling Strategies

Horizontal Pod Autoscaler (HPA)

HPA automatically adjusts the number of pod replicas based on CPU or memory utilization. First, make sure the metrics server is installed (it is by default on DOKS):

kubectl top nodes
# Output:
# NAME                                CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%
# my-app-cluster-default-pool-cx4q2   89m          4%     1124Mi          29%
# my-app-cluster-default-pool-cx4q3   76m          3%     987Mi           25%
# my-app-cluster-default-pool-cx4q4   112m         5%     1203Mi          31%
# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: my-node-app-hpa
  namespace: default
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-node-app
  minReplicas: 3
  maxReplicas: 15
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 75
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 60
      policies:
        - type: Pods
          value: 3
          periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - type: Pods
          value: 1
          periodSeconds: 120

The behavior section is critical. Without it, HPA can scale up and down too aggressively, causing thrashing. The configuration above means: scale up by at most 3 pods per minute, but scale down only 1 pod every 2 minutes with a 5-minute stabilization window.

kubectl apply -f k8s/hpa.yaml
kubectl get hpa
# Output:
# NAME              REFERENCE                TARGETS           MINPODS   MAXPODS   REPLICAS   AGE
# my-node-app-hpa   Deployment/my-node-app   23%/60%, 41%/75%  3         15        3          2m

Node Auto-Scaling

DOKS supports cluster auto-scaling at the node pool level. When pods cannot be scheduled because of insufficient resources, the autoscaler adds nodes. When nodes are underutilized, it removes them.

# Enable auto-scaling on an existing node pool
doctl kubernetes cluster node-pool update my-app-cluster default-pool \
  --auto-scale \
  --min-nodes 3 \
  --max-nodes 10

# Or create a new auto-scaling node pool
doctl kubernetes cluster node-pool create my-app-cluster \
  --name high-memory-pool \
  --size s-4vcpu-8gb \
  --count 2 \
  --auto-scale \
  --min-nodes 1 \
  --max-nodes 5 \
  --tag env:production

The combination of HPA and node auto-scaling gives you two layers of elasticity: HPA handles pod-level scaling within existing capacity, and the node autoscaler expands capacity when HPA needs more room.

Monitoring with the DigitalOcean Metrics Stack

DOKS offers a one-click monitoring stack based on Prometheus and Grafana. You can install it from the DigitalOcean marketplace:

# Install the monitoring stack via the 1-click app
doctl kubernetes 1-click install my-app-cluster \
  --1-clicks monitoring

# Or install Prometheus and Grafana manually via Helm
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update

helm install prometheus prometheus-community/kube-prometheus-stack \
  --namespace monitoring \
  --create-namespace \
  --set grafana.adminPassword='securePassword123' \
  --set prometheus.prometheusSpec.retention=15d \
  --set prometheus.prometheusSpec.storageSpec.volumeClaimTemplate.spec.storageClassName=do-block-storage \
  --set prometheus.prometheusSpec.storageSpec.volumeClaimTemplate.spec.resources.requests.storage=20Gi

Add custom metrics to your Node.js application with prom-client:

// metrics.js
var prometheus = require("prom-client");

// Create a registry
var register = new prometheus.Registry();

// Add default metrics (CPU, memory, event loop lag, etc.)
prometheus.collectDefaultMetrics({ register: register });

// Custom metrics
var httpRequestDuration = new prometheus.Histogram({
  name: "http_request_duration_seconds",
  help: "Duration of HTTP requests in seconds",
  labelNames: ["method", "route", "status_code"],
  buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
});
register.registerMetric(httpRequestDuration);

var httpRequestsTotal = new prometheus.Counter({
  name: "http_requests_total",
  help: "Total number of HTTP requests",
  labelNames: ["method", "route", "status_code"]
});
register.registerMetric(httpRequestsTotal);

var activeConnections = new prometheus.Gauge({
  name: "active_connections",
  help: "Number of active connections"
});
register.registerMetric(activeConnections);

module.exports = {
  register: register,
  httpRequestDuration: httpRequestDuration,
  httpRequestsTotal: httpRequestsTotal,
  activeConnections: activeConnections
};

Wire the metrics into your Express app:

// In server.js, add metrics middleware
var metrics = require("./metrics");

app.use(function(req, res, next) {
  var start = Date.now();
  metrics.activeConnections.inc();

  res.on("finish", function() {
    var duration = (Date.now() - start) / 1000;
    var route = req.route ? req.route.path : req.path;
    metrics.httpRequestDuration.observe(
      { method: req.method, route: route, status_code: res.statusCode },
      duration
    );
    metrics.httpRequestsTotal.inc({
      method: req.method,
      route: route,
      status_code: res.statusCode
    });
    metrics.activeConnections.dec();
  });

  next();
});

// Metrics endpoint for Prometheus scraping
app.get("/metrics", function(req, res) {
  res.set("Content-Type", metrics.register.contentType);
  metrics.register.metrics().then(function(data) {
    res.end(data);
  });
});

Then add a ServiceMonitor so Prometheus discovers your pods:

# k8s/service-monitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: my-node-app-monitor
  namespace: default
  labels:
    release: prometheus
spec:
  selector:
    matchLabels:
      app: my-node-app
  endpoints:
    - port: http
      path: /metrics
      interval: 15s

Deploying with Helm Charts

For complex applications with multiple microservices, Helm charts bring structure and reusability:

# Create a Helm chart scaffold
helm create my-node-app-chart

# This generates:
# my-node-app-chart/
#   Chart.yaml
#   values.yaml
#   templates/
#     deployment.yaml
#     service.yaml
#     ingress.yaml
#     hpa.yaml
#     serviceaccount.yaml
#     _helpers.tpl

Customize values.yaml for your application:

# my-node-app-chart/values.yaml
replicaCount: 3

image:
  repository: registry.digitalocean.com/my-registry/my-node-app
  tag: "1.0.0"
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 80
  targetPort: 3000

ingress:
  enabled: true
  className: nginx
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
  hosts:
    - host: api.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: api-example-tls
      hosts:
        - api.example.com

resources:
  limits:
    cpu: 500m
    memory: 256Mi
  requests:
    cpu: 100m
    memory: 128Mi

autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 15
  targetCPUUtilizationPercentage: 60
  targetMemoryUtilizationPercentage: 75

env:
  NODE_ENV: production
  LOG_LEVEL: info

secrets:
  DB_PASSWORD: ""
  API_KEY: ""

Deploy and manage releases:

# Install the chart
helm install my-app ./my-node-app-chart -f production-values.yaml

# Upgrade a release
helm upgrade my-app ./my-node-app-chart \
  --set image.tag=1.1.0 \
  -f production-values.yaml

# Rollback if something goes wrong
helm rollback my-app 1

# List releases
helm list
# Output:
# NAME     NAMESPACE  REVISION  UPDATED                                 STATUS    CHART                   APP VERSION
# my-app   default    2         2026-02-08 14:30:22.123456 -0500 EST    deployed  my-node-app-chart-0.1.0 1.1.0

CI/CD Integration with GitHub Actions

Here is a complete GitHub Actions workflow that builds, pushes, and deploys to DOKS on every push to the main branch:

# .github/workflows/deploy.yaml
name: Deploy to DOKS

on:
  push:
    branches: [main]

env:
  REGISTRY: registry.digitalocean.com/my-registry
  IMAGE_NAME: my-node-app
  CLUSTER_NAME: my-app-cluster

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - run: npm test

  build-and-deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install doctl
        uses: digitalocean/action-doctl@v2
        with:
          token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}

      - name: Log in to DOCR
        run: doctl registry login --expiry-seconds 600

      - name: Build and tag image
        run: |
          docker build \
            -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
            -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
            .

      - name: Push image to DOCR
        run: |
          docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
          docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

      - name: Save DigitalOcean kubeconfig
        run: doctl kubernetes cluster kubeconfig save ${{ env.CLUSTER_NAME }}

      - name: Deploy to Kubernetes
        run: |
          kubectl set image deployment/my-node-app \
            my-node-app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}

      - name: Wait for rollout
        run: |
          kubectl rollout status deployment/my-node-app --timeout=300s

      - name: Verify deployment
        run: |
          kubectl get pods -l app=my-node-app
          echo "---"
          kubectl get svc my-node-app-service

You need to add DIGITALOCEAN_ACCESS_TOKEN to your GitHub repository secrets. Generate an API token with read/write scope from the DigitalOcean control panel.

Cost Optimization

DOKS costs can creep up if you are not paying attention. Here are strategies to keep your bill under control.

Right-size your nodes. Do not default to the biggest node size. Run kubectl top nodes and kubectl top pods regularly. If your nodes consistently show under 40% CPU and memory utilization, you are overpaying. Drop to smaller nodes and add more of them — this also improves fault tolerance.

kubectl top pods --all-namespaces --sort-by=memory
# Output:
# NAMESPACE     NAME                                      CPU(cores)   MEMORY(bytes)
# default       my-node-app-6d4f5e3a2b-x7k9m              12m          87Mi
# default       my-node-app-6d4f5e3a2b-p3q8n              15m          92Mi
# default       my-node-app-6d4f5e3a2b-r5t2v              11m          84Mi
# monitoring    prometheus-server-0                        45m          512Mi
# kube-system   coredns-5f9b7c4d3a-2x8k9                  3m           24Mi

Use node pools strategically. Run your always-on base workload on a dedicated node pool with fixed nodes. Use a second auto-scaling pool for burst traffic. This prevents the autoscaler from scaling down too aggressively during off-peak hours and killing your base capacity.

Set resource requests and limits. Without resource requests, the Kubernetes scheduler cannot pack pods efficiently, leading to wasted capacity. Without limits, a runaway process can starve other pods.

Clean up unused resources. Orphaned PersistentVolumeClaims, unused LoadBalancers, and stale container images in your registry all cost money. Automate cleanup:

# Find PVCs not bound to any pod
kubectl get pvc --all-namespaces -o json | \
  jq -r '.items[] | select(.status.phase != "Bound") | .metadata.name'

# Garbage collect old images in DOCR
doctl registry garbage-collection start my-registry --include-untagged-manifests

Consider Basic nodes over Premium. DigitalOcean offers Basic (regular) and Premium (NVMe, dedicated CPU) Droplet types. For most Node.js workloads that are I/O-bound and not CPU-intensive, Basic nodes at $24/month for 2vCPU/4GB are more than sufficient.

Complete Working Example

Let me pull everything together. Here is the full set of manifests for deploying a production Node.js application to DOKS with all the pieces wired up.

Create a k8s/ directory with the following files:

# k8s/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    env: production
# k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: my-node-app-config
  namespace: production
data:
  NODE_ENV: "production"
  PORT: "3000"
  APP_VERSION: "1.0.0"
  LOG_LEVEL: "info"
# k8s/secrets.yaml
apiVersion: v1
kind: Secret
metadata:
  name: my-node-app-secrets
  namespace: production
type: Opaque
stringData:
  DB_HOST: "private-db-cluster.db.ondigitalocean.com"
  DB_PASSWORD: "your-secure-password"
  API_KEY: "sk-prod-your-api-key"
# k8s/deployment.yaml
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:
      maxUnavailable: 1
      maxSurge: 1
  template:
    metadata:
      labels:
        app: my-node-app
    spec:
      containers:
        - name: my-node-app
          image: registry.digitalocean.com/my-registry/my-node-app:1.0.0
          ports:
            - containerPort: 3000
          envFrom:
            - configMapRef:
                name: my-node-app-config
            - secretRef:
                name: my-node-app-secrets
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "500m"
              memory: "256Mi"
          livenessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 10
            periodSeconds: 15
          readinessProbe:
            httpGet:
              path: /ready
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10
      imagePullSecrets:
        - name: my-registry
# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: my-node-app-service
  namespace: production
spec:
  type: ClusterIP
  selector:
    app: my-node-app
  ports:
    - port: 80
      targetPort: 3000
      name: http
# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-node-app-ingress
  namespace: production
  annotations:
    kubernetes.io/ingress.class: nginx
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  tls:
    - hosts:
        - api.example.com
      secretName: api-example-tls
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: my-node-app-service
                port:
                  number: 80
# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: my-node-app-hpa
  namespace: production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-node-app
  minReplicas: 3
  maxReplicas: 15
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 75
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 60
      policies:
        - type: Pods
          value: 3
          periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - type: Pods
          value: 1
          periodSeconds: 120

Deploy everything in order:

# Apply all manifests
kubectl apply -f k8s/namespace.yaml
kubectl apply -f k8s/configmap.yaml
kubectl apply -f k8s/secrets.yaml
kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/service.yaml
kubectl apply -f k8s/ingress.yaml
kubectl apply -f k8s/hpa.yaml

# Verify everything is running
kubectl get all -n production
# Output:
# NAME                               READY   STATUS    RESTARTS   AGE
# pod/my-node-app-6d4f5e3a2b-x7k9m   1/1     Running   0          45s
# pod/my-node-app-6d4f5e3a2b-p3q8n   1/1     Running   0          45s
# pod/my-node-app-6d4f5e3a2b-r5t2v   1/1     Running   0          45s
#
# NAME                              TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
# service/my-node-app-service       ClusterIP   10.245.45.67    <none>        80/TCP    40s
#
# NAME                          READY   UP-TO-DATE   AVAILABLE   AGE
# deployment.apps/my-node-app   3/3     3            3           45s
#
# NAME                                     DESIRED   CURRENT   READY   AGE
# replicaset.apps/my-node-app-6d4f5e3a2b   3         3         3       45s
#
# NAME                                              REFERENCE                TARGETS           MINPODS   MAXPODS   REPLICAS   AGE
# horizontalpodautoscaler.autoscaling/my-node-app-hpa   Deployment/my-node-app   12%/60%, 34%/75%  3         15        3          35s

Here is the complete GitHub Actions pipeline for this setup:

# .github/workflows/deploy-production.yaml
name: Deploy to Production (DOKS)

on:
  push:
    branches: [main]
  workflow_dispatch:

env:
  REGISTRY: registry.digitalocean.com/my-registry
  IMAGE_NAME: my-node-app
  CLUSTER_NAME: my-app-cluster

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - run: npm test
      - run: npm run lint

  build-push:
    needs: test
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ github.sha }}
    steps:
      - uses: actions/checkout@v4

      - name: Install doctl
        uses: digitalocean/action-doctl@v2
        with:
          token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}

      - name: Log in to DOCR
        run: doctl registry login --expiry-seconds 600

      - name: Build image
        run: |
          docker build \
            --build-arg APP_VERSION=${{ github.sha }} \
            -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
            -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
            .

      - name: Push image
        run: |
          docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
          docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

  deploy:
    needs: build-push
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install doctl
        uses: digitalocean/action-doctl@v2
        with:
          token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}

      - name: Save kubeconfig
        run: doctl kubernetes cluster kubeconfig save ${{ env.CLUSTER_NAME }}

      - name: Update deployment image
        run: |
          kubectl set image deployment/my-node-app \
            my-node-app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
            -n production

      - name: Wait for rollout
        run: kubectl rollout status deployment/my-node-app -n production --timeout=300s

      - name: Smoke test
        run: |
          INGRESS_IP=$(kubectl get ingress my-node-app-ingress -n production -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://$INGRESS_IP/health)
          if [ "$STATUS" != "200" ]; then
            echo "Smoke test failed with status $STATUS"
            kubectl rollout undo deployment/my-node-app -n production
            exit 1
          fi
          echo "Smoke test passed with status $STATUS"

      - name: Notify on failure
        if: failure()
        run: |
          echo "Deployment failed! Rolling back..."
          kubectl rollout undo deployment/my-node-app -n production

Common Issues and Troubleshooting

1. ImagePullBackOff — Registry Authentication

Events:
  Warning  Failed     12s   kubelet  Failed to pull image "registry.digitalocean.com/my-registry/my-node-app:1.0.0":
  rpc error: code = Unknown desc = Error response from daemon: pull access denied for
  registry.digitalocean.com/my-registry/my-node-app, repository does not exist or may require 'docker login'

This happens when your DOKS cluster is not integrated with your container registry. Fix it:

doctl kubernetes cluster registry add my-app-cluster
# Then restart the failing pods
kubectl rollout restart deployment/my-node-app

2. CrashLoopBackOff — Application Crashes on Startup

NAME                           READY   STATUS             RESTARTS      AGE
my-node-app-7f8a9b0c1d-x3k2m  0/1     CrashLoopBackOff   5 (32s ago)   3m

Check the container logs to find the root cause:

kubectl logs my-node-app-7f8a9b0c1d-x3k2m --previous
# Common causes:
# - Missing environment variables (DB_HOST, API_KEY)
# - Cannot connect to database (wrong host/credentials)
# - Port conflict (PORT env var not matching containerPort)
# - Missing node_modules (npm ci failed during build)

# Check events for more context
kubectl describe pod my-node-app-7f8a9b0c1d-x3k2m

3. Pods Stuck in Pending — Insufficient Resources

Events:
  Warning  FailedScheduling  15s  default-scheduler  0/3 nodes are available:
  3 Insufficient cpu, 3 Insufficient memory. preemption: 0/3 nodes are available:
  3 No preemption victims found for incoming pod.

Your nodes are out of capacity. Either reduce your resource requests, add more nodes, or enable auto-scaling:

# Check node resource usage
kubectl describe nodes | grep -A 5 "Allocated resources"

# Scale up the node pool
doctl kubernetes cluster node-pool update my-app-cluster default-pool --count 5

# Or enable auto-scaling
doctl kubernetes cluster node-pool update my-app-cluster default-pool \
  --auto-scale --min-nodes 3 --max-nodes 8

4. Ingress Returns 502 Bad Gateway

<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx</center>
</body>
</html>

This usually means the NGINX ingress controller cannot reach your backend pods. Common causes:

# Check if pods are actually ready
kubectl get pods -l app=my-node-app
# If READY shows 0/1, the readiness probe is failing

# Check the service endpoints
kubectl get endpoints my-node-app-service
# If ENDPOINTS is empty, the service selector doesn't match pod labels

# Check ingress configuration
kubectl describe ingress my-node-app-ingress
# Look for: "service my-node-app-service does not have any active Endpoint"

# Verify the targetPort matches your application's listening port
kubectl get svc my-node-app-service -o yaml | grep targetPort

5. PVC Stuck in Pending State

Events:
  Warning  ProvisioningFailed  5s  persistentvolume-controller  Failed to provision volume:
  failed to create DO volume: POST https://api.digitalocean.com/v2/volumes: 422
  Volume limit exceeded. You can only have 7 volumes per Droplet.

DigitalOcean has a limit of 7 volumes per Droplet. If you are hitting this, spread your workloads across more nodes or consolidate volumes. You can also open a support ticket to request a limit increase.

Best Practices

  • Always set resource requests and limits. Without them, the scheduler cannot make intelligent placement decisions, and a single misbehaving pod can take down an entire node. Start with requests at your application's baseline usage and limits at 2x the request.

  • Use readiness and liveness probes aggressively. The readiness probe gates traffic, preventing users from hitting pods that are not ready. The liveness probe restarts hung processes. Set different endpoints for each — /ready should check dependencies (database, cache), while /health should only verify the process is alive.

  • Pin your image tags. Never use :latest in production Kubernetes manifests. Use the Git SHA or a semantic version. Kubernetes caches images and will not pull :latest again if it already has an image with that tag, leading to stale deployments.

  • Use namespaces for environment isolation. Run staging and production in separate namespaces on the same cluster. Apply resource quotas per namespace to prevent one environment from starving the other.

  • Store Kubernetes manifests in Git. Every change to your cluster should go through a pull request. This gives you an audit trail, easy rollbacks, and the ability to recreate your entire cluster from scratch. Consider tools like ArgoCD or Flux for GitOps-style continuous delivery.

  • Run at least 3 replicas for production workloads. With 3 replicas spread across 3 nodes, you can survive a node failure without downtime. Set podAntiAffinity to ensure replicas land on different nodes:

affinity:
  podAntiAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 100
        podAffinityTerm:
          labelSelector:
            matchExpressions:
              - key: app
                operator: In
                values:
                  - my-node-app
          topologyKey: kubernetes.io/hostname
  • Implement graceful shutdown in your Node.js application. When Kubernetes sends SIGTERM, your app gets 30 seconds (by default) to finish in-flight requests before SIGKILL. Handle it properly:
// graceful-shutdown.js
var server = require("./server");

process.on("SIGTERM", function() {
  console.log("SIGTERM received. Starting graceful shutdown...");

  server.close(function() {
    console.log("HTTP server closed. Cleaning up...");
    // Close database connections, flush logs, etc.
    process.exit(0);
  });

  // Force exit after 25 seconds if graceful shutdown hangs
  setTimeout(function() {
    console.error("Graceful shutdown timed out. Forcing exit.");
    process.exit(1);
  }, 25000);
});
  • Keep your cluster version up to date. DOKS supports automatic patch upgrades. Enable them. For minor version upgrades, test in staging first, then upgrade production. Falling too far behind on versions means painful multi-version jumps later.

References

Powered by Contentful