Containerising It
A multi-stage Dockerfile, a non-root image, configuration taken from the environment, and the whole stack running under Compose.
docker run -p 8080:8080 -e ConnectionStrings__Shortly="Host=db;Database=shortly" shortly:1.4.0A container image is a filesystem plus a command to run in it. Building one for the service means deciding exactly what ships: the compiled application and the .NET runtime, and nothing else. No SDK, no source, no build tools, no shell if you can avoid it.
Two stages
A multi-stage build is one Dockerfile with more than one FROM. The first stage starts from the SDK image, restores packages and publishes. The second starts from the much smaller aspnet runtime image and copies only the published output across. The SDK stage is thrown away, so the compiler never reaches production.
Copy the .csproj on its own and run dotnet restore before copying the source. Docker caches each instruction, so restore then re-runs only when a dependency changes, not on every edit.
Non-root, and the ports
Since .NET 8 the base images define APP_UID as 1654 and listen on port 8080, not 80, precisely so a container can run as an ordinary user. End the Dockerfile with USER $APP_UID. Kubernetes checks the numeric id, which is why the variable exists. The -noble-chiseled variants go further: no shell and no package manager, so there is nothing for an attacker to run.
Configuration from outside
The image must contain no connection string and no key. Configuration arrives as environment variables, which the configuration system merges over appsettings.json. Nothing in the image changes between staging and production; only the variables do.
Example
// Needs the Microsoft.Extensions.Configuration and
// Microsoft.Extensions.Configuration.EnvironmentVariables packages.
using Microsoft.Extensions.Configuration;
// This is what Compose or Kubernetes passes in. Two underscores stand in for
// the colon that separates a section from a key.
Environment.SetEnvironmentVariable(
"ConnectionStrings__Shortly", "Host=db;Database=shortly");
Environment.SetEnvironmentVariable(
"Shortly__BaseUrl", "https://s.example.com");
// One underscore is not a separator. This becomes the flat key
// "Shortly_MaxLinks", which nothing in the app is looking for.
Environment.SetEnvironmentVariable("Shortly_MaxLinks", "500");
IConfiguration config = new ConfigurationBuilder()
.AddEnvironmentVariables()
.Build();
Console.WriteLine(config.GetConnectionString("Shortly"));
Console.WriteLine(config["Shortly:BaseUrl"]);
Console.WriteLine(config["Shortly:MaxLinks"] ?? "(not set)");
// Output:
// Host=db;Database=shortly
// https://s.example.com
// (not set)When to use it
- Shipping one artefact that behaves the same on a laptop and in a cluster.
- Keeping the SDK, the source and the test projects out of the published image.
- Bringing the API and Postgres up together with a single command.
More examples
The Dockerfile
This is the file to copy for any ASP.NET Core service. Swap the final FROM for mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled once the image is working: it is a few tens of megabytes smaller, already runs as a non-root user and contains no shell, at the cost of not being able to docker exec into it to look around.
# Stage one: build. This image has the compiler. The final image will not.
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# The project file alone, so the restore layer is cached until a package changes.
COPY Shortly.Api/Shortly.Api.csproj Shortly.Api/
RUN dotnet restore Shortly.Api/Shortly.Api.csproj
COPY . .
RUN dotnet publish Shortly.Api/Shortly.Api.csproj -c Release -o /app --no-restore
# Stage two: run. Only the published output crosses over.
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
# ASP.NET Core images have listened on 8080 since .NET 8. Before that it was 80,
# which an unprivileged user cannot bind.
EXPOSE 8080
ENV ASPNETCORE_HTTP_PORTS=8080
# APP_UID is 1654, set by the base image. Kubernetes runAsNonRoot wants the
# number, not the name, which is why the variable is used rather than "app".
USER $APP_UID
ENTRYPOINT ["dotnet", "Shortly.Api.dll"]The whole stack with Compose
Keep this in the repository root so a new team member can clone and run one command. It is also the fastest way to reproduce a bug that only appears when the database is a real Postgres. The named volume means data survives a restart; delete it with docker compose down -v when you want a clean database.
# compose.yaml - docker compose up brings the API and Postgres up together.
services:
api:
build: .
ports:
- "8080:8080"
environment:
ASPNETCORE_ENVIRONMENT: Development
ConnectionStrings__Shortly: "Host=db;Port=5432;Database=shortly;Username=shortly;Password=devonly"
depends_on:
db:
# Without condition: service_healthy this waits only for the container
# to exist, and the API starts before Postgres can accept connections.
condition: service_healthy
db:
image: postgres:17
environment:
POSTGRES_DB: shortly
POSTGRES_USER: shortly
POSTGRES_PASSWORD: devonly # a local throwaway, never a real secret
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shortly -d shortly"]
interval: 2s
timeout: 3s
retries: 20
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:Checking the image before it ships
Run these four checks before pushing any image. Starting the container with a deliberately wrong connection string is the useful one: a service that exits immediately is wrong, because Kubernetes will restart it for ever, and a service that starts and reports itself not ready is right.
# Tag with the version, never only latest: a rollback needs a name to go back to.
docker build -t shortly:1.4.0 .
# Who does the process run as? 1654 means the USER line worked.
docker run --rm --entrypoint id shortly:1.4.0 -u
# How large is it, and does it start with no database to talk to?
docker image inspect shortly:1.4.0 --format '{{.Size}}'
docker run --rm -p 8080:8080 -e ConnectionStrings__Shortly="Host=nowhere" shortly:1.4.0
# With the stack up, the readiness endpoint is the one worth trusting.
docker compose up -d
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/healthz/ready
Discussion