Zero-Downtime Cloud Migration: A Technical Blueprint

A comprehensive technical guide to migrating from Azure to DigitalOcean with zero downtime. Includes Terraform configurations, CI/CD migration strategies, and a detailed cutover playbook.

Zero-Downtime Cloud Migration: A Technical Blueprint

So you've done the ROI analysis and decided that migrating to DigitalOcean makes sense for your workload. Now comes the hard part: actually doing it without bringing down your production systems.

This guide provides a detailed technical blueprint for migrating from Azure to DigitalOcean with zero downtime. We'll cover infrastructure provisioning, data migration strategies, CI/CD pipeline migration, and a step-by-step cutover process.

This isn't theoretical. It's based on actual migration work, with specific configurations and code examples you can adapt for your own projects.

Migration architecture overview

Before diving into specifics, let's establish the migration architecture. The goal is to run both environments in parallel during the transition, then perform a clean cutover.

Source environment (Azure)

  • Compute: Azure Kubernetes Service (AKS) running .NET APIs
  • Database: Azure Database for PostgreSQL
  • Storage: Azure Blob Storage for media files
  • CI/CD: Azure DevOps Pipelines
  • DNS: Azure DNS

Target environment (DigitalOcean)

  • Compute: DigitalOcean Kubernetes (DOKS)
  • Database: DigitalOcean Managed PostgreSQL
  • Storage: DigitalOcean Spaces (S3-compatible)
  • CI/CD: GitHub Actions
  • DNS: Cloudflare (or DigitalOcean DNS)

Migration phases

  1. Infrastructure provisioning (Week 1-2)
  2. Data migration (Week 3-4)
  3. Application deployment (Week 5-6)
  4. Parallel running and testing (Week 7-8)
  5. DNS cutover (Week 9)
  6. Post-migration cleanup (Week 10+)

Phase 1: Infrastructure provisioning with Terraform

We'll use Terraform to provision the DigitalOcean infrastructure. This ensures reproducibility and makes it easy to tear down and rebuild if needed.

Provider configuration

terraform {
  required_providers {
    digitalocean = {
      source  = "digitalocean/digitalocean"
      version = "~> 2.0"
    }
  }
}

provider "digitalocean" {
  token = var.do_token
}

variable "do_token" {
  description = "DigitalOcean API token"
  type        = string
  sensitive   = true
}

variable "region" {
  description = "DigitalOcean region"
  type        = string
  default     = "lon1"  # London for UK data residency
}

Kubernetes cluster

resource "digitalocean_kubernetes_cluster" "main" {
  name    = "softweb-production"
  region  = var.region
  version = "1.28.2-do.0"

  node_pool {
    name       = "default-pool"
    size       = "s-2vcpu-4gb"
    node_count = 2
    
    labels = {
      environment = "production"
    }
  }

  maintenance_policy {
    start_time = "04:00"
    day        = "sunday"
  }
}

output "cluster_endpoint" {
  value     = digitalocean_kubernetes_cluster.main.endpoint
  sensitive = true
}

output "kubeconfig" {
  value     = digitalocean_kubernetes_cluster.main.kube_config[0].raw_config
  sensitive = true
}

Managed PostgreSQL

resource "digitalocean_database_cluster" "postgres" {
  name       = "softweb-postgres"
  engine     = "pg"
  version    = "15"
  size       = "db-s-2vcpu-4gb"
  region     = var.region
  node_count = 2  # HA configuration

  maintenance_window {
    day  = "sunday"
    hour = "02:00:00"
  }
}

resource "digitalocean_database_firewall" "postgres" {
  cluster_id = digitalocean_database_cluster.postgres.id

  rule {
    type  = "k8s"
    value = digitalocean_kubernetes_cluster.main.id
  }
}

resource "digitalocean_database_db" "main" {
  cluster_id = digitalocean_database_cluster.postgres.id
  name       = "softweb"
}

output "postgres_connection_string" {
  value     = digitalocean_database_cluster.postgres.uri
  sensitive = true
}

Spaces (Object Storage)

resource "digitalocean_spaces_bucket" "media" {
  name   = "softweb-media"
  region = var.region
  acl    = "private"

  cors_rule {
    allowed_headers = ["*"]
    allowed_methods = ["GET", "HEAD"]
    allowed_origins = ["https://softweb.uk", "https://www.softweb.uk"]
    max_age_seconds = 3600
  }

  lifecycle_rule {
    enabled = true

    noncurrent_version_expiration {
      days = 30
    }
  }
}

