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
doctlCLI installed and authenticated (doctl auth init)kubectlinstalled (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 —
/readyshould check dependencies (database, cache), while/healthshould only verify the process is alive.Pin your image tags. Never use
:latestin production Kubernetes manifests. Use the Git SHA or a semantic version. Kubernetes caches images and will not pull:latestagain if it already has an image with that tag, leading to stale deployments.Use namespaces for environment isolation. Run
stagingandproductionin 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
podAntiAffinityto 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.
