Skip to content

Agent Gateway on Fly.io

nyuchi/agentgateway is the agent gateway all Nyuchi agents route through. It runs the agentgateway Docker image on Fly.io (app agentgateway, org nyuchi-web-services, region iad).

Live at fundi.nyuchi.com.

File Does
Dockerfile.vercel FROM cr.agentgateway.dev/agentgateway:v1.4.1, copies in config.yaml with --chown=65532:65532 (see below), runs agentgateway -f /config.yaml. Named .vercel for historical reasons — fly.toml’s [build] block still points at this exact filename.
config.yaml config.adminAddr: "off", the single gateway listener, one routes entry per namespaced MCP server, the ui OIDC policy
fly.toml One [[services]] block, one port (3000 internal → 80/443 external)
FROM cr.agentgateway.dev/agentgateway:v1.4.1
COPY --chown=65532:65532 config.yaml /config.yaml
CMD ["-f", "/config.yaml"]

fly.toml declares exactly one [[services]] block, port 3000 internal, forwarded externally on 80 (redirects to 443) and 443:

[[services]]
internal_port = 3000
protocol = "tcp"
auto_stop_machines = false
auto_start_machines = true
min_machines_running = 1
[[services.ports]]
port = 80
handlers = ["http"]
force_https = true
[[services.ports]]
port = 443
handlers = ["tls", "http"]

An earlier revision added a second [[services]] block exposing the admin UI on its own port, 15000. It was reverted: verified working over Fly’s internal WireGuard network, but the TLS handshake on 15000 reset from every public-internet path tested. OIDC is what actually gates the UI, not the port — a second exposed port bought no security and cost real reachability. The base gateway on 443 now serves the proxy, the namespaced MCP routes, and the OIDC-gated UI together.

The ui block has no explicit gateways: key, so it falls back to the default gateway — same port as everything else — and requires a browser OIDC login before serving anything:

config:
adminAddr: "off"
ui:
policies:
oidc:
issuer: ${WORKOS_ISSUER}
clientId: ${WORKOS_CLIENT_ID}
clientSecret: ${WORKOS_CLIENT_SECRET}
redirectURI: https://fundi.nyuchi.com/oauth/callback

config.adminAddr stays "off" — agentgateway’s dedicated admin port has no built-in auth at all (adminAddr only controls where it binds, never how it authenticates).

The ${WORKOS_*} tokens are not placeholders we substitute — agentgateway resolves $VAR/${VAR} natively from its own process environment at startup, scanning the whole config file. That’s confirmed the hard way: an earlier revision tried routing secrets through a hand-rolled envsubst entrypoint in a custom debian:bookworm-slim image, and a stray literal ${WORKOS_*} left in a comment made agentgateway try to look up an env var literally named WORKOS_* and refuse to boot. Once that surfaced agentgateway’s native substitution, the custom-image detour turned out to be unnecessary.

The OIDC identity is a WorkOS Connect OAuth Application (first-party, confidential — not Public, since agentgateway exchanges the code server-side), created in the “Nyuchi Identity” WorkOS project’s Production environment — the same pattern mzizi-mcp and bushtrade-mcp use for gating internal services against the same identity pool.

Four secrets, set with flyctl secrets set, not written to config.yaml or committed to git:

Var Value
WORKOS_ISSUER The Connect OAuth Application’s issuer, e.g. https://identity.nyuchi.com
WORKOS_CLIENT_ID Its Client ID
WORKOS_CLIENT_SECRET Its Client Secret
OIDC_COOKIE_SECRET 64 hex chars (openssl rand -hex 32) — a 32-byte AES-256-GCM key

With the OIDC policy active, agentgateway redirects every unauthenticated request that doesn’t match a more specific route — including /, not just /ui/* — to the WorkOS login. /docs/mcp is unaffected because it’s an explicit route matched first.

Fly machines have an ephemeral root filesystem. The --chown fix above makes the admin UI’s live saves to /config.yaml succeed, but nothing currently backs that file with a Fly Volume — any edit made through the UI is lost on the next flyctl deploy or machine restart, both of which revert to whatever’s baked into the image. Durable UI-driven config editing needs a volume-backed config path; not done yet.

The actual point of running an API gateway in front of the Nyuchi agents: one domain, one path prefix per backend MCP server, instead of a different worker hostname for each one. agentgateway’s mcp backend type is protocol-aware — it terminates the MCP session itself, rather than doing a raw reverse proxy:

routes:
- name: docs-mcp
matches:
- path:
pathPrefix: /docs/mcp
backends:
- mcp:
targets:
- name: nyuchi-docs-mcp
mcp:
host: https://nyuchi-docs-mcp.nyuchi.workers.dev/mcp

That puts nyuchi-docs-mcp at fundi.nyuchi.com/docs/mcp, still reachable directly at docs.nyuchi.com/mcp and nyuchi-docs-mcp.nyuchi.workers.dev/mcp — the gateway is an additional front door, not a replacement:

Terminal window
curl -X POST https://fundi.nyuchi.com/docs/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"0.0.1"}}}'

Every request past initialize needs the Mcp-Session-Id the gateway hands back. Add another Nyuchi agent’s MCP server the same way: a new routes entry, a new namespace, flyctl deploy.

There’s no CI/CD wired up yet — every deploy so far has been manual from a checkout:

Terminal window
flyctl deploy -a agentgateway --local-only

--local-only builds with the local Docker daemon instead of Fly’s remote (Depot) builder. That matters in some sandboxed/proxied environments where the remote builder’s gRPC connection can’t be tunneled through an HTTP proxy — it isn’t required from a normal machine with unrestricted network access, but it’s the flag that got a build through when the remote builder timed out with a TLS handshake failure.

  • Domain: fundi.nyuchi.com — Cloudflare DNS (nyuchi.com zone), A/AAAA records to the app’s dedicated IPs, plus _fly-ownership and _acme-challenge records, following the same pattern already used for api.nyuchi.com and auth.nyuchi.com.
  • Dedicated IPs: IPv4 ($2/mo) and IPv6 — flyctl ips list -a agentgateway. Fly requires a dedicated IP once an app runs more than one [[services]] block; kept even after collapsing back to one, since it’s already allocated and paid for.
  • TLS: Let’s Encrypt, issued and managed by Fly (flyctl certs list/check/add -a agentgateway).

Shipped on Vercel first — Dockerfile.vercel running as a Vercel Function via container image support, admin UI folded into the single port Vercel forwards, domain mcp.nyuchi.com. Migrated to Fly.io for full multi-service control. The Vercel project, its domain attachment, and all its deployments were deleted after the migration; mcp.nyuchi.com was retired in favor of fundi.nyuchi.com. Two detours along the way, both reverted: a standalone debian:bookworm-slim + envsubst entrypoint (unnecessary — agentgateway substitutes env vars into its own config natively), and a dedicated admin-UI port on 15000 (unreachable from the public internet from every network tested).