.NET 10 Core API with PostgreSQL Template
Overview
This is a production-ready .NET 10 Web API template pre-configured with PostgreSQL via Entity Framework Core. It is designed to serve as a clean, opinionated starting point for new API projects, eliminating boilerplate setup so you can focus on building features immediately.
Target Framework: .NET 10
Database: PostgreSQL (via Npgsql EF Core provider)
Repository: dotnet-core-api-w-postgres
The template bundles the following capabilities out of the box:
- Structured logging with Serilog
- PostgreSQL database access via Entity Framework Core 10
- API documentation via Swagger (Swashbuckle) and the native OpenAPI endpoint
- Global exception handling following RFC 9457 Problem Details
- A
BaseEntitymodel with automaticCreatedAt/UpdatedAttimestamp management - Docker and Docker Compose support
- A GitHub Actions CI pipeline
Project Structure
dotnet-core-api-w-postgres/
├── .github/
│ └── workflows/
│ └── build.yml # CI pipeline
├── dotnet-core-api-w-postgres/
│ ├── Controllers/
│ │ └── PingController.cs # Health check endpoint
│ ├── Data/
│ │ └── AppDbContext.cs # EF Core DbContext
│ ├── Middleware/
│ │ └── GlobalExceptionHandler.cs
│ ├── Models/
│ │ └── BaseEntity.cs # Base model with audit fields
│ ├── Properties/
│ │ └── launchSettings.json
│ ├── appsettings.json
│ ├── appsettings.Development.json
│ ├── Dockerfile
│ └── Program.cs # Application entry point & service registration
├── compose.yaml # Docker Compose for local development
└── dotnet-core-api-w-postgres.sln
NuGet Dependencies
| Package | Version | Purpose |
|---|---|---|
Microsoft.AspNetCore.OpenApi |
10.0.2 | Native OpenAPI endpoint (/openapi/v1.json) |
Microsoft.EntityFrameworkCore.Design |
10.0.5 | EF Core tooling (migrations, scaffolding) |
Npgsql.EntityFrameworkCore.PostgreSQL |
10.0.1 | PostgreSQL EF Core provider |
Serilog.AspNetCore |
10.0.0 | Structured logging + HTTP request logging |
Swashbuckle.AspNetCore |
10.1.5 | Swagger UI and Swagger JSON generation |
Logging
Logging is handled by Serilog, configured directly in Program.cs before the application builder is built. This ensures that any startup errors are captured by the logger.
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.Console()
.Enrich.FromLogContext()
.CreateLogger();
builder.Host.UseSerilog();
The logger is configured with:
- Minimum level:
Information— debug-level noise is suppressed in all environments by default. - Console sink: All log output goes to stdout, making it compatible with Docker and cloud log aggregators.
- Log context enrichment: Properties pushed onto the
LogContext(e.g., correlation IDs, user identifiers) are automatically included in every log event within that context scope.
HTTP Request Logging
After the app is built, Serilog's request logging middleware is registered:
app.UseSerilogRequestLogging();
This replaces ASP.NET Core's default per-request log output with a single, structured Serilog log event per request, including method, path, status code, and elapsed time. This middleware is optional and can be removed if the default ASP.NET Core request logging is preferred.
Extending Logging
To add additional sinks (e.g., file, Seq, Application Insights), extend the LoggerConfiguration chain in Program.cs:
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.Console()
.WriteTo.File("logs/log-.txt", rollingInterval: RollingInterval.Day) // example
.Enrich.FromLogContext()
.CreateLogger();
DbContext Configuration
Registration
The AppDbContext is registered in Program.cs using the Default connection string from configuration:
builder.Services.AddDbContext<AppDbContext>(opts =>
opts.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
The connection string should be added to appsettings.json (or via an environment variable / secrets manager in production):
{
"ConnectionStrings": {
"Default": "Host=localhost;Database=dotnet_api_db;Username=postgres;Password=postgres"
}
}
When running via Docker Compose the connection string is injected through the environment variable ConnectionStrings__Default (note the double underscore — ASP.NET Core's configuration system maps this to the ConnectionStrings:Default key, matching the "Default" key used in GetConnectionString("Default")).
Note: The
compose.yamlin this template usesConnectionStrings__DefaultConnection, which would map to the key"DefaultConnection"— a mismatch with the"Default"key expected byProgram.cs. If you use Docker Compose and find the API cannot connect to the database, update the environment variable incompose.yamlfromConnectionStrings__DefaultConnectiontoConnectionStrings__Default.
AppDbContext
AppDbContext extends DbContext and uses the primary constructor pattern introduced in C# 12:
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
Entity configurations are loaded automatically via OnModelCreating:
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
Any class implementing IEntityTypeConfiguration<T> in the assembly will be discovered and applied automatically. This keeps entity configuration out of AppDbContext and in dedicated, per-entity configuration classes.
Adding new entities — uncomment or add a DbSet property following the pattern shown in the placeholder comment:
public DbSet<MyEntity> MyEntities => Set<MyEntity>();
Automatic Timestamp Management
SaveChangesAsync is overridden to automatically manage CreatedAt and UpdatedAt on any entity that inherits from BaseEntity:
public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
var now = DateTime.UtcNow;
foreach (var entry in ChangeTracker.Entries<BaseEntity>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
break;
case EntityState.Modified:
entry.Entity.UpdatedAt = now;
entry.Property(e => e.CreatedAt).IsModified = false; // prevents overwrite
break;
}
}
return base.SaveChangesAsync(ct);
}
All timestamps are stored in UTC. On Added, both fields are set. On Modified, only UpdatedAt is set and CreatedAt is explicitly marked as unmodified to prevent accidental overwrites.
BaseEntity
All domain entities should inherit from BaseEntity to get automatic audit field management:
public abstract class BaseEntity
{
[Key]
public int Id { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
Swagger / OpenAPI
The template registers both Swashbuckle (for the Swagger UI) and the native ASP.NET Core OpenAPI endpoint.
Registration
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "API Template", Version = "v1" });
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.ApiKey,
In = ParameterLocation.Header,
Description = "JWT Authorization header using the Bearer scheme. Example: \"Bearer {token}\""
});
});
builder.Services.AddOpenApi();
A Bearer token security definition is pre-registered in the Swagger UI, making it straightforward to test authenticated endpoints once authentication middleware is wired up.
Middleware
app.MapOpenApi(); // Native OpenAPI JSON: /openapi/v1.json
app.UseSwagger(); // Swashbuckle JSON: /swagger/v1/swagger.json
app.UseSwaggerUI(); // Swagger UI: /swagger/index.html
By default, the Swagger UI is available in all environments. If you want to restrict it to the development environment only, replace the above block with the commented-out version already present in Program.cs:
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseSwagger();
app.UseSwaggerUI();
}
Accessing the Docs
| URL | Description |
|---|---|
/swagger/index.html |
Swagger UI |
/swagger/v1/swagger.json |
Swashbuckle-generated OpenAPI JSON |
/openapi/v1.json |
Native ASP.NET Core OpenAPI JSON |
Global Exception Handling
Unhandled exceptions are caught by GlobalExceptionHandler, which implements IExceptionHandler — the standard ASP.NET Core exception handling interface introduced in .NET 8.
Registration
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
Behavior
The handler logs every unhandled exception at the Error level and returns a structured RFC 9457 Problem Details JSON response. Specific exception types are mapped to appropriate HTTP status codes:
| Exception Type | HTTP Status |
|---|---|
ArgumentException |
400 Bad Request |
KeyNotFoundException |
404 Not Found |
UnauthorizedAccessException |
401 Unauthorized |
| All other exceptions | 500 Internal Server Error |
Every response includes the ASP.NET Core TraceIdentifier as an extension field (traceId), enabling correlation between client-facing error responses and server-side log entries.
Example response:
{
"status": 404,
"title": "Not Found",
"detail": "The requested resource could not be found.",
"traceId": "00-abc123-def456-00"
}
To add new exception mappings, extend the switch expression in GlobalExceptionHandler.TryHandleAsync.
Ping / Health Check Endpoint
A PingController is included as a minimal health check:
GET /ping → 200 OK "Pong"
This is also the endpoint used by the Docker Compose health check for the API container.
Docker
Dockerfile
The Dockerfile uses a multi-stage build:
- base — runtime image (
mcr.microsoft.com/dotnet/aspnet:10.0), exposes ports8080and8081. - build — SDK image (
mcr.microsoft.com/dotnet/sdk:10.0), restores and builds the project in Release configuration. - publish — publishes the build output.
- final — copies the published output into the runtime image.
The default target OS for Docker is Linux (set via <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> in the .csproj).
Docker Compose
compose.yaml defines a local development stack with two services:
postgres — PostgreSQL 16 with a persistent named volume (postgres_data) and a health check that polls pg_isready.
dotnet-core-api-w-postgres — The API container, which:
- Builds from the local Dockerfile
- Exposes ports 8080 (HTTP) and 8081 (HTTPS)
- Injects the database connection string via environment variable
- Depends on the postgres service being healthy before starting
- Has its own health check that polls GET /ping
Both services run on a shared bridge network (dotnet_network).
To start the full stack locally:
docker compose up --build
GitHub Actions CI Pipeline
The workflow defined in .github/workflows/build.yml runs on every push and pull request to main.
Steps:
- Checkout the repository
- Set up .NET 10
- Restore NuGet dependencies
- Build the solution in Release configuration
- Run all tests and output results in TRX format
- Upload test results as a build artifact (always, even on failure)
Getting Started
Local Development (without Docker)
- Ensure you have the .NET 10 SDK installed.
- Start a local PostgreSQL instance (or use the Docker Compose
postgresservice:docker compose up postgres -d). - Add your connection string to
appsettings.Development.json:
{
"ConnectionStrings": {
"Default": "Host=localhost;Database=dotnet_api_db;Username=postgres;Password=postgres"
}
}
- Run the application:
dotnet run --project dotnet-core-api-w-postgres
- Open the Swagger UI at
http://localhost:5109/swagger/index.html.
Running Migrations
# Add a new migration
dotnet ef migrations add <MigrationName> --project dotnet-core-api-w-postgres
# Apply migrations to the database
dotnet ef database update --project dotnet-core-api-w-postgres
Using as a Template
To use this as the base for a new project, clone the repository and do a global find-and-replace of dotnet-core-api-w-postgres and dotnet_core_api_w_postgres with your new project name. Update the SwaggerDoc title in Program.cs to reflect the new API name.