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
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;
}
awaitusing (var conn = OpenDb())
{
awaitusing var cmd = conn.CreateCommand();
cmd.CommandText = usePostgres
? """
create table if not exists messages (
id serial primary key,
created_at timestamptz not nulldefault now(),
text text not null );
"""
: """
create table if not exists messages (
id integer primary key autoincrement,
created_at text not nulldefault 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>();
awaitusing var conn = OpenDb();
awaitusing var cmd = conn.CreateCommand();
cmd.CommandText = "select id, created_at, text from messages order by created_at desc";
awaitusing 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) =>
{
awaitusing var conn = OpenDb();
awaitusing 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();
recordMessageInput(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:
Package
Purpose
Npgsql
PostgreSQL driver for .NET
Microsoft.Data.Sqlite
SQLite 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 variable
Effect
POSTGRES_CONNECTION_STRING
Connect to PostgreSQL; SQLite is not used
SQLITE_CONNECTION_STRING
Use 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.
Copying MyApp.csproj and running dotnet restorebefore 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.