Skip to content

Deploying firmflow. to DigitalOcean Kubernetes (DOKS)

Domain: firmflow.com.ng
Target: DigitalOcean Kubernetes Service (DOKS)
TLS: Let's Encrypt via cert-manager
Registry: DigitalOcean Container Registry (DOCR)
CI/CD: GitHub Actions → DOCR (.github/workflows/deploy-docr.yml)


CI/CD Pipeline (GitHub Actions)

The repository includes a GitHub Actions workflow that automatically builds and pushes a new Docker image to DOCR on every push to main.

Required GitHub Secrets

Go to your repository → Settings → Secrets and variables → Actions → New repository secret and add:

Secret Name Value Where to get it
DO_ACCESS_TOKEN Your DigitalOcean Personal Access Token cloud.digitalocean.com/account/api/tokens
DO_REGISTRY_NAME Your DOCR name (e.g. firmflow-registry) Created in Step 2 below

What the workflow does

Push to main
Job 1: Lint & Typecheck (npm run typecheck + lint)
    │ passes
Job 2: Build Docker image (multi-stage, node:20-alpine)
    ├── Tags: latest, sha-<commit>, main, semver (if tagged)
    ├── Caches layers in GitHub Actions cache (faster builds)
    └── Pushes to registry.digitalocean.com/<registry>/firmflow-app
GitHub Actions Summary shows image tag + Helm deploy command

To also auto-deploy to your DOKS cluster after each push, uncomment the deploy job in .github/workflows/deploy-docr.yml.


Ensure the following tools are installed and authenticated on your local machine:

# Install doctl (DigitalOcean CLI)
brew install doctl          # macOS
# or: snap install doctl   # Ubuntu

# Authenticate with your DigitalOcean API token
doctl auth init

# Install kubectl
brew install kubectl

# Install Helm 3
brew install helm

# Verify all tools
doctl version && kubectl version --client && helm version

Step 1: Create the DOKS Cluster

# List available regions (choose one close to Nigeria — e.g. Amsterdam 'ams3' or Frankfurt 'fra1')
doctl kubernetes options regions

# Create the cluster (takes ~5 minutes)
doctl kubernetes cluster create firmflow-prod \
  --region fra1 \
  --node-pool "name=worker-pool;size=s-4vcpu-8gb;count=2" \
  --version latest

# Configure kubectl to use the new cluster
doctl kubernetes cluster kubeconfig save firmflow-prod

# Verify cluster access
kubectl get nodes

Recommended Node Size

s-4vcpu-8gb (4 vCPU, 8 GB RAM) handles up to 30 concurrent users. Scale to s-8vcpu-16gb for 50+ users.


Step 2: Set Up DigitalOcean Container Registry (DOCR)

# Create a private container registry
doctl registry create firmflow-registry --region fra1

# Authenticate Docker with DOCR
doctl registry login

# Your registry URL will be:
# registry.digitalocean.com/firmflow-registry

Build & Push the Docker Image

# Build the production image
docker build -t registry.digitalocean.com/firmflow-registry/firmflow-app:v1.2.0 .
docker tag registry.digitalocean.com/firmflow-registry/firmflow-app:v1.2.0 \
           registry.digitalocean.com/firmflow-registry/firmflow-app:latest

# Push to DOCR
docker push registry.digitalocean.com/firmflow-registry/firmflow-app:v1.2.0
docker push registry.digitalocean.com/firmflow-registry/firmflow-app:latest

Integrate Registry with Cluster

# Allow the cluster to pull from DOCR automatically
doctl registry kubernetes-manifest | kubectl apply -f -

Step 3: Configure DNS

In your domain registrar for firmflow.com.ng, point the nameservers to DigitalOcean:

ns1.digitalocean.com
ns2.digitalocean.com
ns3.digitalocean.com

Then manage DNS records in DigitalOcean:

# After the Ingress is created (Step 7), get the Load Balancer IP:
kubectl get svc -n ingress-nginx

# Add DNS A records pointing to that IP:
doctl compute domain records create firmflow.com.ng \
  --record-type A \
  --record-name @ \
  --record-data <LOAD_BALANCER_IP> \
  --record-ttl 3600

