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
- Infrastructure provisioning (Week 1-2)
- Data migration (Week 3-4)
- Application deployment (Week 5-6)
- Parallel running and testing (Week 7-8)
- DNS cutover (Week 9)
- 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:
- Provision infrastructure before you need it - Terraform makes this repeatable
- Migrate data incrementally - Don't attempt a big-bang data migration
- Run parallel environments - Test thoroughly before cutover
- Keep TTLs low - Enables fast rollback if needed
- 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.