BrandGhost
Deploying ASP.NET Core Web API to Azure and Docker

Deploying ASP.NET Core Web API to Azure and Docker

When you need to deploy ASP.NET Core Web APIs to production, .NET 10 gives you more options than ever. Docker containers, Azure App Service, Azure Container Apps -- the choices have multiplied, and each fits a different set of requirements. Choosing where to deploy ASP.NET Core applications used to require deep platform expertise. Today the tooling has matured enough that the decision is mostly about your architecture and operational preferences, not about fighting the framework. This guide covers the practical steps to containerize your Web API, run it locally with Docker Compose, configure environment-based settings, publish to Azure, and keep it healthy in production.

Whether you are shipping a simple CRUD API or something more layered -- like the architecture discussed in the Modular Monolith in C# guide -- the deployment fundamentals are the same. Get the container right first. Everything else builds on top of it.


.NET 10 Base Images

Microsoft publishes official .NET container images on the Microsoft Container Registry (mcr.microsoft.com). For deploying ASP.NET Core applications you will use two of them:

The mcr.microsoft.com/dotnet/sdk:10.0 image includes the full .NET SDK -- the compiler, the CLI tools, NuGet, and everything else needed to build and publish your application. It is large by design. You should only use it during the build stage of a multi-stage Docker build. Never ship the SDK image to production.

The mcr.microsoft.com/dotnet/aspnet:10.0 image includes only the ASP.NET Core runtime. No SDK, no compiler. It is much smaller -- typically around 200 MB (approximate; varies by .NET version and base image) compared to 800+ MB for the SDK image. This is what runs in production. It has everything your published application binary needs and nothing it doesn't.

Both images come in Debian (default) and Alpine variants. The Alpine variants use mcr.microsoft.com/dotnet/aspnet:10.0-alpine and are significantly smaller -- often under 100 MB (approximate; varies by .NET version and base image). Alpine is a good choice when image size is a concern, for example in high-frequency container scaling scenarios. The tradeoff is that Alpine uses musl libc rather than glibc, which can cause compatibility issues with certain native dependencies. Test Alpine builds before committing to them in production.


Multi-Stage Docker Build for .NET 10

The multi-stage build is the standard pattern for containerizing .NET applications. When you deploy ASP.NET Core APIs with Docker, this approach keeps your production image lean -- it uses the SDK image only during the build phase, then copies only the published output into the final runtime image. The result is a small, production-ready image with no build artifacts and a minimal attack surface.

# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src

# Copy csproj and restore dependencies first (layer caching)
COPY ["src/MyApi/MyApi.csproj", "src/MyApi/"]
RUN dotnet restore "src/MyApi/MyApi.csproj"

# Copy the rest of the source code
COPY . .

# Publish in Release configuration
RUN dotnet publish "src/MyApi/MyApi.csproj" 
    -c Release 
    -o /app/publish 
    --no-restore

# Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app

# Create a non-root user for security
RUN addgroup --system --gid 1001 appgroup 
    && adduser --system --uid 1001 --ingroup appgroup appuser

# Copy published output from build stage
COPY --from=build /app/publish .

# Switch to non-root user
USER appuser

# ASP.NET Core listens on 8080 by default in .NET 8+
EXPOSE 8080

ENTRYPOINT ["dotnet", "MyApi.dll"]

A few things worth highlighting here. Copying the .csproj files and running dotnet restore before copying the full source code is an intentional layer-caching optimization. Docker caches each layer. If your source code changes but your project file doesn't, Docker reuses the cached restore layer and skips re-downloading all your NuGet packages. This makes incremental builds significantly faster.

Running as a non-root user is a security best practice. The appuser created above has no elevated permissions. If an attacker exploits a vulnerability in your application, they land in a restricted context rather than as root inside the container.

Note that .NET 8 and later default to listening on port 8080 rather than 80. This eliminates the need for port 80 (which requires elevated privileges) and aligns with the non-root user pattern. Your EXPOSE 8080 and any load balancer configuration should reflect this.


Docker Compose for Local Development

Docker Compose is the right tool for running your API alongside its dependencies -- a database, a cache, a message broker -- during local development. It defines the full environment in a single YAML file and brings it up with one command.

version: '3.9'

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ConnectionStrings__DefaultConnection=Server=db;Database=MyApiDb;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;
    depends_on:
      db:
        condition: service_healthy
    networks:
      - api-network

  db:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      - SA_PASSWORD=YourStrong!Passw0rd
      - ACCEPT_EULA=Y
    ports:
      - "1433:1433"
    healthcheck:
      test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P YourStrong!Passw0rd -Q 'SELECT 1' -No || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 10
    networks:
      - api-network

