5.1. ASP.NET Core App

Build a minimal ASP.NET Core application that serves a small HTML frontend and a two-endpoint JSON API, backed by SQLite locally and PostgreSQL in production — all in a single container.

Step 1 — Create the project

Create the application directory inside cloud-native-lab and add the two source files.

mkdir -p cloud-native-lab/csharp-app
cd cloud-native-lab/csharp-app

Create MyApp.csproj:

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Npgsql" Version="8.0.5" />
    <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" />
  </ItemGroup>
</Project>

Create Program.cs:

using System.Data.Common;
using Microsoft.Data.Sqlite;
using Npgsql;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var pg = builder.Configuration.GetConnectionString("Postgres")
         ?? Environment.GetEnvironmentVariable("POSTGRES_CONNECTION_STRING");

var usePostgres = !string.IsNullOrWhiteSpace(pg);

var sqlite = Environment.GetEnvironmentVariable("SQLITE_CONNECTION_STRING");
var useDefaultSqlite = string.IsNullOrWhiteSpace(sqlite);

if (useDefaultSqlite)
{
    sqlite = "Data Source=app.db";
}

DbConnection OpenDb()
{
    DbConnection conn = usePostgres
        ? new NpgsqlConnection(pg)
        : new SqliteConnection(sqlite);

    conn.Open();
    return conn;
}

await using (var conn = OpenDb())
{
    await using var cmd = conn.CreateCommand();

    cmd.CommandText = usePostgres
        ? """
          create table if not exists messages (
              id serial primary key,
              created_at timestamptz not null default now(),
              text text not null
          );
          """
        : """
          create table if not exists messages (
              id integer primary key autoincrement,
              created_at text not null default current_timestamp,
              text text not null
          );
          """;

    await cmd.ExecuteNonQueryAsync();
}

app.MapGet("/health", () => Results.Ok(new
{
    status = "ok",
    database = usePostgres ? "postgres" : "sqlite",
    sqliteMode = usePostgres ? null : useDefaultSqlite ? "default" : "configured"
}));

app.MapGet("/", () => Results.Content("""
<!doctype html>
<html>
<body>
  <input id="text" />
  <button onclick="submitText()">Submit</button>
  <ul id="items"></ul>

  <script>
    async function load() {
      const res = await fetch('/messages');
      const texts = await res.json();
      items.innerHTML = texts.map(x =>
        `<li>${new Date(x.createdAt).toLocaleString()}: ${x.text}</li>`
      ).join('');
    }

    async function submitText() {
      await fetch('/messages', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text: text.value })
      });
      text.value = '';
      load();
    }

    load();
  </script>
</body>
</html>
""", "text/html"));

app.MapGet("/messages", async () =>
{
    var rows = new List<object>();

    await using var conn = OpenDb();
    await using var cmd = conn.CreateCommand();

    cmd.CommandText = "select id, created_at, text from messages order by created_at desc";

    await using var reader = await cmd.ExecuteReaderAsync();

    while (await reader.ReadAsync())
    {
        rows.Add(new
        {
            Id = reader.GetInt32(0),
            CreatedAt = reader.GetValue(1).ToString(),
            Text = reader.GetString(2)
        });
    }

    return rows;
});

app.MapPost("/messages", async (MessageInput input) =>
{
    await using var conn = OpenDb();
    await using var cmd = conn.CreateCommand();

    cmd.CommandText = "insert into messages (text) values (@text)";

    var p = cmd.CreateParameter();
    p.ParameterName = "text";
    p.Value = input.Text;
    cmd.Parameters.Add(p);

    await cmd.ExecuteNonQueryAsync();

    return Results.Ok();
});

app.Run();

record MessageInput(string Text);

Explanation

MyApp.csproj uses the Microsoft.NET.Sdk.Web SDK — this pulls in ASP.NET Core, the Kestrel web server, and all framework defaults with a single line. Two NuGet packages are the only explicit dependencies:

PackagePurpose
NpgsqlPostgreSQL driver for .NET
Microsoft.Data.SqliteSQLite driver backed by the native library

Program.cs uses the ASP.NET Core minimal API style introduced in .NET 6 — no Startup class, no controllers. The entire app is a single top-level file. Database selection happens at startup:

Environment variableEffect
POSTGRES_CONNECTION_STRINGConnect to PostgreSQL; SQLite is not used
SQLITE_CONNECTION_STRINGUse the specified SQLite file path
(neither set)Fall back to Data Source=app.db in the CWD

OpenDb() is a factory function that opens a fresh connection each request — straightforward for a lab app with low concurrency. The create table if not exists block runs once at startup so the schema is always in place without a separate migration tool.


Step 2 — Write the Dockerfile

The Dockerfile uses a multi-stage build: the full .NET SDK image compiles and publishes the app, then a smaller ASP.NET runtime image runs it. The final image contains only the published output — no compiler, no source files.

Create Dockerfile:

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY MyApp.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /out

FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /out .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApp.dll"]

Explanation

StageBase imageWhat it does
buildmcr.microsoft.com/dotnet/sdk:8.0Restores packages, compiles, publishes to /out
(final)mcr.microsoft.com/dotnet/aspnet:8.0Copies the published output and runs it

