Configuration Reference
All configuration is managed via appsettings.json and can be overridden with environment variables using the __ (double-underscore) convention.
Connection Strings
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=HeadlessEcommerce;Username=postgres;Password=postgres123"
}
}
| Key | Description |
|---|---|
DefaultConnection | PostgreSQL connection string |
JWT Authentication
{
"Jwt": {
"SecretKey": "YourSuperSecretKeyHere12345678901234567890",
"Issuer": "HeadlessEcommerceApi",
"Audience": "HeadlessEcommerceClient",
"ExpirationMinutes": 60
}
}
| Key | Description | Default |
|---|---|---|
SecretKey | HMAC signing key (min 32 chars) | — |
Issuer | Token issuer claim | HeadlessEcommerceApi |
Audience | Token audience claim | HeadlessEcommerceClient |
ExpirationMinutes | Access token lifetime | 60 |
Use a strong, random SecretKey in production. Never reuse the development key.
RabbitMQ
{
"RabbitMQ": {
"Host": "localhost",
"Port": 5672,
"VirtualHost": "/",
"Username": "guest",
"Password": "guest",
"QueuePrefix": "quark",
"EnableOutbox": true,
"OutboxDeliveryIntervalSeconds": 5,
"MaxRetryCount": 5,
"RetryIntervalSeconds": 5
}
}
| Key | Description | Default |
|---|---|---|
Host | RabbitMQ server hostname | localhost |
Port | AMQP port | 5672 |
VirtualHost | RabbitMQ virtual host | / |
QueuePrefix | Prefix for queue names | quark |
EnableOutbox | Enable transactional outbox | true |
OutboxDeliveryIntervalSeconds | Polling interval for outbox publisher | 5 |
MaxRetryCount | Max delivery attempts before marking failed | 5 |
The outbox publisher writes events to the database in the same transaction as business operations, then a background service polls and publishes them to RabbitMQ via a topic exchange (quark.events).
Storage
{
"Storage": {
"Provider": "Local",
"BaseUrl": "http://localhost:8080/media",
"MaxFileSizeMB": 50,
"AllowedExtensions": [".jpg", ".png", ".webp", "..."],
"Local": {
"RootPath": "wwwroot/media",
"RequestPath": "/media"
},
"NetworkShare": {
"RootPath": "//nas01/commerce-media",
"BaseUrl": "https://cdn.example.com"
},
"S3": {
"Bucket": "my-commerce-assets",
"Region": "eu-central-1",
"BaseUrl": "https://cdn.example.com",
"UsePresignedUrls": false
}
}
}
| Key | Description | Default |
|---|---|---|
Provider | Storage backend: Local, NetworkShare, or S3 | Local |
BaseUrl | Public base URL for media files | http://localhost:8080/media |
MaxFileSizeMB | Maximum upload file size | 50 |
In production, set Storage.BaseUrl to your public domain (e.g., https://quarkapi.yourdomain.com/media). This URL is stored in the database and used to construct media URLs returned by the API.
Rate Limiting
{
"RateLimiting": {
"Enabled": false,
"Fixed": { "PermitLimit": 100, "WindowSeconds": 60, "QueueLimit": 0 },
"Search": { "PermitLimit": 60, "WindowSeconds": 60, "QueueLimit": 0 },
"Sliding": { "PermitLimit": 20, "WindowSeconds": 10, "SegmentsPerWindow": 2, "QueueLimit": 0 },
"ChannelRedirect": { "PermitLimit": 60, "WindowSeconds": 60, "QueueLimit": 0 }
}
}
Rate limiting is off by default. A new installation limits nothing until you set RateLimiting:Enabled to true (environment variable RateLimiting__Enabled=true); the API logs which state it started in. Turn it on once the installation is reachable through its real proxy chain and you have confirmed visitor addresses arrive — see Reverse Proxies and Client Addresses. Sign-in codes and password attempts have their own per-account throttling, which is always active and does not depend on this switch.
Every limit is per client, never for the API as a whole. A signed-in user is counted by user id; anyone else by address (an IPv6 visitor by their /64). One visitor running out of requests has no effect on any other.
| Policy | Applied to | Default |
|---|---|---|
Search | Product search | 60 per minute |
Sliding | Session refresh and one-time-code sign-in | 20 per 10 seconds |
Fixed | Submitting a review | 100 per minute |
ChannelRedirect | Channel short-link redirects | 60 per minute |
Endpoints without a policy are not rate limited. QueueLimit is how many over-limit requests wait for the next window instead of being rejected; leave it at 0 so a client is told at once (429 with Retry-After) rather than holding a connection open.
Reverse Proxies and Client Addresses
Per-client limits are only as good as the address the API sees. Behind a reverse proxy, load balancer or ingress every connection arrives from the proxy's address, so the API reads the visitor's address from X-Forwarded-For — but only from proxies it trusts. The same address is what payment providers, consent records and sign-in throttling receive.
{
"ForwardedHeaders": {
"Enabled": true,
"TrustPrivateNetworks": true,
"KnownProxies": [],
"KnownNetworks": [],
"ForwardLimit": null
}
}
| Setting | Description | Default |
|---|---|---|
Enabled | Read X-Forwarded-For and X-Forwarded-Proto at all | true |
TrustPrivateNetworks | Trust peers on loopback, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 and fc00::/7 as proxies | true |
KnownProxies | Further proxy addresses to trust | none |
KnownNetworks | Further proxy networks to trust, in CIDR form — for example a CDN's published ranges | none |
ForwardLimit | Most hops to accept; null means as many as are trusted | null |
The header is read from right to left, and a hop is accepted only while the address that reported it is trusted. The result is the first address outside your own infrastructure. Anything a client writes into the header itself sits further left and is never reached, so it cannot be used to dodge a limit or impersonate someone else. If nothing is trusted, the headers are ignored entirely.
The defaults suit Docker, Coolify and Kubernetes, where the proxy's address is private and changes between deployments. Two things to check:
- Every proxy in the chain must pass the address on. If you run two (an edge proxy in front of the platform's own), the inner one must be told to trust the outer — in Traefik,
forwardedHeaders.trustedIPson the entry point; in NGINX,set_real_ip_from/real_ip_recursive. Otherwise it overwrites the header with the outer proxy's address. - A CDN in front (Cloudflare and the like) connects from public addresses: add its ranges to
KnownNetworks.
If the API still ends up counting visitors against a private address, it logs a warning the first time it happens: "Rate limiting is counting anonymous requests against the private address …". Until that is fixed, all anonymous visitors behind that proxy share one limit.
Set TrustPrivateNetworks to false only if the API is reachable directly from an internal network whose users you do not want to be able to set their own X-Forwarded-For. Then list your proxies explicitly in KnownProxies.
Checkout
{
"FrontendUrl": "https://shop.example.com",
"Checkout": {
"AllowedReturnUrls": [ "myapp://", "https://second-shop.example.com/" ]
}
}
| Setting | Description |
|---|---|
FrontendUrl | Where shoppers are sent after an off-site payment step when the store has no Url of its own |
Checkout:AllowedReturnUrls | Prefixes a client may name as returnUrl when it starts a payment — a mobile app's scheme, a further storefront domain. Anything under the store's own Url is always allowed; everything else is refused, because the API redirects to it |
As environment variables: Checkout__AllowedReturnUrls__0=myapp://, Checkout__AllowedReturnUrls__1=…. See Payments.
Payment Reconciliation
Settles payments whose result never reached the store (the shopper closed the tab, a lost browser return, a late bank answer) by asking the gateway. On by default.
{
"Payments": {
"Reconciliation": {
"Enabled": true,
"IntervalSeconds": 300,
"MinAgeMinutes": 10,
"MaxAgeHours": 24,
"BatchSize": 50
}
}
}
| Setting | Description |
|---|---|
Enabled | Turn the background check off (e.g. on a second API replica; running it on several is safe but redundant) |
IntervalSeconds | How often it runs (minimum 30) |
MinAgeMinutes | Payments younger than this are left to the shopper's own return |
MaxAgeHours | After this, a payment with no trace at the gateway is closed as abandoned; one the gateway knows of but never decided is flagged requires review |
BatchSize | Payments checked per run. Each is re-checked with back-off: a quarter of its age, between 5 minutes and 6 hours |
As environment variables: Payments__Reconciliation__Enabled=false, …
Licensing
{
"Licensing": {
"PublicKeyBase64": "base64-encoded-rsa-4096-public-key",
"LicenseFilePath": "/path/to/license.lic",
"LicenseFileBase64": "base64-encoded-license-file",
"PhoneHomeUrl": "https://license-server.yourdomain.com/api/phone-home"
}
}
| Key | Description |
|---|---|
PublicKeyBase64 | RSA-4096 public key for signature verification |
LicenseFilePath | Path to license file on disk (local dev) |
LicenseFileBase64 | Base64-encoded license file (production, via env var) |
PhoneHomeUrl | Optional URL for periodic license validation callbacks |
For production, prefer environment variables:
Licensing__PublicKeyBase64=...
Licensing__LicenseFileBase64=...
Stripe
{
"Stripe": {
"SecretKey": "sk_test_..."
}
}
Caching
{
"Caching": {
"Enabled": true
}
}
When enabled, storefront endpoints use response caching (300s default) with Vary on store/language/currency headers.
Logging
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
Standard ASP.NET Core logging configuration. Adjust log levels per namespace as needed.
Environment Variable Mapping
The __ convention maps to JSON nesting:
| Environment Variable | JSON Path |
|---|---|
ConnectionStrings__DefaultConnection | ConnectionStrings.DefaultConnection |
Jwt__SecretKey | Jwt.SecretKey |
RabbitMQ__Host | RabbitMQ.Host |
Storage__BaseUrl | Storage.BaseUrl |
Licensing__PublicKeyBase64 | Licensing.PublicKeyBase64 |