Skip to content

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.

  • 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-For and X-Forwarded-Proto.
  • Never route anything to Hydra’s admin port (:4445).

Since the move to Ory Hydra, a deployment has two things to expose:

SurfaceBackendPaths
ShellWatch:3000Everything 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:4444All 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:

  1. Single domain (recommended)https://shellwatch.example.com serves both; the proxy routes /oauth2/* and /.well-known/jwks.json to Hydra, everything else to ShellWatch. One certificate, one origin, no CORS configuration.
  2. Split domainshttps://shellwatch.example.com for ShellWatch, https://auth.example.com for 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.

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.allowedNetworks allowlist (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: true in production. That trusts X-Forwarded-For from 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’s trustProxy reference.)

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).

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-server etc.) — these URLs are constructed from externalUrl rather 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.

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-web

Hydra 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 flow

No CORS configuration needed — the SPA and the token endpoint share an origin.

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-web

Hydra 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 flow

Both domains need WebAuthn to agree too: security.rpId stays the app domain (shellwatch.example.com) — passkeys are registered against ShellWatch, not Hydra.

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.

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 CIDR

Set 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).

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.

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.

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.