Prerequisites and Tool Versions

You need three things before issuing your first cluster command: a DigitalOcean account with API access enabled, doctl 1.104 or later, and kubectl 1.30. Version mismatches between kubectl and the server API surface cause subtle failures - stick within one minor version of the cluster.

Install doctl on Linux:

After installation, authenticate with a personal access token. Generate the token at cloud.digitalocean.com/account/api/tokens with read and write scopes. The token only appears once, so store it in a secrets manager immediately.

If you are naming your project or cluster in a way that will be publicly visible - for example, tying it to a product domain - take five minutes with a tool like nicename.me to lock down a clean, matching domain before you start routing real traffic. Renaming Kubernetes services and load balancer hostnames after the fact is tedious.

# Install doctl 1.104 on Linux x86_64
curl -sL https://github.com/digitalocean/doctl/releases/download/v1.104.0/doctl-1.104.0-linux-amd64.tar.gz | tar xz
sudo mv doctl /usr/local/bin/

# Verify
doctl version
# doctl version 1.104.0-release

# Authenticate
doctl auth init --access-token YOUR_TOKEN_HERE

# Confirm connectivity
doctl account get

Provisioning a DOKS Cluster

DOKS clusters are regional. Picking the wrong region adds 80-120ms of round-trip latency if your users are in Europe and you deploy in nyc1. List available regions and Kubernetes versions before creating anything:

The cluster below uses three s-2vcpu-4gb nodes (2 vCPU, 4 GB RAM, $24/month each as of mid-2026) in a single node pool named app-pool. The --auto-upgrade flag enables patch-level upgrades during your maintenance window. Without it, DOKS will notify you but not act.

Cluster creation takes 3-6 minutes. The command blocks until the cluster is ready when you use --wait. Drop --wait if you are scripting this and want to poll status separately with doctl kubernetes cluster get.

Once the cluster is RUNNING, save the kubeconfig. DOKS merges it into ~/.kube/config by default, which will overwrite your current-context if you have multiple clusters. Use --set-current-context=false if you manage context switching manually.

# List regions
doctl kubernetes options regions

# List available Kubernetes versions
doctl kubernetes options versions

# Create a 3-node cluster in Frankfurt
doctl kubernetes cluster create prod-cluster \
  --region fra1 \
  --version 1.30.4-do.0 \
  --node-pool "name=app-pool;size=s-2vcpu-4gb;count=3;auto-scale=true;min-nodes=3;max-nodes=8" \
  --auto-upgrade \
  --maintenance-window "saturday=03:00" \
  --wait

# Save kubeconfig
doctl kubernetes cluster kubeconfig save prod-cluster

# Verify nodes are Ready
kubectl get nodes -o wide

Node Pools and Autoscaling

DOKS autoscaling is handled by the Kubernetes Cluster Autoscaler running inside the control plane - you do not manage it. When a pod enters Pending state because no node has sufficient allocatable CPU or memory, the autoscaler adds a node within 2-3 minutes. Scale-down triggers after a node has been underutilized for 10 minutes by default.

You can run multiple node pools on a single cluster. This is useful when you have latency-sensitive API workloads that need dedicated CPU nodes alongside batch jobs that can share resources. Create a second pool for GPU or CPU-optimized work without rebuilding the cluster:

Node taints on the second pool force explicit toleration in your workload specs, preventing pods from landing on expensive nodes accidentally. A forgotten toleration is far cheaper than an accidentally overscheduled c-32vcpu-64gb node running idle.

Check current node pool utilization directly:

The allocatable column is what Kubernetes schedulers see. On a s-2vcpu-4gb node, expect roughly 1800m allocatable CPU and 3.4Gi allocatable memory after system DaemonSets consume their share.

# Add a CPU-optimized pool for compute-heavy workloads
doctl kubernetes cluster node-pool create prod-cluster \
  --name compute-pool \
  --size c-8vcpu-16gb \
  --count 1 \
  --auto-scale \
  --min-nodes 1 \
  --max-nodes 4 \
  --tag compute \
  --taint "workload=compute:NoSchedule"