networks:
  api-network:
    driver: bridge

The depends_on with condition: service_healthy is important. It prevents the API container from starting before SQL Server is ready to accept connections. Without it, your API container starts, tries to run migrations or establish a connection, finds SQL Server still initializing, and fails. The healthcheck polls SQL Server every 10 seconds and the API waits until it passes.

Connection strings in Docker Compose use double underscores (__) to represent the colon separator in ASP.NET Core's configuration hierarchy. ConnectionStrings__DefaultConnection maps to ConnectionStrings:DefaultConnection in IConfiguration. This is standard behavior across all ASP.NET Core environment variable configuration.


Health Checks

Health checks are not optional when you deploy ASP.NET Core to a container orchestrator. Kubernetes, Azure Container Apps, and Azure App Service all use health endpoints to determine whether your container is alive and ready to serve traffic. Without them, a crashed application may receive traffic indefinitely. Every deploy ASP.NET Core guide worth reading will tell you to add health checks before you add almost anything else.

ASP.NET Core has first-class health check support. Here is a complete setup including a custom health check:

using Microsoft.Extensions.Diagnostics.HealthChecks;

// In Program.cs
builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>("database")
    .AddCheck<ExternalApiHealthCheck>("external-api", tags: ["ready"]);

// Map health endpoints
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = _ => false  // Liveness: just check if app is running
});

app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready")
});

app.MapHealthChecks("/health", new HealthCheckOptions
{
    ResponseWriter = async (context, report) =>
    {
        context.Response.ContentType = "application/json";
        var result = System.Text.Json.JsonSerializer.Serialize(new
        {
            status = report.Status.ToString(),
            checks = report.Entries.Select(e => new
            {
                name = e.Key,
                status = e.Value.Status.ToString(),
                description = e.Value.Description
            })
        });
        await context.Response.WriteAsync(result);
    }
});

// Custom IHealthCheck implementation
public class ExternalApiHealthCheck : IHealthCheck
{
    private readonly HttpClient _httpClient;

    public ExternalApiHealthCheck(IHttpClientFactory httpClientFactory)
    {
        _httpClient = httpClientFactory.CreateClient("external");
    }

    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        try
        {
            var response = await _httpClient.GetAsync(
                "/health", cancellationToken);

            return response.IsSuccessStatusCode
                ? HealthCheckResult.Healthy("External API is reachable")
                : HealthCheckResult.Degraded("External API returned non-success status");
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy("External API is unreachable", ex);
        }
    }
}

There are two important distinctions here: liveness vs readiness. A liveness probe answers "is the application process running and not deadlocked?" The liveness endpoint in this example returns 200 as long as the application is running -- no health checks are evaluated. A readiness probe answers "is the application ready to handle traffic?" It checks actual dependencies like the database. A container orchestrator will restart a container that fails liveness, but will just stop routing traffic to one that fails readiness.


Environment-Based Configuration

ASP.NET Core's configuration system is layered. When you deploy ASP.NET Core Web APIs across multiple environments -- development, staging, production -- this layered approach keeps environment-specific values separate from shared defaults. appsettings.json provides the base configuration. appsettings.{Environment}.json overlays environment-specific values. Environment variables override everything else at runtime.

// appsettings.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "ApiSettings": {
    "MaxPageSize": 100,
    "DefaultPageSize": 20,
    "CacheExpirationMinutes": 5
  }
}
// IOptions<T> pattern in Program.cs
builder.Services.Configure<ApiSettings>(
    builder.Configuration.GetSection("ApiSettings"));

// ApiSettings.cs
public sealed record ApiSettings
{
    public int MaxPageSize { get; init; } = 100;
    public int DefaultPageSize { get; init; } = 20;
    public int CacheExpirationMinutes { get; init; } = 5;
}

// Injection in a controller or service
public class ProductsController : ControllerBase
{
    private readonly ApiSettings _settings;

    public ProductsController(IOptions<ApiSettings> options)
    {
        _settings = options.Value;
    }
}

The IOptions<T> pattern is the idiomatic way to consume configuration in deploying ASP.NET Core applications. It gives you strongly typed access to configuration values, eliminates magic strings, and integrates with DI naturally. For configuration that changes at runtime, IOptionsMonitor<T> reloads automatically when the underlying configuration changes.

Never put secrets -- connection strings, API keys, passwords -- in appsettings.json committed to source control. Use User Secrets for local development (dotnet user-secrets set) and Azure Key Vault for production. The Logging in .NET: The Complete Developer's Guide covers how to configure log levels per environment, which pairs closely with this pattern.


Publishing to Azure App Service

