Your own reverse proxy
The five rules for putting an installation behind a proxy you already run.
COMPOSE_PROFILES=app,data. The bundled nginx is not started; the Server and
the Client are published on 127.0.0.1:8080 and 127.0.0.1:8082, and you point
your own proxy at them.
The five rules
1. One origin, /api/v1/ to the Server and everything else to the Client
Keeping them on one origin is what makes API_BASE_URL=/ work, and then there
is no CORS to get wrong. The Client appends /api/v1 itself — the path is
fixed on every installation — so that variable names an origin and never a path.
It is fixed because getting it wrong is quiet. Where one domain serves both,
the API cannot live at the root, because the root is the application; and a
prefix that is only sometimes present is one every deployment has to get right on
its own. Get it wrong and the Client asks the right host for the wrong path, and
your reverse proxy answers with the application's own index.html instead of an
error. A value written in either of the older shapes — ending in /v1 or
/api/v1 — is normalised rather than refused.
The event socket is /api/v1/ws, under the same prefix, so it needs no rule of
its own beyond the upgrade headers and a read timeout longer than a quiet
socket.
2. Do not route /api/v1/admin
`/api/v1/admin` must never be proxied
It answers only on the Server's own loopback interface. A request through a proxy arrives inside that container as the bridge gateway, so it would get a 404 one hop later anyway.
Refuse it at the edge regardless. A refusal that costs no upstream connection is better than one that does, and somebody reading your configuration should be able to see that the operator's surface is deliberately not proxied rather than wonder whether it was forgotten. The bundled nginx returns 404 for the path for exactly these two reasons.
The way in is docker compose exec -T server aj-admin …, and that is the whole
of the supported way in. The published 127.0.0.1:8080 is not a back door: a
connection to the host's own loopback also arrives as the bridge gateway.
3. Do not turn a 502, 503 or 504 from the Server into your own page
The Client reads those. A maintenance window is the Server answering 503
with a server.maintenance body and a Retry-After, and the Client turns
exactly that into its own maintenance page, in this installation's branding and
language, and polls /api/v1/health to learn it may come back.
Intercepting it replaces that with something worse, hides the reason, and — if
your rule is broad enough to catch the Runner too — defeats the drain, so a
backup would start while a Runner was still writing a result. In nginx terms:
proxy_intercept_errors off on the API route.
The one case a static page helps is the application route, and only for 502 and 504: the Client container is gone, so the browser gets a connection error and has no application to show a message with. Never 503 there — that is the Server speaking through the API route.
4. Set TRUSTED_PROXY_NETWORKS to your proxy's network
The Server refuses to start until it has been told whose word to take for a
visitor's address. Trusting every sender of X-Forwarded-For lets a visitor
state their own address; trusting only loopback records the proxy instead of the
person.
The default names the Compose network the bundled nginx sits on. Behind your
own proxy, that default is wrong — put your proxy's network there. Reached
with no proxy at all, none is a complete answer.
Write a network address, not a host one: 172.28.0.5/24 is refused at
startup, by name, with the address it should have been. .NET normalises a CIDR
with host bits set without a word, which would turn "one machine" into "a whole
laboratory".
5. Send X-Forwarded-Proto
Without it the Server sees http, and its HTTPS redirect answers a redirect to
itself. That is an infinite loop, and it looks like the application never
loading.
What else to carry
The bundled nginx sends Host, X-Real-IP, X-Forwarded-For,
X-Forwarded-Proto, X-Forwarded-Host and X-Forwarded-Port, sets
client_max_body_size to 128 MB to match the Server's own request ceiling, and
turns proxy buffering off in both directions — the Client streams nothing, and
the Server's downloads are already streamed and would be pointlessly spooled to
disk first.
Turning buffering off does not turn off the header buffer, and that is the
one worth knowing before it happens. Response headers always have to fit in
proxy_buffer_size, 4 kB by default, and when they do not nginx answers 502
and logs upstream sent too big header. Nothing reaches the browser, so it
reads as an upstream that fell over rather than as a proxy that refused.
The response that gets close is the one that ends a federated sign-in: it
deletes .AspNetCore.Correlation.* and .AspNetCore.OpenIdConnect.Nonce.*
while setting .AspNetCore.Identity.Application, which is itself split into
C1, C2 once the claims make it long enough. An identity product behind the
same proxy is worse — measured 2026-09-01, a Keycloak authorization response
carrying AUTH_SESSION_ID, KC_AUTH_SESSION_HASH and KC_RESTART, the last a
JWT, did not fit in 4 kB and every sign-in ended 502. The bundled nginx sets
these; yours has to as well.
proxy_buffer_size 16k;
proxy_buffers 4 16k;
proxy_busy_buffers_size 16k;It sends Strict-Transport-Security, X-Content-Type-Options,
Referrer-Policy, X-Frame-Options: SAMEORIGIN and Permissions-Policy, each
with always so the header is not omitted on error responses — which are
exactly the ones an attacker can provoke most readily.
X-Frame-Options is SAMEORIGIN rather than DENY on purpose — but it does
not cover the LTI case it was chosen for. SAMEORIGIN permits framing only
from the same origin. An LTI
launch is framed by the LMS, so a Moodle at moodle.example.edu framing this
installation at algojudge.example.edu is refused exactly as DENY would refuse
it. Measured in a browser rather than deduced from the specification.
It is right for the arrangement it does cover — an LMS and this installation on
one origin, routed by path — and it is left alone because loosening the frame
policy of every installation by default is not a decision the stack makes for
you. If your LMS is on a different name, replace the header, because
X-Frame-Options cannot name a foreign origin and
Content-Security-Policy
can:
# instead of the X-Frame-Options line
add_header Content-Security-Policy "frame-ancestors https://moodle.example.edu" always;naming every platform allowed to frame you, and dropping X-Frame-Options so the
two cannot disagree. There is no other Content-Security-Policy here — see below
— so this header is the whole of the frame policy you get by default.
There is no Content-Security-Policy, and that is a decision
The Client image writes its runtime configuration into index.html as an inline
script at container start — that is what makes one image serve any installation
— and a policy strict enough to be worth having would need a nonce on it.
Nothing in the Client emits one, so a CSP would either break the application or
be unsafe-inline, which is a header that says nothing. Closing it belongs in
the Client.
Two different origins
Serving the Client and the Server on different origins works. Then
API_BASE_URL is the Server's public address, APP_BASE_URL is where a browser
reaches the Client, and the Client's origin goes in the Server's
AJ_Cors__AllowedOrigins.
They must be the same site: one registrable domain, one scheme
https://algojudge.example beside https://api.algojudge.example is one
site — different origins, same registrable domain — and everything works.
https://algojudge.example beside https://algojudge-api.other is two, and
then nobody can sign in.
The session cookie is SameSite=Lax. A browser will not keep such a cookie when
the answer that sets it comes from another site, so the sign-in fails in the way
that is hardest to diagnose: POST /identity/login answers 200 and sends a
Set-Cookie, the browser stores nothing, the next request is 401, and
the application sits on the login screen with no error anywhere.
A Domain on the cookie does not rescue it. Domain decides which hosts a
cookie is sent to, and a server may only widen one to its own parent domain —
api.algojudge.example cannot set a cookie for algojudge-api.other. It is also
unnecessary on one site: measured on two hostnames of one domain, the figures in
a problem statement drew from the API's origin with the cookie host-only, and
widening it only sent the session to the Client's host on every asset request.
What survives any arrangement is the part that needs no session at all: the instance logo, a branded typeface and the documents an operator publishes are public files, and they load from anywhere.
The API's address must not send X-Frame-Options
This is the part of two origins that has nothing to do with sessions, and it is
easy to walk into from the section above about which headers to carry: the
bundled security-headers.conf sends X-Frame-Options: SAMEORIGIN, and putting
those same headers in front of the API breaks problem statements in PDF.
The Client draws one with <object data="…/api/v1/files/…">. An <object> is an
embedding, exactly as an <iframe> is, and X-Frame-Options covers both — so on
two origins the API is a foreign origin and SAMEORIGIN refuses it.
The refusal is silent
X-Frame-Options writes nothing to the console when it blocks an <object>;
the Content-Security-Policy form does. What a person sees is the <object>'s
fallback — an empty box with a download link — and a console with nothing in
it, which looks exactly like a problem published without a statement. Measured
2026-09-08 in Chromium against the same document under four header values.
Either send no frame policy on the API's address, or send frame-ancestors
naming the Client's origin and every LMS platform — an LTI launch puts both
in the ancestor chain. Sending nothing is defensible here: embedding the API
gains an attacker nothing, because the session cookie is SameSite=Lax and an
embedder from anywhere else is answered 401.
Setting the allowlist
This stack does not set AJ_Cors__AllowedOrigins, for the reason it does not set
AJ_PublicApiUrl: an empty list entry is not the same as no list. Write an
overlay.
# state/origins.compose.yaml
services:
server:
environment:
AJ_Cors__AllowedOrigins__0: https://algojudge.exampleOne index per origin — AJ_Cors__AllowedOrigins=a,b binds zero, silently.
An <img> needs no CORS at all, so a figure can draw while every API call is
being refused; the symptom of a missing origin is the offline screen, not a
missing picture.
Certificates
Nothing in the stack issues one, and with your own proxy in front the bundled
nginx is not running at all — TLS is yours. If you use the bundled edge instead,
/.well-known/acme-challenge/ is served on both ports so an HTTP-01 challenge
is never redirected away.