Containerizing a configuration
A configuration scaffolded with kandra-new-config already contains everything needed to build and run its own
container image: a Dockerfile, a .dockerignore, an .env.example and one compose file per database provider
you scaffolded. This page walks through them, so you know what to change and what to leave alone.
Starting & deploying a configuration covers what the application does once it runs; this page is
only about putting it in a container.
The files are the same ones the reference configuration, Kandra WMS, ships; the template parameterizes them by your configuration name. They were run end to end on all three providers (build from the public feed, apply migrations, serve HTTP 200 on port 8080). Nothing here has been run on a particular cloud or orchestrator.
The wizard asks "Include Docker files?". With dotnet new kandra-config --IncludeDocker false none of the files
below are generated, and each compose.<provider>.yml also follows its Include<Provider> flag: a configuration
scaffolded without PostgreSQL has no compose.postgres.yml.
What you get
| File | Purpose |
|---|---|
Dockerfile | Two-stage build: SDK image publishes the web host, ASP.NET runtime image runs it |
.dockerignore | Keeps bin/, obj/, .git, local databases and secrets out of the build context |
.env.example | The variables the compose files read; copy to .env (never commit .env) |
compose.sqlite.yml | One container and one volume |
compose.sqlserver.yml | App plus SQL Server Express |
compose.postgres.yml | App plus PostgreSQL 18 |
compose.demo.yml | Optional overlay for a populated demo system (see below) |
.gitattributes | Pins these files to LF line endings, so a Windows checkout does not break shell scripts in the image |
For a configuration called Acme, the image is named kandra-acme, and so are the SQLite file and the database
on SQL Server and PostgreSQL.
Build and run
docker compose -f compose.sqlite.yml up -d --build
Open http://localhost:8080. The first start applies migrations and seeds, exactly as
dotnet run does, and the first-run wizard asks for the administrator password.
docker compose -f compose.sqlite.yml down # stop, data stays in the volume
docker compose -f compose.sqlite.yml down -v # stop AND delete all data
down -v deletes the named volumes, including the database and the signing key. That is what you want for a
reset and never what you want on a server you care about.
To build the image without compose:
docker build -t acme .
docker buildx build --platform linux/amd64,linux/arm64 --build-arg VERSION=1.0.0 -t acme:1.0.0 .
The Dockerfile, step by step
Restore comes from the public feed. The Docker build copies the whole folder (minus .dockerignore) and runs
dotnet publish on src/Acme.WebApi. The packages restore anonymously from the feed in your nuget.config
(https://nugets.kandra.tech/index.json, with packageSourceMapping), so the build needs no token and no
secret. If you replaced that feed with a private mirror, give the build access to it the usual Docker way
(a BuildKit secret), not by copying a credential into the image.
One image, every provider. Nothing about the database is baked in. Provider=Sqlite, SqlServer or
PostgreSql and the matching connection string are plain environment variables at run time, so the same image
runs against any of the providers you scaffolded.
Build arguments.
| Argument | Default | Effect |
|---|---|---|
VERSION | 0.0.0 | Written to the informational version and the org.opencontainers.image.version label |
KANDRA_CORE_VERSION | empty | Overrides the exact Kandra Core pin in Directory.Build.props; leave empty to build with the pin |
TARGETARCH is set by BuildKit. The publish uses it to pick x64 or arm64, and the SDK stage runs on the
build machine's platform (--platform=$BUILDPLATFORM), so a multi-architecture build does not run the compiler
under emulation.
Data folder. The runtime stage creates /data owned by app, sets Kandra__DataPath=/data and declares it a
VOLUME. Everything the instance keeps on its own disk (the SQLite file, uploaded attachments, the generated JWT
key, the pre-migration backups) lives under that one folder; see
The data folder. Mount a volume there and a redeploy loses nothing.
Non-root. The image ends with USER app (uid 1654 in the official ASP.NET image). It is why /data is
created and chowned in the image. A bind mount from the host is owned by whoever created the folder, so the
app cannot write to it until you chown 1654 it; a named volume copies the ownership of the image folder and
just works. The compose files use named volumes.
Port. ASPNETCORE_URLS=http://+:8080: the container speaks plain HTTP on 8080 (the non-root user cannot bind
a port below 1024). TLS is terminated in front of it.
Forwarded headers. ASPNETCORE_FORWARDEDHEADERS_ENABLED=true makes the app trust X-Forwarded-For and
X-Forwarded-Proto, so behind a reverse proxy it sees the original client address and https scheme. That is
what makes the HTTPS redirect and the issued URLs right. It also means the app trusts those headers from
anyone who can reach port 8080, so publish 8080 only to the proxy (a private network, 127.0.0.1:8080:8080),
never to the internet.
Server GC off. DOTNET_gcServer=0 selects workstation GC, which keeps the memory footprint small for the
one-instance-per-container shape.
Labels. The template sets org.opencontainers.image.title and version. Add source, vendor and
licenses for your own configuration in the marked place. The files belong to you from the moment they are
scaffolded: the image does not carry a Kandra EULA copy or a Kandra licence label.
Choosing the provider at run time
Each compose file sets the same three things:
environment:
Provider: PostgreSql
ConnectionStrings__PostgreSql: "Host=db;Database=kandra-acme;Username=kandra;Password=${POSTGRES_PASSWORD}"
JwtOptions__Audience: ${APP_PUBLIC_URL:-http://localhost:8080}
Provider selects the database provider and ConnectionStrings__<Provider> its connection string
(see Configuration). To run an existing image against a database you host
elsewhere, set those two variables and leave the compose db service out.
| Compose file | Database service | Volumes |
|---|---|---|
compose.sqlite.yml | none: the file lives under /data/db | app-data |
compose.sqlserver.yml | mcr.microsoft.com/mssql/server:2025-latest, MSSQL_PID=Express | sql-data at /var/opt/mssql, app-data |
compose.postgres.yml | postgres:18 | pg-data at /var/lib/postgresql, app-data |
The SQL Server image is built for x64 only. On an arm64 machine (Apple Silicon Macs, Windows on Arm, Raspberry
Pi, AWS Graviton, Azure Ampere VMs) compose.sqlserver.yml pins the db service to platform: linux/amd64, so
Docker runs it under emulation: it is slow, its first start can take minutes, and it can fail or crash under
emulation depending on the Docker runtime. Do not use it for anything but a quick local trial.
- On arm64, use
compose.postgres.ymlorcompose.sqlite.yml. The application image itself is multi-architecture (linux/amd64andlinux/arm64), only the SQL Server database image is not. - A production arm64 host needs a database that is not this container: PostgreSQL, or a managed SQL Server (Azure SQL, Amazon RDS) that you connect to by connection string.
Details that are easy to get wrong:
- The app waits for the database. On SQL Server and PostgreSQL the
dbservice has a healthcheck and the app hasdepends_on: condition: service_healthy. Migrations run at startup, and an app that starts before the database is up crashes. Keep the dependency if you edit the file. - PostgreSQL 18 changed its volume. Mount the volume at
/var/lib/postgresql(the data lives in a versioned subfolder). The/var/lib/postgresql/datapath used up to 17 fails on 18. - SQL Server image. It is large (about 2.4 GB), so the first pull is slow (see the arm64 warning above). The compose
file sets the Express edition because Developer is not licensed for production. The
sapassword must satisfy the SQL Server complexity rules (MSSQL_SA_PASSWORDin.env), andTrustServerCertificate=Truein the connection string is for the private compose network only: with a managed SQL Server, use a trusted certificate and drop it. - Uploaded files stay in
/data. On SQL Server and PostgreSQL only attachments up to 8 KB are stored inline in the database; larger ones go to/data/blobs, so keep theapp-datavolume on those providers too. - A database name with a hyphen is fine.
kandra-acmeworks unquoted in the generated migrations on both servers. - The compose files have no healthcheck for the app itself. The host answers
/health/liveand/health/ready(Health endpoints); point your orchestrator's HTTP probe at them rather than adding a command-basedHEALTHCHECKthat needs a tool the image does not have.
Variables
.env.example lists the variables the compose files read:
| Variable | Default | What it does |
|---|---|---|
POSTGRES_PASSWORD | required for PostgreSQL | Database password |
MSSQL_SA_PASSWORD | required for SQL Server | sa password |
APP_TAG / APP_IMAGE | latest / kandra-acme | Image tag and name; pin the tag in anything you deploy |
APP_PORT | 8080 | Host port |
APP_PUBLIC_URL | http://localhost:8080 | Becomes JwtOptions__Audience |
APP_JWT_SECRET | empty | Empty: a per-install key is generated under /data/keys |
APP_ADMIN_PASSWORD | empty | Sets the administrator password on a fresh install; empty: the wizard asks |
A compose file refuses to start without its database password (${POSTGRES_PASSWORD:?...}), so a forgotten
.env fails loudly instead of running with an empty password.
The demo overlay
compose.demo.yml adds two settings to the app service:
docker compose -f compose.sqlite.yml -f compose.demo.yml up -d --build
ACCEPT_EULA=Y accepts the licence agreement without the wizard, and Kandra__Setup__Mode=Demo makes the first
start apply the demo import package and create the demo users. The template ships the import package
(src/Acme.Application/ImportPackages) and the demo-user list (Seeding/AcmeDemoUsers.cs) empty: fill them in
and one command gives an evaluator a populated system. See Data import and demo data.
Never use the overlay for a real installation: it pre-accepts the licence and creates users with known
passwords.
Production-safe defaults checklist
The compose files are meant to start in one command; some of their defaults are not for production. Go through this list, together with Before you expose it, before the first real deploy:
- Do not use the demo overlay, and do not set
ACCEPT_EULAorKandra__Setup__Modeon a real install. - Set the administrator password at the first start (
APP_ADMIN_PASSWORD, or answer the wizard) and remove the variable afterwards. It only applies to a fresh install. - Pin the image tag (
APP_TAG=1.4.2), notlatest, and keep older tags so you can roll back (but see migration history is a contract: a rollback after a migration needs a database restore). - Keep secrets out of the repository and the image:
.envis in.dockerignoreand should be in.gitignore. Prefer your platform's secret store to a.envfile on a server. - Set a real
APP_PUBLIC_URL(JwtOptions__Audience) and decide whether you want to setAPP_JWT_SECRETyourself. Required if you ever run more than one container, because they must share a key. - Terminate TLS in a reverse proxy and publish port 8080 only to it (
127.0.0.1:8080:8080or a private network). The container speaks plain HTTP. - Do not publish the database port. The compose files do not, and an added
ports:entry ondbexposes it to the network. - Back up the volumes:
app-dataalways (attachments, signing key, SQLite backups) and the database volume (pg-data,sql-data) or the managed database. A volume is not a backup; see The data folder and Backups before migrations. - Switch off Swagger (
Kandra__Swagger__Enabled=false) unless you want the API documentation public, and list any other origin inKandra__Cors__AllowedOrigins__0. - Run one container. The scheduler and the report cache are per process; see Running more than one instance.
- Use a database you have rehearsed. SQLite in a single container is fine for a small install; for SQL Server or PostgreSQL run the full path (migrations, seeding, your data) before relying on it.
- Rebuild on base-image updates. The runtime image is a plain
mcr.microsoft.com/dotnet/aspnet:10.0; a rebuild picks up OS and runtime patches.
Gotchas
- Line endings. A Windows checkout with
core.autocrlf=truerewrites the files to CRLF;.gitattributespins the container files to LF. Keep it if you move the repository. - The image applies the migrations it was built with. After
kandra-migrate add(see Database migrations), rebuild the image; restarting the old one changes nothing. down -vis a full reset, including the JWT key. Every signed-in session becomes invalid.- Cached build layers.
docker compose up -d --buildreuses layers; changeDirectory.Build.propsor the Kandra version pin and, if you suspect a stale layer,--no-cache. - First start is slow on SQL Server. The healthcheck has a 30 second start period and 20 retries for that reason; a slower machine may need more.
See also
- Starting & deploying a configuration: what the application does at start, health endpoints, data folder, backups, licensing.
- How to start: scaffolding, the NuGet feed, the wizard.
- Database migrations: how the database gets its schema, and
kandra-migrate. - Data import and demo data: what the demo overlay populates.