Azure App Service is a common choice for many teams deploying ASP.NET Core Web APIs without managing servers. It supports .NET 10 natively, handles TLS termination, provides auto-scaling, and integrates with GitHub Actions for CI/CD. GitHub Actions is a strong default for many teams -- it gives you build history, approval gates, and rollback capability.

# .github/workflows/deploy.yml
name: Deploy ASP.NET Core API to Azure App Service

on:
  push:
    branches: [ main ]

env:
  DOTNET_VERSION: '10.0.x'
  AZURE_WEBAPP_NAME: 'my-api-app'
  AZURE_WEBAPP_PACKAGE_PATH: './publish'

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v4

    - name: Set up .NET
      uses: actions/setup-dotnet@v4
      with:
        dotnet-version: ${{ env.DOTNET_VERSION }}

    - name: Restore dependencies
      run: dotnet restore

    - name: Build
      run: dotnet build --configuration Release --no-restore

    - name: Test
      run: dotnet test --no-build --configuration Release

    - name: Publish
      run: dotnet publish src/MyApi/MyApi.csproj 
        --configuration Release 
        --output ${{ env.AZURE_WEBAPP_PACKAGE_PATH }} 
        --no-build

    - name: Deploy to Azure App Service
      uses: azure/webapps-deploy@v3
      with:
        app-name: ${{ env.AZURE_WEBAPP_NAME }}
        publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
        package: ${{ env.AZURE_WEBAPP_PACKAGE_PATH }}

The publish profile is a secret stored in your GitHub repository settings. Download it from the Azure Portal (App Service -- Deployment Center -- Manage publish profile) and paste it into a repository secret named AZURE_WEBAPP_PUBLISH_PROFILE. The workflow uses it to authenticate the deployment without storing credentials in code.

Set ASPNETCORE_ENVIRONMENT to Production in the App Service Application Settings (not in code). This tells ASP.NET Core which configuration layer to use at runtime. Set your connection strings in Application Settings as well -- Azure encrypts these at rest and they override your appsettings.json values automatically.


Azure Container Apps

Azure Container Apps (ACA) is Microsoft's serverless container platform. It sits above raw Kubernetes and below full AKS in the complexity/control spectrum. You bring a container image, describe how it should scale, and Azure handles the orchestration.

ACA is a strong choice when you deploy ASP.NET Core applications as containers and want automatic scaling, including scale-to-zero for non-production environments. It supports DAPR for service-to-service communication, HTTP and queue-based scaling triggers, and managed ingress. The billing model is consumption-based -- you pay for what you use, not for idle instances.

The basic deployment command looks like this:

az containerapp create 
  --name my-api 
  --resource-group my-rg 
  --environment my-env 
  --image myregistry.azurecr.io/my-api:latest 
  --target-port 8080 
  --ingress external 
  --min-replicas 0 
  --max-replicas 10 
  --cpu 0.5 
  --memory 1Gi

Setting --min-replicas 0 enables scale-to-zero. The container stops running when there is no traffic and starts automatically when a request arrives. Cold starts introduce latency -- typically a few seconds for a .NET application. For dev/test environments, this is an excellent cost optimization. For production APIs with latency SLAs, keep --min-replicas 1 to eliminate cold starts.

For architectural context on when to reach for containers vs a simpler deployment model, the Monolith Architecture in C#: The Complete Guide covers deployment considerations as part of the architecture decision.


Secrets Management

Hard-coded secrets are a security incident waiting to happen. Even if you never push them to a public repository, they end up in build logs, crash reports, and developer machines. The right answer is a secrets management system.

For local development, .NET User Secrets stores secrets outside your project directory, never in the repository:

dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=localhost;..."
dotnet user-secrets set "ApiKeys:Stripe" "sk_test_..."

For production on Azure, Azure Key Vault is the standard. The DefaultAzureCredential from the Azure.Identity package handles the authentication chain -- it tries managed identity, environment variables, Visual Studio credentials, and Azure CLI credentials in order. In production, the App Service's system-assigned managed identity authenticates automatically with no credentials to manage.

// Program.cs
var keyVaultUrl = builder.Configuration["KeyVaultUrl"];
if (!string.IsNullOrEmpty(keyVaultUrl))
{
    builder.Configuration.AddAzureKeyVault(
        new Uri(keyVaultUrl),
        new DefaultAzureCredential());
}

This single block -- added to your Program.cs -- pulls all Key Vault secrets into the IConfiguration system. Your services and controllers consume them via the normal IConfiguration or IOptions<T> interfaces with no awareness of Key Vault. The structure of the Key Vault secret name uses double-dash (--) as the hierarchy separator: ConnectionStrings--DefaultConnection maps to ConnectionStrings:DefaultConnection in configuration.

