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

  1. Routing Overview
  2. Required Configuration Per Service
  3. TLS Termination
  4. Forwarded Headers
  5. Health Check Endpoints
  6. Proxy Configuration Examples
  7. 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.example is a working, validated version of this topology, wired into docker/docker-compose.production.yml as the caddy service (enabled with --profile direct). Copy it (cp caddy/Caddyfile.example caddy/Caddyfile) and point CADDYFILE_HOST_PATH at your copy rather than starting from scratch. Deployments behind a Pangolin tunnel use --profile pangolin instead — there the subdomain topology (below) is declared as pangolin.* 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:CookieDomain in appsettings.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 committed appsettings.json uses (.wallow.dev). Leave it empty for local development (appsettings.Development.json ships 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_PATH is a build input, not a runtime variable. vite build bakes 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 must up --build rather than pull). The published ghcr.io/bc-solutions-coder/wallow-auth image is built at root, so it suits subdomain routing as-is; served under /auth it 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 PathBase empty 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-For rather than appending to it unless its own servers { 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.example carries 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, never handle_path. In Caddy, handle_path strips the matched prefix while handle preserves it, and neither prefix may be stripped here. The path /api /api/* matcher (rather than /api*) is what keeps the match on a segment boundary, so /apidocs and /authentication reach the web app.

No header_up needed. Caddy's reverse_proxy sets X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host on every upstream request by default; adding explicit directives is redundant and caddy validate warns 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 sets X-Forwarded-Proto from the connection it actually served and discards what the client sent, so putting it behind another TLS terminator makes it forward http and breaks every OIDC redirect; in that topology declare the outer proxy trusted with servers { 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 secret in the file. The seed file is deliberately secret-less (its _comment says so), which is what lets it be committed. Client secrets arrive at runtime as environment variables keyed by clientId on the seeder service in docker-compose.production.yml — each ClientSecrets__<clientId> value attaches to the seed client with that id, so the order of the clients array 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": true aborts, and so does a non-empty secret whose clientId matches no client in the seed file.

  • firstParty — true marks one of the platform's own clients: it skips the consent screen and is bound to no organization (no tenantName/tenantId, no seedMembers). 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, false for 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's OIDC_REDIRECT_URI and OIDC_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-logout endpoint 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 companion backchannelLogoutSessionRequired (default false) declares that the client requires a sid claim in those tokens. See the Configuration guide.

  • scopes — openid, email, profile, and offline_access for 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": true client, 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