resource "digitalocean_cdn" "media" {
  origin         = digitalocean_spaces_bucket.media.bucket_domain_name
  custom_domain  = "media.softweb.uk"
  certificate_id = digitalocean_certificate.media.id
}

Load balancer

resource "digitalocean_loadbalancer" "main" {
  name   = "softweb-lb"
  region = var.region

  forwarding_rule {
    entry_port     = 443
    entry_protocol = "https"
    target_port    = 80
    target_protocol = "http"
    certificate_name = digitalocean_certificate.main.name
  }

  forwarding_rule {
    entry_port     = 80
    entry_protocol = "http"
    target_port    = 80
    target_protocol = "http"
  }

  redirect_http_to_https = true

  healthcheck {
    port     = 80
    protocol = "http"
    path     = "/health"
  }

  droplet_tag = "k8s:${digitalocean_kubernetes_cluster.main.id}"
}

Phase 2: Database migration

Database migration is the most critical part of the process. We'll use a staged approach to minimise risk.

Pre-migration: Schema and baseline data

# Export schema from Azure PostgreSQL
pg_dump \
  --host=your-azure-host.postgres.database.azure.com \
  --username=admin@your-azure-host \
  --dbname=softweb \
  --schema-only \
  --file=schema.sql

# Export data (for initial load)
pg_dump \
  --host=your-azure-host.postgres.database.azure.com \
  --username=admin@your-azure-host \
  --dbname=softweb \
  --data-only \
  --file=data.sql

# Import to DigitalOcean
psql \
  "postgresql://doadmin:password@your-do-host.db.ondigitalocean.com:25060/softweb?sslmode=require" \
  < schema.sql

psql \
  "postgresql://doadmin:password@your-do-host.db.ondigitalocean.com:25060/softweb?sslmode=require" \
  < data.sql

Continuous replication with pgloader

For larger databases with ongoing writes, use pgloader for continuous replication:

LOAD DATABASE
  FROM postgresql://admin:password@azure-host/softweb
  INTO postgresql://doadmin:password@do-host:25060/softweb

WITH include drop, create tables, create indexes, 
     reset sequences, foreign keys

SET work_mem to '128MB', maintenance_work_mem to '512MB'

BEFORE LOAD DO
  $$ ALTER DATABASE softweb SET timezone TO 'UTC'; $$;

Verification queries

Before cutover, verify data integrity:

-- Compare row counts
SELECT 
  (SELECT COUNT(*) FROM users) as users,
  (SELECT COUNT(*) FROM orders) as orders,
  (SELECT COUNT(*) FROM products) as products;

-- Compare recent records
SELECT id, created_at, updated_at 
FROM orders 
ORDER BY created_at DESC 
LIMIT 10;

-- Check for orphaned records
SELECT COUNT(*) 
FROM orders o 
LEFT JOIN users u ON o.user_id = u.id 
WHERE u.id IS NULL;

Phase 3: Object storage migration

Media files need to be migrated from Azure Blob Storage to DigitalOcean Spaces.

Using rclone for migration

# Configure rclone
rclone config

# Azure Blob configuration
[azure]
type = azureblob
account = your-storage-account
key = your-storage-key
container = media

# DigitalOcean Spaces configuration
[spaces]
type = s3
provider = DigitalOcean
env_auth = false
access_key_id = your-spaces-key
secret_access_key = your-spaces-secret
endpoint = lon1.digitaloceanspaces.com
acl = private

# Sync files
rclone sync azure:media spaces:softweb-media --progress

# Verify sync
rclone check azure:media spaces:softweb-media

URL rewriting strategy

You'll need to update media URLs in your application. Create a configuration-based approach:

// appsettings.json
{
  "Storage": {
    "Provider": "Spaces",  // or "Azure"
    "Spaces": {
      "Endpoint": "https://lon1.digitaloceanspaces.com",
      "Bucket": "softweb-media",
      "CdnEndpoint": "https://media.softweb.uk"
    },
    "Azure": {
      "ConnectionString": "...",
      "ContainerName": "media"
    }
  }
}

