Reverse proxy¶
A reverse proxy in front of the two processes terminates TLS and publishes them under public names. This page gives what it must do, then a configuration for nginx, Apache, HAProxy and Traefik.
What the proxy must do¶
Route each path to its process. The MCP server and the Controller server are two processes (see Architecture):
| Paths | Process | Port (Compose files) |
|---|---|---|
/mcp, /.well-known/oauth-protected-resource/… (OAuth login) |
MCP server | 8000 |
everything else: /ui, /api, /.well-known/oauth-authorization-server, /authorize, /token, /register, /revoke |
Controller server | 8080 |
With one host per process (mcp.secreview.example.com and secreview.example.com), each host
sends every path to its process: no path rule. With a single host, route by path as in the
examples below.
Declare the public URLs. The servers give the clients absolute URLs built from the settings,
never from the Host header: they must be those of the proxy, as seen from the workstations.
| Variable (Controller server) | One host per process | Single host |
|---|---|---|
SECREVIEW_PUBLIC_URL |
https://secreview.example.com |
https://secreview.example.com |
SECREVIEW_MCP_URL (OAuth login) |
https://mcp.secreview.example.com/mcp |
https://secreview.example.com/mcp |
The hosts then use the same address as SECREVIEW_MCP_URL.
Keep the MCP answers streamed. /mcp may answer with server-sent events: no response
buffering, and a read timeout long enough for a review (one hour below).
Let the bodies through. A scan of security-review-ci sends up to
SECREVIEW_MAX_SCAN_REQUEST_MB (64 MiB) to the Controller server, an MCP request up to
SECREVIEW_MAX_MCP_REQUEST_MB (16 MiB): the proxy limit must not be lower.
Give the client address. The proxy sends X-Forwarded-For and X-Forwarded-Proto; both
processes trust them only from the addresses of SECREVIEW_FORWARDED_ALLOW_IPS (login
throttling, rate limit, audit log, HSTS). With the Compose files and a proxy on the host, the
containers see the address of the Docker bridge, not 127.0.0.1: set for instance
SECREVIEW_FORWARDED_ALLOW_IPS=172.16.0.0/12 (the range of your Docker networks).
The examples use a single host secreview.example.com, TLS terminated by the proxy and the ports
of the Compose files on 127.0.0.1. For one host per process, keep the Controller server part on
secreview.example.com and give the MCP part its own host, without path rule. When the proxy
runs on another machine than the processes, the tokens would cross the network in clear: enable
the built-in TLS of both processes and proxy to https://… instead of
http://127.0.0.1.
nginx¶
upstream secreview_controller { server 127.0.0.1:8080; }
upstream secreview_mcp { server 127.0.0.1:8000; }
server {
listen 443 ssl;
http2 on;
server_name secreview.example.com;
ssl_certificate /etc/ssl/secreview/fullchain.pem;
ssl_certificate_key /etc/ssl/secreview/privkey.pem;
client_max_body_size 64m;
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;
# MCP server: the endpoint (streamed answers) and its OAuth metadata
location /mcp {
proxy_pass http://secreview_mcp;
proxy_buffering off;
proxy_read_timeout 1h;
}
location /.well-known/oauth-protected-resource {
proxy_pass http://secreview_mcp;
}
# Controller server: web interface, REST API, OAuth authorization server
location / {
proxy_pass http://secreview_controller;
}
}
Apache¶
Modules proxy, proxy_http, ssl and headers (a2enmod proxy proxy_http ssl headers).
mod_proxy adds X-Forwarded-For itself.
<VirtualHost *:443>
ServerName secreview.example.com
SSLEngine on
SSLCertificateFile /etc/ssl/secreview/fullchain.pem
SSLCertificateKeyFile /etc/ssl/secreview/privkey.pem
ProxyPreserveHost On
ProxyTimeout 3600
LimitRequestBody 67108864
RequestHeader set X-Forwarded-Proto "https"
# No compression of the streamed MCP answers
SetEnvIf Request_URI "^/mcp" no-gzip
# MCP server first (the most specific paths win only when declared first)
ProxyPass /mcp http://127.0.0.1:8000/mcp flushpackets=on
ProxyPassReverse /mcp http://127.0.0.1:8000/mcp
ProxyPass /.well-known/oauth-protected-resource http://127.0.0.1:8000/.well-known/oauth-protected-resource
# Controller server: everything else
ProxyPass / http://127.0.0.1:8080/
ProxyPassReverse / http://127.0.0.1:8080/
</VirtualHost>
HAProxy¶
frontend secreview
mode http
bind :443 ssl crt /etc/haproxy/certs/secreview.pem alpn h2,http/1.1
option forwardfor
http-request set-header X-Forwarded-Proto https
timeout client 1h
acl to_mcp path /mcp
acl to_mcp path_beg /mcp/ /.well-known/oauth-protected-resource
use_backend secreview_mcp if to_mcp
default_backend secreview_controller
backend secreview_mcp
mode http
option httpchk GET /healthz
timeout server 1h
server mcp 127.0.0.1:8000 check
backend secreview_controller
mode http
option httpchk GET /healthz
timeout server 5m
server controller 127.0.0.1:8080 check
HAProxy does not buffer the answers and sets no body limit by default.
Traefik¶
Dynamic configuration (file provider). Traefik sends X-Forwarded-*, streams the server-sent
events and gives the longer rule the higher priority: the MCP router wins on its paths.
http:
routers:
secreview-mcp:
rule: Host(`secreview.example.com`) && (PathPrefix(`/mcp`) || PathPrefix(`/.well-known/oauth-protected-resource`))
entryPoints: [websecure]
service: secreview-mcp
tls:
certResolver: letsencrypt
secreview-controller:
rule: Host(`secreview.example.com`)
entryPoints: [websecure]
service: secreview-controller
tls:
certResolver: letsencrypt
services:
secreview-mcp:
loadBalancer:
servers:
- url: http://127.0.0.1:8000
secreview-controller:
loadBalancer:
servers:
- url: http://127.0.0.1:8080
With the Docker provider, the same routers go in the labels of the mcp and controller
services (traefik.http.routers.secreview-mcp.rule=…), each with
traefik.http.services.<name>.loadbalancer.server.port=8000, the port inside the container.
Check¶
curl -s https://secreview.example.com/.well-known/oauth-protected-resource/mcp # MCP server (OAuth on)
curl -s https://secreview.example.com/.well-known/oauth-authorization-server # Controller server (OAuth on)
curl -si -X POST https://secreview.example.com/mcp | head -1 # 401 (OAuth on)
curl -s https://secreview.example.com/api/health # {"status":"ok"}
The issuer of the second answer must be SECREVIEW_PUBLIC_URL exactly, and the
authorization_servers of the first one the same URL.