Last Updated: 2026-05-14 Status: Active Service:
apps/gateway
Step-by-step guide for working on the Gateway: local development, adding new services, understanding internals, and deploying.
1. Prerequisites
| Requirement | Version | Notes |
|---|---|---|
| Node.js | ≥ 18 | Workers uses the V8 isolate, but Wrangler CLI runs in Node |
| pnpm | ≥ 9 | Workspace package manager |
| Wrangler CLI | v4 (bundled in devDependencies) |
Run via pnpm exec wrangler or workspace scripts |
| Cloudflare account | — | Required for KV namespace IDs; not needed for pure local dev |
2. Local Development Setup
2.1 Environment Variables
Create apps/gateway/.env (gitignored). This file is loaded by wrangler dev --env-file=.env.
# apps/gateway/.env
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----"
GATEWAY_SECRET="dev-shared-secret-change-in-prod"
DATABASE_URL="postgresql://user:pass@ep-xxx.neon.tech/neondb?sslmode=require"
DEBUG="true"IMPORTANT
Never commit .env or .dev.vars. These contain live secrets. Wrangler will use the local KV simulator regardless of the KV namespace IDs in wrangler.toml — you don't need real Cloudflare credentials to develop locally.
2.2 Starting the Gateway
From the workspace root:
pnpm devOr from the gateway package directly:
cd apps/gateway
pnpm devThis runs wrangler dev --env-file=.env on port 8788.
2.3 Running Alongside Other Services
All Workers in the monorepo must be running for Service Bindings to resolve. In Wrangler v4, wrangler dev with Service Bindings will auto-connect to other locally-running workers on the same machine.
Start every service you need:
# Terminal 1 — gateway
cd apps/gateway && pnpm dev
# Terminal 2 — manager (provides auth)
cd apps/manager && pnpm dev
# Terminal 3 — blog service
cd apps/blog-service && pnpm devOr use the Turborepo dev pipeline from root which starts all services in parallel.
2.4 Verifying the Gateway is Running
curl http://localhost:8788/healthExpected response:
{ "status": "ok", "env": { "BLOG_SERVICE": true, ... } }If any binding shows false, that downstream Worker is not running.
3. Making Authenticated Requests Locally
3.1 With a JWT
Obtain an access token from the manager service (login endpoint):
curl -X POST http://localhost:8787/auth/login \
-H "Content-Type: application/json" \
-d '{ "email": "you@example.com", "password": "..." }'Use the returned accessToken:
curl http://localhost:8788/blog/admin/posts \
-H "Authorization: Bearer eyJhbGci..."3.2 With an API Key
Create an API key in the manager UI or directly in the database. Then:
curl http://localhost:8788/blog/public/posts \
-H "x-api-key: sk_test_xxxxx"4. Adding a New Downstream Service
Follow these six steps every time a new Cloudflare Worker needs to be proxied through the Gateway.
Step 1 — Add the Service Binding in wrangler.toml
# apps/gateway/wrangler.toml
[[services]]
binding = "MY_NEW_SERVICE"
service = "logicspike-my-new-service"Step 2 — Add the Binding to GatewayBindings
// apps/gateway/src/types.ts
export type GatewayBindings = {
// ... existing bindings ...
MY_NEW_SERVICE: Fetcher
}Step 3 — Create the Route Proxy File
Create apps/gateway/src/routes/my-new.proxy.ts. Use the pattern below — copy the simplest existing proxy (e.g. brain.proxy.ts) and adapt:
// apps/gateway/src/routes/my-new.proxy.ts
import { Hono } from "hono"
import { authMiddleware } from "../middleware/auth.middleware"
import { accessMiddleware } from "../middleware/access.middleware"
import { subscriptionGuard } from "../middleware/subscription.middleware"
import { logError } from "../utils/logger"
import { buildDownstreamHeaders, proxyFetch, proxyResponseArgs } from "../utils/proxy.utils"
import type { GatewayBindings } from "../types"
export const myNewProxy = new Hono<{ Bindings: GatewayBindings }>()
// Apply the middleware stack appropriate for this service:
myNewProxy.use("/*", authMiddleware)
myNewProxy.use("/*", accessMiddleware("my-new-service-code"))
myNewProxy.use("/*", subscriptionGuard)
myNewProxy.all("/*", async (c) => {
const MY_NEW_SERVICE = c.env.MY_NEW_SERVICE
if (!MY_NEW_SERVICE) {
return c.json({ error: "Configuration error: MY_NEW_SERVICE binding missing" }, 500)
}
const { headers, targetUrl } = buildDownstreamHeaders(c, "/my-new")
try {
const response = await proxyFetch(MY_NEW_SERVICE, targetUrl.toString(), c.req.method, headers, c.req.raw.body)
const [body, status, responseHeaders] = proxyResponseArgs(response)
if (!response.ok) {
logError(c, {
message: `My New Service Error: ${response.status}`,
status: response.status,
error: { url: targetUrl.toString() },
})
}
return c.newResponse(body, status, responseHeaders)
} catch (error) {
logError(c, {
message: "Gateway failed to connect to my-new service",
status: 502,
error,
})
return c.json({ error: "Gateway failed to connect to my-new service" }, 502)
}
})Step 4 — Mount the Route in index.ts
// apps/gateway/src/index.ts
import { myNewProxy } from "./routes/my-new.proxy"
// ...
app.route("/my-new", myNewProxy)Step 5 — Add the Binding to the Health Check (optional but recommended)
// apps/gateway/src/index.ts — inside app.get("/health", ...)
MY_NEW_SERVICE: !!c.env.MY_NEW_SERVICE,Step 6 — Register ServiceCode in @repo/core-types
The accessMiddleware uses a ServiceCode type from @repo/core-types. If your new service needs PBAC access control, add its code there and create the corresponding ServiceAccess entitlement in the manager service.
5. Adding a Public (API-Key) Route to an Existing Service
If a service needs a public-facing door (like blog's /public/*), follow this pattern:
// In your proxy file:
// 1. Apply API-key auth to the public sub-path
myProxy.use("/public/*", apiKeyMiddleware("my-service"))
// 2. Forward all public requests
myProxy.all("/public/*", async (c) => { /* ... same proxyFetch pattern */ })
// 3. Admin door (existing)
myProxy.use("/admin/*", authMiddleware)
myProxy.use("/admin/*", accessMiddleware("my-service"))
myProxy.use("/admin/*", subscriptionGuard)
myProxy.all("/admin/*", async (c) => { /* ... */ })Then add open CORS for the public sub-path in index.ts:
app.use("/my-new/public/*", cors({
origin: "*",
allowMethods: ["GET", "OPTIONS"],
allowHeaders: ["Authorization", "Content-Type", "x-api-key"],
maxAge: 86400,
}))6. Understanding proxy.utils.ts
Three functions handle all the low-level proxy plumbing.
6.1 buildDownstreamHeaders(c, prefix)
Builds the Headers object and targetUrl for the downstream request.
- Strips
prefixfrom the start of the path (anchored — won't mangle paths containing the prefix mid-string). - Deletes the
hostheader (prevents downstream host-header rejection). - Sets
x-gateway-keyfromc.env.GATEWAY_SECRET. - Injects identity headers from
ctx.authif present.
6.2 proxyFetch(service, url, method, headers, body)
Calls service.fetch() with the correct duplex: "half" option for streaming request bodies. This option is required by the Fetch spec for non-GET requests but is absent from the DOM RequestInit TypeScript types — the function handles the type cast internally so callers don't need @ts-ignore.
6.3 proxyResponseArgs(response)
Converts a Response into the three-tuple [body, status, headers] that Hono's c.newResponse() accepts. The body is the raw ReadableStream — no buffering occurs. This is what makes SSE streaming work end-to-end.
7. Logging
File: src/utils/logger.ts
The logger emits structured JSON to console.log / console.error. Cloudflare Workers Logs (or any log drain) picks this up.
// Convenience wrappers:
await logInfo(c, { message: "Post published", status: 200 })
await logError(c, { message: "Upstream returned 500", error, status: 502 })Every log entry includes:
{
"level": "ERROR",
"message": "Blog Service Error: 500 Internal Server Error",
"timestamp": "2026-05-14T10:23:00.000Z",
"status": 500,
"path": "/blog/admin/posts",
"method": "POST",
"error": { "message": "...", "stack": "..." }
}Trace logging (every request) is gated on DEBUG === "true" to avoid burning log budget in production:
app.use("*", async (c, next) => {
if (c.env.DEBUG === "true") {
console.log(`[Gateway] ${c.req.method} ${c.req.url}`)
}
await next()
})8. Type System
8.1 GatewayBindings
src/types.ts declares all Cloudflare bindings as TypeScript types. Every Hono instance is parameterized with Hono<{ Bindings: GatewayBindings }> which gives type-safe access to c.env.*.
8.2 ContextVariableMap
src/context.ts augments Hono's ContextVariableMap interface so c.get("context") and c.set("context", ...) are fully typed:
declare module "hono" {
interface ContextVariableMap {
context: RequestContext // from @repo/core-types
}
}Without this augmentation, c.get("context") would return unknown.
9. Deployment
9.1 Deploy to Production
cd apps/gateway
pnpm deployThis runs wrangler deploy --minify. Wrangler bundles the Worker, tree-shakes, minifies, and uploads to Cloudflare. The Worker is deployed globally across all Cloudflare PoPs.
9.2 Setting Secrets
Secrets are never in wrangler.toml. Set them via:
wrangler secret put JWT_PUBLIC_KEY
wrangler secret put GATEWAY_SECRET
wrangler secret put DATABASE_URLWrangler will prompt for the value interactively.
9.3 Creating the KV Namespace (first deploy only)
wrangler kv namespace create RATE_LIMIT_KV
wrangler kv namespace create RATE_LIMIT_KV --previewCopy the id and preview_id values printed by Wrangler into wrangler.toml:
[[kv_namespaces]]
binding = "RATE_LIMIT_KV"
id = "<id from wrangler output>"
preview_id = "<preview_id from wrangler output>"9.4 Type Checking
pnpm check-typesRuns tsc --noEmit. Fix all errors before deploying — Wrangler will still deploy even with type errors, but the type check should be part of CI.
10. Common Issues and Fixes
Binding shows false in /health
The downstream Worker is not running locally. Start it in a separate terminal.
502 Gateway failed to connect to X service
Either:
- The Service Binding name in
wrangler.tomldoesn't match the Worker'snamein that Worker'swrangler.toml. - The downstream Worker crashed on startup (check its terminal).
401 Invalid token even with a valid-looking JWT
The JWT_PUBLIC_KEY in .env doesn't match the private key the manager used to sign the token. Re-fetch the public key from the manager's /auth/jwks.json endpoint or regenerate the key pair.
Rate limit firing in dev at low request counts
The in-memory store resets on every Wrangler hot-reload. You're counting requests across reloads incorrectly. Bind RATE_LIMIT_KV with the preview ID for persistent local rate-limit state.
Cannot read properties of undefined (reading 'auth') in access middleware
The accessMiddleware runs after authMiddleware and reads ctx.auth. If auth failed and returned early, accessMiddleware should never fire. If you see this, a route is applying accessMiddleware without authMiddleware before it.
11. Security Notes
-
Never add public paths to
manager.proxy.tsusingstartsWith— always use exact matching (PUBLIC_PATHS.has(rel)) to prevent path-traversal bypasses. -
Never put secrets in
wrangler.toml[vars]— those are plaintext in the Cloudflare dashboard. Usewrangler secret putfor everything sensitive. -
The
x-gateway-keyis the only trust anchor downstream services have. Downstream workers must reject requests without it. Never expose a downstream worker's URL publicly. -
Publishable keys in URLs — query-param key lookup (
?api_key=) is safe only for publishable keys (read-only, domain-locked). Never use secret keys in URLs — they appear in logs and browser history. -
The 5-minute API-key cache means a revoked key stays valid for up to 5 minutes. If you need instant revocation (e.g. a suspected compromise), flush the KV cache entry manually:
wrangler kv key delete ak:<sha256-hash> --binding=RATE_LIMIT_KV.