Publishing and deployment
This chapter turns the Todo API from Chapter 22 into a program that can run on another machine. The central question is: after publishing the code, where do configuration and data live? The image carries the program, environment variables provide configuration, and a data volume stores the database.
Start with the complete entry point. The endpoints and feature-based directories carry over from the previous chapter; the additions are a separate migration command and a liveness check:
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;
using TodoApi.Data;
using TodoApi.Features.Auth;
using TodoApi.Features.Todos;
var migrateOnly = args.Contains("--migrate");
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
var connectionString = builder.Configuration.GetConnectionString("Todos")
?? throw new InvalidOperationException("Missing ConnectionStrings:Todos configuration.");
builder.Services.AddDbContext<TodoDbContext>(options => options.UseSqlite(connectionString));
builder.Services.AddScoped<TodoService>();
builder.Services.AddTodoAuthentication(builder.Configuration, builder.Environment, migrateOnly);
builder.Services.AddCors(options => options.AddPolicy("LocalFrontend", policy =>
policy.WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? [])
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Authorization", "Content-Type")
.WithExposedHeaders("Location")));
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseRouting();
app.UseCors("LocalFrontend");
app.UseAuthentication();
app.UseAuthorization();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
if (migrateOnly)
{
await TodoDatabase.MigrateAsync(app.Services);
Console.WriteLine("Database migration complete.");
return;
}
app.MapGet("/health", () => TypedResults.Ok(new { Status = "ok" }));
app.MapAuthEndpoints();
app.MapTodoEndpoints();
app.Run();Publishing configuration and database initialization
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ContainerRepository>todo-api</ContainerRepository>
<ContainerImageTag>chapter23</ContainerImageTag>
<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:10.0</ContainerBaseImage>
<UserSecretsId>aspnetcore-first-steps-23-deployment</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Tests/**/*.cs" />
<Content Remove="Tests/**;.env;compose.yaml" />
<None Remove="Tests/**;.env" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.10" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
</ItemGroup>
</Project>namespace TodoApi.Features.Auth;
public static class AuthConfiguration
{
public static IServiceCollection AddTodoAuthentication(this IServiceCollection services,
IConfiguration configuration, IHostEnvironment environment, bool migrateOnly)
{
if (!environment.IsDevelopment() && !migrateOnly)
{
var authority = configuration["Authentication:Schemes:Bearer:Authority"];
var audience = configuration["Authentication:Schemes:Bearer:Audience"];
if (!Uri.TryCreate(authority, UriKind.Absolute, out var uri) || uri.Scheme != "https"
|| string.IsNullOrWhiteSpace(audience))
throw new InvalidOperationException("Production requires an HTTPS Authority and an Audience. Configure a trusted identity service.");
}
services.AddAuthentication("Bearer").AddJwtBearer();
services.AddAuthorization(options => options.AddPolicy("CanWriteTodos",
policy => policy.RequireAuthenticatedUser().RequireRole("editor")));
return services;
}
}using Microsoft.EntityFrameworkCore;
namespace TodoApi.Data;
public static class TodoDatabase
{
// Run once during deployment; normal API startup does not change the database schema.
public static async Task MigrateAsync(IServiceProvider services)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
await db.Database.MigrateAsync();
}
}using Microsoft.EntityFrameworkCore;
using TodoApi.Features.Todos;
namespace TodoApi.Data;
public class TodoDbContext(DbContextOptions<TodoDbContext> options) : DbContext(options)
{
public DbSet<Todo> Todos => Set<Todo>();
public DbSet<Category> Categories => Set<Category>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Category>().HasData(
new Category { Id = 1, Name = "Work" },
new Category { Id = 2, Name = "Life" });
}
}Run it locally first
Run from the repository root:
cd samples/23-deployment
dotnet run -- --migrate
dotnet user-jwts create --name alice --role editor
dotnet runThe first run command applies migrations and exits. Its final line is Database migration complete.. The development token created next is for local debugging, and the service is still at http://localhost:5080.
Open another terminal:
curl http://localhost:5080/health{"status":"ok"}/health only confirms that the process can respond to HTTP; it does not check the database or identity service. To check those dependencies, you can also use ASP.NET Core health checks.
Use this chapter's own database
This chapter starts with a new todos-23.db and uses migrations to create its tables. Do not run migrations directly against databases created with EnsureCreated in Chapters 15–22; these two table-creation approaches cannot be mixed directly. If you need to upgrade existing data, first read Database migrations.
Publish the app and create an image
Publishing gathers the assemblies and configuration needed to run the application. A container image also includes the runtime and operating system base files. Containers start from images; data is stored separately.
For a regular publish, run:
dotnet publish -c Release -o ./publishOn a machine with the appropriate ASP.NET Core runtime installed, you can start Deployment.dll in the output directory with dotnet Deployment.dll. The published output does not automatically use the development settings in launchSettings.json; provide the authentication configuration below before running it in production.
This chapter uses the .NET SDK to create a Linux x64 image directly, without maintaining a separate Dockerfile:
dotnet publish -c Release --os linux --arch x64 /t:PublishContainerBy default, this command writes todo-api:chapter23 to the local container runtime. The next steps require Docker to be running and use Linux containers. For an ARM64 target machine, change x64 to arm64 and run the image on a matching machine.
The image is based on mcr.microsoft.com/dotnet/aspnet:10.0, and the app runs as a non-root user. The default user ID in this example is 1654. The SDK can also push directly to a container registry or create an archive; see the official container publishing documentation.
Configure production
Here is the complete Compose configuration. Docker Compose uses one file to describe the containers, environment variables, and data volumes to start:
name: todo-chapter23
services:
init-data:
image: todo-api:chapter23
user: "0:0"
entrypoint: ["sh", "-c"]
command: ["mkdir -p /data && chown 1654:1654 /data"]
volumes:
- todo-data:/data
migrate:
image: todo-api:chapter23
command: ["--migrate"]
environment:
ASPNETCORE_ENVIRONMENT: Production
ConnectionStrings__Todos: Data Source=/data/todos.db
volumes:
- todo-data:/data
api:
image: todo-api:chapter23
restart: unless-stopped
env_file: .env
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_HTTP_PORTS: "8080"
ConnectionStrings__Todos: Data Source=/data/todos.db
ports:
- "127.0.0.1:5080:8080"
volumes:
- todo-data:/data
volumes:
todo-data:The three services each have a task: init-data prepares permissions on the data directory, migrate upgrades the database and exits, and api continues serving HTTP requests.
Copy the environment-variable template:
Copy-Item .env.example .envcp .env.example .env# Replace these with real identity-service settings; this placeholder domain cannot issue or validate tokens.
Authentication__Schemes__Bearer__Authority=https://identity.example.com
Authentication__Schemes__Bearer__Audience=todo-api
Cors__Origins__0=https://frontend.example.comReplace the placeholder values in .env with your configuration:
| Setting | Value |
|---|---|
Authority | HTTPS address of the trusted identity service. It must provide the metadata and public keys needed to validate JWTs. |
Audience | The audience identifier configured for this API in the identity service. |
Cors__Origins__0 | The frontend origin allowed to read the API, such as https://todo.example.com, without a trailing /. |
Double underscores in environment variable names correspond to colons in configuration paths. Compose sets the database connection string separately to /data/todos.db, so it does not depend on the container's working directory.
Do not take development tokens to production
dotnet user-jwts is for local development. This chapter's production configuration trusts access tokens issued by an identity service. Obtain a token for this API from that service; write operations also require the service to identify the editor role. Role claims differ between services, so configure role mapping for the service's format.
Keeping the placeholder addresses in the template lets you check /health and confirm that anonymous requests get a 401, but it cannot authenticate a valid token. That does not mean the identity service is connected.
If the production environment has no HTTPS Authority or no Audience, the example refuses to start and reports the missing configuration. This check only confirms that the settings are present; it does not prove the remote identity service is available.
Create the database, then start the API
Run these commands from the samples/23-deployment directory:
docker compose run --rm init-data
docker compose run --rm migrate
docker compose up -d apiinit-data runs as root only to change ownership of the data directory; both the migration and API run as a regular user. Start the API after the migration succeeds so requests do not reach tables that are still being upgraded.
The migration uses MigrateAsync() to apply pending changes; migrations already applied are not run again. The example in this repository includes two migrations: create the initial tables, then add a nullable Note column to Todo. The column demonstrates an upgrade and is not yet part of HTTP requests or responses. See Database migrations for the migration files and upgrade verification.
Check the results:
curl http://localhost:5080/health
curl -i http://localhost:5080/todos
curl -i http://localhost:5080/openapi/v1.json
curl -i http://localhost:5080/scalar| Request | Expected |
|---|---|
/health | 200, with response body {"status":"ok"} |
/todos, without a token | 401 |
/openapi/v1.json | 404 |
/scalar | 404 |
OpenAPI and Scalar are registered only in the Development environment. Production still uses the error-handling middleware and does not expose the developer exception page to callers.
Keep data when the app is upgraded
Compose mounts the named todo-data volume at /data. When a container is replaced, the database stays in the volume and the new container continues using it.
Use an editor token issued by the identity service to call the create endpoint from the previous chapter, and note the returned task ID. Then recreate the container:
docker compose up -d --force-recreate apiSend another request to /todos/{id} with the token; the task should still be there. docker compose down stops and removes the containers but keeps named volumes by default. Do not add --volumes or -v unless you intend to delete the data.
Before upgrading the app, back up the database and publish the new image. Stop the API, run the migration, and finally recreate the API container. This example has a single API instance, so it will be briefly unavailable. An SQLite backup must cover a consistent data state: stop writes before making a copy or use SQLite's backup mechanism. Do not assume copying a single .db file while writes are in progress is a complete backup.
From local verification to a public deployment
The port mapping is 127.0.0.1:5080:8080, which only allows access from the host machine. Put the image and Compose configuration on a server with Docker, configure a domain and HTTPS in a reverse proxy on that server, and forward requests to 127.0.0.1:5080. Add the frontend's actual origin to the CORS configuration too.
If the proxy changes the request scheme, host name, or client address, configure trusted proxies in the app as described in the proxy and forwarded headers documentation. Do not unconditionally trust forwarded headers from the public internet.
Cloudflare Pages hosts the static website generated for this tutorial; it cannot directly run the ASP.NET Core API. The API needs a host that can run .NET or containers.
To investigate startup problems, use:
docker compose ps
docker compose logs --tail 100 api
docker compose logs --tail 100 migrate| Symptom | Check first |
|---|---|
| API reports missing authentication configuration on startup | Does .env exist, and are Authority and Audience set? |
| SQLite cannot open the file or reports read-only access | Check the volume mount path and whether init-data succeeded. |
| Still getting 401 with a token | Check the issuer, audience, signature, expiry, and identity-service metadata. |
| Reads work, but writes return 403 | Can the token's role be mapped to editor? |
Automatically verify behavior before publishing
dotnet test --project Tests/TodoApi.Tests.csproj -c Release -p:TreatWarningsAsErrors=trueThis chapter has 13 test cases: the previous chapter's 11 plus production behavior and a migration test that preserves existing data. CI also creates the image, starts Compose, recreates the container, and confirms that database records have not been lost.
FastAPI comparison
This is similar to putting a FastAPI app in a container: the program is in the image, environment configuration is outside it, and persistent data lives in a volume. The container does not configure authentication or apply database migrations for you.
Summary
- Published output carries the program, and environment variables provide production settings; development launch settings do not automatically apply in production.
- The .NET SDK can create a container image directly, and the app runs as a regular user.
- Apply database migrations before starting the API, and back up real data before an upgrade.
- The SQLite file lives in a data volume and remains after the container is recreated.
- A successful liveness check is only the first step; also verify authentication, authorization, data persistence, and public HTTPS.
Previous: Organizing a project by feature. The main path ends here; next, read Advanced topics as needed.
