Why it matters how you get to the cloud
You migrate an application to the cloud and expect lower bills, automatic scaling, and zero downtime. A few months later you realize you are paying more than before, scaling doesn’t work properly, and deployments are still stressful. What happened?
Most likely you did a lift & shift — you took the application exactly as it was and moved it to a virtual machine in the cloud. The application doesn’t know it’s in the cloud. It doesn’t use anything the platform offers. It’s like buying a Tesla and pushing it by hand.
Cloud-native means building (or transforming) applications that actively leverage the features of the cloud environment: elasticity, managed services, observability, automated deployment, resilience to partial failures.
In this series we will systematically go through all the pieces of the puzzle: from principles and mindset (this article) to containerization, Azure Container Apps, Cosmos DB, observability with Application Insights, CI/CD, and security with Managed Identity.
Lift & shift vs. cloud-native — the real difference
Lift & shift is not wrong per se. It’s a valid strategy when you have time or budget constraints. The problem is confusing migration with modernization.
| Criterion | Lift & shift | Cloud-native |
|---|---|---|
| Infrastructure | VMs with OS managed by you | Managed services (App Service, ACA, AKS) |
| Scaling | Manual | Automatic, based on metrics or KEDA |
| Configuration | Config files on disk, set manually | Azure App Configuration, Key Vault, injected env vars |
| State | In-memory sessions, local files | Externalized sessions (Redis), separate storage (Blob) |
| Deploy | RDP, FTP, manual scripts | Automated CI/CD, blue-green, canary |
| Failures | Application crashes, someone restarts it | Health checks, automatic restart, circuit breakers |
| Observability | Logs in files, checked manually | Structured logging, distributed tracing, dashboards |
The fundamental difference is in mindset: in cloud-native you assume things will fail — instances crash, network has latency, external services become unavailable — and you build resilience in code, not in infrastructure.
The Twelve-Factor App — relevance in 2025
The 12-Factor App methodology was published by Heroku in 2012. It seems old, but the 12 factors are more relevant than ever — all modern cloud best practices (containers, microservices, serverless) are based on them.
I. Codebase — one repository, many deployments
An application = one Git repository. The same code runs in Development, Staging, and Production — the difference is made by configurations, not code. You don’t have separate branches for Production.
II. Dependencies — declared explicitly, never implicit
All dependencies are declared in .csproj. You don’t assume a tool already exists on the OS. If your application depends on wkhtmltopdf installed globally on the server, you have a cloud-native problem.
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.0.0" />
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.3.2" />
<PackageReference Include="Azure.Identity" Version="1.13.1" />
</ItemGroup>
III. Config — stored in environment, not in code
Configuration that differs between deployments has no place in code or files committed to Git.
var builder = WebApplication.CreateBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: false)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true)
.AddEnvironmentVariables();
// Key Vault in production, via Managed Identity
if (builder.Environment.IsProduction())
{
var kvUri = builder.Configuration["KeyVaultUri"]
?? throw new InvalidOperationException("KeyVaultUri is not configured");
builder.Configuration.AddAzureKeyVault(new Uri(kvUri), new DefaultAzureCredential());
}
Simple rule: if you have to change code or rebuild to go from Development to Production, the configuration is in the wrong place.
IV. Backing services — treated as attached resources
Database, Redis cache, message queues — all are external resources identified by URL/connection string. You can replace local Cosmos DB (emulator) with the cloud one by changing an environment variable, without code changes.
V. Logs — treated as event streams
The application writes logs to stdout/stderr. It doesn’t manage log files or rotate them. The environment (Azure Monitor, Application Insights) captures and aggregates.
// Structured logging -- no string interpolations
_logger.LogInformation("Request processed for {CustomerId} in {ElapsedMs}ms",
customerId, elapsed.TotalMilliseconds);
// NOT like this -- loses structure, becomes plain text:
_logger.LogInformation($"Request processed for {customerId}");
Structured logging produces JSON instead of text — much easier to query in Application Insights or Log Analytics.
Stateless services — why and how
A stateless service does not retain any local state between requests. You can run 1, 10, or 100 instances of the same service and any instance can serve any request. Horizontal scaling becomes trivial — Azure App Service can automatically scale from 1 to N instances. If the application has local state, scaling causes inconsistencies.
Anti-pattern: in-memory state
// WRONG -- list exists only in current instance
public class OrderService
{
private readonly List<Order> _pendingOrders = new();
public void AddPendingOrder(Order order)
{
_pendingOrders.Add(order); // lost on restart or on another instance
}
}
Correct pattern: state externalized in Redis
// CORRECT -- state in Redis, visible to all instances
public class OrderService
{
private readonly IDistributedCache _cache;
public OrderService(IDistributedCache cache) => _cache = cache;
public async Task AddPendingOrderAsync(Order order)
{
var key = $"pending-order:{order.Id}";
var json = JsonConvert.SerializeObject(order);
await _cache.SetStringAsync(key, json, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24)
});
}
}
// Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
options.Configuration = builder.Configuration.GetConnectionString("Redis"));
Local files follow the same rule: Azure Blob Storage instead of local disk. HTTP sessions are externalized similarly, via AddStackExchangeRedisCache + AddSession.
Externalizing configuration — complete pattern
Level 1: appsettings.json — structure and default values
{
"Logging": { "LogLevel": { "Default": "Information" } },
"Features": { "EnableNewDashboard": false, "MaxUploadSizeMb": 10 },
"CosmosDb": { "DatabaseName": "MyAppDb", "Endpoint": "" }
}
Level 2: environment variables — override in Azure App Service
The convention for nested structures uses __ (double underscore) instead of ::
CosmosDb__Endpoint=https://myaccount.documents.azure.com:443/
CosmosDb__DatabaseName=MyAppDb-Prod
Features__EnableNewDashboard=true
Level 3: Azure Key Vault — real secrets, no credentials in code
Add validation at startup to detect missing configuration at deploy time, not at first request:
builder.Services.AddOptions<CosmosDbOptions>()
.BindConfiguration("CosmosDb")
.ValidateDataAnnotations()
.ValidateOnStart();
public class CosmosDbOptions
{
[Required] public string Endpoint { get; set; } = default!;
[Required] public string DatabaseName { get; set; } = default!;
public string? AccountKey { get; set; } // comes from Key Vault
}
Health Checks — basic observability
Health checks are HTTP endpoints used by Azure App Service, Azure Container Apps, and Kubernetes to decide if an instance should receive traffic or be restarted.
Separate liveness (the application is alive) from readiness (the database is accessible, the cache is started). A service can be “alive” but not “ready” — in this case you don’t restart the instance, you just temporarily remove it from rotation.
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: new[] { "live" })
.AddAzureCosmosDB(
sp => sp.GetRequiredService<CosmosClient>(),
name: "cosmos", tags: new[] { "ready" })
.AddRedis(
builder.Configuration.GetConnectionString("Redis")!,
name: "redis", tags: new[] { "ready" });
app.MapHealthChecks("/health/live", new HealthCheckOptions
{ Predicate = c => c.Tags.Contains("live") });
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{ Predicate = c => c.Tags.Contains("ready") });
Custom health check for external dependencies
public class ExternalApiHealthCheck : IHealthCheck
{
private readonly HttpClient _http;
public ExternalApiHealthCheck(IHttpClientFactory f)
=> _http = f.CreateClient("external-api");
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext ctx, CancellationToken ct = default)
{
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(5));
var r = await _http.GetAsync("/health", cts.Token);
return r.IsSuccessStatusCode
? HealthCheckResult.Healthy("External API available")
: HealthCheckResult.Degraded($"HTTP {(int)r.StatusCode}");
}
catch (Exception ex)
{ return HealthCheckResult.Unhealthy("External API inaccessible", ex); }
}
}
builder.Services.AddHealthChecks()
.AddCheck<ExternalApiHealthCheck>("external-api", tags: new[] { "ready" });
Graceful Shutdown — clean termination
In a cloud environment, application instances are frequently started and stopped: deployments, scale down, rebalancing. Without graceful shutdown, requests are abruptly cut, DB transactions remain open, messages in queues are lost or processed twice.
On receiving the SIGTERM signal, the ASP.NET Core host triggers IHostApplicationLifetime.ApplicationStopping and waits for active requests to complete. Configure the timeout accordingly:
builder.Services.Configure<HostOptions>(options =>
options.ShutdownTimeout = TimeSpan.FromSeconds(45));
Background services with graceful shutdown
The CancellationToken received in ExecuteAsync is automatically cancelled when the host receives the shutdown signal:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Message processor started");
await foreach (var message in _queue.ReadAllAsync(stoppingToken))
{
try
{
await ProcessMessageAsync(message, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Shutdown detected -- stopping processing");
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Message error {MessageId}", message.Id);
// Do not rethrow -- a bad message does not stop the service
}
}
_logger.LogInformation("Message processor stopped");
}
Also enable the health check in the Azure App Service portal (Configuration → Health check, path: /health/ready, threshold: 2 failures = instance removed from rotation).
Summary: cloud-native checklist
- Externalized configuration — no secrets in code or Git; environment variables or Key Vault for Production
- Stateless — sessions and transient state are in Redis, not in memory
- Externalized files — uploads go to Blob Storage, not local disk
- Configured health checks —
/health/liveand/health/readywith real dependency checks - Graceful shutdown implemented — background services respect CancellationToken; ShutdownTimeout set appropriately
- Structured logging — named parameters, no string interpolations; no local log files
- Dependencies declared explicitly — no implicit OS or global tool dependencies
What’s next in the series
- Containerization with Docker — optimized Dockerfile for .NET, multi-stage builds
- Azure Container Apps — deployment, scaling rules, KEDA, managed certificates
- Cloud-native Cosmos DB — partition keys, change feed, consistency levels
- Observability with Application Insights — distributed tracing, custom metrics, alerts
- CI/CD with GitHub Actions — complete pipeline from commit to Production
- Security with Managed Identity — zero hardcoded credentials, RBAC for Azure resources
If you have questions or want to discuss how to apply these principles in your project, write to me at contact@ludoprogramming.com.