Sound Architecture in the Cloud: the Well-Architected Framework, Backup and DR, Lock-in and IaC as the Next Step
Sound architecture in the cloud means asking a workload the five Well-Architected pillars regularly, turning RPO and RTO into written targets and testing backups by restoring them. We review Bookstore against the five pillars and cover what zone redundancy and failover groups do and don't save, a real restore drill, a conscious approach to lock-in, landing zones, the move to IaC and the end-of-series checklist.
Version note: The examples in this post were run with .NET 10 (SDK 10.0.404), Microsoft.Data.SqlClient 7.1.2, a SQL Server 2025 container, Azure CLI 2.92.0 and ShellCheck 0.11.0. The restore drill really ran on a local SQL Server; the script written for Azure SQL was only checked with ShellCheck and az ... --help. Azure and AWS details come from Microsoft Learn and the AWS documentation (14 November 2026).
Guess First
The production database of Bookstore, the online bookstore used as the shared example across this site's series, was damaged by a faulty migration on a Friday evening: the price column of order lines was set to zero. In post 8 we set point-in-time restore to 14 days. The question is simple: how many minutes until Bookstore is back to its earlier state, and how many minutes of orders are lost? If you don't know these two numbers, the two targets this post is about, RTO and RPO, haven't been defined for Bookstore yet. A second, smaller question: how many pillars does AWS's Well-Architected Framework have, and how many does Azure's?
The Well-Architected Framework: Five Pillars
Microsoft's Azure Well-Architected Framework is a guide for reviewing a workload from five angles. Each pillar asks a question. Reliability: does the system survive failures, come back within the expected time and meet its SLA? Security: are data and identities protected, and is the impact of an attack limited? Cost Optimization: does the money spent match the value received? Operational Excellence: are deployment, monitoring and incident response a repeatable process? Performance Efficiency: is the system fast enough under load and able to scale? AWS's framework adds Sustainability, the workload's environmental impact, added in December 2021, to the same five pillars and gets to six; that's the answer to the second guess.
It isn't a checklist but a way of having the conversation. On Azure there's an assessment tool called the Well-Architected Review, and Azure Advisor scores its recommendations along the same five pillars. We turn this series into our own checklist. The table below shows where each pillar shows up in this series and its name on the AWS side; AWS has been the comparison in every post of this series.
| Azure pillar | In Bookstore in this series | AWS equivalent |
|---|---|---|
| Reliability | Composite SLA (1), health checks (5), retries (8), backup and restore (8, 11) | Reliability |
| Security | Managed identity and role assignments (3), short-lived signed URLs (7), Entra-only SQL (8), private endpoints (9) | Security |
| Cost Optimization | Choosing compute (4), access tiers (7), serverless and the free offer (8), budgets and tags (10) | Cost Optimization |
| Operational Excellence | Naming and tags (2), setup with scripts (2-10), restore drill (11) | Operational Excellence |
| Performance Efficiency | Cold start measurement (4), scale rules (5), caching covers (7) | Performance Efficiency |
| No equivalent | Energy impact of region and resource size choices | Sustainability |
Reliability: RPO, RTO and Zones
Learn defines the two targets like this: RTO is the longest time the application may be unavailable after an incident; RPO is the largest amount of data loss that's acceptable. These are business decisions, not technical ones: you ask Selin "is it acceptable not to take orders for an hour on a campaign night?", and the answer shapes the architecture.
For Azure SQL, the current table on Learn lists the options. In a zone-redundant configuration, losing a zone is handled in under 30 seconds with no data loss. With a failover group or geo-replication, losing a region means an RTO under 60 seconds and an RPO of zero or a little more. With geo-restore, which relies only on backups, both are "minutes or hours". The answer to the guess comes from here too: a broken migration isn't a region failure but a logical error. A failover group copies the error to the secondary as well; what saves you is point-in-time restore. Since log backups are taken about every 10 minutes, you can go back to the moment just before the migration, but you have to move the orders that arrived after the migration by hand. How long it takes depends on the size of the database; the only way to measure that is to try.
At the application tier, zone redundancy differs per service. According to Learn, App Service needs a Premium v2, v3 or v4 plan and at least two instances; in Container Apps the environment must be inside a VNet and zone redundancy can only be turned on when the environment is created; for storage you choose ZRS. West Europe has three availability zones and its pair is North Europe. A realistic target for Bookstore: zone redundancy in production (a few seconds of interruption, no data loss) and recovery from backups for losing a region (hours). Keeping a second region ready all the time nearly doubles compute and database costs; the composite SLA calculation in post 1 showed the minutes gained in return for that cost.
Testing Backups by Restoring Them
Learn's disaster recovery guidance says to test restores regularly and run drills. Having a backup isn't proof that it can be restored. For a SQL Server you run yourself (on a VM or in a container) we wrote a small drill tool: it backs up the database, restores it under a new name, compares row counts table by table and writes down how long it took:
// Restore drill for a SQL Server you run yourself (VM or container): back up BookstoreDb, restore it under
// a new name, compare it with the source and report how long it took. Azure SQL: infra/cli/11-restore-drill.sh.
// Run: dotnet run tools/cloud/restore-drill.cs (BOOKSTORE_DB = connection string to the source database)
#:package Microsoft.Data.SqlClient
using System.Diagnostics;
using Microsoft.Data.SqlClient;
SqlConnectionStringBuilder source = new(Environment.GetEnvironmentVariable("BOOKSTORE_DB")
?? throw new InvalidOperationException("Set BOOKSTORE_DB to the source connection string."));
string database = source.InitialCatalog;
string drill = $"{database}_RestoreDrill";
string backupFile = $"/var/opt/mssql/data/{database}_drill.bak"; // a path on the SQL Server machine
SqlConnectionStringBuilder master = new(source.ConnectionString) { InitialCatalog = "master" };
await using SqlConnection server = new(master.ConnectionString);
await server.OpenAsync();
Stopwatch backup = Stopwatch.StartNew();
await ExecAsync($"BACKUP DATABASE [{database}] TO DISK = N'{backupFile}' WITH INIT, COPY_ONLY, CHECKSUM");
backup.Stop();
// The restored copy needs its own file names next to the original ones
List<string> moves = [];
await using (SqlCommand files = new($"RESTORE FILELISTONLY FROM DISK = N'{backupFile}'", server))
await using (SqlDataReader reader = await files.ExecuteReaderAsync())
{
while (await reader.ReadAsync())
{
string logical = reader.GetString(0);
string physical = reader.GetString(1);
string target = Path.Combine(Path.GetDirectoryName(physical)!, $"{drill}_{logical}{Path.GetExtension(physical)}");
moves.Add($"MOVE N'{logical}' TO N'{target}'");
}
}
Stopwatch restore = Stopwatch.StartNew();
await ExecAsync($"RESTORE DATABASE [{drill}] FROM DISK = N'{backupFile}' WITH {string.Join(", ", moves)}, REPLACE, CHECKSUM");
restore.Stop();
const string CountsSql = """
SELECT s.name + '.' + t.name, SUM(p.rows)
FROM sys.tables t JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.partitions p ON p.object_id = t.object_id AND p.index_id IN (0, 1)
GROUP BY s.name, t.name ORDER BY 1
""";
Dictionary<string, long> expected = await CountsAsync(database);
Dictionary<string, long> actual = await CountsAsync(drill);
int mismatches = expected.Count(e => !actual.TryGetValue(e.Key, out long rows) || rows != e.Value);
Console.WriteLine($"Backup {database,-24} {backup.Elapsed.TotalSeconds,6:0.00} s");
Console.WriteLine($"Restore {drill,-24} {restore.Elapsed.TotalSeconds,6:0.00} s");
Console.WriteLine($"Tables compared: {expected.Count}, rows: {expected.Values.Sum()}, mismatches: {mismatches}");
Console.WriteLine(mismatches == 0 ? "Drill passed: the backup restores and matches the source." : "Drill FAILED.");
await ExecAsync($"ALTER DATABASE [{drill}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{drill}]");
return mismatches == 0 ? 0 : 1;
async Task ExecAsync(string sql)
{
await using SqlCommand command = new(sql, server) { CommandTimeout = 600 };
await command.ExecuteNonQueryAsync();
}
async Task<Dictionary<string, long>> CountsAsync(string db)
{
await server.ChangeDatabaseAsync(db);
Dictionary<string, long> counts = [];
await using (SqlCommand command = new(CountsSql, server))
await using (SqlDataReader reader = await command.ExecuteReaderAsync())
{
while (await reader.ReadAsync())
{
counts[reader.GetString(0)] = reader.GetInt64(1);
}
}
await server.ChangeDatabaseAsync("master");
return counts;
}
The tool uses Microsoft.Data.SqlClient 7.1.2, which needs no extra package because no Entra ID is involved; the version is in the repo's Directory.Packages.props. On the local SQL Server container, against BookstoreDb from post 8:
$ BOOKSTORE_DB="Server=localhost,1433;Database=BookstoreDb;..." dotnet run tools/cloud/restore-drill.cs
Backup BookstoreDb 0.05 s
Restore BookstoreDb_RestoreDrill 0.20 s
Tables compared: 10, rows: 64, mismatches: 0
Drill passed: the backup restores and matches the source.
For a database with twenty books the times are meaninglessly small; what's valuable is the process itself. When the same tool runs on a database of real size, you know the data tier's share of the RTO from a measurement rather than a guess. Azure SQL Database has no BACKUP command; there the drill is opening a new database with point-in-time restore:
#!/usr/bin/env bash
# Restore drill on Azure SQL: point-in-time restore of BookstoreDb into a new database, timed, then deleted.
# Uses GNU date (Cloud Shell, WSL, Git Bash, GitHub-hosted runners).
set -euo pipefail
: "${AZURE_SUBSCRIPTION_ID:?Set AZURE_SUBSCRIPTION_ID to the target subscription}"
ENVIRONMENT="${1:?Usage: 11-restore-drill.sh <dev|staging|prod>}"
RG="rg-bookstore-${ENVIRONMENT}-weu"
SQL_SERVER="sql-bookstore-${ENVIRONMENT}-weu"
DRILL="BookstoreDb-drill-$(date -u +%Y%m%d)"
POINT_IN_TIME=$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ)
az account set --subscription "$AZURE_SUBSCRIPTION_ID"
# Restore always creates a new database; the original is never touched
STARTED=$(date +%s)
az sql db restore --resource-group "$RG" --server "$SQL_SERVER" --name BookstoreDb \
--dest-name "$DRILL" --time "$POINT_IN_TIME"
echo "Restored ${POINT_IN_TIME} into ${DRILL} in $(( $(date +%s) - STARTED )) s"
# Compare the copy with the source (row counts, last order) before deleting it, e.g. with tools/cloud/restore-drill.cs queries
read -r -p "Delete ${DRILL}? [y/N] " ANSWER
if [[ "$ANSWER" == "y" ]]; then
az sql db delete --resource-group "$RG" --server "$SQL_SERVER" --name "$DRILL" --yes
fi
Tie this drill to a monthly task in the calendar and write down the measured time. If the time grows every month, your RTO target is silently being exceeded as the database grows.
Lock-in: How Much of It Is a Real Problem?
Depending on a cloud provider, lock-in, is the price of every managed service. Every piece we added to Bookstore in this series has a different level of portability. A container image runs in every cloud and on your own servers. A SQL Server or PostgreSQL schema moves with a bacpac or a dump. Logs, metrics and traces collected with OpenTelemetry can be sent to any backend; according to Learn Azure Monitor supports OpenTelemetry too, and the project reached graduated status at the CNCF in May 2026. On the other hand, Functions triggers, Event Grid subscriptions, managed identity and the Entra ID integration are specific to Azure; moving to another cloud means rewriting those pieces.
For most teams the right answer isn't multi-cloud. Running the application in two clouds at once means dropping to the lowest common denominator of both and doubling the operational load. More realistic is choosing lock-in consciously: keeping business logic in Bookstore.Api and gathering provider-specific pieces in thin adapters (the thumbnail generation from post 6 lives in a class separate from the function), and writing a short exit plan: "if we left Azure, what would we rewrite and how long would it take?" A one-page answer to that question is much cheaper insurance than a multi-cloud architecture.
Landing Zones and the Next Step: IaC
We set up everything in this series with the portal and az CLI scripts. The scripts document it, but they have problems: running the same script twice doesn't give the same result for every command, a change someone makes in the portal isn't reflected in the script, and the answer to "what's on Azure right now" isn't written in any file. Infrastructure as Code solves these three problems: the desired state is written in a file, the tool finds the difference and applies only the difference. The Infrastructure as Code: Terraform series rebuilds the resources from this series with Terraform, starting from Bookstore's resource group; for the first step see the post "Infrastructure as Code Nedir? Terraform, OpenTofu, Bicep, Pulumi ve Ne Zaman Hangisi".
As an organisation grows, one more layer is needed: the landing zone. According to the Cloud Adoption Framework, a landing zone has two parts: the platform landing zone, which carries shared infrastructure such as identity, network, policy and monitoring, and workload landing zones, where each workload runs in its own subscriptions. While Bookstore is a single-team product, the subscription and tag layout from post 2 is enough; when the fifth team arrives, the shared network, policies and log collection should go to a platform team. On monitoring, this series deliberately stayed on the surface: the Observability series covers the OpenTelemetry setup, metrics, traces and log backends in detail.
End-of-Series Checklist
The list that comes out of this series for moving Bookstore to the cloud. Each item maps to a post, and you shouldn't go to production without a "yes" for each:
- Identity: people get access through groups, applications through managed identities; nobody has a permanent Owner role, and data roles are at the narrowest scope (3).
- Network: the database and storage sit behind private endpoints, public access is deliberately on or off, and private DNS zones are linked to the VNet (9).
- Secrets: no account key, password or connection string secret in code or app settings; shared key is off, and SAS tokens are short-lived and signed with user delegation (3, 7, 8).
- Cost: every resource is tagged, budgets and their notifications are set up, the estimate tool is current, and temporary environments have an end date (2, 10).
- Backups: RPO and RTO are written down, a restore has been tried at least once and timed, and soft delete is on (7, 8, 11).
- Monitoring: health checks are wired up, poison queue and budget alerts reach someone, and log cost is under control (5, 6, 10).
In Production
Write the targets down. "We'll recover quickly" isn't a target; "RTO 1 hour, RPO 15 minutes" is. Without written targets you can't decide which configuration is enough.
Repeat the architecture review. Don't do the Well-Architected Review once and leave it; after a big change or twice a year, look at Advisor's scores and recommendations across the five pillars.
Start chaos testing small. Azure Chaos Studio is still available and its new model is in preview; but as a first step, even stopping SQL in a dev environment and watching how the application behaves reveals things that don't show up on paper, as we saw in post 8.
Common Pitfalls
Treating a failover group as a backup. Replication copies errors too: a dropped table is dropped in both regions. What protects against logical errors is point-in-time restore and long-term retention.
Turning on zone redundancy in only one place. If the database is zone-redundant but App Service has a single instance, the weakest link in the chain is still the application.
Avoiding managed services out of fear of lock-in. A PostgreSQL VM you run yourself is portable, but its backups, patches and failover are your job; for most teams that work costs more than the lock-in.
Leaving IaC until everything is done. As the number of resources grows, moving changes made in the portal back into code gets harder; starting before the first production environment is the cheapest.
A Small Experiment
★☆☆ Write Bookstore's RTO and RPO targets in a paragraph and choose, with reasons, which Azure SQL configuration (backups, zone redundancy, failover group) meets them.
★★☆ Add a check to restore-drill.cs that compares the time of the last order (MAX(PlacedAtUtc)). After a point-in-time restore, how does this value show what data was lost?
★★★ Write a one-page exit plan for Bookstore: put the AWS equivalent of every Azure piece in this series (App Service, Container Apps, Functions, Blob Storage, Azure SQL, Entra ID, Front Door), the code that would have to move and an estimated duration into a table.
The Cloud Temelleri series ends here. The natural next step is to define the resources from this series as code: the Infrastructure as Code: Terraform series sets up the same Bookstore on Azure with Terraform, and the Observability series completes the monitoring side.