Gateway Specification โ App Gateway¶
Status: Draft ยท Date: 2026-06-16
Implementation contract for the App Gateway โ the nginx reverse proxy that is the single public surface in front of the Frontend SPA and the Dashboard.Api. A separate demo-gateway image layers /demo/* routing on top for evaluation use.
Sources of truth¶
| Source | Owns |
|---|---|
docs/SAD.md ยง7 |
Architecture โ gateway as sole public surface, internal upstreams. |
docs/api/openapi.yaml |
API paths the gateway routes (/api/*, /healthz, /readyz). |
docs/API_SPECIFICATION.md |
API CORS / SSE behaviour the gateway pairs with (D3, D6). |
docs/DEMO_DRIVER_SPECIFICATION.md |
Demo driver paths (/demo/*) and SSE streams the demo-gateway proxies. |
CR-####/ADR-####documents referenced elsewhere do not exist โ ignore those citations.
1. Role¶
Thin nginx reverse proxy โ routing + SSE plumbing only: - No auth, business logic, API response caching, rate limiting, or request rewriting beyond path routing. - All state stays in the API. - Config-only image โ nginx + a replacement conf template; no second component that knows the contract.
The production image carries no demo routes, no resolver, and no DEMO_DRIVER_UPSTREAM. The demo-gateway image layers those on via the *.snippet include mechanism (GW12, ยง3).
2. Decisions¶
| # | Decision | Rationale |
|---|---|---|
| GW1 | Single public surface; fronts internal-only upstreams (frontend SPA, API). | SAD ยง7, NFR-04. |
| GW2 | Config-only image โ nginx + a replacement conf template. No unit tests. | Config-only by design; behaviour coverage via the integration suite. |
| GW3 | SPA fallback (try_files โฆ /index.html) is owned by the frontend container, not the gateway. |
Gateway proxies / blindly โ stays contract-agnostic. (G-Q1) |
| GW4 | /healthz + /readyz are proxied to the API. |
Ops reachability through the single surface. (G-Q2) |
| GW5 | Env-agnostic config via envsubst with a narrowed var filter per image. |
One image pair, per-environment upstreams (local-compose vs Azure). Platform-agnostic (SAD ยง6). (G-Q3) |
| GW6 | Gateway mode is the default; API CORS stays off (CORS_ALLOWED_ORIGINS empty). |
Single origin โ no CORS. The split-domain CORS path (backend D6) is the gateway-less alternative; the two modes are mutually exclusive. |
| GW7 | Base = nginxinc/nginx-unprivileged, non-root, listens 8080. |
Matches integration :8080 + a non-root container target. |
| GW8 | Gateway does not terminate TLS; serves plain HTTP on :8080. |
Internal-only network (NFR-04). TLS, where required, is a hosting concern outside this spec. (G-Q4) |
| GW9 | No build-time nginx -t; no API caching; no rate limiting (reserved, guidelines ยง9). |
Keep the build + image minimal. (G-Q5) |
| GW10 | Scalar reference UI + OpenAPI document are proxied to the API (/scalar*, /openapi/*). |
API docs reachable through the single public surface; read-only, consistent with public GET /api/* reads. |
| GW11 | Host set per-location to the upstream FQDN, never $host. |
ACA's internal Envoy routes by Host header to the upstream FQDN. Using $host (the gateway's public hostname) causes 404s on ACA. Per-location Host makes one config work on both Compose and ACA. |
| GW12 | Demo split โ the demo-gateway image FROMs the production image and layers /demo/* routing via the *.snippet include. Production carries no demo routes, no resolver, no DEMO_DRIVER_UPSTREAM. |
Keeps production minimal; demo concerns are fully isolated in the demo-gateway image (#266). |
3. Solution layout¶
Two images build from gateway/ with the same build context.
gateway/
Dockerfile # Production image: FROM nginxinc/nginx-unprivileged; COPY default.conf.template
Dockerfile.demo # Demo image: FROM production image; adds 15-detect-dns-resolver.envsh + demo.snippet.template
15-detect-dns-resolver.envsh # Entrypoint drop-in โ auto-detects DNS_RESOLVER from resolv.conf (demo image only)
default.conf.template # โ /etc/nginx/templates/default.conf.template (envsubst at start)
demo.snippet.template # โ /etc/nginx/templates/demo.snippet.template (envsubst at start, demo image only)
*.snippet render + include mechanism.
The stock nginxinc/nginx-unprivileged entrypoint renders every *.template file in /etc/nginx/templates/ into /etc/nginx/conf.d/ via envsubst at container start. This produces files with the original extension:
- default.conf.template โ conf.d/default.conf (loaded by nginx's http-level include conf.d/*.conf)
- demo.snippet.template โ conf.d/demo.snippet
The conf.d/*.snippet files are not matched by nginx's default http-level include conf.d/*.conf. They are only loaded by the explicit include /etc/nginx/conf.d/*.snippet; directive inside the production server{} block. In the production image, no .snippet file is present โ the include is a legal nginx no-op. In the demo-gateway image, demo.snippet is rendered and picked up by that directive, activating /demo/* routing inside the existing server block without any modification to default.conf.template.
4. Routing matrix¶
Production gateway¶
| Path | Upstream | Treatment |
|---|---|---|
/api/events/stream |
api |
Dedicated SSE block โ see ยง5 |
/api/ |
api |
JSON read/write; default buffering |
/scalar, /scalar/ |
api |
Scalar API-reference UI (read-only docs) โ bare path redirects to /scalar/v1 |
/openapi/ |
api |
OpenAPI document (/openapi/v1.json), fetched in-browser by Scalar |
/healthz, /readyz |
api |
API probes, proxied (GW4) |
/health |
gateway-local | return 200 โ gateway liveness (integration + platform probe) |
/ (all else) |
frontend |
SPA assets + Angular routes; frontend owns try_files fallback (GW3) |
Demo-gateway additions (demo-gateway image only)¶
These routes are absent from the production image. They are activated by demo.snippet (ยง3) when the demo-gateway image is used.
| Path | Upstream | Treatment |
|---|---|---|
/demo/stream |
demo-driver |
Dedicated SSE block โ same settings as /api/events/stream; see ยง5 |
/demo/control-stream |
demo-driver |
Dedicated SSE block โ same settings as /api/events/stream; see ยง5 |
/demo/ |
demo-driver |
Demo driver control API + panel; default buffering |
5. SSE handling (the one critical block)¶
The stream location must disable every buffering/batching layer or live updates stall and violate NFR-03 (5 s):
location = /api/events/stream {
proxy_pass http://api;
proxy_http_version 1.1;
proxy_set_header Host ${API_UPSTREAM};
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection '';
proxy_buffering off; # nginx must not batch the stream
proxy_cache off;
gzip off; # gzip buffers the stream
proxy_read_timeout 3600s; # long-lived connection
chunked_transfer_encoding on;
}
The API additionally emits X-Accel-Buffering: no (belt-and-braces).
Demo SSE blocks. The same settings apply to /demo/stream and /demo/control-stream in demo.snippet.template, with upstream demo-driver (variable-based proxy_pass โ see ยง6 demo snippet shape). These blocks live in demo.snippet.template, not in default.conf.template.
6. Config template (shape)¶
Production template (default.conf.template)¶
upstream frontend { server ${FRONTEND_UPSTREAM}; }
upstream api { server ${API_UPSTREAM}; }
server {
listen 8080;
server_tokens off;
client_max_body_size 256k;
# Server-level proxy defaults โ do NOT set Host here.
# Any location-level proxy_set_header cancels server-level inheritance,
# so every proxy location re-declares Host + forwarded headers in full.
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
location = /health { return 200 "ok\n"; access_log off; default_type text/plain; }
location = /api/events/stream { ... } # ยง5 โ SSE block; Host: ${API_UPSTREAM}
location /api/ { proxy_pass http://api; proxy_set_header Host ${API_UPSTREAM}; ... }
location /healthz { proxy_pass http://api; proxy_set_header Host ${API_UPSTREAM}; ... }
location /readyz { proxy_pass http://api; proxy_set_header Host ${API_UPSTREAM}; ... }
location = /scalar { proxy_pass http://api; proxy_set_header Host ${API_UPSTREAM}; ... }
location /scalar/ { proxy_pass http://api; proxy_set_header Host ${API_UPSTREAM}; ... }
location /openapi/ { proxy_pass http://api; proxy_set_header Host ${API_UPSTREAM}; ... }
location / { proxy_pass http://frontend; proxy_set_header Host ${FRONTEND_UPSTREAM}; ... }
# Demo extension point โ zero matches in production = legal nginx no-op.
include /etc/nginx/conf.d/*.snippet;
}
Key properties:
- No upstream demo-driver block โ production image carries no demo config.
- No resolver โ upstreams resolved at startup via named upstream blocks; variable proxy_pass is not used in the production image.
- Per-location Host set to the upstream FQDN (GW11): ${API_UPSTREAM} for api locations, ${FRONTEND_UPSTREAM} for the frontend. Never $host.
- include /etc/nginx/conf.d/*.snippet; is the extension point; the demo-gateway image drops demo.snippet there.
Demo snippet shape (demo.snippet.template)¶
# DNS resolver โ required for variable-based proxy_pass (demo-driver is optional).
# Variable proxy_pass defers DNS resolution; gateway starts cleanly when
# demo-driver is not running (returns 502 until available).
# DNS_RESOLVER is auto-detected at container start (see ยง7) โ no default ENV
# baked into the image. Examples of what detection yields per platform:
# Docker Compose (embedded DNS): 127.0.0.11
# Azure Container Apps (Azure DNS): 168.63.129.16
# Kubernetes (kube-dns/CoreDNS): cluster DNS ClusterIP
resolver ${DNS_RESOLVER} valid=10s ipv6=off;
location = /demo/stream {
set $dd http://${DEMO_DRIVER_UPSTREAM};
proxy_pass $dd;
proxy_http_version 1.1;
proxy_set_header Host ${DEMO_DRIVER_UPSTREAM};
... # same SSE settings as ยง5
}
location = /demo/control-stream {
set $dd http://${DEMO_DRIVER_UPSTREAM};
proxy_pass $dd;
proxy_http_version 1.1;
proxy_set_header Host ${DEMO_DRIVER_UPSTREAM};
... # same SSE settings as ยง5
}
location /demo/ {
set $dd http://${DEMO_DRIVER_UPSTREAM};
proxy_pass $dd;
proxy_set_header Host ${DEMO_DRIVER_UPSTREAM};
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
Key properties:
- Variable proxy_pass (set $dd โฆ; proxy_pass $dd) defers DNS resolution โ demo-driver is optional; gateway starts without it (returns 502 until demo-driver is available).
- resolver directive is required by nginx when variable proxy_pass is used.
- Host set to ${DEMO_DRIVER_UPSTREAM} for ACA Envoy routing (GW11).
7. Configuration (env)¶
Production image env¶
| Var | Example | Purpose |
|---|---|---|
FRONTEND_UPSTREAM |
frontend:80 |
Frontend upstream host:port |
API_UPSTREAM |
api:8080 |
API upstream host:port |
NGINX_ENVSUBST_FILTER |
^(FRONTEND_UPSTREAM\|API_UPSTREAM)$ |
Narrows envsubst to production vars; nginx's own $host/$uri/$scheme/$proxy_add_x_forwarded_for survive unchanged |
The production image filter excludes DNS_RESOLVER and DEMO_DRIVER_UPSTREAM โ those vars are demo-image-only (GW12).
Demo-gateway image additional env¶
| Var | Default in image | Example override | Purpose |
|---|---|---|---|
DNS_RESOLVER |
(unset โ auto-detected) | 168.63.129.16 (Azure DNS) |
DNS resolver address for variable-based proxy_pass in demo.snippet |
DEMO_DRIVER_UPSTREAM |
โ | demo-driver:3001 |
Demo driver upstream host:port; required by the snippet's resolver + proxy_pass |
NGINX_ENVSUBST_FILTER |
^(FRONTEND_UPSTREAM\|API_UPSTREAM\|DEMO_DRIVER_UPSTREAM\|DNS_RESOLVER)$ |
โ | Widened filter to include demo vars in addition to production vars |
The demo image ships no DNS_RESOLVER default ENV. A sourced entrypoint drop-in (gateway/15-detect-dns-resolver.envsh) auto-detects it at container start: if DNS_RESOLVER is unset or empty, it reads the first nameserver line from /etc/resolv.conf, falling back to 127.0.0.11 (Docker embedded DNS) when none is found. An explicitly set DNS_RESOLVER env always overrides โ the script only fills in a value when one is not supplied. This makes the demo image zero-config on Compose, Kubernetes, and Azure Container Apps alike; override only when the auto-detected value is wrong for the target environment.
8. Testing¶
Config-only โ no unit tests. Behaviour is verified by the cross-stack integration suite, which drives real traffic โ routing, SSE passthrough, health โ through the running gateway at http://localhost:8080.
9. Out of scope¶
- Auth (the API owns
X-Api-Key). - Rate limiting (reserved; guidelines ยง9).
- API response caching (NFR-05 statelessness โ every read hits the DB).
- SPA-fallback logic (frontend container owns it).
- TLS termination (internal-only; hosting concern).
- Request rewriting beyond path routing.