Self-Hosting ODK Central on DigitalOcean with Kubernetes and PostgreSQL

ODK Central is a powerful open-source platform for creating and managing digital forms for data collection. While ODK offers cloud-hosted solutions, many organizations require self-hosted deployments for data sovereignty, custom integrations, or compliance requirements.

In this post, we’ll walk through how to self-host ODK Central using the Caktus Helm chart on DigitalOcean Kubernetes (DOKS) with PostgreSQL 18. This setup provides a production-ready, scalable deployment with automatic Let’s Encrypt SSL certificates.

Why Self-Host ODK Central?

Self-hosting ODK Central offers several advantages:

  • Data sovereignty: Keep all data within your infrastructure
  • Compliance: Meet strict data governance requirements
  • Cost control: Better predict and manage costs for large-scale deployments
  • Integration: Connect with existing systems hosted behind corporate firewalls

Architecture Overview

Our deployment uses the following managed services from DigitalOcean. However, the same steps can be applied to any Kubernetes cluster and managed PostgreSQL database:

  • Kubernetes cluster as the infrastructure foundation
  • PostgreSQL 18 database cluster for data persistence

Prerequisites

Before we begin, you’ll need:

  • A DigitalOcean account
  • Helm and kubectl
  • A domain name with DNS you can manage (e.g., Route 53, Cloudflare, DigitalOcean)
  • The kubeconfig file for the Kubernetes cluster (downloaded in Step 1)
  • The PostgreSQL CA certificate (downloaded in Step 2)

Step 1: Create a Kubernetes Cluster

  1. In the DigitalOcean Control Panel, click CreateKubernetes
  2. Choose the latest stable Kubernetes version
  3. Select the same region as your database
  4. Under Choose cluster capacity, configure the node pool:
    • Shared CPU - Basic for dev and testing, Dedicated CPU - General Purpose for production
    • 2 nodes minimum for production (1 is okay for testing)
  5. Ensure High Availability is enabled for the control plane for production (disable for testing to save costs)
  6. Enter a unique cluster name, e.g., odk-cluster
  7. Click Create Kubernetes Cluster

The Kubernetes cluster takes 5-10 minutes to provision.

Get Your Kubeconfig

After creation, on the Kubernetes cluster page, download the kubeconfig for the cluster:

  1. Click Download Config File
  2. Save it to a file, such as odk-cluster-kubeconfig.yaml
  3. Open a Terminal shell on your computer, and test the connection:
export KUBECONFIG=/path/to/odk-cluster-kubeconfig.yaml

kubectl get node

You should see the nodes that were created for your cluster, for example:

NAME                    STATUS   ROLES    AGE   VERSION
pool-qly76y290-3cx0db   Ready    <none>   46h   v1.36.0
pool-qly76y290-3cx0dr   Ready    <none>   46h   v1.36.0

Step 2: Create a PostgreSQL 18 Database Cluster

DigitalOcean’s managed PostgreSQL service provides high availability, automated backups, and easy scaling. We’ll use PostgreSQL 18 in this post; however, we recommend using the latest version of PostgreSQL supported by ODK Central.

Create the Database

  1. In the DigitalOcean Control Panel, click CreateManaged Database
  2. Choose PostgreSQL as the engine and select v18 as the version
  3. Choose Standard Edition (for most use cases) and a Basic - Shared CPU plan (e.g., 1 vCPU / 1 GB for ~$13/mo)
  4. Set storage to at least 10 GiB and enable Storage Autoscaling
  5. Select a datacenter region – use the same region as your Kubernetes cluster for private hostname connectivity
  6. Give your database cluster a unique name, such as odk-central-db
  7. Click Create Database Cluster

The database takes 5-10 minutes to provision. You can work on this step in parallel with Step 1.

Get Connection Details

After creation, on the database page, click on VPC network under Connection Details to review the private connection details:

  • Private Hostname: For same-region connections (e.g., private-db-pgsql-<snip>.m.db.ondigitalocean.com) – use this from your Kubernetes cluster
  • Port: 25060
  • Database: defaultdb
  • Username: doadmin
  • Password: Auto-generated – save it securely

Download the CA Certificate

At the bottom of the Connection Details section:

  1. Click Download CA certificate
  2. Save it to ca-certificate.crt

You’ll need this file in Step 5 to create a Kubernetes ConfigMap.


Step 2.5: Allow Kubernetes to Access the Database

For better data security, you should add your Kubernetes cluster as a trusted source to lock down the database and prevent public access:

  1. Click the Network Access tab
  2. Click Add Trusted Sources
  3. Under Quick select, find Kubernetes, and then select your cluster
  4. Click Add Trusted Sources

