Skip to content

Containers & Multi-Replica

The same code that ran fine under dotnet run starts demanding configuration the moment it goes into a container. The reason is one line in compose: the backend runs with ASPNETCORE_ENVIRONMENT=Production, so everything development quietly covered for you now has to be given explicitly. One docker compose up is therefore a rehearsal of your first production startup.

Who this compose setup is for

The Dockerfile at the repo root builds the sample host MinimalHost from source, for the kernel's own CI. If you're a NuGet consumer, the directory generated by dotnet new smart-app already ships a Dockerfile that "installs the kernel from NuGet and builds your own host" — use that one directly, and the steps below still apply.

The frontend works the same way. web/Dockerfile builds this repo's web/ workspace from source, the kernel package and the template together. A template pulled with degit carries no container files, so write your own on the same two stages: a node stage running npm ci and npm run build, and a Caddy stage serving dist with web/Caddyfile.

Bring up the whole stack

Copy the environment-variable template first and fill in the three required values — none of them has a default in docker-compose.yml, so missing one makes docker compose up fail right at the interpolation stage, before any container starts:

bash
cp .env.example .env
# edit .env: fill in SMART_JWT_SECRET, SMART_DB_PASSWORD, SMART_ADMIN_PASSWORD
docker compose up -d --build

This one command brings up four services (defined in the repo-root docker-compose.yml): db (MySQL 8.0), redis (Redis 7), app (the backend), and web (Caddy, serving the frontend's static build and reverse-proxying /api). Once it's up:

bash
open http://localhost:8080                 # Frontend
curl http://127.0.0.1:8081/health/ready    # Backend debug port, loopback-only — reason below
docker compose logs app                    # First-startup info

app runs ASPNETCORE_ENVIRONMENT=Production, and that is deliberate. Production has three hard gates: the JWT secret must be given explicitly (leave it out and you're in dev-key mode, each replica signing its own, giving random 401s); a first production deploy against an empty database must explicitly permit table creation (a production database isn't ALTERed automatically); and the upload root must be moved out of wwwroot. docker-compose.yml writes all three as environment variables — copy them and swap in your own values; miss one and you get a readable startup error naming the config item, far easier to debug than "the process comes up fine and only blows up at the driver layer on the first write." These three gates, plus the table-creation / column-adding details on upgrade, are covered in full in the Deployment Overview.

The first-login super-admin account is superAdmin, and the password is whatever you set SMART_ADMIN_PASSWORD to in .env. This line in compose has no default:

yaml
SmartAdmin__Seed__AdminPassword: ${SMART_ADMIN_PASSWORD:?please create .env from .env.example, or set SMART_ADMIN_PASSWORD in the environment}

:? is the required-value syntax: an unset or empty variable refuses to start outright, printing whatever comes after the colon — it never falls back to a default the way :- does. The zero-config random-password path doesn't exist inside this compose setup; to use it, run locally with dotnet run instead (see Quick Start).

.env holds real secrets — keep it out of version control

.env is excluded by .gitignore, so the copy you make from .env.example stays out of version control. Of the three required values, rotating SMART_JWT_SECRET invalidates every token already issued (everyone has to log back in); SMART_DB_PASSWORD is written into the data volume on the first up, so changing it here afterward doesn't change the password in the database — that takes docker compose down -v (which deletes data). A deployment platform's secret manager works just as well as the .env file for a real deployment.

The frontend web service runs Caddy. Swap the site label in web/Caddyfile from :8080 to your domain and remove auto_https off, and it automatically obtains and renews a Let's Encrypt certificate, saving all the manual TLS work when self-hosting. To use nginx instead, switch the run stage in web/Dockerfile to nginx:alpine with the repo's web/nginx.conf; the full nginx / Caddy reverse-proxy config is in Route B: Reverse Proxy.

A few container gotchas you'll hit if they aren't spelled out

PointWhy
Named volumes, not bind mountsThe image runs as a non-root user. A named volume inherits ownership from the image directory on first mount, so the container can write to it; a bind mount overrides that with the host's ownership, and the app simply can't write to SQLite or the upload directory. app-data and upload-data in docker-compose.yml are both named volumes.
No HEALTHCHECK in the imageThe aspnet runtime image has neither curl nor wget, so a health-check instruction would just always fail. Health checking is left to the orchestration layer, probing /health (liveness) and /health/ready (DB + cache).
.dockerignore is a security itemA dev machine's data/ may hold a real SmartAdmin.db and a JWT signing key auto-generated in development (dev-jwt.key). The repo-root .dockerignore excludes it — without it, a single COPY . . bakes the signing key into an image layer, and once the image is pushed, anyone can forge a super-admin token.
Change WorkerId per replicaEach instance needs a distinct value in 0–63, or same-millisecond issuance collides on the primary key; configuring Redis without giving it explicitly also refuses startup on the spot. See "Multiple replicas and WorkerId" below.
Share one DataProtection:Key across replicasLeave it unset and each replica generates its own throwaway in-process key; envelopes encrypted through ISecretProtector — TOTP seeds, AI provider API keys — become unreadable on any replica but the one that wrote them. See "Every replica needs the same DataProtection:Key" below.

Multiple replicas and WorkerId

Before starting a second replica, none of the following can be skipped — skip one and mostly there's no error, it just quietly starts doing the wrong thing (WorkerId is the one exception that stops you on the spot). The repo has a ready-made two-replica overlay, the same one actually exercised in CI:

bash
docker compose -f docker-compose.yml -f docker-compose.scale.yml up -d --build
bash scripts/smoke-multi-replica.sh http://localhost:8080   # verifies each of the guarantees below, one by one

docker-compose.scale.yml adds an explicit app2 service rather than using docker compose --scale, because --scale can't give each replica its own separate environment variables, and "a distinct WorkerId per replica" below requires exactly that.

Switch the cache to Redis — a precondition, not an optimization

A single replica can get by with Memory (in-process cache), but an in-process cache means an invalidation on replica A never reaches replica B. The consequence isn't "a bit slower," it's security features flat-out failing — and the failure window is measured in days:

SymptomDetail
Forced logout fails (the worst one)The session cache's TTL is the refresh token's lifetime (days). Force a logout on A → the DB records the revocation and A clears its own memory, but B's copy is still there, still judging the session "active," so through a load balancer roughly half the requests sail through as normal — for days on end.
Permissions persist after revocationThe permission / data-scope cache defaults to 20 minutes. Someone whose access was revoked still has it on another replica; the data scope also still feeds SqlSugar's global filter — they keep seeing other orgs' data.
Lockout / rate-limit thresholds doubleLogin-failure counts and rate-limit counts are each counted per replica: MaxFailCount=5 becomes 10 across two replicas, and a 20/min auth bucket becomes 40/min.
CAPTCHA always failsA one-time ticket issued on A and verified on B — B doesn't have that key.

Set SmartAdmin:Cache:Provider=Redis + Cache:RedisConnectionString and all of the above is fixed automatically — invalidation goes through a shared cache keyspace, not an event bus, with zero changes to business code.

A distinct WorkerId per replica

The snowflake generator's machine bit comes from SmartAdmin:Id:WorkerId (0–63). When unset, the kernel claims a number on this machine with a file lock — but each container has its own filesystem, so two replicas both end up with 0, and generating IDs in the same millisecond collides on the primary key — a data-corruption-level bug. The kernel backs this with two checks. The first runs at startup: once Cache:Provider=Redis is configured (a clear signal of multi-instance intent) without an explicit WorkerId, startup throws outright, naming SmartAdmin:Id:WorkerId and the 0–63 range. The second runs against the database: WorkerIdLeaseGuard claims a lease per machine number in the sys_worker_lease table and renews it periodically — if another live instance on the same database already holds that number, the later one throws on the spot, regardless of whether Redis is configured. Writing 0 explicitly clears the first check by declaring you know what you're doing; the second check is the one you can't opt out of — every replica still needs its own distinct SmartAdmin:Id:WorkerId.

  • compose: --scale app=2 can't give replicas different environment variables, so split it into multiple explicit app services each configured on its own — app2 in docker-compose.scale.yml explicitly sets SmartAdmin__Id__WorkerId: "1", different from app's 0.
  • k8s: use a StatefulSet and inject from the Pod name's ordinal (app-0/app-1); a Deployment's random Pod names can't give you a stable ordinal.

Every replica needs the same DataProtection:Key

Leave SmartAdmin:Security:DataProtection:Key unset and the kernel, in production, either refuses to start outright (when TOTP or cookie sessions are on) or falls back to a throwaway in-process key — never written to disk, a fresh one every restart and every replica. TOTP seeds and AI provider API keys — anything encrypted through ISecretProtector — get written on replica A with one key and read back on replica B with a different one, and decryption throws a CryptographicException outright. Saving an AI provider's key checks whether the current key is a throwaway one and refuses to persist it if so (error 49030), but that guard only catches "this replica can't read back what it wrote after a restart" — it does nothing for "each replica configured its own key."

Generate one and give every replica the same value:

bash
openssl rand -base64 32
yaml
SmartAdmin__Security__DataProtection__Key: "paste the command's output verbatim, identical on every replica"

Behind the proxy, ForwardedHeaders is required

The app service already configures:

yaml
SmartAdmin__Api__ForwardedHeaders__Enabled: "true"
SmartAdmin__Api__ForwardedHeaders__KnownNetworks__0: 172.16.0.0/12

This ForwardedHeaders config is the same thing as Route B: Reverse Proxy, just with the trusted source swapped for the Docker bridge subnet — why it's needed and what breaks without it is covered there in full, not repeated here. With multiple replicas each one needs it configured, or all of them only ever see the load balancer's single IP. app's port mapping binds only to 127.0.0.1 for the same reason, also covered on that page:

yaml
ports:
  - "127.0.0.1:${SMART_API_PORT:-8081}:8080"

Binding to 0.0.0.0 would expose "forge an IP, bypass rate limiting" to the whole LAN; normal traffic all goes through Caddy in front (which already proxies /api and /health). In production, go a step further and just drop this port mapping, keeping only the reverse-proxy entry point.

On a cold start, bring up one replica first

CodeFirst table creation + seeding is "check then insert," not atomic: if two replicas start for the first time simultaneously, one crashes on a unique-key collision. In compose, app2's depends_on: app: condition: service_healthy waits for the first replica to finish writing tables and seeds before starting the second — solved with zero code; on k8s, use an init job / migration job to build the database first, then open up the replicas.

The upload directory must be a shared writable volume

LocalFileStorage / ChunkStorage write to local disk. In compose, both replicas share the same upload-data named volume, so there's naturally no issue; but on k8s, if each Pod uses its own PVC, a file uploaded on A returns a flat 404 on B, and chunked uploads inevitably hit ChunkMissing (chunks scattered across different Pods can never be merged). For multiple replicas, either mount an RWX (ReadWriteMany) shared volume at the upload root, or swap IFileStorage for object storage (S3 / OSS) up front.

If you'd rather not go to containers, the Deployment Overview also lays out three other hosting routes — monolithic, reverse proxy, and true cross-origin — and the post-go-live health checks and self-check list are there too.

Released under the Apache License 2.0