For setting up structured logging in production -- including log shipping to a centralized sink -- see How to Set Up Serilog in ASP.NET Core. Structured logs sent to Application Insights or a centralized log aggregator are essential for diagnosing production issues.


Performance Considerations for Deployment

How you publish your application affects startup time, memory usage, and throughput. Understanding these options is essential before you deploy ASP.NET Core APIs to production. Three options matter most.

Framework-dependent deployment (FDD) is the default. Your published output contains your application's DLLs but relies on the .NET runtime being installed on the host. Small publish output, shared runtime. Ideal for cloud environments where you control the runtime version. Docker images using mcr.microsoft.com/dotnet/aspnet:10.0 use this model.

Self-contained deployment (SCD) bundles the runtime with your application. No runtime installation needed on the host. Larger output, but fully portable. Useful for environments where you can't guarantee a specific runtime version. Add --self-contained true -r linux-x64 to your dotnet publish command.

ReadyToRun (R2R) compilation pre-compiles your IL to native code at publish time. Startup time drops noticeably because the JIT has less work to do on first run. Add --self-contained true -p:PublishReadyToRun=true. This is a strong choice for containerized deployments where cold start latency matters.

Native AOT produces a fully native binary with no JIT overhead at all. Startup is near-instant and memory usage is minimal. .NET 10 has expanded AOT support for web APIs, but limitations remain -- reflection-heavy code, certain middleware, and dynamic assembly loading are not compatible. Review the compatibility matrix and verify compatibility for your specific dependencies and app shape before committing to a Native AOT deployment. The C# Reflection Complete .NET 10 Guide is relevant here -- reflection and AOT are fundamentally incompatible, and understanding where reflection appears in your codebase (including DI containers) is essential before enabling AOT.


Frequently Asked Questions

What is the difference between Azure App Service and Azure Container Apps for deploying asp.net core?

Azure App Service is a platform-as-a-service (PaaS) offering. You deploy a package -- either a published .NET output or a container image -- and Azure manages the underlying infrastructure. Configuration is done through the portal or CLI. It supports .NET natively, including direct deployment from a published folder without building a Docker image. It is often the simpler option for teams that want a managed platform without container orchestration complexity.

Azure Container Apps is a container-native platform built on Kubernetes under the hood, but abstracted away. You bring a container image and describe your scaling rules. ACA is the better fit when you are already containerizing your application, want scale-to-zero, or need Dapr integration for microservice communication. It is also the path of least resistance when moving toward Azure Kubernetes Service (AKS) later.

For most teams shipping a single API, App Service is the faster path to production. For teams building a system of services or needing advanced scaling behavior, Container Apps is worth the slightly higher initial setup cost.

How do I handle database migrations when deploying asp.net core to Azure?

Running dotnet ef database update at application startup is a common approach but comes with risks in multi-instance deployments. If two instances start simultaneously, both try to run migrations and you get conflicts or errors. The safer approach is to run migrations as a one-off pre-deploy step rather than during application startup.

In a GitHub Actions workflow, add a step before deployment that runs dotnet ef database update against the production connection string using a migration bundle or a direct command. Only one instance runs at a time, so there is no race condition. After migrations complete, deploy the new application version. This separates the migration concern from the startup concern.

If you prefer code-based migration at startup, use context.Database.MigrateAsync() wrapped in a startup service with a distributed lock. This is more complex but handles environments where you cannot run a pre-deploy step. The Entity Framework Core documentation covers the migration bundle approach for containerized deployments.

What should I set for ASPNETCORE_ENVIRONMENT in production?

Set ASPNETCORE_ENVIRONMENT to Production. This is the standard value that ASP.NET Core recognizes and it activates the appsettings.Production.json configuration layer. It also disables the developer exception page (which shows stack traces in the browser -- a security risk in production) and enables production-appropriate error handling middleware.

Do not set it to Development in production. The developer exception page exposes internal details. Detailed error messages from your exception handling middleware may leak implementation details. Production environments should return generic error responses to clients and log detailed information internally.

In Azure App Service, set this in Application Settings -- not in your code or Dockerfile. Keeping environment-specific configuration in the platform rather than in the container image means you can use the same image across environments and only change the configuration.

How do I keep secrets out of my Docker images and source control?

Never COPY a file containing secrets into a Docker image. Docker layers are readable by anyone who has access to the image. Build arguments (ARG) are slightly better but still end up in the image layer history. Secrets in Docker images are almost certainly going to leak.