Copying MyApp.csproj and running dotnet restore before copying source files is a deliberate layer ordering: as long as the project file does not change, Docker reuses the cached restore layer and skips the NuGet download on every subsequent build.

ASPNETCORE_URLS=http://+:8080 tells Kestrel to listen on port 8080 on all interfaces inside the container. http://+ is the ASP.NET Core wildcard — equivalent to 0.0.0.0:8080 but expressed in Kestrel’s URL format.


Step 3 — Build and run the container

Build the image and start a container, mapping port 8080 to your local machine.

docker build -t csharp-messages .
docker run -p 8080:8080 csharp-messages

Open http://localhost:8080 — the app behaves identically to dotnet run, now running inside the container. Submit a message and check the health endpoint:

curl http://localhost:8080/health

Stop the container with Ctrl-C. Note that submitted messages are gone — the SQLite database lives inside the container’s ephemeral filesystem.

To persist data between runs, mount a host directory and point the app at it:

mkdir -p data
docker run -p 8080:8080 \
  -v $(pwd)/data:/app/data \
  -e SQLITE_CONNECTION_STRING="Data Source=/app/data/app.db" \
  csharp-messages

Explanation

With no volume mount the SQLite file lives at /app/app.db inside the container. When the container stops that file is discarded — acceptable for a smoke test, but not for real use. Mounting ./data to /app/data and setting SQLITE_CONNECTION_STRING moves the database file onto the host filesystem so it survives container restarts.

This is the same pattern used in production: the app reads SQLITE_CONNECTION_STRING (or POSTGRES_CONNECTION_STRING) from the environment. The container image itself is database-agnostic — the same image will connect to PostgreSQL in the Kubernetes deployment lab simply by changing the environment variable.

SQLite in production

SQLite works fine here but is not suitable for the Kubernetes deployment: multiple pod replicas would each write to their own isolated file. In a later step you provision a managed PostgreSQL database and switch the app to it via a single environment variable.


Complete files

MyApp.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Npgsql" Version="8.0.5" />
    <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" />
  </ItemGroup>
</Project>

Program.cs

using System.Data.Common;
using Microsoft.Data.Sqlite;
using Npgsql;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var pg = builder.Configuration.GetConnectionString("Postgres")
         ?? Environment.GetEnvironmentVariable("POSTGRES_CONNECTION_STRING");

var usePostgres = !string.IsNullOrWhiteSpace(pg);

var sqlite = Environment.GetEnvironmentVariable("SQLITE_CONNECTION_STRING");
var useDefaultSqlite = string.IsNullOrWhiteSpace(sqlite);

if (useDefaultSqlite)
{
    sqlite = "Data Source=app.db";
}

DbConnection OpenDb()
{
    DbConnection conn = usePostgres
        ? new NpgsqlConnection(pg)
        : new SqliteConnection(sqlite);

    conn.Open();
    return conn;
}

await using (var conn = OpenDb())
{
    await using var cmd = conn.CreateCommand();

    cmd.CommandText = usePostgres
        ? """
          create table if not exists messages (
              id serial primary key,
              created_at timestamptz not null default now(),
              text text not null
          );
          """
        : """
          create table if not exists messages (
              id integer primary key autoincrement,
              created_at text not null default current_timestamp,
              text text not null
          );
          """;

    await cmd.ExecuteNonQueryAsync();
}

app.MapGet("/health", () => Results.Ok(new
{
    status = "ok",
    database = usePostgres ? "postgres" : "sqlite",
    sqliteMode = usePostgres ? null : useDefaultSqlite ? "default" : "configured"
}));

app.MapGet("/", () => Results.Content("""
<!doctype html>
<html>
<body>
  <input id="text" />
  <button onclick="submitText()">Submit</button>
  <ul id="items"></ul>

  <script>
    async function load() {
      const res = await fetch('/messages');
      const texts = await res.json();
      items.innerHTML = texts.map(x =>
        `<li>${new Date(x.createdAt).toLocaleString()}: ${x.text}</li>`
      ).join('');
    }

    async function submitText() {
      await fetch('/messages', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text: text.value })
      });
      text.value = '';
      load();
    }

    load();
  </script>
</body>
</html>
""", "text/html"));

app.MapGet("/messages", async () =>
{
    var rows = new List<object>();

    await using var conn = OpenDb();
    await using var cmd = conn.CreateCommand();

    cmd.CommandText = "select id, created_at, text from messages order by created_at desc";

    await using var reader = await cmd.ExecuteReaderAsync();

    while (await reader.ReadAsync())
    {
        rows.Add(new
        {
            Id = reader.GetInt32(0),
            CreatedAt = reader.GetValue(1).ToString(),
            Text = reader.GetString(2)
        });
    }

    return rows;
});

app.MapPost("/messages", async (MessageInput input) =>
{
    await using var conn = OpenDb();
    await using var cmd = conn.CreateCommand();

    cmd.CommandText = "insert into messages (text) values (@text)";

    var p = cmd.CreateParameter();
    p.ParameterName = "text";
    p.Value = input.Text;
    cmd.Parameters.Add(p);

    await cmd.ExecuteNonQueryAsync();

    return Results.Ok();
});

app.Run();

record MessageInput(string Text);

Dockerfile

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY MyApp.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /out

FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /out .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApp.dll"]