A lightweight, configurable API gateway built on Bun. It sits in front of your backend services and provides auth, rate limiting, request/response transformation, and structured logging through a composable plugin pipeline.
- JWT and API key authentication per route
- Redis-backed rate limiting with atomic fixed-window counters (safe for multi-instance deployments, fails open if Redis is unavailable)
- Path rewriting — strip the route prefix before forwarding
- Header sanitization — strips
x-powered-by,server, and spoofable trust headers; injectsx-request-id,x-user-id,x-forwarded-for - Upstream timeout — configurable via
UPSTREAM_TIMEOUT_MS(default 30 s); slow upstreams return504instead of hanging indefinitely - Structured JSON logging to stdout (newline-delimited, ready for log aggregators)
- Dynamic route configuration via a mounted
routes.yamlfile — no rebuild required
Each request flows through a composed plugin chain:
logger → auth → rate-limit → proxy
Every plugin has the signature:
(ctx: GatewayContext, next: () => Promise<Response>) => Promise<Response>;Calling next() advances the chain. Not calling it short-circuits (e.g. rate-limit returns 429 directly). The logger sits outermost so it always captures the final status and duration. The proxy is the terminal handler and never calls next().
Shared state passed through the pipeline for each request:
| Field | Type | Description |
|---|---|---|
req |
Request |
Original incoming request |
route |
IRoute |
Matched route config |
claims |
JWTPayload | null |
JWT claims (set by auth plugin) |
clientId |
string |
Identifies the caller for rate limiting |
rateInfo |
RateInfo |
Remaining quota, reset time |
requestId |
string |
UUID injected as x-request-id |
startedAt |
number |
performance.now() at request start |
| Variable | Required | Default | Description |
|---|---|---|---|
JWT_SECRET |
Yes* | — | Secret for HS256 JWT verification |
API_KEY |
Yes* | — | Comma-separated list of valid API keys |
PORT |
No | 3000 |
Listening port |
REDIS_URL |
No | redis://localhost:6379 |
Redis connection URL |
ROUTES_FILE |
No | — | Path to a routes.yaml file. If set, routes are loaded from this file instead of the built-in routes.ts |
UPSTREAM_TIMEOUT_MS |
No | 30000 |
Upstream fetch timeout in ms. Returns 504 Gateway Timeout if the backend does not respond in time |
*Required only if at least one route uses
jwtorapikeyauth respectively.
Routes can be defined in two ways:
Edit src/routing/routes.ts directly:
export const routes: IRoute[] = [
{
prefix: "/api/users",
target: "http://users-service:3001",
auth: "jwt",
rateLimit: { window: 60_000, max: 100 },
},
];Set the ROUTES_FILE environment variable to the path of a YAML file. The gateway loads it at startup and fails hard if the file is missing or invalid.
routes:
# JWT-authenticated route with prefix stripping and rate limiting
- prefix: /api/users
target: http://users-service:3001
auth: jwt
rewrite:
stripPrefix: true # /api/users/123 → /123
rateLimit:
window: 60000 # milliseconds
max: 100 # requests per window per client
# API key authenticated route, path forwarded as-is
- prefix: /api/orders
target: http://orders-service:3002
auth: apikey
rateLimit:
window: 60000
max: 50
# Unauthenticated public route with prefix stripping
- prefix: /public
target: http://static-service:8080
auth: none
rewrite:
stripPrefix: true| Field | Required | Description |
|---|---|---|
prefix |
Yes | URL prefix to match. Must start with / |
target |
Yes | Backend base URL (http:// or https://) |
auth |
Yes | jwt, apikey, or none |
rewrite.stripPrefix |
No | If true, strips the route prefix from the forwarded path |
rateLimit.window |
No | Time window in milliseconds |
rateLimit.max |
No | Max requests per client per window |
Routes are matched in order — first prefix match wins.
# Install dependencies
bun install
# Start with hot reload
bun run devThe gateway listens on http://localhost:3000 by default. A health check endpoint is available at GET /health.
Mount your routes.yaml as a read-only volume and pass the path via ROUTES_FILE:
services:
gateway:
build: .
ports:
- "3000:3000"
environment:
PORT: 3000
JWT_SECRET: your-secret-here
API_KEY: key1,key2
REDIS_URL: redis://redis:6379
ROUTES_FILE: /etc/gateway/routes.yaml
volumes:
- ./routes.yaml:/etc/gateway/routes.yaml:ro
depends_on:
- redis
redis:
image: redis:7-alpineTo update routes, replace routes.yaml and restart the gateway container. No rebuild is needed.
Store your routes as a ConfigMap and mount it into the gateway pod:
apiVersion: v1
kind: ConfigMap
metadata:
name: gateway-routes
data:
routes.yaml: |
routes:
- prefix: /api/users
target: http://users-service:3001
auth: jwt
rewrite:
stripPrefix: true
rateLimit:
window: 60000
max: 100
- prefix: /api/orders
target: http://orders-service:3002
auth: apikey
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: gateway
spec:
replicas: 2
selector:
matchLabels:
app: gateway
template:
metadata:
labels:
app: gateway
spec:
containers:
- name: gateway
image: your-registry/ts-gateway:latest
ports:
- containerPort: 3000
env:
- name: PORT
value: "3000"
- name: ROUTES_FILE
value: /etc/gateway/routes.yaml
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: gateway-secrets
key: jwt-secret
- name: API_KEY
valueFrom:
secretKeyRef:
name: gateway-secrets
key: api-key
- name: REDIS_URL
value: redis://redis:6379
volumeMounts:
- name: routes-config
mountPath: /etc/gateway
volumes:
- name: routes-config
configMap:
name: gateway-routesTo update routes, apply the updated ConfigMap and roll the deployment:
kubectl apply -f configmap.yaml
kubectl rollout restart deployment/gatewayAdd a plugin: implement the Plugin type and insert it into the createPipeline([...]) call in src/index.ts. Request-handling logic (routing, error mapping) lives in src/handler.ts.
import type { Plugin } from "./pipeline/pipeline";
export const myPlugin: Plugin = async (ctx, next) => {
// pre-processing
const response = await next();
// post-processing
return response;
};Add a static route: append an entry to src/routing/routes.ts.
bun run dev # Start with hot reload
bun test # Run tests (env vars set automatically via preload)
bun run build # Build
bun run clean # Remove dist/