The correct pattern is to pass secrets at runtime via environment variables. For Azure deployments, Azure Key Vault with managed identity is the gold standard -- your application authenticates with its identity, reads secrets from Key Vault, and no credentials are stored anywhere. For local Docker development, use Docker Compose's env_file option pointing to a .env file that is listed in .gitignore.

For local development outside Docker, dotnet user-secrets stores secrets in your user profile directory, completely outside the repository. Any developer on the team can have their own secrets without risk of committing them. This is the recommended local development pattern for deploying ASP.NET Core applications securely.

What are health checks and why are they required for container deployments?

Health checks are HTTP endpoints that tell your container orchestrator whether your application is functioning correctly. Without them, a container that has started but is stuck in an infinite loop, a deadlock, or a state where it cannot connect to its database will continue to receive traffic indefinitely. Health checks give the platform the information it needs to stop routing traffic to broken instances and restart them.

Liveness checks answer "is the process running?" They should always return 200 if the application is not completely dead. A liveness failure triggers a container restart. Readiness checks answer "is the application ready to serve traffic?" They evaluate actual dependencies -- database connectivity, external service availability. A readiness failure causes the orchestrator to stop routing traffic to that instance without restarting it.

ASP.NET Core's AddHealthChecks() makes this straightforward. Add it in Program.cs, map it to /health/live and /health/ready, and configure your orchestrator's probe settings to match. AddDbContextCheck<T> gives you database health out of the box.

How does ReadyToRun compilation help with container startup time?

ReadyToRun (R2R) compiles your .NET IL code to native code at publish time rather than waiting for the JIT compiler to do it when each method is first called. In a container that starts cold -- for example after scale-to-zero -- the JIT has to compile every method the first time it runs. This takes time and consumes CPU that could be serving requests. R2R pre-does that work so the methods are ready to execute immediately.

The tradeoff is publish time and image size. R2R images are larger because they contain both the original IL and the pre-compiled native code. The native code is platform-specific, so you need separate images for different architectures (linux/amd64 vs linux/arm64). For most Azure deployments on AMD64, this is straightforward.

Enable R2R in your dotnet publish command: dotnet publish -c Release -r linux-x64 --self-contained true -p:PublishReadyToRun=true. Measure the actual startup time difference before and after -- for small APIs the improvement may be modest, but for APIs with significant cold start latency on ACA with scale-to-zero, the difference can be a few seconds.

What is a pragmatic approach to configuring logging when deploying asp.net core to production?

Production logging has different requirements than development logging. Verbose console logging that is great for local debugging becomes noise and cost in production. A pragmatic approach is structured logging with a minimum log level appropriate to production -- typically Warning or Information at the application level.

For Azure, Application Insights integration is available out of the box. Add the Microsoft.ApplicationInsights.AspNetCore package, configure the instrumentation key or connection string, and you get request telemetry, dependency tracking, and exception logging automatically. For more control over log structure and sinks, Serilog is the standard choice -- see Serilog in .NET: Complete Guide to Structured Logging for the configuration patterns.

Never log sensitive information -- passwords, connection strings, tokens, PII -- in any environment. Structured logging makes it easy to accidentally include object properties that contain secrets. Use destructuring policies in Serilog or log-level guards in the logging configuration to prevent this. Review your log output in a staging environment before promoting to production.


Summary

Deploying ASP.NET Core to Azure or Docker is a multi-layer decision: pick your base image wisely, write a proper multi-stage Dockerfile, configure health checks for container orchestration, keep secrets out of code and images, and choose between App Service and Container Apps based on your operational model. The GitHub Actions workflow gives you reproducible deployments with a clear audit trail. ReadyToRun and proper configuration management ensure your API starts fast and runs reliably.

The architecture decisions you make before deployment -- monolith vs. modules, synchronous vs. event-driven -- shape what your deployment looks like. Start with the Monolith Architecture in C# guide if you are early in that decision, and revisit the Modular Monolith approach when your application outgrows a single-module structure. The deployment patterns in this guide apply cleanly to both.

How to Build An ASP.NET Core Web API: A Practical Beginner's Tutorial

Learn how to build an ASP.NET core web API! This tutorial for beginners will guide you through setting up the project to building the API endpoints.

ASP.NET Core Web API in .NET: The Complete Guide

Master ASP.NET Core Web API in .NET 10 -- learn request pipelines, routing, controllers, JWT authentication, error handling, and deployment strategies.

Azure AI Foundry Agents with Microsoft Agent Framework in C#

Connect azure ai foundry agents with microsoft agent framework in C# -- learn Azure backend setup, project connections, managed deployment, and cost management.

An error has occurred. This application may no longer respond until reloaded. Reload