// IStorageService implementation
public class SpacesStorageService : IStorageService
{
    private readonly IAmazonS3 _s3Client;
    private readonly string _bucket;
    private readonly string _cdnEndpoint;

    public string GetPublicUrl(string key)
    {
        return $"{_cdnEndpoint}/{key}";
    }

    public async Task<Stream> GetFileAsync(string key)
    {
        var response = await _s3Client.GetObjectAsync(_bucket, key);
        return response.ResponseStream;
    }
}

Phase 4: CI/CD migration

Migrating from Azure DevOps to GitHub Actions requires translating your pipeline definitions.

Azure DevOps to GitHub Actions mapping

Azure DevOps GitHub Actions
trigger on: push/pull_request
pool: vmImage runs-on
stages jobs
task: DotNetCoreCLI@2 dotnet commands
task: Docker@2 docker/build-push-action
task: KubernetesManifest@0 kubectl apply
variables env or secrets

Example GitHub Actions workflow

name: Deploy to DigitalOcean

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  REGISTRY: registry.digitalocean.com
  IMAGE_NAME: softweb/api

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'
      
      - name: Restore dependencies
        run: dotnet restore
      
      - name: Build
        run: dotnet build --no-restore
      
      - name: Test
        run: dotnet test --no-build --verbosity normal

  deploy:
    needs: build-and-test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Install doctl
        uses: digitalocean/action-doctl@v2
        with:
          token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
      
      - name: Login to DigitalOcean Container Registry
        run: doctl registry login --expiry-seconds 1200
      
      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
      
      - name: Save DigitalOcean kubeconfig
        run: doctl kubernetes cluster kubeconfig save softweb-production
      
      - name: Deploy to Kubernetes
        run: |
          kubectl set image deployment/api \
            api=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
            --namespace=production
          kubectl rollout status deployment/api --namespace=production

Phase 5: DNS cutover strategy

The DNS cutover is the moment of truth. Here's how to execute it with minimal risk.

Pre-cutover checklist

  • All data migrated and verified
  • Application deployed and tested on DigitalOcean
  • SSL certificates provisioned and validated
  • Health checks passing on all services
  • Monitoring and alerting configured
  • Rollback plan documented and tested
  • Team on standby for cutover window

Blue-green DNS approach

# Current state (pointing to Azure)
softweb.uk.    300  IN  A     azure-lb-ip
www.softweb.uk. 300  IN  CNAME softweb.uk.
api.softweb.uk. 300  IN  A     azure-api-ip

# Reduce TTL 24 hours before cutover
softweb.uk.    60   IN  A     azure-lb-ip
www.softweb.uk. 60   IN  CNAME softweb.uk.
api.softweb.uk. 60   IN  A     azure-api-ip

# Cutover (point to DigitalOcean)
softweb.uk.    60   IN  A     do-lb-ip
www.softweb.uk. 60   IN  CNAME softweb.uk.
api.softweb.uk. 60   IN  A     do-api-ip

# Post-cutover verification (1 hour later)
# Increase TTL back to normal
softweb.uk.    3600 IN  A     do-lb-ip
www.softweb.uk. 3600 IN  CNAME softweb.uk.
api.softweb.uk. 3600 IN  A     do-api-ip

Cutover execution script

#!/bin/bash
# cutover.sh - Execute DNS cutover

set -e

DO_LB_IP="your-do-loadbalancer-ip"
DOMAIN="softweb.uk"

echo "Starting DNS cutover at $(date)"

# Update A records (using Cloudflare API example)
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data "{\"content\":\"${DO_LB_IP}\",\"ttl\":60}"

echo "DNS updated. Waiting for propagation..."

# Monitor propagation
for i in {1..30}; do
  RESOLVED_IP=$(dig +short ${DOMAIN} @8.8.8.8)
  if [ "$RESOLVED_IP" == "$DO_LB_IP" ]; then
    echo "DNS propagated to Google DNS"
    break
  fi
  sleep 10
done

# Verify services
echo "Verifying services..."
curl -f https://${DOMAIN}/health || exit 1
curl -f https://api.${DOMAIN}/health || exit 1

echo "Cutover complete at $(date)"

Rollback procedure

If something goes wrong, you need to rollback quickly:

#!/bin/bash
# rollback.sh - Emergency rollback to Azure

AZURE_LB_IP="your-azure-loadbalancer-ip"