# List all node pools
doctl kubernetes cluster node-pool list prod-cluster

# Check allocatable resources on each node
kubectl describe nodes | grep -A5 'Allocatable:'
// advertisement

Deploying the NGINX Ingress Controller

DOKS does not include an ingress controller out of the box. The most common setup is ingress-nginx backed by a DigitalOcean Load Balancer, which provisions automatically when you apply the service type LoadBalancer. As of 2026, DigitalOcean Load Balancers start at $12/month.

Install ingress-nginx via Helm. If you do not have Helm 3.14+ installed, get it first with the official install script or your package manager.

The annotation do-loadbalancer-hostname is important. Without it, the load balancer provisions with an IP but no stable hostname, which breaks TLS certificate provisioning if you use cert-manager with DNS-01 challenges.

After applying, wait for the external IP to propagate - it takes 30-90 seconds for DigitalOcean to provision the load balancer:

Point your DNS A record at that IP. If you registered your domain through a service that offers API-managed DNS, you can automate this step. For teams managing DevOps automation pipelines, tools like taskbotshub.ai can wire DNS updates, cert renewals, and deployment triggers into a single automated workflow without building custom glue scripts.

# Add ingress-nginx Helm repo
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update

# Install with DigitalOcean load balancer annotations
helm install ingress-nginx ingress-nginx/ingress-nginx \
  --namespace ingress-nginx \
  --create-namespace \
  --set controller.service.annotations."service\.beta\.kubernetes\.io/do-loadbalancer-name"=prod-cluster-ingress \
  --set controller.service.annotations."service\.beta\.kubernetes\.io/do-loadbalancer-hostname"=ingress.yourdomain.com \
  --set controller.service.annotations."service\.beta\.kubernetes\.io/do-loadbalancer-protocol"=http \
  --version 4.10.1

# Wait for external IP
kubectl -n ingress-nginx get svc ingress-nginx-controller --watch

Deploying a Real Workload with Ingress

A hello-world deployment is useless for validating real cluster behavior. The example below deploys a small Go HTTP service with a proper Deployment, HorizontalPodAutoscaler, and Ingress resource. It exercises the ingress controller, the DO load balancer, and the HPA in one shot.

Apply the manifest with kubectl apply -f app.yaml and then watch the rollout:

The HPA will not scale until the Metrics Server is available. On DOKS, the Metrics Server is included and runs in the kube-system namespace as of Kubernetes 1.28+. Verify with kubectl top pods before assuming HPA is functional.

Test your ingress immediately after deploying. If you get a 404 from nginx, the ingressClassName field is missing or wrong. DOKS provisions ingress-nginx with the class name nginx - match it exactly in your Ingress spec.

# app.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-service
spec:
  replicas: 2
  selector:
    matchLabels:
      app: api-service
  template:
    metadata:
      labels:
        app: api-service
    spec:
      containers:
      - name: api
        image: registry.digitalocean.com/your-registry/api:1.4.2
        ports:
        - containerPort: 8080
        resources:
          requests:
            cpu: 250m
            memory: 128Mi
          limits:
            cpu: 500m
            memory: 256Mi
---
apiVersion: v1
kind: Service
metadata:
  name: api-service
spec:
  selector:
    app: api-service
  ports:
  - port: 80
    targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-ingress
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  ingressClassName: nginx
  rules:
  - host: api.yourdomain.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: api-service
            port:
              number: 80
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-service
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 60

# Apply and watch
kubectl apply -f app.yaml
kubectl rollout status deployment/api-service
kubectl get hpa api-service-hpa --watch

DigitalOcean Container Registry Integration

Pulling images from Docker Hub on every deployment burns egress and hits rate limits fast on clusters with many nodes. DigitalOcean Container Registry (DOCR) is the right answer for DOKS - it integrates directly with the cluster and eliminates the need for imagePullSecrets when you configure it correctly.

Create a registry and push your first image:

The doctl kubernetes cluster registry add command injects a pre-configured pull secret into every namespace in the cluster automatically. This is the important step that most tutorials skip. Without it, pods fail with ErrImagePull even though the registry is accessible.