Note: Selecting the cluster adds its network; there is no need to manually specify IP ranges.


Step 3: Install Traefik with Let’s Encrypt

Traefik is a modern ingress controller with built-in ACME/Let’s Encrypt support. We use the TLS-ALPN challenge, which obtains SSL certificates automatically – no DNS API credentials or port 80 needed.

Create Traefik Values File

Create traefik-values.yaml, replacing all required placeholders:

# traefik-values.yaml
certificatesResolvers:
  letsencrypt:
    acme:
      email: "[email protected]" # Replace with your email
      storage: "/data/acme.json"
      tlsChallenge: true

additionalArguments:
  - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
  - "--entrypoints.web.http.redirections.entrypoint.scheme=https"
  - "--entrypoints.web.http.redirections.entrypoint.permanent=true"
  - "--entrypoints.websecure.http.tls.certresolver=letsencrypt"

persistence:
  enabled: true
  name: data
  accessMode: ReadWriteOnce
  size: 128Mi
  path: /data

podSecurityContext:
  runAsGroup: 65532
  runAsNonRoot: true
  runAsUser: 65532
  fsGroup: 65532
  fsGroupChangePolicy: OnRootMismatch

ports:
  web:
    expose:
      default: true
    exposedPort: 80
    port: 80
  websecure:
    expose:
      default: true
    exposedPort: 443
    port: 443

providers:
  kubernetesCRD:
    enabled: true
    allowEmptyServices: true
  kubernetesIngress:
    enabled: true
    allowEmptyServices: true

api:
  dashboard: false

Install Traefik

helm repo add traefik https://traefik.github.io/charts
helm repo update

helm install traefik traefik/traefik \
  --namespace traefik \
  --create-namespace \
  --values traefik-values.yaml

Verify

kubectl get pods -n traefik

You should see one Traefik pod running and ready, for example:

NAME                      READY   STATUS    RESTARTS   AGE
traefik-88f847c66-snnv7   1/1     Running   0          87s

Reconfiguring Traefik

In case you need to update traefik-values.yaml, use helm upgrade to apply the changes without reinstalling:

helm upgrade traefik traefik/traefik \
  --namespace traefik \
  --values traefik-values.yaml

Step 4: Configure DNS

Create an A record pointing your domain (e.g., odk.example.com) at the Traefik LoadBalancer IP.

# Get the Traefik LoadBalancer IP
kubectl get svc -n traefik
# Look for the EXTERNAL-IP column -- it may take a minute to appear

Then create the A record in your DNS provider (Route 53, Cloudflare, DigitalOcean, etc.) pointing your domain to that IP. Wait for DNS propagation before proceeding:

dig +short odk.your-domain.com

Step 5: Install ODK Central

Add the Helm Repository

helm repo add caktus https://caktus.github.io/helm-charts
helm repo update

Create Kubernetes Resources

# CA certificate for PostgreSQL SSL - this command requires the file you downloaded in Step 2
kubectl create ns odk
kubectl create configmap postgres-ca-cert \
  -n odk \
  --from-file=ca.crt=/path/to/ca-certificate.crt

Create the Values File

Create odk-values.yaml with your actual values. Replace all placeholders. For the Enketo secrets, you can use openssl to generate random values, for example:

openssl rand -hex 32  # Generates 64 ASCII characters
openssl rand -hex 16  # Generates 32 ASCII characters
openssl rand -hex 64  # Generates 128 ASCII characters
# odk-values.yaml
global:
  centralDomain: "odk.example.com" # Your domain
  enketoSecretName: "central-enketo-secrets"
  enketoSecrets:
    enketoSecret: "YOUR_64_CHAR_SECRET" # Output of `openssl rand -hex 32`
    enketoLessSecret: "YOUR_32_CHAR_SECRET" # Output of `openssl rand -hex 16`
    enketoApiKey: "YOUR_128_CHAR_SECRET" # Output of `openssl rand -hex 64`

