Your e-commerce site processes millions in revenue daily. A simple database migration that takes your site offline for 10 minutes costs thousands in lost sales and damages customer trust.
Traditional migration approaches require downtime: stop the application, run migrations, restart with new code. Zero-downtime migrations eliminate this disruption using careful planning and deployment strategies that keep your application running throughout database updates.
The Zero-Downtime Challenge
Standard EF Core migrations assume a simple deployment model:
- Stop application
- Run
dotnet ef database update
- Deploy new application code
- Start application
This works for small applications but creates unacceptable downtime for production systems. The challenge is that database schema changes often require corresponding application code changes, creating a chicken-and-egg problem.
Backward-Compatible Migration Patterns
Zero-downtime migrations require backward compatibility: the database must work with both old and new application versions during the transition.
Adding New Tables
Adding new tables is always safe:
public partial class AddOrderHistoryTable : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "OrderHistory",
columns: table => new
{
Id = table.Column<int>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
OrderId = table.Column<int>(nullable: false),
Status = table.Column<string>(maxLength: 50, nullable: false),
ChangedAt = table.Column<DateTime>(nullable: false),
ChangedBy = table.Column<string>(maxLength: 100, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OrderHistory", x => x.Id);
table.ForeignKey(
name: "FK_OrderHistory_Orders_OrderId",
column: x => x.OrderId,
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
}
Old application versions ignore the new table. New versions can use it immediately after deployment.
Adding Nullable Columns
New nullable columns are backward compatible:
public partial class AddCustomerPreferences : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "MarketingPreferences",
table: "Customers",
maxLength: 500,
nullable: true); // Nullable = backward compatible
migrationBuilder.AddColumn<bool>(
name: "EmailNotifications",
table: "Customers",
nullable: true); // Old code won't break
}
}
Old application versions work fine because they never read these columns. New versions can start using them immediately.
Adding Non-Nullable Columns with Defaults
Non-nullable columns need default values for backward compatibility:
public partial class AddCustomerStatus : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Status",
table: "Customers",
maxLength: 20,
nullable: false,
defaultValue: "Active"); // Default ensures old code works
// Populate existing rows
migrationBuilder.Sql(@"
UPDATE Customers
SET Status = 'Active'
WHERE Status IS NULL
");
}
}
Existing rows get the default value, new rows get the default unless explicitly set. Old application versions continue working.
Blue-Green Deployment Strategy
Blue-green deployment maintains two identical environments and switches between them:
Environment Setup
# docker-compose.blue-green.yml
version: '3.8'
services:
database:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
- ACCEPT_EULA=Y
- SA_PASSWORD=YourStrong!Passw0rd
volumes:
- db_data:/var/opt/mssql
app-blue:
image: myapp:current
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ConnectionStrings__DefaultConnection=Server=database;Database=MyApp;...
depends_on:
- database
app-green:
image: myapp:next
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ConnectionStrings__DefaultConnection=Server=database;Database=MyApp;...
depends_on:
- database
loadbalancer:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
ports:
- "80:80"
Deployment Process
#!/bin/bash
# Blue-Green Deployment Script
CURRENT_ENV=$(curl -s http://loadbalancer/health | jq -r '.environment')
echo "Current environment: $CURRENT_ENV"
if [ "$CURRENT_ENV" = "blue" ]; then
DEPLOY_ENV="green"
ACTIVE_ENV="blue"
else
DEPLOY_ENV="blue"
ACTIVE_ENV="green"
fi
echo "Deploying to: $DEPLOY_ENV"
# 1. Deploy new code to inactive environment
docker-compose build app-$DEPLOY_ENV
docker-compose up -d app-$DEPLOY_ENV
# 2. Run backward-compatible migrations
docker-compose exec app-$DEPLOY_ENV dotnet ef database update
# 3. Wait for application to be healthy
echo "Waiting for $DEPLOY_ENV to be healthy..."
for i in {1..30}; do
if curl -f http://app-$DEPLOY_ENV/health; then
echo "$DEPLOY_ENV is healthy"
break
fi
sleep 10
done
# 4. Run smoke tests
echo "Running smoke tests on $DEPLOY_ENV..."
./run-smoke-tests.sh http://app-$DEPLOY_ENV
# 5. Switch load balancer
echo "Switching traffic to $DEPLOY_ENV..."
sed -i "s/app-$ACTIVE_ENV/app-$DEPLOY_ENV/g" nginx.conf
docker-compose exec loadbalancer nginx -s reload
# 6. Verify traffic switch
sleep 30
NEW_ENV=$(curl -s http://loadbalancer/health | jq -r '.environment')
if [ "$NEW_ENV" = "$DEPLOY_ENV" ]; then
echo "Traffic successfully switched to $DEPLOY_ENV"
# 7. Stop old environment
docker-compose stop app-$ACTIVE_ENV
else
echo "Traffic switch failed, rolling back..."
sed -i "s/app-$DEPLOY_ENV/app-$ACTIVE_ENV/g" nginx.conf
docker-compose exec loadbalancer nginx -s reload
exit 1
fi
Backward-Compatible Application Code
Your application needs to handle missing columns gracefully:
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
// New property - might not exist in database yet
public string Status { get; set; } = "Active";
public string MarketingPreferences { get; set; }
}
// Configure entity to handle missing columns
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>(entity =>
{
// Make new columns optional for backward compatibility
entity.Property(e => e.Status)
.HasDefaultValue("Active")
.IsRequired(false); // Allows null during transition
entity.Property(e => e.MarketingPreferences)
.IsRequired(false);
});
}
Rolling Deployment Strategy
Rolling deployments update application instances gradually while maintaining backward compatibility:
Kubernetes Rolling Update
# kubernetes-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp-deployment
spec:
replicas: 6
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1 # Keep 5/6 instances running
maxSurge: 1 # Allow 1 extra instance during update
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: myapp:v2.1.0
env:
- name: ConnectionStrings__DefaultConnection
valueFrom:
secretKeyRef:
name: db-connection
key: connection-string
readinessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 30
periodSeconds: 10
Pre-Deployment Migration
Run backward-compatible migrations before starting the rolling update:
#!/bin/bash
# Rolling Deployment Script
echo "Step 1: Running backward-compatible migrations..."
kubectl exec deployment/myapp-deployment -- dotnet ef database update
echo "Step 2: Waiting for migration completion..."
kubectl wait --for=condition=Ready pod -l app=myapp --timeout=300s
echo "Step 3: Starting rolling update..."
kubectl set image deployment/myapp-deployment myapp=myapp:v2.1.0
echo "Step 4: Monitoring rollout..."
kubectl rollout status deployment/myapp-deployment --timeout=600s
echo "Step 5: Verifying deployment..."
kubectl get pods -l app=myapp
Multi-Phase Migration Strategy
For breaking changes, use a multi-phase approach:
Phase 1: Add New Schema (Backward Compatible)
// Phase 1 Migration: Add new column alongside old one
public partial class AddCustomerFullName : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// Add new column
migrationBuilder.AddColumn<string>(
name: "FullName",
table: "Customers",
maxLength: 200,
nullable: true);
// Populate from existing data
migrationBuilder.Sql(@"
UPDATE Customers
SET FullName = CONCAT(FirstName, ' ', LastName)
WHERE FullName IS NULL
");
}
}
Phase 2: Deploy Application Changes
Update application to use new column but keep reading old columns:
public class Customer
{
public int Id { get; set; }
// Keep old properties for backward compatibility
public string FirstName { get; set; }
public string LastName { get; set; }
// New property
public string FullName { get; set; }
// Property to handle transition
public string DisplayName => !string.IsNullOrEmpty(FullName)
? FullName
: $"{FirstName} {LastName}";
}
public class CustomerService
{
public async Task UpdateCustomerAsync(Customer customer)
{
// Write to both old and new columns during transition
if (!string.IsNullOrEmpty(customer.FullName))
{
var parts = customer.FullName.Split(' ', 2);
customer.FirstName = parts[0];
customer.LastName = parts.Length > 1 ? parts[1] : "";
}
await context.SaveChangesAsync();
}
}
Phase 3: Remove Old Schema (After Full Deployment)
// Phase 3 Migration: Remove old columns (only after all instances updated)
public partial class RemoveCustomerFirstLastName : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "FirstName", table: "Customers");
migrationBuilder.DropColumn(name: "LastName", table: "Customers");
// Make FullName required now
migrationBuilder.AlterColumn<string>(
name: "FullName",
table: "Customers",
maxLength: 200,
nullable: false,
oldNullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "FirstName",
table: "Customers",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "LastName",
table: "Customers",
maxLength: 100,
nullable: true);
// Repopulate from FullName
migrationBuilder.Sql(@"
UPDATE Customers
SET
FirstName = LEFT(FullName, CHARINDEX(' ', FullName + ' ') - 1),
LastName = LTRIM(RIGHT(FullName, LEN(FullName) - CHARINDEX(' ', FullName + ' ')))
");
}
}
Monitoring and Rollback
Health Checks During Deployment
public class DatabaseMigrationHealthCheck : IHealthCheck
{
private readonly DbContext context;
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
// Check if database is accessible
await this.context.Database.CanConnectAsync(cancellationToken);
// Check for pending migrations
var pendingMigrations = await this.context.Database.GetPendingMigrationsAsync(cancellationToken);
if (pendingMigrations.Any())
{
return HealthCheckResult.Degraded(
$"Pending migrations: {string.Join(", ", pendingMigrations)}");
}
// Verify critical tables exist
var customerCount = await this.context.Database.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'Customers'",
cancellationToken);
if (customerCount == 0)
{
return HealthCheckResult.Unhealthy("Critical tables missing");
}
return HealthCheckResult.Healthy("Database migration state is healthy");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Database health check failed", ex);
}
}
}
Automated Rollback
#!/bin/bash
# Rollback script for failed deployments
ROLLBACK_MIGRATION="$1"
DEPLOYMENT_NAME="myapp-deployment"
echo "Rolling back to migration: $ROLLBACK_MIGRATION"
# 1. Scale down to single instance to avoid migration conflicts
kubectl scale deployment $DEPLOYMENT_NAME --replicas=1
# 2. Wait for scale down
kubectl wait --for=jsonpath='{.status.readyReplicas}'=1 deployment/$DEPLOYMENT_NAME
# 3. Rollback database migration
kubectl exec deployment/$DEPLOYMENT_NAME -- dotnet ef database update $ROLLBACK_MIGRATION
# 4. Rollback application code
kubectl rollout undo deployment/$DEPLOYMENT_NAME
# 5. Scale back up
kubectl scale deployment $DEPLOYMENT_NAME --replicas=6
# 6. Wait for rollout completion
kubectl rollout status deployment/$DEPLOYMENT_NAME
Pro Tip: I’ve implemented zero-downtime migrations for systems handling millions of requests daily. The key is thorough testing of backward compatibility and having robust monitoring to catch issues immediately. Never skip the smoke tests after traffic switches.
Testing Zero-Downtime Migrations
Integration Tests
[Test]
public async Task Migration_ShouldBeBackwardCompatible()
{
// Arrange: Start with old schema
using var oldContext = new CustomerContext(oldSchemaOptions);
var customer = new Customer { FirstName = "John", LastName = "Doe" };
oldContext.Customers.Add(customer);
await oldContext.SaveChangesAsync();
// Act: Apply migration
using var migrationContext = new CustomerContext(migrationOptions);
await migrationContext.Database.MigrateAsync();
// Assert: Old context still works
var retrievedCustomer = await oldContext.Customers.FirstAsync();
Assert.AreEqual("John", retrievedCustomer.FirstName);
// Assert: New context can read migrated data
using var newContext = new CustomerContext(newSchemaOptions);
var migratedCustomer = await newContext.Customers.FirstAsync();
Assert.AreEqual("John Doe", migratedCustomer.FullName);
}
Key Takeaway
Zero-downtime migrations require careful planning and adherence to backward compatibility principles. The strategies you choose depend on your infrastructure and risk tolerance, but the core principle remains: the database must work with both old and new application versions during the transition.
Invest in proper tooling, comprehensive testing, and monitoring. The complexity is worth it when your application stays online during critical updates, maintaining revenue and user trust.