Skip to main content

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.

Opting out

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​

FilePurpose
DockerfileTwo-stage build: SDK image publishes the web host, ASP.NET runtime image runs it
.dockerignoreKeeps bin/, obj/, .git, local databases and secrets out of the build context
.env.exampleThe variables the compose files read; copy to .env (never commit .env)
compose.sqlite.ymlOne container and one volume
compose.sqlserver.ymlApp plus SQL Server Express
compose.postgres.ymlApp plus PostgreSQL 18
compose.demo.ymlOptional overlay for a populated demo system (see below)
.gitattributesPins 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.

ArgumentDefaultEffect
VERSION0.0.0Written to the informational version and the org.opencontainers.image.version label
KANDRA_CORE_VERSIONemptyOverrides 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 fileDatabase serviceVolumes
compose.sqlite.ymlnone: the file lives under /data/dbapp-data
compose.sqlserver.ymlmcr.microsoft.com/mssql/server:2025-latest, MSSQL_PID=Expresssql-data at /var/opt/mssql, app-data
compose.postgres.ymlpostgres:18pg-data at /var/lib/postgresql, app-data
SQL Server does not run natively on arm64

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.yml or compose.sqlite.yml. The application image itself is multi-architecture (linux/amd64 and linux/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 db service has a healthcheck and the app has depends_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/data path 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 sa password must satisfy the SQL Server complexity rules (MSSQL_SA_PASSWORD in .env), and TrustServerCertificate=True in 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 the app-data volume on those providers too.
  • A database name with a hyphen is fine. kandra-acme works unquoted in the generated migrations on both servers.
  • The compose files have no healthcheck for the app itself. The host answers /health/live and /health/ready (Health endpoints); point your orchestrator's HTTP probe at them rather than adding a command-based HEALTHCHECK that needs a tool the image does not have.

Variables​

.env.example lists the variables the compose files read:

VariableDefaultWhat it does
POSTGRES_PASSWORDrequired for PostgreSQLDatabase password
MSSQL_SA_PASSWORDrequired for SQL Serversa password
APP_TAG / APP_IMAGElatest / kandra-acmeImage tag and name; pin the tag in anything you deploy
APP_PORT8080Host port
APP_PUBLIC_URLhttp://localhost:8080Becomes JwtOptions__Audience
APP_JWT_SECRETemptyEmpty: a per-install key is generated under /data/keys
APP_ADMIN_PASSWORDemptySets 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:

  1. Do not use the demo overlay, and do not set ACCEPT_EULA or Kandra__Setup__Mode on a real install.
  2. 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.
  3. Pin the image tag (APP_TAG=1.4.2), not latest, 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).
  4. Keep secrets out of the repository and the image: .env is in .dockerignore and should be in .gitignore. Prefer your platform's secret store to a .env file on a server.
  5. Set a real APP_PUBLIC_URL (JwtOptions__Audience) and decide whether you want to set APP_JWT_SECRET yourself. Required if you ever run more than one container, because they must share a key.
  6. Terminate TLS in a reverse proxy and publish port 8080 only to it (127.0.0.1:8080:8080 or a private network). The container speaks plain HTTP.
  7. Do not publish the database port. The compose files do not, and an added ports: entry on db exposes it to the network.
  8. Back up the volumes: app-data always (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.
  9. Switch off Swagger (Kandra__Swagger__Enabled=false) unless you want the API documentation public, and list any other origin in Kandra__Cors__AllowedOrigins__0.
  10. Run one container. The scheduler and the report cache are per process; see Running more than one instance.
  11. 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.
  12. 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=true rewrites the files to CRLF; .gitattributes pins 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 -v is a full reset, including the JWT key. Every signed-in session becomes invalid.
  • Cached build layers. docker compose up -d --build reuses layers; change Directory.Build.props or 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​