backend:
  environmentVariables:
    PGHOST: "private-db-pgsql-<region>-<id>.m.db.ondigitalocean.com" # Your private hostname
    PGPORT: "25060"
    PGDATABASE: "defaultdb"
    PGUSER: "doadmin"
    PGPASSWORD: "your-database-password" # Your database password
    PGSSLMODE: "require"
    # Node.js pg library needs NODE_EXTRA_CA_CERTS (not PGSSLROOTCERT)
    NODE_EXTRA_CA_CERTS: "/etc/ssl/certs/pg-ca/ca.crt"
    DB_POOL_SIZE: "10"
    SESSION_LIFETIME: "86400"
    DOMAIN: "odk.example.com" # Your domain
    EMAIL_FROM: "[email protected]"
    # Your SMTP settings
    EMAIL_HOST: "smtp.example.com"
    EMAIL_PORT: "587"
    EMAIL_IGNORE_TLS: "false"
    EMAIL_SECURE: "false"
    EMAIL_USER: "your-smtp-username"
    EMAIL_PASSWORD: "your-smtp-password"
    HTTPS_PORT: "443"
    SYSADMIN_EMAIL: "[email protected]" # Your sysadmin email
    S3_SERVER: ""
    S3_ACCESS_KEY: ""
    S3_SECRET_KEY: ""
    S3_BUCKET_NAME: ""
    SENTRY_DSN: ""
    SENTRY_TRACE_RATE: "0"
  volumes:
    - name: postgres-ca-cert
      configMap:
        name: postgres-ca-cert
  volumeMounts:
    - name: postgres-ca-cert
      mountPath: /etc/ssl/certs/pg-ca
      readOnly: true

frontend:
  ingressApp:
    enabled: false
  ingress:
    enabled: true
    className: "traefik"
    annotations:
      traefik.ingress.kubernetes.io/router.entrypoints: websecure
      traefik.ingress.kubernetes.io/tls.certresolver: letsencrypt
      traefik.ingress.kubernetes.io/buffering: |
        maxRequestBodyBytes: 104857600
    hosts:
      - host: "odk.example.com" # Your domain
        paths:
          - path: /
            pathType: ImplementationSpecific
    tls:
      - hosts:
          - "odk.example.com" # Your domain
  environmentVariables:
    DOMAIN: "odk.example.com" # Your domain
    SSL_TYPE: "upstream"
    OIDC_ENABLED: "false"

# Simplified Redis (no haproxy)
enketo-redis-main:
  replicas: 1
  haproxy:
    enabled: false
enketo-redis-cache:
  replicas: 1
  haproxy:
    enabled: false

enketo:
  config:
    redisMainHost: "odk-central-enketo-redis-main"
    redisCacheHost: "odk-central-enketo-redis-cache"

Key notes:

  • Your domain name needs to be configured in several places; be sure you’ve replaced all instances of odk.example.com.
  • Other common issues involve the database settings; be sure you’ve copied these exactly from the VPC Network connection details for your database, including a private hostname that starts with private-....

Install

helm install odk-central caktus/odk-central \
  --namespace odk \
  --values odk-values.yaml

Verify

helm list -n odk
kubectl get pods -n odk
kubectl logs -n odk -l app.kubernetes.io/name=backend -f

Wait 3-5 minutes for the pods to settle and for Traefik to obtain the Let’s Encrypt certificate:

kubectl logs -n traefik -l app.kubernetes.io/name=traefik -f
# Look for "Register..." and certificate issuance

Reconfiguring ODK Central

In case you need to update odk-values.yaml, use helm upgrade to apply the changes without reinstalling:

helm upgrade odk-central caktus/odk-central \
  --namespace odk \
  --values odk-values.yaml

Step 6: Create Your First Admin User

ODK Central has no self-service sign-up. The first admin must be created via the command line.

Create and Promote the Admin

# Create the user (you'll be prompted for a password)
kubectl exec -it -n odk deployment/odk-central-backend \
  -- odk-cmd --email [email protected] user-create

# Promote to administrator (required -- user-create does NOT auto-promote)
kubectl exec -it -n odk deployment/odk-central-backend \
  -- odk-cmd --email [email protected] user-promote

After this, visit your configured domain in a web browser and log in. You can create additional users from the web interface.


Troubleshooting

“no pg_hba.conf entry for host”: Add your Kubernetes cluster’s network to the database’s trusted sources in the DigitalOcean Control Panel.

Pods stuck in Pending: Check cluster resource availability in the DigitalOcean Control Panel.

Enketo secrets errors: Ensure secrets are the correct length (64, 32, and 128 hex characters).


Resources

Alternatives

If self-hosting ODK Central isn’t for you, several alternatives exist:

  • ODK Cloud – Get ODK, Inc. offers a managed hosting solution for ODK Central on AWS, providing a turnkey option with official support
  • Caktus Group – as maintainers of the unofficial Helm chart, we can help deploy and manage ODK Central for organizations that want it as part of their Kubernetes-based infrastructure

Conclusion

Self-hosting ODK Central on DigitalOcean Kubernetes with PostgreSQL gives you a production-ready platform with managed infrastructure, automatic SSL certificates, and Helm-based deployment. The combination of DigitalOcean’s managed services and modern Kubernetes tooling keeps ongoing maintenance minimal while giving you full control over your data.