doctl compute domain records create firmflow.com.ng \
  --record-type A \
  --record-name www \
  --record-data <LOAD_BALANCER_IP> \
  --record-ttl 3600

DNS Propagation

DNS changes can take up to 48 hours, but typically resolve within 15–30 minutes with DigitalOcean nameservers. Verify with: nslookup firmflow.com.ng


Step 4: Install Nginx Ingress Controller

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

# Install — DigitalOcean will auto-provision a Load Balancer
helm install ingress-nginx ingress-nginx/ingress-nginx \
  --namespace ingress-nginx \
  --create-namespace \
  --set controller.service.type=LoadBalancer \
  --set controller.service.annotations."service\.beta\.kubernetes\.io/do-loadbalancer-name"=firmflow-lb

# Wait for the Load Balancer IP to be assigned (1-2 minutes)
kubectl get svc -n ingress-nginx --watch

Step 5: Install cert-manager (Let's Encrypt TLS)

# Add the cert-manager Helm repo
helm repo add jetstack https://charts.jetstack.io
helm repo update

# Install cert-manager with CRDs
helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --set crds.enabled=true

# Verify all cert-manager pods are running
kubectl get pods -n cert-manager

Create a ClusterIssuer for Let's Encrypt

cat <<EOF | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: admin@firmflow.com.ng
    privateKeySecretRef:
      name: letsencrypt-prod
    solvers:
      - http01:
          ingress:
            class: nginx
EOF

Step 6: Create Kubernetes Secrets

Generate all required secrets before deploying the application:

# Generate secure random values
AUTH_SECRET=$(openssl rand -base64 32)
LICENSE_SECRET=$(openssl rand -base64 32)
FIELD_KEY=$(openssl rand -hex 32)
STORAGE_KEY=$(openssl rand -hex 32)

# Create the main application secret
kubectl create secret generic firmflow-secrets \
  --namespace default \
  --from-literal=AUTH_SECRET="$AUTH_SECRET" \
  --from-literal=LICENSE_SECRET="$LICENSE_SECRET" \
  --from-literal=FIELD_ENCRYPTION_KEY="$FIELD_KEY" \
  --from-literal=STORAGE_ENCRYPTION_KEY="$STORAGE_KEY" \
  --from-literal=DATABASE_URL="postgresql://firmflow_user:YOUR_DB_PASSWORD@your-db-host:5432/firmflow" \
  --from-literal=NEXTAUTH_URL="https://firmflow.com.ng" \
  --from-literal=NEXT_PUBLIC_APP_URL="https://firmflow.com.ng" \
  --from-literal=GOOGLE_GENAI_API_KEY="YOUR_GEMINI_API_KEY" \
  --from-literal=RESEND_API_KEY="YOUR_RESEND_API_KEY" \
  --from-literal=PAYSTACK_SECRET_KEY="YOUR_PAYSTACK_SECRET_KEY"

Secret Storage

Save all generated values (especially AUTH_SECRET, LICENSE_SECRET) in a secure password manager immediately. They cannot be recovered from Kubernetes once created.

Use DigitalOcean Managed PostgreSQL instead of in-cluster:

# Create a managed PostgreSQL cluster
doctl databases create firmflow-db \
  --engine pg \
  --region fra1 \
  --size db-s-1vcpu-1gb \
  --num-nodes 1

# Get the connection string
doctl databases connection firmflow-db --format URI

Step 7: Deploy firmflow. via Helm

Create a production values override file:

cat > values.do-prod.yaml << 'EOF'
replicaCount: 2

image:
  repository: registry.digitalocean.com/firmflow-registry/firmflow-app
  pullPolicy: Always
  tag: "v1.2.0"

imagePullSecrets:
  - name: registry-firmflow-registry

service:
  type: ClusterIP
  port: 3000

ingress:
  enabled: true
  className: "nginx"
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
    nginx.ingress.kubernetes.io/proxy-body-size: "100m"
    nginx.ingress.kubernetes.io/enable-modsecurity: "true"
    nginx.ingress.kubernetes.io/enable-owasp-core-rules: "true"
    nginx.ingress.kubernetes.io/limit-connections: "20"
    nginx.ingress.kubernetes.io/limit-rps: "10"
  hosts:
    - host: firmflow.com.ng
      paths:
        - path: /
          pathType: Prefix
    - host: www.firmflow.com.ng
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: firmflow-tls-cert
      hosts:
        - firmflow.com.ng
        - www.firmflow.com.ng

resources:
  limits:
    cpu: 2000m
    memory: 4Gi
  requests:
    cpu: 500m
    memory: 1Gi

autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 5
  targetCPUUtilizationPercentage: 70

config:
  nextAuthUrl: "https://firmflow.com.ng"
  storageDriver: "local"
  storageLocalPath: "/data/uploads"

postgresql:
  enabled: false  # Using DigitalOcean Managed PostgreSQL

persistence:
  enabled: true
  size: 100Gi
  accessMode: ReadWriteOnce
  storageClass: "do-block-storage"
EOF

Deploy

# Deploy (or upgrade if already installed)
helm upgrade --install firmflow ./kubernetes/firmflow \
  --namespace default \
  --values kubernetes/firmflow/values.yaml \
  --values values.do-prod.yaml \
  --set env.secretName=firmflow-secrets \
  --wait \
  --timeout 10m

# Watch the rollout
kubectl rollout status deployment/firmflow

Step 8: Run Database Migrations

# Run Prisma migrations on the live cluster
kubectl exec -it deployment/firmflow -- npx prisma migrate deploy

# Seed the database (creates super admin)
kubectl exec -it deployment/firmflow -- npx tsx prisma/seed.ts

Step 9: Verify the Deployment

# Check all pods are running
kubectl get pods

# Check ingress and TLS certificate
kubectl get ingress
kubectl describe certificate firmflow-tls-cert

# Check application logs
kubectl logs -f deployment/firmflow --tail=50

# Test the health endpoint
curl https://firmflow.com.ng/api/health

A successful health check returns:

{ "status": "ok", "db": "connected", "uptime": 120 }


Step 10: Post-Deployment Checklist

  • [ ] Login at https://firmflow.com.ng/login with Super Admin credentials
  • [ ] Change all default passwords immediately
  • [ ] Enable MFA on the Super Admin account
  • [ ] Verify document upload and AI analysis works end-to-end
  • [ ] Set up automated DigitalOcean database backups (Daily snapshots)
  • [ ] Configure Resend email with firmflow.com.ng domain verification
  • [ ] Verify Paystack webhook URL: https://firmflow.com.ng/api/billing/webhook
  • [ ] Apply the Kubernetes NetworkPolicy: kubectl apply -f kubernetes/firmflow/templates/network-policy.yaml

Upgrading the Application

# Build and push a new image version
docker build -t registry.digitalocean.com/firmflow-registry/firmflow-app:v1.3.0 .
docker push registry.digitalocean.com/firmflow-registry/firmflow-app:v1.3.0

# Rolling upgrade with zero downtime
helm upgrade firmflow ./kubernetes/firmflow \
  --values kubernetes/firmflow/values.yaml \
  --values values.do-prod.yaml \
  --set image.tag=v1.3.0 \
  --wait

# Run any new migrations
kubectl exec -it deployment/firmflow -- npx prisma migrate deploy

Monitoring & Observability

# View real-time application logs
kubectl logs -f deployment/firmflow

# Check resource usage
kubectl top pods
kubectl top nodes

# Access the DigitalOcean Kubernetes dashboard
doctl kubernetes cluster get firmflow-prod

Estimated Monthly Cost (DigitalOcean)

Resource Size Est. Cost
DOKS Worker Nodes (×2) s-4vcpu-8gb ~$96/mo
Managed PostgreSQL db-s-1vcpu-1gb ~$15/mo
Load Balancer Standard ~$12/mo
Container Registry Starter ~$5/mo
Block Storage (100Gi) DO Volumes ~$10/mo
Total ~$138/mo

Nigerian Market Note

DigitalOcean's fra1 (Frankfurt) region provides the best latency for Nigerian users (~60-80ms). The blr1 (Bangalore) region is not recommended. Consider the Singapore (sgp1) datacenter as an alternative.

helm upgrade --install firmflow ./kubernetes/firmflow -f values.do-prod.yaml