A simple API gateway template: a single Go entrypoint that reverse-proxies requests to your backend services by path prefix, with security, traffic control, and resilience features built in and configurable per route.
┌──────────────────┐
│ client │
└─────────┬────────┘
│
┌─────────▼────────┐
│ gateway │ (this repo)
│ request ID, log, │
│ security headers,│
│ Keycloak auth │
└──┬─────┬──────┬───┘
/api/auth/* │ │ │ /* (catch-all)
│ │/api/db/*
┌─────▼┐ ┌──▼───┐ ┌▼──────────┐
│ auth │ │ db │ │ SvelteKit │
│ svc │ │ svc │ │ frontend │
└──────┘ └──────┘ └───────────┘
Requests to /api/db/* are validated against a Keycloak realm's JWKS
endpoint before being proxied, and can additionally require specific
Keycloak roles, be rate-limited, CORS-restricted, deadline-bounded, and
resilience-wrapped (retries + circuit breaking) — all per route, all
config-driven. /api/auth/* and everything else pass through untouched
unless configured otherwise.
- Go 1.24+
- (optional, for the full local walkthrough) Docker, to run a local Keycloak
cmd/gateway/main.go entrypoint: flags, logging, config, server, TLS, hot-reload, shutdown
internal/config/ Config struct, Load()/Watch() (Viper), defaults, validation
internal/logging/ structured (slog) logger constructor
internal/middleware/ AuthFunc extension point, security headers, CORS, rate limiting, roles, request logging
internal/auth/ KeycloakValidator: the default AuthFunc implementation
internal/proxy/ per-route httputil.ReverseProxy construction, retry + circuit-breaker transport
internal/server/ wires config + middleware + proxies into a hot-reloadable *http.Server
config/config.yaml sample config (auth, db, frontend routes; db shows every optional feature)
This template intentionally uses a handful of small, well-known libraries instead of staying 100% standard library:
| Library | Why |
|---|---|
spf13/viper |
YAML config with comments, automatic env-var overrides (GATEWAY_*), and built-in file-watching for hot-reload |
go-chi/chi |
Mount() is a natural fit for "hand off everything under this prefix" reverse-proxy routing, plus reusable RequestID/RealIP middleware |
lestrrat-go/jwx |
JWKS fetching/caching and JWT parsing/validation for the Keycloak auth hook |
sony/gobreaker |
Circuit breaker state machine for the per-route resilience wrapper |
golang.org/x/time/rate |
Token-bucket rate limiting (Go team maintained, effectively an stdlib extension) |
Everything else — proxy construction, the middleware chain, structured logging, graceful shutdown — is standard library only.
All fields live in config/config.yaml (path overridable via -config).
Every field can also be set via env var: GATEWAY_<SECTION>_<FIELD>, e.g.
GATEWAY_SERVER_ADDR or GATEWAY_AUTH_JWKS_URL.
| Field | Default | Description |
|---|---|---|
addr |
:8080 |
Listen address |
read_timeout_seconds |
10 |
http.Server.ReadTimeout |
write_timeout_seconds |
30 |
http.Server.WriteTimeout; also the fallback per-route request timeout |
idle_timeout_seconds |
120 |
http.Server.IdleTimeout |
shutdown_timeout_seconds |
15 |
Max time to drain in-flight requests on shutdown |
hsts_enabled |
false |
Send Strict-Transport-Security even without local TLS — set this when TLS is terminated upstream (e.g. behind a Cloudflare Tunnel) |
Optional. Leave both empty (the default) to serve plain HTTP — the normal case behind a TLS-terminating proxy or tunnel. Set both to have the gateway terminate TLS itself.
| Field | Default | Description |
|---|---|---|
cert_file |
"" |
PEM certificate file |
key_file |
"" |
PEM private key file |
Required only if at least one route has require_auth: true — config
loading fails fast otherwise.
| Field | Default | Description |
|---|---|---|
jwks_url |
— | Keycloak realm's JWKS endpoint, e.g. .../realms/<realm>/protocol/openid-connect/certs |
issuer |
— | Expected iss claim, e.g. http://localhost:8180/realms/<realm> |
audience |
— | Optional; if set, the expected aud claim |
jwks_refresh_seconds |
300 |
How often to refresh cached signing keys |
| Field | Description |
|---|---|
name |
Human-readable, used in logs |
path_prefix |
Must end in /, e.g. /api/db/ |
target |
Backend base URL, e.g. http://localhost:8082 |
strip_prefix |
If true, strips path_prefix before forwarding |
require_auth |
If true, requests must carry a valid Bearer token |
required_roles |
Optional list; if set, the token's Keycloak realm roles must include at least one. Requires require_auth: true |
timeout_seconds |
Optional; bounds this route's full handling time (auth, rate limiting, proxying incl. retries). Falls back to server.write_timeout_seconds when 0 |
cors |
Optional block, see below |
rate_limit |
Optional block, see below |
circuit_breaker |
Optional block, see below |
retry |
Optional block, see below |
Routes are matched by longest-prefix, so registration order in the file
doesn't matter — /api/db/ always wins over the catch-all / regardless
of where each appears in the list.
| Field | Default | Description |
|---|---|---|
enabled |
false |
|
allowed_origins |
[] |
Exact origin strings, e.g. https://app.example.com |
allowed_methods |
GET,POST,PUT,PATCH,DELETE,OPTIONS |
|
allowed_headers |
Content-Type,Authorization |
|
allow_credentials |
false |
Cannot be true together with "*" in allowed_origins — config loading rejects that combination, since browsers do too |
max_age_seconds |
— | Access-Control-Max-Age on preflight responses |
Per-client-IP token bucket.
| Field | Default | Description |
|---|---|---|
enabled |
false |
|
requests_per_second |
— | Required (> 0) when enabled |
burst |
max(1, requests_per_second) |
Wraps the outbound call to this route's backend.
| Field | Default | Description |
|---|---|---|
enabled |
false |
|
max_failures |
5 |
Consecutive failed requests before the breaker opens |
open_timeout_seconds |
30 |
How long the breaker stays open before allowing a trial request |
A "failure" here is a connection error, or a response status of 502/503/504 — counted per client request (after any retries for that request are exhausted), not per individual retry attempt.
Retries idempotent requests (GET/HEAD/OPTIONS/PUT/DELETE — never
POST/PATCH) on connection errors or a 502/503/504 response, with
exponential backoff.
| Field | Default | Description |
|---|---|---|
enabled |
false |
|
max_attempts |
2 |
Total attempts, i.e. 2 = one retry |
backoff_base_ms |
100 |
Backoff doubles each attempt: base, base*2, base*4, ... |
Request bodies up to 2 MiB are buffered in memory to support replaying them across attempts; larger or unknown-length bodies are sent once, without retries, to bound memory use.
-
Start stand-in backends for
authanddb(anything that listens and responds works for testing routing — SvelteKit's own dev server covers the frontend):// stand-in.go — go run stand-in.go, then again on a different port package main import ("fmt"; "net/http"; "os") func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "hit %s %s\n", r.Method, r.URL.Path) }) http.ListenAndServe(":"+os.Args[1], nil) }
go run stand-in.go 8081 & # auth go run stand-in.go 8082 & # db cd your-sveltekit-app && npm run dev -- --port 5173 &
-
(Only needed if you want
/api/db/*to actually require a token/role) run a local Keycloak and import a realm:docker run -p 8180:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin \ quay.io/keycloak/keycloak:latest start-dev
Create a realm
gateway-demo, a confidential client, and a user with realm roleapi-user(the sampleconfig.yaml'sdbroute requires it viarequired_roles); then obtain a token (client-credentials or password grant) via Keycloak's token endpoint for use withcurlbelow. -
Run the gateway:
go run ./cmd/gateway # or, to hot-reload config.yaml without restarting: go run ./cmd/gateway -watch-config -
Try it:
curl localhost:8080/healthz curl -i localhost:8080/api/auth/login # open route curl -i localhost:8080/ # frontend catch-all curl -i localhost:8080/api/db/widgets # 401 — no token curl -i localhost:8080/api/db/widgets \ -H "Authorization: Bearer $TOKEN" # 200 if the token has the api-user role, else 403
Every response carries an
X-Request-Idheader and baseline security headers (X-Content-Type-Options,X-Frame-Options,Referrer-Policy, andStrict-Transport-Securitywhenserver.hsts_enabledortlsis set). Requests that pass Keycloak validation are forwarded withX-User-Id(andX-User-Roles, if the token has arealm_access.rolesclaim) so backends don't need to re-validate the token themselves.
Add an entry to routes: in config.yaml and restart (or, with
-watch-config running, just save the file) — no code changes needed.
Run with -watch-config to have the gateway watch config.yaml and
rebuild its routing table (and re-fetch JWKS if auth changed) on every
save, without dropping connections or restarting the process. A save that
fails to load or validate is logged and ignored — the gateway keeps serving
the last good config. Off by default; enable it deliberately, since it
means the process trusts whatever the config file says at any point in its
lifetime, not just at startup.
internal/middleware.AuthFunc is the extension point:
type AuthFunc func(w http.ResponseWriter, r *http.Request) (*http.Request, bool)internal/auth.KeycloakValidator.Authenticate is the shipped
implementation. To use a different IdP, write a type with the same method
shape and swap the wiring in cmd/gateway/main.go's buildAuthFn —
nothing else in the middleware chain or route configuration needs to
change.
JSON logs via log/slog, one line per request plus lifecycle events.
Level is controlled by LOG_LEVEL (debug, info, warn, error;
defaults to info).
GET /healthz returns {"status":"ok"}. It only reports the gateway
process itself — it has no dependencies of its own to check.
On SIGINT/SIGTERM, the gateway stops accepting new connections and
waits (up to shutdown_timeout_seconds) for in-flight requests to
complete before exiting. To see it in action, start a slow request and hit
Ctrl+C on the gateway — the request should finish before the process
exits, and the log should show shutdown signal received followed by
gateway stopped cleanly.
go test ./...docker build -t go-gateway .
docker run -p 8080:8080 go-gatewayMount a custom config with -v ./my-config.yaml:/etc/gateway/config.yaml.
If you enable tls in that config, also mount the cert/key files at the
paths it references (e.g. -v ./certs:/etc/gateway/certs).
This is a template, not a production-hardened gateway. Deliberately not included:
- Load balancing across multiple instances of the same backend — a
straightforward future extension would be changing
Route.TargettoRoute.Targets []stringwith round-robin selection - Full OIDC discovery / multi-IdP support — this template targets a single
Keycloak realm's static JWKS URL, not
.well-knowndiscovery - Observability (metrics, distributed tracing) — planned for a later phase
- ACME/Let's Encrypt automatic certificates for the optional
tlsblock — bring your own cert/key, or terminate TLS upstream instead