echo "EMERGENCY ROLLBACK INITIATED at $(date)"

# Revert DNS
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data "{\"content\":\"${AZURE_LB_IP}\",\"ttl\":60}"

echo "DNS reverted to Azure. Monitoring propagation..."

# Alert team
curl -X POST "https://hooks.slack.com/services/xxx" \
  -H "Content-Type: application/json" \
  -d '{"text":"⚠️ PRODUCTION ROLLBACK EXECUTED - DNS reverted to Azure"}'

Phase 6: Post-migration tasks

Monitoring setup

Ensure you have visibility into the new environment:

# prometheus-config.yaml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'kubernetes-pods'
    kubernetes_sd_configs:
      - role: pod
    relabel_configs:
      - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
        action: keep
        regex: true

  - job_name: 'digitalocean-database'
    static_configs:
      - targets: ['your-db-host:9187']

Cost monitoring

Set up budget alerts to validate your cost projections:

resource "digitalocean_monitor_alert" "cost_alert" {
  alerts {
    email = ["ops@softweb.uk"]
    slack {
      channel = "#alerts"
      url     = var.slack_webhook_url
    }
  }

  window      = "5m"
  type        = "v1/insights/droplet/cpu"
  compare     = "GreaterThan"
  value       = 80
  enabled     = true
  description = "High CPU usage alert"
}

Azure resource cleanup

Once you're confident in the new environment (typically 2-4 weeks post-cutover):

# List Azure resources to delete
az resource list --resource-group softweb-production -o table

# Delete resource group (after final backup)
az group delete --name softweb-production --yes --no-wait

Timeline summary

Week Phase Key activities
1-2 Infrastructure Terraform provisioning, networking setup
3-4 Data migration Database replication, storage sync
5-6 Application Docker builds, Kubernetes deployments
7-8 Testing Load testing, integration testing, team training
9 Cutover DNS switch, monitoring, on-call support
10+ Cleanup Azure decommissioning, cost verification

Common pitfalls and how to avoid them

Database connection limits

DigitalOcean managed databases have connection limits. Configure connection pooling:

# pgbouncer-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: pgbouncer-config
data:
  pgbouncer.ini: |
    [databases]
    softweb = host=your-db-host port=25060 dbname=softweb

    [pgbouncer]
    listen_addr = 0.0.0.0
    listen_port = 5432
    auth_type = scram-sha-256
    pool_mode = transaction
    max_client_conn = 1000
    default_pool_size = 20

SSL certificate timing

Order SSL certificates well before cutover. Let's Encrypt rate limits can catch you out:

# Pre-provision certificates using cert-manager
kubectl apply -f - <<EOF
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: softweb-tls
spec:
  secretName: softweb-tls
  issuerRef:
    name: letsencrypt-production
    kind: ClusterIssuer
  dnsNames:
    - softweb.uk
    - www.softweb.uk
    - api.softweb.uk
EOF

Kubernetes resource limits

Don't forget to set resource limits to prevent runaway pods:

resources:
  requests:
    memory: "256Mi"
    cpu: "250m"
  limits:
    memory: "512Mi"
    cpu: "500m"

Conclusion

Zero-downtime cloud migration is achievable with careful planning and execution. The key principles are:

  1. Provision infrastructure before you need it - Terraform makes this repeatable
  2. Migrate data incrementally - Don't attempt a big-bang data migration
  3. Run parallel environments - Test thoroughly before cutover
  4. Keep TTLs low - Enables fast rollback if needed
  5. Have a rollback plan - And test it before you need it

The technical complexity is manageable. The real challenge is coordination and communication. Make sure your team understands each phase and has clear responsibilities during cutover.


Need help with your cloud migration?

We've helped organisations migrate from Azure, AWS, and on-premises infrastructure to DigitalOcean. Whether you need a full migration service or just technical guidance, we can help you plan and execute a zero-downtime transition.

Schedule a migration consultation to discuss your specific requirements.


This guide is based on migrations completed in 2025. Cloud provider interfaces and features change regularly - always verify current documentation before implementing.

If you're evaluating DigitalOcean for your migration, you can sign up with $200 in free credits to test the platform before committing.

Tags:cloud-migrationdevopsterraformkubernetesdigitalocean

Want to discuss this article?

Get in touch with our team.