Reverse Proxy Deployment
Wallow supports deployment behind a reverse proxy. The stack is three services — the .NET API plus two Node (TanStack Start) apps — and TLS terminates at the proxy while each service runs plain HTTP internally. Two topologies are supported: path-based routing under a single domain, or subdomain routing.
Table of Contents
- Routing Overview
- Required Configuration Per Service
- TLS Termination
- Forwarded Headers
- Health Check Endpoints
- Proxy Configuration Examples
- Seeding the Production Client
1. Routing Overview
Path-based routing (default)
Route incoming requests to each service based on path prefix. All services expose HTTP on port
8080 inside the container network:
| Public path | Internal target | Prefix handling |
|---|---|---|
/api/* |
wallow-api:8080 |
Forward the full path; the API strips /api itself via PathBase=/api. Do not strip in the proxy. |
/auth/* |
wallow-auth:8080 |
Forward the full path; the auth app is built with AUTH_BASE_PATH=/auth and serves under it. Do not strip either. |
/* |
wallow-web:8080 |
The Node web app serves at root (catch-all). |
The proxy strips nothing. Both prefixed services rebase themselves — the API at runtime via
PathBase, the auth app at build time via AUTH_BASE_PATH — so a proxy that removes a prefix
breaks the service behind it.
Routing precedence: the /api and /auth prefixes must be evaluated before the catch-all
/* rule, and prefix matches must respect segment boundaries so that /apidocs and
/authentication fall through to the web app rather than matching /api or /auth.
Reference implementation.
docker/caddy/Caddyfile.exampleis a working, validated version of this topology, wired intodocker/docker-compose.production.ymlas thecaddyservice (enabled with--profile direct). Copy it (cp caddy/Caddyfile.example caddy/Caddyfile) and pointCADDYFILE_HOST_PATHat your copy rather than starting from scratch. Deployments behind a Pangolin tunnel use--profile pangolininstead — there the subdomain topology (below) is declared aspangolin.*labels in the compose file and no Caddy runs; see the Deployment guide.
Subdomain routing
Set both API_PATH_BASE= and AUTH_BASE_PATH= (empty) in .env.production and route by
host instead:
| Public host | Internal target | Notes |
|---|---|---|
api.example.com/* |
wallow-api:8080 |
API serves at subdomain root (PathBase empty). |
auth.example.com/* |
wallow-auth:8080 |
Node auth app serves at root. |
example.com/* |
wallow-web:8080 |
Node web app serves at root. |
With subdomains, align API_PUBLIC_URL / AUTH_PUBLIC_URL (and COOKIE_DOMAIN) to the
subdomains.
2. Required Configuration Per Service
Set these environment variables for each service when running behind a proxy.
Wallow.Api (.NET)
# Strip /api prefix before ASP.NET Core route matching (leave empty for subdomain routing)
PathBase=/api
# The public-facing base URL including the path prefix; used to build redirect/link URLs
API_PUBLIC_URL=https://example.com/api
# OIDC issuer is the browser-facing auth URL (the auth app proxies /connect and /.well-known)
OpenIddict__Issuer=https://example.com/auth
# CORS must allow the public origin of any browser client
Cors__AllowedOrigins__0=https://example.com
# Cookie domain for cross-origin auth cookies (see note below)
Authentication__CookieDomain=example.com
Cookie domain (
Authentication:CookieDomain). The API reads this key (double underscore in env-var form,Authentication:CookieDomaininappsettings.json) to scope its auth cookies. For path-based routing under one domain, set it to that bare host (example.com). For subdomain routing where the API, auth, and web apps live on sibling subdomains (api.example.com,auth.example.com,example.com), set it to a leading-dot parent domain (.example.com) so the cookie is shared across them — this is what the committedappsettings.jsonuses (.wallow.dev). Leave it empty for local development (appsettings.Development.jsonships it blank).
wallow-auth (Node — apps/wallow-auth)
The auth app is a pure same-origin reverse proxy: it holds no session and no cookie jar. Two variables get it routing, but they are not the whole set — a proxy configured from the first two alone silently loses the app's browser logs.
PORT=8080
# Upstream the app reverse-proxies /v1/**, /connect/**, /.well-known/** to (container-to-container)
WALLOW_API_INTERNAL_URL=http://wallow-api:8080
# Where browser log batches go. UNSET IS NOT AN ERROR: the /logs route still answers 204 and the
# records go to this container's stdout instead of the collector, so the loss is silent.
OTEL_EXPORTER_OTLP_ENDPOINT=http://alloy:4318
# Optional — the fork's outbound links for THIS deployment. Unset falls back to
# packages/styles/branding.json, so a fork that edited that file need set neither.
WALLOW_REPOSITORY_URL=https://github.com/your-org/your-fork
WALLOW_DOCS_URL=https://docs.example.com
# Optional but wanted behind any proxy — which peers' X-Forwarded-For to believe.
# See "Telling the Node apps which proxy to believe" under Forwarded Headers.
WALLOW_TRUSTED_PROXIES=private
AUTH_BASE_PATH is the sixth, and it is a build argument rather than a runtime variable — see the
callout below. (E2E_BASE_URL exists too, but only the Playwright suite sets it.)
Under path-based routing the app serves everything — SSR HTML, client assets, and its /v1,
/connect, and /.well-known passthrough routes — under the /auth prefix, so the proxy
forwards the full path unchanged.
AUTH_BASE_PATHis a build input, not a runtime variable.vite buildbakes it into every emitted asset URL, so the prefix is fixed when the image is built and setting it in the container environment does nothing. Build with--build-arg AUTH_BASE_PATH=/auth(the production compose file does this for you, which is why path-based deployments mustup --buildrather thanpull). The publishedghcr.io/bc-solutions-coder/wallow-authimage is built at root, so it suits subdomain routing as-is; served under/authit returns HTML pointing at/assets/*and every asset 404s.
wallow-web (Node BFF — apps/wallow-web)
The web app is a BFF: its server holds the OIDC token set and proxies /api/** to the API. It
serves at root.
PORT=8080
# Browser-facing issuer (must match the API's OpenIddict issuer for redirects)
OIDC_ISSUER=https://example.com/auth
# OPTIONAL: container-reachable discovery URL (avoids a hairpin back through the proxy).
# Unset, discovery is derived from OIDC_ISSUER.
OIDC_METADATA_URL=http://wallow-api:8080/.well-known/openid-configuration
OIDC_CLIENT_ID=wallow-web-client
OIDC_CLIENT_SECRET=your-secret
OIDC_REDIRECT_URI=https://example.com/bff/callback
# Where the browser lands after logout (must be a registered post-logout redirect URI)
OIDC_POST_LOGOUT_REDIRECT_URI=https://example.com/
# Downstream API for the /api reverse proxy (container-to-container)
BFF_API_BASE_URL=http://wallow-api:8080
# Secret (32+ chars) that seals/unseals the session and transaction cookies
COOKIE_PASSWORD=a-32-plus-character-random-secret
# Optional but wanted behind any proxy — which peers' X-Forwarded-For to believe.
# See "Telling the Node apps which proxy to believe" under Forwarded Headers.
WALLOW_TRUSTED_PROXIES=private
Seven of those are required — PORT and the optional OIDC_METADATA_URL are the two that are
not, which is why loadBffConfigFromEnv calls requireEnv exactly seven times.
OIDC_CLIENT_SECRET and COOKIE_PASSWORD are
confidential — set them from the container environment or a secrets manager, never in source
control. The full variable reference (including the optional OIDC_SCOPES, COOKIE_SECURE, and
SESSION_TTL_SECONDS knobs) lives in the
TypeScript SDK guide.
Local development: no proxy configuration is needed. Leave
PathBaseempty on the API and run the apps directly. See the Developer Guide for local setup.
3. TLS Termination
The proxy accepts HTTPS from clients and forwards plain HTTP to each service internally. The services do not need certificates.
Because the proxy terminates TLS, the API sees incoming requests as http:// even though clients
connected over https://. Forwarded-headers handling (next section) restores the original scheme
so that redirect URIs, the OIDC issuer URL, and cookie Secure flags all work correctly.
4. Forwarded Headers
Enable ASP.NET Core's forwarded-headers handling on the API so it reconstructs the original
scheme and host from X-Forwarded-Proto / X-Forwarded-Host:
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
Without it, the API generates OIDC discovery documents and redirect URIs with http:// instead
of https://, causing authentication failures. The auth app's passthrough server routes append
the real client IP to X-Forwarded-For on the requests they tunnel, so an outer ingress's
leftmost entry survives the hop.
Telling the Node apps which proxy to believe
Behind a proxy, the address a Node app reads off the connection is the proxy's, not the
caller's. Left uncorrected, every user of a deployment shares one rate-limit bucket at the API
and every log record's clientIp names the ingress. The caller's real address is in
X-Forwarded-For — but believing that header unconditionally is worse than ignoring it, because
any caller can send one and would then choose its own bucket and its own recorded address.
WALLOW_TRUSTED_PROXIES is what separates the two, and it gates both forwarded headers as
one trust policy: X-Forwarded-For (who the caller is) and X-Forwarded-Proto (which scheme
the browser used, which each app folds into the SDK's baseUrl and the log-ingest origin
check). Set it on wallow-auth and wallow-web to the addresses your ingress reaches them
from:
# Named shorthands (Express's `trust proxy` vocabulary):
# loopback 127.0.0.0/8, ::1/128
# linklocal 169.254.0.0/16, fe80::/10
# uniquelocal 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7
# private all of the above — the right answer for a container network,
# where the bridge subnet is assigned rather than fixed
WALLOW_TRUSTED_PROXIES=private
# Or an explicit list, comma- or space-separated. CIDR blocks and bare addresses:
WALLOW_TRUSTED_PROXIES=10.42.0.0/16, 203.0.113.7
Never name a range you do not control. A trusted peer's forwarded chain is believed, so trusting a shared network hands every host on it the ability to pick its own address.
Unset — the default — nothing is trusted: each app reports the address it actually sees and
ignores X-Forwarded-Proto. That is safe (it over-buckets rather than accepting a forged
address) and it is correct for a deployment with nothing in front. Behind an HTTPS-terminating
proxy it is a misconfiguration, not just a pessimisation: the SSR pass derives http:// origins
the hydrating browser never matches, so every prefetched query refetches. Set it whenever there
is a proxy, leave it unset whenever there is not.
When the peer IS trusted, the chain is walked from the right — the end each hop appends to — and the first entry that is not itself a trusted proxy is taken as the caller. That is what makes the result independent of how many proxies are stacked in front, and what makes a prefix the caller prepended inert.
The proxy needs its own, separate setting, and the two must agree. Caddy REPLACES
X-Forwarded-Forrather than appending to it unless its ownservers { trusted_proxies … }is configured. So in a two-proxy topology (Cloudflare → Caddy → app), an outer entry only reaches the app if Caddy was told to keep it.docker/caddy/Caddyfile.examplecarries both halves of this note.
5. Health Check Endpoints
| Service | Internal URL |
|---|---|
| Wallow.Api | http://wallow-api:8080/health/ready |
| wallow-auth | http://wallow-auth:8080/health |
| wallow-web | http://wallow-web:8080/health |
Configure your proxy or container orchestrator to poll these. A 200 OK means the service is
ready to serve traffic.
6. Proxy Configuration Examples
The examples below show path-based routing. The key rule: forward the full path and strip
nothing — the API rebases itself via PathBase, and the auth app is built with
AUTH_BASE_PATH=/auth. docker/caddy/Caddyfile.example is the maintained, validated version of
the Caddy config below.
nginx
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# API — forward the full path (the app strips /api via PathBase).
# The (/|$) regex keeps the match on a segment boundary, so /apidocs falls
# through to the web app instead of being routed here.
location ~ ^/api(/|$) {
proxy_pass http://wallow-api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
# Auth — also forwarded unstripped; the app is built with AUTH_BASE_PATH=/auth
# and serves its HTML, assets, and passthrough routes under that prefix.
location ~ ^/auth(/|$) {
proxy_pass http://wallow-auth:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
# Web (catch-all) — must be last
location / {
proxy_pass http://wallow-web:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
Caddy
example.com {
encode zstd gzip
# API — forward the full path (PathBase=/api strips it inside the app)
@api path /api /api/*
handle @api {
reverse_proxy wallow-api:8080
}
# Auth — also forwarded unstripped; the image is built with AUTH_BASE_PATH=/auth
@auth path /auth /auth/*
handle @auth {
reverse_proxy wallow-auth:8080
}
# Web (catch-all)
handle {
reverse_proxy wallow-web:8080
}
# Caddy handles TLS automatically via Let's Encrypt
}
Use
handle, neverhandle_path. In Caddy,handle_pathstrips the matched prefix whilehandlepreserves it, and neither prefix may be stripped here. Thepath /api /api/*matcher (rather than/api*) is what keeps the match on a segment boundary, so/apidocsand/authenticationreach the web app.
No
header_upneeded. Caddy'sreverse_proxysetsX-Forwarded-For,X-Forwarded-Proto, andX-Forwarded-Hoston every upstream request by default; adding explicit directives is redundant andcaddy validatewarns about it. That default is also the contract any replacement proxy must reproduce — the nginx example above sets the same three headers by hand. Note that Caddy at the edge setsX-Forwarded-Protofrom the connection it actually served and discards what the client sent, so putting it behind another TLS terminator makes it forwardhttpand breaks every OIDC redirect; in that topology declare the outer proxy trusted withservers { trusted_proxies static <ranges> }.
In nginx, the equivalent rule is proxy_pass without a URI part (no trailing slash, no
path): that forwards the original request URI untouched. Adding a trailing slash —
proxy_pass http://wallow-auth:8080/; — is what strips the prefix, and it must not be used
here.
7. Seeding the Production Client
The reference frontend (apps/wallow-web) authenticates as a confidential OIDC client. The
seeder (Wallow.SeederService) provisions that client — in production from the committed,
secret-less docker/seed.production.json, which the compose file mounts over the image-bundled
development seed.json (SEED_FILE_PATH), so localhost client definitions never leak in. The
key rule is that the issuer is the auth origin, which proxies the OIDC endpoints to the API
and serves the login UX — the client's redirect and post-logout URIs point at your public web
app, and its OIDC_ISSUER (above) points at the auth app.
Edit the clients array of docker/seed.production.json. The committed reference entry looks
like this (adapted here to example.com):
{
"clientId": "wallow-web-client",
"displayName": "Wallow Web",
"firstParty": true,
"public": false,
"redirectUris": ["https://example.com/bff/callback"],
"postLogoutRedirectUris": ["https://example.com/"],
"frontchannelLogoutUri": "https://example.com/bff/frontchannel-logout",
"scopes": [
"openid",
"email",
"profile",
"roles",
"offline_access",
"inquiries.read",
"inquiries.write",
"notifications.read",
"notifications.write"
]
}
Rules that make this a valid production client:
No
secretin the file. The seed file is deliberately secret-less (its_commentsays so), which is what lets it be committed. Client secrets arrive at runtime as environment variables keyed by clientId on the seeder service indocker-compose.production.yml— eachClientSecrets__<clientId>value attaches to the seed client with that id, so the order of theclientsarray never matters:ClientSecrets__wallow-web-client: ${OIDC_CLIENT_SECRET}Wire the same value into the BFF's
OIDC_CLIENT_SECRET. The seeder fails closed in both misconfiguration directions: a seed client with no secret that does not declare"public": trueaborts, and so does a non-empty secret whose clientId matches no client in the seed file.firstParty—truemarks one of the platform's own clients: it skips the consent screen and is bound to no organization (notenantName/tenantId, noseedMembers). The flag is the only thing that makes a client first-party — the client id,wallow-prefix or not, decides nothing. Every other client is third-party: it must name exactly one organization and always sees the consent screen. The seeder refuses to start on a client that breaks either rule, so a production seed holds first-party clients only — the organization does not exist until first-run setup creates it.public— declared explicitly on every client,falsefor a confidential client like this one. The explicit declaration is what arms the fail-closed check above.redirectUris/postLogoutRedirectUris— absolute HTTPS URLs on your public web origin. They must exactly match the BFF'sOIDC_REDIRECT_URIandOIDC_POST_LOGOUT_REDIRECT_URI. Because the API validates registered URIs at seed time, adding a client for a new domain needs no source change.frontchannelLogoutUri— the SDK BFF's/bff/frontchannel-logoutendpoint on the same origin. Registers the client for front-channel logout notifications when the SSO session ends elsewhere; omit it to opt out.backchannelLogoutUri— optional: an absolute http(s) URL Wallow POSTs a signed logout token to, server-to-server, when the SSO session ends. Its companionbackchannelLogoutSessionRequired(defaultfalse) declares that the client requires asidclaim in those tokens. See the Configuration guide.scopes—openid,email,profile, andoffline_accessfor login, plus whichever API scopes the app calls.refreshTokenLifetime— optional, seconds (60–31,536,000). How long a refresh token issued to this client lives; omit it for the kind default — 7 days for a"firstParty": trueclient, 1 day otherwise. See the Configuration guide.
Both api/seed.json and docker/seed.production.json are marked merge=ours in
.gitattributes, so your fork's seed edits survive upstream merges. See the
Deployment guide for the seeder's full production
behavior and the Configuration guide for the full seed
schema.
Common Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Proxy strips /api |
API routes return 404 | Forward /api unstripped; the app removes it via PathBase=/api |
Proxy strips /auth |
Auth app routes 404 | Forward /auth unstripped (no trailing slash on nginx proxy_pass; Caddy handle, not handle_path) |
Auth image built without AUTH_BASE_PATH=/auth |
Auth page loads but every asset 404s under /auth |
Rebuild with --build-arg AUTH_BASE_PATH=/auth (up --build, not pull) |
ASPNETCORE_FORWARDEDHEADERS_ENABLED missing on the API |
OIDC redirects use http://; login fails |
Set it on the API service |
OpenIddict__Issuer / OIDC_ISSUER mismatch |
redirect_uri or issuer errors during login |
Point both at the public auth URL |
| Redirect URIs not updated | OIDC login returns redirect_uri mismatch |
Update the seeded client redirect URIs to the public https://example.com/... URLs |