Region colocation matters. If your cluster is in fra1, create your registry in Frankfurt (fra1 maps to registry.digitalocean.com with fra.registry.digitalocean.com as the regional endpoint). Cross-region pulls add 50-100ms to pod startup time and incur bandwidth costs.

You can set up DOCR through the DigitalOcean console at https://digitalocean.com/ or directly via doctl. The Starter tier includes 1 repository and 500MB storage, which is enough to validate the workflow before committing to a paid tier.

# Create a registry (starter tier, Frankfurt)
doctl registry create your-registry --region fra1 --subscription-tier starter

# Authenticate Docker to DOCR
doctl registry login

# Tag and push an image
docker tag api:1.4.2 registry.digitalocean.com/your-registry/api:1.4.2
docker push registry.digitalocean.com/your-registry/api:1.4.2

# Grant the cluster pull access to the registry
doctl kubernetes cluster registry add prod-cluster

# Verify the pull secret was created in default namespace
kubectl get secret -n default | grep registry
// advertisement

Persistent Storage with the CSI Driver

DOKS ships with the DigitalOcean Block Storage CSI driver pre-installed. It provisions DO Volumes (NVMe-backed block storage) automatically when you create a PersistentVolumeClaim. The default StorageClass is do-block-storage with WaitForFirstConsumer binding mode, meaning the volume only provisions when a pod is actually scheduled - this prevents cross-AZ mismatches.

For a stateful workload like PostgreSQL, use a StatefulSet with a volumeClaimTemplate:

DO Volumes have a minimum size of 1Gi and cost $0.10/GB/month as of 2026. The access mode for block storage is ReadWriteOnce - a single node at a time. If you need ReadWriteMany, you need a network file system. DigitalOcean Spaces (S3-compatible object storage) is the pragmatic answer for shared assets, not a shared block device.

Check that your PVC bound successfully before troubleshooting the pod:

A PVC stuck in Pending is almost always a StorageClass name typo or an AZ where DO Volumes are temporarily capacity-constrained. Switch node regions with doctl kubernetes cluster node-pool update if a specific AZ is consistently problematic.

# StorageClass is pre-installed - verify
kubectl get storageclass
# NAME                         PROVISIONER                 RECLAIMPOLICY
# do-block-storage (default)   dobs.csi.digitalocean.com   Delete

# PostgreSQL StatefulSet with persistent volume
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
spec:
  serviceName: postgres
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
      - name: postgres
        image: postgres:16.3
        env:
        - name: POSTGRES_PASSWORD
          valueFrom:
            secretKeyRef:
              name: pg-secret
              key: password
        volumeMounts:
        - mountPath: /var/lib/postgresql/data
          name: pg-data
  volumeClaimTemplates:
  - metadata:
      name: pg-data
    spec:
      accessModes: ["ReadWriteOnce"]
      storageClassName: do-block-storage
      resources:
        requests:
          storage: 20Gi

# Check PVC status
kubectl get pvc
kubectl describe pvc pg-data-postgres-0

Cluster Upgrades and Maintenance

DOKS patches Kubernetes automatically within the patch level when --auto-upgrade is set. Minor version upgrades (1.30 to 1.31) require manual initiation. DigitalOcean notifies you via email when an upgrade is available and gives you a 30-day window before forcing it.

Upgrade the control plane first, then node pools. The control plane upgrade takes 3-5 minutes. Node pool upgrades replace nodes one at a time using a rolling strategy - each node is cordoned, drained, terminated, and replaced. With three nodes this takes approximately 9-15 minutes total.

Before any upgrade, run kubectl get pods --all-namespaces to spot any pods not owned by a controller. Standalone pods are not rescheduled after node drain and will be lost.

Check for deprecated API versions before upgrading minor versions. The tool kubent (kube no-trouble) catches these in under 30 seconds:

We tested a 1.29 to 1.30 upgrade on a 5-node cluster in our lab. Total downtime for the API server was 0 seconds - DOKS uses HA control planes with load-balanced endpoints. Pod disruption during node replacement was bounded by our PodDisruptionBudgets, which you should have configured for any production workload.

