Reverse proxy & TLS
ShellWatch listens HTTP-only on a single port, and Ory Hydra’s public port sits next to it. In production you should put both behind a TLS-terminating reverse proxy. WebAuthn requires HTTPS (with the exception of http://localhost), so this isn’t optional for any internet-facing deployment.
What the proxy needs to do
Section titled “What the proxy needs to do”- Terminate TLS for
https://shellwatch.example.com. - Forward HTTP to the broker (e.g.
http://127.0.0.1:3000). - Route the Hydra public surface —
/oauth2/*and/.well-known/jwks.json— to Hydra’s public port (e.g.http://127.0.0.1:4444), either as paths on the same domain or as a separate auth subdomain (both shown below). - Forward WebSocket upgrades for
/ws,/agent-proxy, and/mcp. - Set
X-Forwarded-ForandX-Forwarded-Proto. - Never route anything to Hydra’s admin port (
:4445).
The two public surfaces
Section titled “The two public surfaces”Since the move to Ory Hydra, a deployment has two things to expose:
| Surface | Backend | Paths |
|---|---|---|
| ShellWatch | :3000 | Everything else — the SPA, /api/* (including the passkey login/consent pages and the mediated DCR endpoint under /api/hydra/*), /ws, /mcp, /agent-proxy, and the OAuth discovery docs under /.well-known/oauth-* |
| Hydra public | :4444 | All of /oauth2/* (/oauth2/auth, /oauth2/token, /oauth2/revoke, /oauth2/sessions/logout, …) plus /.well-known/jwks.json |
The split is clean — all of /oauth2/* is Hydra’s, and ShellWatch’s mediated DCR lives at /api/hydra/register (under the /api/ prefix that already goes to ShellWatch), so there’s no special-case path to carve out. The one subtlety: /.well-known/ is split — ShellWatch serves the authorization-server metadata (/.well-known/oauth-authorization-server, blended so registration_endpoint points at ShellWatch’s /api/hydra/register and authorization/token point at Hydra), while the JWKS (/.well-known/jwks.json) comes from Hydra.
You can lay this out two ways:
- Single domain (recommended) —
https://shellwatch.example.comserves both; the proxy routes/oauth2/*and/.well-known/jwks.jsonto Hydra, everything else to ShellWatch. One certificate, one origin, no CORS configuration. - Split domains —
https://shellwatch.example.comfor ShellWatch,https://auth.example.comfor Hydra. Useful if you already centralize an auth hostname. Requires Hydra CORS for the app origin, because the browser SPA calls the token endpoint cross-origin.
Whichever you choose, the issuer URL must be consistent in three places: Hydra’s urls.self.issuer, ShellWatch’s hydra.publicUrl, and what your proxy actually serves. A mismatch breaks token issuance in non-obvious ways.
Configure server.trustProxy
Section titled “Configure server.trustProxy”Without configuration, every request to ShellWatch looks like it came from the proxy’s IP. That breaks:
- The “Source IP” field on
/sign/:id(always shows the proxy). - The
security.allowedNetworksallowlist (matches the proxy IP, not the real client).
Pin trustProxy to the CIDR(s) of the proxy you actually run:
server: externalUrl: https://shellwatch.example.com trustProxy: - 10.0.0.0/8 - 172.16.0.0/12
security: # Real client IPs are now visible to the allowlist. Either narrow it to your # known clients, or open it up explicitly: allowedNetworks: - 0.0.0.0/0 - "::/0"Do not set
trustProxy: truein production. That trustsX-Forwarded-Forfrom any source — clients can spoof their own IP. Always pin to the CIDR(s) of the proxy you control. (The other accepted forms — a number-of-hops or a single CIDR string — are documented in Fastify’strustProxyreference.)
Make sure the proxy itself sets X-Forwarded-For. Don’t rely on the client.
Hydra has the equivalent knob: it only accepts a TLS-terminated (X-Forwarded-Proto: https) request from CIDRs listed in serve.tls.allow_termination_from in hydra.yml. Pin that to the same proxy CIDR(s).
server.externalUrl and hydra.publicUrl
Section titled “server.externalUrl and hydra.publicUrl”Set server.externalUrl to the URL clients use to reach ShellWatch (i.e. the proxy’s URL, not http://127.0.0.1:3000). It’s used for:
- OAuth discovery metadata (
/.well-known/oauth-authorization-serveretc.) — these URLs are constructed fromexternalUrlrather than request headers, because a directly-exposed deployment can’t trust the headers. - The SPA’s OAuth redirect URI (
${externalUrl}/auth/callback). - WebAuthn relying-party origin checks (combined with
trustedWebauthnOrigins). - Deep links in toasts and Web Push payloads.
Set hydra.publicUrl to the public issuer URL — the same domain in the single-domain layout, the auth subdomain in the split layout. It must exactly equal Hydra’s urls.self.issuer.
Caddy — single domain (recommended)
Section titled “Caddy — single domain (recommended)”Everything behind shellwatch.example.com; the OAuth2 endpoints are paths on the same host:
shellwatch.example.com { # Hydra public surface — all of /oauth2/* plus the JWKS. @hydra path /oauth2/* /.well-known/jwks.json handle @hydra { reverse_proxy 127.0.0.1:4444 }
# Everything else: ShellWatch (SPA, /api incl. /api/hydra/register DCR, # /ws, /mcp, /agent-proxy, /.well-known/oauth-*). Caddy preserves WebSocket # upgrades and X-Forwarded-* automatically. handle { reverse_proxy 127.0.0.1:3000 }}ShellWatch config:
server: externalUrl: https://shellwatch.example.com trustProxy: - 127.0.0.1/32 # Caddy is local
hydra: publicUrl: https://shellwatch.example.com adminUrl: http://hydra:4445 # compose-internal (or http://127.0.0.1:4445) spa: clientId: shellwatch-webHydra config (hydra.yml) — issuer and the browser-facing login/consent URLs all live on the one domain:
serve: tls: allow_termination_from: - 172.16.0.0/12 # where Hydra sees the proxy's requests come from
urls: self: issuer: https://shellwatch.example.com public: https://shellwatch.example.com login: https://shellwatch.example.com/api/hydra/login consent: https://shellwatch.example.com/api/hydra/consent logout: https://shellwatch.example.com/api/hydra/logout error: https://shellwatch.example.com/api/hydra/error post_logout_redirect: https://shellwatch.example.com/ # SPA root — auth guard restarts the OAuth flowNo CORS configuration needed — the SPA and the token endpoint share an origin.
Caddy — split domains
Section titled “Caddy — split domains”ShellWatch on shellwatch.example.com, Hydra on auth.example.com:
shellwatch.example.com { reverse_proxy 127.0.0.1:3000}
auth.example.com { reverse_proxy 127.0.0.1:4444}ShellWatch config:
server: externalUrl: https://shellwatch.example.com trustProxy: - 127.0.0.1/32
hydra: publicUrl: https://auth.example.com # the issuer is the auth domain now adminUrl: http://hydra:4445 spa: clientId: shellwatch-webHydra config — the issuer moves to the auth domain, the login/consent providers stay on the app domain, and CORS must allow the app origin (the browser SPA calls https://auth.example.com/oauth2/token cross-origin):
serve: tls: allow_termination_from: - 172.16.0.0/12 public: cors: enabled: true allowed_origins: - https://shellwatch.example.com
urls: self: issuer: https://auth.example.com public: https://auth.example.com login: https://shellwatch.example.com/api/hydra/login consent: https://shellwatch.example.com/api/hydra/consent logout: https://shellwatch.example.com/api/hydra/logout error: https://shellwatch.example.com/api/hydra/error post_logout_redirect: https://shellwatch.example.com/ # SPA root — auth guard restarts the OAuth flowBoth domains need WebAuthn to agree too: security.rpId stays the app domain (shellwatch.example.com) — passkeys are registered against ShellWatch, not Hydra.
nginx — single domain
Section titled “nginx — single domain”server { listen 443 ssl http2; server_name shellwatch.example.com;
ssl_certificate /etc/letsencrypt/live/shellwatch.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/shellwatch.example.com/privkey.pem;
# WebSocket-friendly defaults — required for /ws, /agent-proxy, /mcp proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";
proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme;
# No buffering — terminal output is byte-stream proxy_buffering off;
# Long-lived connections (WebSocket, MCP streaming) proxy_read_timeout 1d; proxy_send_timeout 1d;
# Hydra public surface — all of /oauth2/* plus the JWKS. location /oauth2/ { proxy_pass http://127.0.0.1:4444; } location = /.well-known/jwks.json { proxy_pass http://127.0.0.1:4444; }
# Everything else: ShellWatch (incl. /api/hydra/register mediated DCR) location / { proxy_pass http://127.0.0.1:3000; }}ShellWatch config is the same as the single-domain Caddy example above. For split domains with nginx, add a second server block for auth.example.com that proxies / to 127.0.0.1:4444, and apply the same Hydra CORS/issuer settings as the split-domain Caddy example.
Behind an AWS ALB
Section titled “Behind an AWS ALB”ALBs terminate TLS, set X-Forwarded-For, and handle WebSocket upgrades transparently. Use path-based routing rules to send /oauth2/* and /.well-known/jwks.json to a Hydra target group and everything else (including the /api/hydra/register DCR endpoint) to ShellWatch. Pin trustProxy to the VPC CIDR (or the ALB’s specific subnets if you can):
server: externalUrl: https://shellwatch.example.com trustProxy: - 10.0.0.0/16 # the VPC CIDRSet the ALB target-group idle timeout high enough for long-lived WebSockets (default 60 s is usually fine for /ws since terminal traffic keeps it warm; raise it for very quiet sessions).
Behind Cloudflare
Section titled “Behind Cloudflare”Add Cloudflare’s published IP ranges to trustProxy. Be aware that Cloudflare proxies WebSockets only on certain plans and can buffer responses; you may need to disable proxying for /ws / /agent-proxy if you see oddities.
Keep the Hydra admin port private
Section titled “Keep the Hydra admin port private”Hydra’s admin API (:4445) accepts login/consent decisions, client CRUD and token introspection without further authentication. ShellWatch needs to reach it (hydra.adminUrl); nothing else does. Don’t create a proxy route for it, don’t publish the port beyond loopback, and verify from outside that https://shellwatch.example.com/admin/... and :4445 are unreachable.
Use HTTPS in production
Section titled “Use HTTPS in production”ShellWatch keeps no session cookie — the web UI is a public OAuth client that holds its tokens in the browser, and every other client sends a Bearer token. What still requires https:// in production is WebAuthn (passkeys only work over a secure origin, http://localhost aside) and the OAuth flow itself. Make sure server.externalUrl and hydra.publicUrl use https://, and that the proxy sets X-Forwarded-Proto: https so ShellWatch and Hydra build correct redirect/issuer URLs.