Configuration Guide
This guide explains how to configure Wallow modules using the Options Pattern and environment-specific settings.
Overview
Wallow uses the Microsoft.Extensions.Options pattern for type-safe configuration. Each module defines its own Options class that binds to a section in appsettings.json. This provides:
- Type safety - Compile-time checking instead of magic strings
- Testability - Easy to mock
IOptions<T>in unit tests - Documentation - The class itself documents available settings
- Validation - Can add validation attributes or custom validators
Configuration Reference
This section documents the configuration sections a fork is most likely to change. It is not an exhaustive dump of api/src/Wallow.Api/appsettings.json — that file also ships FeatureManagement (the Modules.* toggles), Plugins, Database, ApiKeys and Performance sections, and the OpenIddict/authentication wiring lives in code rather than configuration. Read appsettings.json itself when you need the complete set. See the "Quick Start" section below for how to create your own module configuration.
Branding
Branding controls the user-facing identity of the React apps -- auth screens (login, register, password reset), the dashboard shell, and the document head. It is not an appsettings.json section: it lives in its own file, packages/styles/branding.json.
That file is the single source of fork identity. packages/styles (@bc-solutions-coder/styles, src/branding.ts) owns the canonical schema, imports packages/styles/branding.json statically at build time, and emits the color tokens as CSS custom properties. Both React apps (apps/wallow-auth, apps/wallow-web) consume it from there, so rebranding a fork needs no source changes -- just edit the JSON.
Top-level keys:
| Key | Type | Description |
|---|---|---|
appName |
string |
Product name shown in page titles, headings, and the landing page |
appIcon |
string |
Brand asset reference. A bare filename ("piggy-icon.svg") is resolved to a root-relative URL so it loads from any route depth |
tagline |
string |
Sub-heading shown under the app name |
repositoryUrl |
string |
Optional. The "GitHub"/fork-attribution link target. Falls back to the upstream Wallow repository |
docsUrl |
string |
Optional. The "Docs" link target. Falls back to the upstream documentation site |
landingPage |
object |
{ "enabled": boolean } -- see below |
theme |
object |
defaultMode plus the light and dark color sets |
landingPage.enabled gates the public marketing page at / in apps/wallow-web. When true, an unauthenticated visitor sees the landing page. When false, they are sent straight to the BFF login (a forced OIDC challenge). Authenticated visitors are redirected to the dashboard either way.
theme:
| Key | Type | Description |
|---|---|---|
defaultMode |
string |
Color scheme applied when the document does not pick one: "light" or "dark" |
light |
object |
Color tokens for light mode |
dark |
object |
Color tokens for dark mode |
Each color set is a map of camelCase token names to CSS values. The tokens the shipped packages/styles/branding.json defines are:
background, foreground, card, cardForeground, popover, popoverForeground, primary, primaryForeground, secondary, secondaryForeground, muted, mutedForeground, accent, accentForeground, destructive, destructiveForeground, sidebar, sidebarForeground, sidebarAccent, success, successForeground, warning, warningForeground, border, input, ring, radius
All except radius are OKLCH colors; radius is a CSS length (0.5rem). The map is open-ended -- unknown keys are passed through as CSS custom properties, so a fork can add its own tokens.
The sidebar*, success* and warning* tokens were added after the original set, so all seven resolve through a two-level fallback (--color-sidebar: var(--sidebar, var(--foreground)), --color-warning: var(--warning, var(--primary))) rather than the plain var(--x) the older tokens use. Because packages/styles/branding.json is merge=ours in .gitattributes, a fork whose copy predates these keys never receives them from an upstream merge -- the fallback lands it on a colour its palette already carries instead of on nothing. The sidebar* family is the theme's general inverted-surface family, not solely a dashboard sidebar.
defaultMode is only the starting point. It is the scheme applied when neither the visitor nor their OS states a preference; the frontends resolve the active scheme at load time as persisted choice, then OS prefers-color-scheme, then this value, and a visitor can change it at any time through the shared theme toggle. See Dark Mode.
Example packages/styles/branding.json:
{
"appName": "YourProduct",
"appIcon": "your-icon.svg",
"tagline": "Your tagline here",
"landingPage": {
"enabled": true
},
"theme": {
"defaultMode": "dark",
"light": {
"primary": "oklch(0.52 0.12 45)",
"primaryForeground": "oklch(0.96 0.01 60)",
"background": "oklch(0.96 0.01 60)",
"foreground": "oklch(0.20 0.03 55)",
"radius": "0.5rem"
},
"dark": {
"primary": "oklch(0.62 0.13 45)",
"primaryForeground": "oklch(0.14 0.015 50)",
"background": "oklch(0.16 0.02 50)",
"foreground": "oklch(0.88 0.02 55)",
"radius": "0.5rem"
}
}
}
The two outbound links can be overridden per deployment. repositoryUrl and docsUrl are the only branding values a running container can change, because they are the only ones that legitimately differ between deployments of the same image -- a staging stack pointing at a staging docs site, a private mirror instead of the public repository. Both React apps resolve them per request:
| Variable | Overrides |
|---|---|
WALLOW_REPOSITORY_URL |
repositoryUrl |
WALLOW_DOCS_URL |
docsUrl |
Resolution order for each link, independently: the environment variable, then packages/styles/branding.json, then the upstream URL. A variable set to an empty string counts as unset -- an unsubstituted WALLOW_DOCS_URL= in a compose file leaves the fork's own link in place rather than rendering a blank href. docker/docker-compose.production.yml already passes both through to wallow-auth and wallow-web; see docker/.env.production.example.
The variables are read on the SERVER, in each app's request middleware, and the resolved pair is published into the document so the browser renders the same links after hydration. They are therefore not VITE_* variables and are never baked into a client bundle: the same image serves different links in different environments.
Every other branding value is imported at build time rather than read at runtime, so changing it requires rebuilding (or restarting the dev server for) the frontends.
WALLOW_WEB_URL tells the auth app where the main app lives. A sign-in that arrives with a returnUrl -- every OIDC hand-off does -- finishes by returning there. One that arrives without -- the first-run administrator coming straight from /setup, or anyone who opened the login page directly -- has nowhere to go, and wallow-auth never invents a destination: it cannot know which sibling serves the site root. Set WALLOW_WEB_URL to the main app's absolute public URL and the login page navigates there instead of stopping at its signed-in banner. It is read the same way as the link overrides above (on the server, published into the document, never baked into a bundle); a blank or non-http(s) value counts as unset. docker/docker-compose.production.yml passes WEB_PUBLIC_URL through as WALLOW_WEB_URL.
Per-OAuth-client branding is a separate, runtime concern: inside an authorize transaction the auth app resolves the requesting client from the transaction's returnUrl via GET /v1/identity/auth/authorize-context, and the client's display name, tagline, logo, and theme are overlaid on top of the fork's for every screen in that transaction. There is no anonymous branding read by client id.
Session Limits
Wallow enforces a per-user concurrent session limit. When a user exceeds the limit, the oldest active session is automatically evicted before the new one is created.
How it works
- Session creation -- on every successful login,
SessionServicecounts the user's active, non-revoked, non-expired sessions. - Eviction -- if the count is at or above the limit (default: 5), the oldest session is revoked. A
UserSessionEvictedEventis published over the Wolverine bus so other modules can react. - Access enforcement -- access is ended at the OIDC layer, not by a cookie denylist. Ending a session at the auth host (
/connect/logout) revokes every token minted under that session'ssid: the refresh grant answersinvalid_grantand the old access token fails token-entry validation on its next bearer request. Deactivating a user revokes all of their tokens the same way. The session ledger itself is bookkeeping for the sessions API below. - Activity tracking --
SessionActivityMiddlewareupdates thelast_activity_attimestamp on each session. Updates are throttled to once per 60 seconds per session (via a Redis NX key) to avoid write amplification. - Pruning --
SessionPruningJobperiodically deletes expired and revoked session rows from the database.
Session limit
The concurrent session limit is a compile-time constant (MaxSessions = 5) in SessionService. It applies globally across all users and tenants. To change the limit, update the constant and redeploy.
Session management API
Users can inspect and revoke their own sessions via the Identity module API (requires authentication):
| Method | Path | Description |
|---|---|---|
GET |
/v1/identity/sessions |
List all active sessions for the authenticated user |
DELETE |
/v1/identity/sessions/{sessionId} |
Revoke a specific session by ID |
List active sessions response:
[
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"createdAt": "2025-01-15T10:30:00Z",
"lastActivityAt": "2025-01-15T14:22:00Z",
"expiresAt": "2025-01-16T10:30:00Z"
}
]
Revoke a session:
DELETE /v1/identity/sessions/3fa85f64-5717-4562-b3fc-2c963f66afa6
Authorization: Bearer {token}
Returns 204 No Content on success. A session can only be revoked by its owner -- attempting to revoke another user's session returns an error.
Redis requirements
Session activity tracking uses Redis for its update throttle. Configure ConnectionStrings__Redis (see Connection Strings below).
session:touched:{token} -- NX key throttling activity updates (60s TTL)
Revocation itself does not depend on Redis: it is enforced by revoking OpenIddict token and authorization entries, which live in Postgres.
Session duration
Sessions expire 24 hours after creation. The SessionPruningJob removes expired and revoked rows from the database on a periodic schedule.
Identity: Refresh-Token Lifetimes
How long a refresh token lives is decided per client, stored on the OpenIddict application itself. Three layers, most specific wins:
- The client's own
refreshTokenLifetime(seconds, 60–31,536,000): settable at registration and viaPATCH /v1/identity/organizations/{orgId}/clients/{clientId}(or the wallow-web ledger's Settings editor), and declarable on a seed client (the samerefreshTokenLifetimekey in theclientsarray ofapi/seed.json/docker/seed.production.json). - A kind default applied when the field is absent: a seeded
"firstParty": trueclient gets 7 days (604,800 s); any other application — seeded third-party or registered through the organization API — gets 1 day (86,400 s). A service account uses only theclient_credentialsgrant and holds no refresh tokens, so it carries no lifetime. - The global fallback
OpenIddict:RefreshTokenLifetimeDays(default 7) for a client with no per-client setting at all, e.g. one registered before per-client lifetimes existed.
A lifetime change applies to new logins only — refresh tokens already issued keep the lifetime they were minted with.
Refresh-token behavior itself is pinned in code, not configuration: tokens are rolling (each
redemption issues a new token and revokes the old one) with sliding expiration disabled (use
does not extend the window). The only related key is OpenIddict:RefreshTokenReuseLeewaySeconds
(default 30), the grace window in which a just-redeemed token may be replayed — covering a
client's own retry after a network failure — before a replay revokes the whole token family.
Identity: Back-Channel Logout
Ending an SSO session (GET /connect/logout) also notifies participating relying parties
server-to-server via OIDC back-channel logout: a signed logout token (typ: logout+jwt,
carrying the session's sid) is POSTed to every participating client that registered a
back-channel URI. Delivery is best-effort and bounded — one retry per client, and a slow or
unreachable relying party never delays the user's sign-out. The discovery document advertises
backchannel_logout_supported and backchannel_logout_session_supported.
A client opts in through two fields, settable at registration, via
PATCH /v1/identity/organizations/{orgId}/clients/{clientId} (or the wallow-web ledger's
editors), and declarable on a seed client in the clients array of api/seed.json /
docker/seed.production.json:
backchannelLogoutUri— the absolute http(s) URL logout tokens are POSTed to (logout_token=<jwt>, form-encoded). Omit it and the client is simply not notified.backchannelLogoutSessionRequired— optional, defaultfalse: whether the client requires asidclaim in its logout tokens. Wallow always includessid, so the flag is a declaration recorded for the client, not a behavior switch.
Delivery tuning lives under Identity:BackchannelLogout:
| Key | Default | Meaning |
|---|---|---|
AllowPrivateNetworkHosts |
false |
Whether logout tokens may be POSTed to loopback, RFC 1918, link-local, or unique-local hosts. Back-channel URIs are registered by org admins, so by default a URI pointing at a private network is refused — otherwise a registration could turn every logout into a server-side request against an internal service. Turn it on only where relying parties genuinely live on a private network (local dev, air-gapped installs). |
PerClientTimeout |
00:00:03 |
How long one delivery attempt to one relying party may take. |
RetryDelay |
00:00:01 |
The pause before the single retry a failed delivery gets. |
OverallTimeout |
00:00:10 |
The bound on the whole notification fan-out. Deliveries run in parallel, so this is a backstop for many slow relying parties, not a per-client budget. |
Identity: email-change throttling
Email-change requests are counted per user in a fixed window starting with the first request.
Configure the limit under Identity:EmailChange:
| Key | Default | Meaning |
|---|---|---|
RateLimitMaxRequests |
3 |
Requests allowed in the window. Zero refuses every request. |
RateLimitWindow |
01:00:00 |
Duration of the counting window. Later requests do not refresh it. |
The cap must be non-negative and the window positive; invalid values fail startup validation.
An over-limit request returns HTTP 429 with RateLimit.Exceeded. Retry-After uses the
positive remaining counter TTL, falling back to the configured window when none remains.
Passwordless requests use the same counting and retry-delay behavior, configured separately
under Passwordless:RateLimitMaxRequests and Passwordless:RateLimitWindow. Defaults are
three requests per email per 15 minutes. Magic-link and OTP requests share that email's counter.
Identity: Invalid-Client Lockout
Every invalid_client answer the token endpoint gives — a wrong or missing secret, an unknown
client_id — is audited (see Audit events) and counted per
client_id. A client that fails too often is temporarily rejected at the token endpoint, correct
secret or not, with the same generic invalid_client answer, so a secret-guesser learns nothing
from finally landing the right one. The counter lives in Redis, so all API instances share one
tally. Tuning lives under Identity:InvalidClientLockout:
| Key | Default | Meaning |
|---|---|---|
FailureThreshold |
5 |
Failed client authentications within the window that trip the lockout. |
WindowMinutes |
5 |
How long the failure counter lives before it resets. |
LockoutMinutes |
5 |
How long a tripped client stays rejected. Fixed window, not sliding — further attempts do not extend it. |
Identity: First-Run Setup and the Bootstrap Admin
The seeder (Wallow.SeederService) can bootstrap the first administrator from the seed file's
admin block, bound to AdminBootstrapOptions and env-overridable as Admin__Email,
Admin__Password, Admin__FirstName, Admin__LastName, Admin__OrganizationName, and
Admin__IsGlobalAdmin. Email, Password, and OrganizationName must all be non-blank for
the options to count as configured — OrganizationName is required because roles are granted
per organization — and IsGlobalAdmin is deliberately settable only from seeded configuration;
no runtime endpoint grants it. The bootstrap runs through the same BootstrapAdminCommand the
setup endpoint uses, so the admin arrives as user, organization, and owner membership in one
step.
Leave the block absent (or Admin__Email blank) and no admin is seeded: the API stays in
setup mode, answering 503 on most endpoints until POST /v1/identity/setup/admin succeeds,
and the auth app's first-run /setup page walks a person through creating the account.
api/seed.json ships an admin block so local development starts signed-in-able; the
committed docker/seed.production.json is deliberately admin-less, so production always
bootstraps its administrator through the setup page. See the
Deployment Guide for the full
setup-mode contract.
Identity: Email Change Flow
Wallow includes a secure two-step email change flow. Users request a change via the API, receive a confirmation link at the new address, and click it to finalize.
Endpoints
Initiate email change -- authenticated users only:
POST /v1/identity/auth/change-email
Authorization: Cookie (authenticated session)
Content-Type: application/json
{
"newEmail": "newaddress@example.com"
}
Responses:
| Status | Body | Meaning |
|---|---|---|
200 OK |
{ "succeeded": true } |
Confirmation email sent to the new address |
400 Bad Request |
{ "succeeded": false, "error": "same_email" } |
New email matches the current email |
429 Too Many Requests |
{ "succeeded": false, "error": "rate_limited" } |
Rate limit exceeded (max 3 per hour) |
401 Unauthorized |
{ "succeeded": false, "error": "unauthorized" } |
Not authenticated |
Confirm email change -- unauthenticated, accessed via the link in the confirmation email:
GET /v1/identity/auth/confirm-email-change
?token=<change-token>
&userId=<user-id>
&newEmail=<new-email>
Responses:
| Status | Body | Meaning |
|---|---|---|
200 OK |
{ "succeeded": true } |
Email changed successfully |
400 Bad Request |
{ "succeeded": false, "error": "token_expired" } |
Confirmation link expired (24-hour window) |
400 Bad Request |
{ "succeeded": false, "error": "invalid_token" } |
Token invalid or already used |
Security Model
- Token expiry: Confirmation tokens are valid for 24 hours from the time of the request. Expired tokens are rejected and the pending change is cleared automatically.
- Rate limiting: A maximum of 3 email change requests per hour per user is enforced server-side via Redis. Requests beyond this limit return
429 Too Many Requests. - Confirmation email goes to the new address: A
UserEmailChangeRequestedEventis published, which the Notifications module handles to send a confirmation email to the new address. - Notification on completion: When a change is confirmed, a
UserEmailChangedEventis published. The Notifications module handles this to send a security notice to the old address, alerting the account holder that their email was changed. - Username sync: On confirmation, the user's username is updated to match the new email address.
- Session continuity: Existing sessions remain valid after an email change. If your fork requires forced re-authentication on email change, add a Wolverine handler for
UserEmailChangedEventthat revokes the user's active sessions.
Integration with Email Verification Infrastructure
The email change flow uses the same ASP.NET Core Identity token infrastructure as initial email verification (GenerateChangeEmailTokenAsync / ChangeEmailAsync). Token generation and validation are handled entirely within the Identity module's UserManager.
The confirmation URL is constructed using the AuthUrl configuration key:
{
"AuthUrl": "https://auth.yourdomain.com"
}
The auth app (apps/wallow-auth) must serve the email-change confirmation route. This page reads the token, userId, and newEmail query parameters and calls the confirm endpoint on the API to finalize the change.
| Config Key | Shipped default | Description |
|---|---|---|
AuthUrl |
"" |
Base URL of the auth app (apps/wallow-auth). Used to build the confirmation link sent to the user. |
appsettings.json ships this empty, and there is a second copy under ServiceUrls:AuthUrl that the ServiceUrlsOptions binder reads (its own code default is http://localhost:3002). appsettings.Development.json fills both in with http://localhost:3002, so a local run works out of the box; a deployment that leaves them empty will mail a confirmation link with no origin. Set both for your fork.
Rate Limiting
The API enforces Redis-backed fixed-window rate limits in every environment except Development.
The limiter runs after authentication and tenant resolution, so its partitions follow the
authenticated user or resolved organization, not the connection: two users behind one address own
independent windows, and an unauthenticated caller falls back to its IP address. Requests count
toward a window regardless of the downstream status code, and a rejected request gets a
429 Too Many Requests problem document with Retry-After.
Four policies bind from the RateLimiting section; each has a PermitLimit and a window:
| Section | Partition | Defaults | Applied to |
|---|---|---|---|
RateLimiting:Auth |
Organization, else user, else IP | 30 per WindowMinutes: 1 |
The token endpoint and other authentication surfaces. |
RateLimiting:Upload |
Organization, else user, else IP | 10 per WindowHours: 1 |
File uploads. |
RateLimiting:Registration |
User, else IP | 5 per WindowHours: 1 |
Organization create and every org-surface client mutation (register, rotate secret, update, suspend, reinstate, delete, branding). |
RateLimiting:Global |
Organization, else user, else IP | 1000 per WindowHours: 1 |
Everything else, as the global limiter. |
The Testing environment keeps the limiter enabled with generous limits
(appsettings.Testing.json), so functional suites exercise it without tripping it.
Connection Strings
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=wallow;Username=wallow;Password=SET_VIA_ENV_OR_USER_SECRETS;SSL Mode=Disable",
"Redis": "localhost:6379,password=,abortConnect=false"
}
}
| Key | Description | Environment Variable |
|---|---|---|
DefaultConnection |
PostgreSQL connection string | ConnectionStrings__DefaultConnection |
Redis |
Redis/Valkey connection string for caching and SignalR backplane | ConnectionStrings__Redis |
Why there is no CORS configuration
Wallow has no Cors configuration section, and the API calls neither AddCors nor UseCors. This is deliberate: the frontends use the same-origin BFF pattern. The browser only ever talks to its own origin, and each React app's server side proxies to the API and holds the token. No cross-origin browser request to the API is made, so there is nothing for CORS to permit.
If you add a genuinely cross-origin client to your fork, you are adding a new deployment shape rather than filling in an existing setting -- wire up ASP.NET Core CORS yourself and treat the allowed origins as fork-owned configuration.
Redirect URIs are a separate mechanism: OIDC clients register their own redirect URIs, which OpenIddictRedirectUriValidator validates per client. Those are not CORS origins.
SMTP (Email)
{
"Smtp": {
"Host": "localhost",
"Port": 1025,
"UseSsl": false,
"Username": "",
"Password": "",
"DefaultFromAddress": "noreply@wallow.local",
"DefaultFromName": "Wallow",
"MaxRetries": 3,
"TimeoutSeconds": 30
}
}
| Key | Default | Description |
|---|---|---|
Host |
localhost |
SMTP server hostname |
Port |
1025 |
SMTP port (1025 for Mailpit, 587 for production) |
UseSsl |
false |
Enable TLS/SSL |
Username |
null |
SMTP authentication username (optional) |
Password |
null |
SMTP authentication password (optional) |
DefaultFromAddress |
noreply@wallow.local |
Default sender email address |
DefaultFromName |
Wallow |
Default sender display name |
MaxRetries |
3 |
Number of retry attempts on failure |
TimeoutSeconds |
30 |
SMTP operation timeout |
Local development: Use Mailpit at localhost:1025 (no auth, no SSL). View emails at http://localhost:8025.
OpenTelemetry (Observability)
{
"OpenTelemetry": {
"EnableLogging": false,
"ServiceName": "Wallow",
"OtlpEndpoint": "http://localhost:4318",
"OtlpGrpcEndpoint": "http://localhost:4317",
"TraceSamplingRatio": 1.0
}
}
| Key | Default | Description |
|---|---|---|
EnableLogging |
false |
Enable OpenTelemetry logging export |
ServiceName |
Wallow |
Service name for traces and metrics |
OtlpEndpoint |
http://localhost:4318 |
OTLP HTTP endpoint |
OtlpGrpcEndpoint |
http://localhost:4317 |
OTLP gRPC endpoint (used for traces/metrics) |
TraceSamplingRatio |
1.0 |
Fraction of traces sampled — 1.0 records everything, which is the shipped default because local development wants complete traces |
Note: The application currently uses OtlpGrpcEndpoint for exporting traces and metrics.
Storage Module
Wallow uses GarageHQ as the default S3-compatible object storage. The S3StorageProvider works with any S3-compatible backend (GarageHQ, AWS S3, Cloudflare R2, MinIO).
{
"Storage": {
"Provider": "S3",
"Local": {
"BasePath": "/var/wallow/storage",
"BaseUrl": "http://localhost:5001"
},
"S3": {
"Endpoint": "http://localhost:3900",
"AccessKey": "SET_VIA_Storage__S3__AccessKey",
"SecretKey": "SET_VIA_Storage__S3__SecretKey",
"BucketName": "wallow-files",
"UsePathStyle": true,
"Region": "us-east-1"
}
}
}
That is the shipped Storage section in full. There is no ClamAv key in appsettings.json — the options class supplies the defaults below, and you add the section only when you want scanning on.
| Key | Default | Description |
|---|---|---|
Provider |
S3 |
Storage provider: Local or S3 |
Local.BasePath |
/var/wallow/storage |
Local filesystem path for file storage |
Local.BaseUrl |
null |
Base URL for serving files (optional) |
S3.Endpoint |
http://localhost:3900 |
S3-compatible endpoint URL (GarageHQ default) |
S3.AccessKey |
- | S3 access key |
S3.SecretKey |
- | S3 secret key |
S3.BucketName |
wallow-files |
S3 bucket name |
S3.UsePathStyle |
true |
Use path-style URLs (required for GarageHQ and MinIO) |
S3.Region |
us-east-1 |
S3 region |
ClamAv.Enabled |
false |
Enable ClamAV virus scanning on file uploads |
ClamAv.Host |
localhost |
ClamAV daemon hostname |
ClamAv.Port |
3310 |
ClamAV daemon port |
Local development: GarageHQ runs on http://localhost:3900 via Docker Compose. The init script auto-creates the access key and bucket. Admin API at http://localhost:3903.
ClamAV Virus Scanning (Optional)
ClamAV virus scanning is disabled by default. When disabled, file uploads skip scanning entirely (a no-op scanner returns clean for all files). To enable scanning:
Start the ClamAV container using the Docker Compose profile:
cd docker && docker compose --profile clamav up -dEnable scanning in your configuration:
{ "Storage": { "ClamAv": { "Enabled": true, "Host": "localhost", "Port": 3310 } } }Or via environment variables:
Storage__ClamAv__Enabled=true Storage__ClamAv__Host=localhost Storage__ClamAv__Port=3310
When enabled, all file uploads are scanned synchronously before storage. Infected files are rejected with a validation error. A ClamAV health check is also registered at /health (tagged clamav).
Quick Start
1. Create an Options Class
Create a class in your module's Infrastructure layer:
// api/src/Modules/YourModule/Wallow.YourModule.Infrastructure/Configuration/YourModuleOptions.cs
namespace Wallow.YourModule.Infrastructure.Configuration;
public sealed class YourModuleOptions
{
public const string SectionName = "YourModule";
public string ApiKey { get; set; } = string.Empty;
public int MaxRetries { get; set; } = 3;
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
}
2. Register in Module Extensions
Bind the configuration section in your module's extension method:
// api/src/Modules/YourModule/Wallow.YourModule.Infrastructure/Extensions/YourModuleExtensions.cs
public static IServiceCollection AddYourModuleInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
// Bind configuration section to options
services.Configure<YourModuleOptions>(
configuration.GetSection(YourModuleOptions.SectionName));
// ... other registrations
return services;
}
3. Add Configuration to appsettings.json
{
"YourModule": {
"ApiKey": "your-api-key",
"MaxRetries": 5,
"Timeout": "00:00:45"
}
}
4. Inject and Use Options
Inject IOptions<YourModuleOptions> into any service or controller and access .Value:
public class YourService(IOptions<YourModuleOptions> options)
{
private readonly YourModuleOptions _options = options.Value;
}
Environment-Specific Configuration
.NET supports layered configuration files that override each other based on the environment.
Configuration Loading Order
Configuration is loaded in this order (later sources override earlier ones):
appsettings.json- Base configuration (all environments)appsettings.{Environment}.json- Environment-specific overrides- Environment variables
- Command-line arguments
- User secrets (Development only)
Environment Names
| Environment | File | Usage |
|---|---|---|
| Development | appsettings.Development.json |
Local development |
| Staging | appsettings.Staging.json |
Pre-production testing |
| Production | appsettings.Production.json |
Live production |
| Testing | appsettings.Testing.json |
Integration tests |
Setting the Environment
# Via environment variable (recommended for servers)
export ASPNETCORE_ENVIRONMENT=Production
# Via command line
dotnet run --environment Production
# Via launchSettings.json (local development)
# Already configured in Properties/launchSettings.json
Example: Environment-Specific Files
appsettings.json (base configuration):
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=wallow;Username=wallow;Password=SET_VIA_ENV_OR_USER_SECRETS",
"Redis": "localhost:6379,password=,abortConnect=false"
},
"Storage": {
"Provider": "S3",
"S3": {
"Endpoint": "http://localhost:3900",
"AccessKey": "SET_VIA_Storage__S3__AccessKey",
"SecretKey": "SET_VIA_Storage__S3__SecretKey",
"BucketName": "wallow-files",
"UsePathStyle": true,
"Region": "us-east-1"
}
}
}
appsettings.Development.json (local dev overrides):
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information",
"Microsoft.EntityFrameworkCore": "Information"
}
},
"OpenTelemetry": {
"EnableLogging": true
}
}
appsettings.Production.json (production overrides):
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.EntityFrameworkCore": "Error"
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=postgres;Port=5432;Database=wallow;Username=OVERRIDE_VIA_ENV_VAR;Password=OVERRIDE_VIA_ENV_VAR"
},
"Smtp": {
"Host": "OVERRIDE_VIA_ENV_VAR",
"Port": 587,
"UseSsl": true,
"Username": "OVERRIDE_VIA_ENV_VAR",
"Password": "OVERRIDE_VIA_ENV_VAR"
},
"Storage": {
"Provider": "S3",
"S3": {
"Endpoint": "http://garage:3900",
"AccessKey": "OVERRIDE_VIA_ENV_VAR",
"SecretKey": "OVERRIDE_VIA_ENV_VAR",
"BucketName": "wallow-files"
}
}
}
Environment Variables
Environment variables override all JSON configuration. Use double underscores (__) for nested keys:
# Connection strings
export ConnectionStrings__DefaultConnection="Host=prod-db;Port=5432;Database=wallow;Username=user;Password=pass"
export ConnectionStrings__Redis="redis-server:6379,password=secret"
# SMTP
export Smtp__Host="smtp.example.com"
export Smtp__Port="587"
export Smtp__UseSsl="true"
export Smtp__Username="smtp-user"
export Smtp__Password="smtp-password"
# Storage
export Storage__Provider="S3"
export Storage__S3__Endpoint="https://s3.amazonaws.com"
export Storage__S3__AccessKey="AKIAIOSFODNN7EXAMPLE"
export Storage__S3__SecretKey="your-secret-key"
export Storage__S3__BucketName="my-production-bucket"
# OpenTelemetry
export OpenTelemetry__ServiceName="Wallow"
export OpenTelemetry__OtlpGrpcEndpoint="http://otel-collector:4317"
Docker/Kubernetes
In containerized deployments, pass configuration via environment variables:
# docker-compose.yml
services:
api:
image: wallow-api
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=wallow;Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}
- ConnectionStrings__Redis=valkey:6379
- Storage__Provider=S3
- Storage__S3__Endpoint=http://garage:3900
- Storage__S3__AccessKey=${GARAGE_ACCESS_KEY}
- Storage__S3__SecretKey=${GARAGE_SECRET_KEY}
- Storage__S3__BucketName=${GARAGE_BUCKET}
# Kubernetes ConfigMap/Secret
apiVersion: v1
kind: ConfigMap
metadata:
name: wallow-config
data:
ASPNETCORE_ENVIRONMENT: "Production"
Storage__Provider: "S3"
OpenTelemetry__OtlpGrpcEndpoint: "http://otel-collector:4317"
---
apiVersion: v1
kind: Secret
metadata:
name: wallow-secrets
type: Opaque
stringData:
ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=wallow;Username=user;Password=secret"
ConnectionStrings__Redis: "redis:6379,password=secret"
Storage__S3__Endpoint: "http://garage:3900"
Storage__S3__AccessKey: "your-access-key"
Storage__S3__SecretKey: "your-secret-key"
Storage__S3__BucketName: "wallow-files"
Local Development Infrastructure
Start infrastructure services using Docker Compose:
pnpm backend:infra # docker compose up -d, from the repo root
pnpm backend:infra:down # stop the containers (volumes are kept)
This starts the following services. Every port is published to 127.0.0.1 only, so nothing here is reachable from another machine:
| Service | Port | Purpose | Default Credentials |
|---|---|---|---|
| PostgreSQL | 5432 | Primary database | POSTGRES_USER / POSTGRES_PASSWORD from docker/.env |
| Valkey | 6379 | Cache and SignalR backplane | See docker/.env |
| GarageHQ | 3900, 3903 | S3-compatible object storage (S3 API: 3900, Admin: 3903) | See docker/.env |
| Mailpit | 1025, 8025 | Email testing (SMTP: 1025, UI: 8025) | N/A |
| Grafana Alloy | 4317, 4318 | OpenTelemetry collector (OTLP gRPC: 4317, OTLP HTTP: 4318) | N/A |
| Grafana LGTM | 3001 | Dashboards and the logs/metrics/traces backend | admin / See docker/.env |
| Docs | 5004 | The built DocFX site | N/A |
| ClamAV (optional) | 3310 | Antivirus file scanning (--profile clamav) |
N/A |
Alloy is the collector the OTLP endpoints belong to; it forwards to the LGTM stack, which is why 4317/4318 and the Grafana UI are separate containers.
Docker environment variables are configured in docker/.env (copy from docker/.env.example and set your own values). The full key list is:
COMPOSE_PROJECT_NAME=wallow
POSTGRES_USER=wallow
POSTGRES_PASSWORD=changeme
POSTGRES_DB=wallow
VALKEY_PASSWORD=changeme
VALKEY_MAXMEMORY=256mb
GARAGE_KEY_NAME=wallow-dev
GARAGE_ACCESS_KEY=changeme
GARAGE_SECRET_KEY=changeme
GARAGE_BUCKET=wallow-files
GARAGE_REGION=us-east-1
GF_ADMIN_PASSWORD=changeme
pnpm lint:env checks that every ${VAR} the compose files interpolate is documented in the paired .env.example, so this list stays in step with docker/docker-compose.yml.
User Secrets (Development Only)
For sensitive configuration during development, use User Secrets to keep credentials out of source control:
# Initialize user secrets (one-time)
cd api/src/Wallow.Api
dotnet user-secrets init
# Set secrets
dotnet user-secrets set "YourModule:ApiKey" "my-dev-api-key"
dotnet user-secrets set "Storage:S3:SecretKey" "my-s3-secret"
# List secrets
dotnet user-secrets list
# Clear all secrets
dotnet user-secrets clear
Secrets are stored in:
- macOS/Linux:
~/.microsoft/usersecrets/<user_secrets_id>/secrets.json - Windows:
%APPDATA%\Microsoft\UserSecrets\<user_secrets_id>\secrets.json
Advanced Patterns
Options Variants
IOptions<T>-- Singleton, read once at startup. Use for configuration that does not change.IOptionsSnapshot<T>-- Scoped, re-reads on each request. Use for configuration that may change at runtime.IOptionsMonitor<T>-- Singleton with change notifications. Use in background services.
Validation with Data Annotations
Register options with ValidateDataAnnotations() and ValidateOnStart() to fail fast on misconfiguration:
services.AddOptions<YourModuleOptions>()
.Bind(configuration.GetSection(YourModuleOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
Best Practices
| Do | Don't |
|---|---|
Use Options classes with SectionName constants |
Use magic strings like configuration["Module:Setting"] |
Inject IOptions<T> into services |
Inject IConfiguration directly into services |
| Set sensible defaults in Options classes | Require all settings to be configured |
| Use environment variables for secrets in production | Commit secrets to source control |
| Use User Secrets for local development secrets | Store API keys in appsettings.json |
Validate configuration with ValidateOnStart() |
Let the app crash with cryptic errors |
| Keep Options classes in Infrastructure layer | Put configuration in Domain layer |
File Locations
| Purpose | Location |
|---|---|
| Options classes | api/src/Modules/{Module}/Wallow.{Module}.Infrastructure/Configuration/ |
| Module registration | api/src/Modules/{Module}/Wallow.{Module}.Infrastructure/Extensions/ |
| Base configuration | api/src/Wallow.Api/appsettings.json |
| Environment overrides | api/src/Wallow.Api/appsettings.{Environment}.json |
Troubleshooting
Configuration Not Loading
- Check the section name matches exactly (case-sensitive)
- Verify the JSON structure matches the Options class hierarchy
- Check environment variable naming:
Section__Property
Options Are Null
Ensure you're registering with services.Configure<T>() before the service is resolved:
// This must happen in AddYourModule(), not later
services.Configure<YourModuleOptions>(
configuration.GetSection(YourModuleOptions.SectionName));
Environment Not Being Applied
# Check current environment
echo $ASPNETCORE_ENVIRONMENT
# Verify file exists
ls -la appsettings.Production.json