# Check available upgrade versions for your cluster
doctl kubernetes cluster get prod-cluster --format ID,Name,Version,AutoUpgrade

# Upgrade control plane to 1.31
doctl kubernetes cluster upgrade prod-cluster --version 1.31.2-do.0

# After control plane upgrade, upgrade node pools
doctl kubernetes cluster node-pool list prod-cluster
doctl kubernetes cluster node-pool update prod-cluster NODE_POOL_ID \
  --version 1.31.2-do.0

# Install kubent and scan for deprecated APIs before upgrading
curl -sL https://github.com/doitintl/kube-no-trouble/releases/download/0.7.3/kubent-0.7.3-linux-amd64.tar.gz | tar xz
sudo mv kubent /usr/local/bin/
kubent

Monitoring and Log Access

DOKS does not include Prometheus or a log aggregator by default. The DigitalOcean Marketplace offers a one-click Prometheus and Grafana stack, but in our experience the marketplace installs use older chart versions and are harder to upgrade cleanly than a direct Helm install.

For production monitoring, install kube-prometheus-stack via Helm:

This installs Prometheus, Grafana, Alertmanager, and the node-exporter DaemonSet in one command. Grafana lands on port 3000. Expose it through your ingress controller with proper authentication - do not expose it directly via LoadBalancer service.

For logs, DigitalOcean's managed OpenSearch cluster pairs cleanly with Fluent Bit running as a DaemonSet. Fluent Bit ships logs from /var/log/containers to OpenSearch without requiring you to run your own Elasticsearch cluster.

If your team is evaluating AI-assisted DevOps workflows for alerting triage or runbook automation, taskbotshub.ai offers integrations with Prometheus alerting webhooks that can execute predefined remediation sequences automatically - worth evaluating before building custom Lambda-based alert handlers.

For quick cluster-level metrics without installing anything, DOKS exposes basic CPU and memory graphs in the DigitalOcean control panel. It is not a replacement for Prometheus but it catches obvious resource saturation without any setup.

# Install kube-prometheus-stack
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update

helm install kube-prometheus prometheus-community/kube-prometheus-stack \
  --namespace monitoring \
  --create-namespace \
  --set grafana.adminPassword=changeme123 \
  --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 \
  --version 61.3.0

# Port-forward Grafana for initial access
kubectl -n monitoring port-forward svc/kube-prometheus-grafana 3000:80
# Access at http://localhost:3000, user: admin
// advertisement

Cost Control and Cleanup

DOKS costs are straightforward: you pay for node Droplets, Load Balancers, and Volumes. The control plane is free. On a three-node s-2vcpu-4gb cluster with one load balancer and 20Gi of block storage, expect roughly $96/month: $72 for nodes, $12 for the load balancer, $2 for storage, and $10 for a Starter container registry.

Autoscaling down is not instant. A node that scaled up for a traffic spike will not be removed for at least 10 minutes after utilization drops. If you have predictable traffic patterns, pre-scaling with a cron-driven HPA override is more cost-effective than pure reactive autoscaling.

Delete unused load balancers explicitly. When you delete an ingress controller service, the associated DO Load Balancer is deleted automatically via the CCM. But if you delete a cluster without first removing LoadBalancer-type services, the load balancer orphans and continues billing.

To fully destroy a cluster and all associated resources:

Verify in the DigitalOcean billing dashboard at https://digitalocean.com/ that no orphaned load balancers or volumes remain after cluster deletion. Orphaned volumes from deleted PVCs with Retain reclaim policy are a common source of unexpected charges.

# List load balancers to check for orphans before cluster delete
doctl compute load-balancer list

# List volumes (DOKS-provisioned volumes have a 'kubernetes' prefix)
doctl compute volume list | grep kubernetes

# Delete a specific orphaned volume
doctl compute volume delete VOLUME_ID

# Destroy the entire cluster
doctl kubernetes cluster delete prod-cluster --dangerous

# Confirm deletion
doctl kubernetes cluster list