Build Your First Gateway
In this guide you'll build a gateway that routes traffic to three backend services, load-balances across instances, runs active health checks, authenticates requests with JWT, and exposes Prometheus metrics.
What you'll build01
A gateway that:
Routes to three backend services (users, orders, products).
Load-balances across multiple instances of a service.
Runs active health checks on upstreams.
Authenticates requests with a JWT auth provider, with public paths excluded.
Serves health probes (
/livez,/readyz,/startupz) and Prometheus metrics.
Project setup02
Create a project directory
mkdir my-gateway
cd my-gatewayStart the gateway config
Create config.yaml. The gateway section is the only required one:
gateway:
listen: "0.0.0.0:8080"
workers: 0 # 0 = one worker per CPU core
request_timeout: 30s
observability:
logging:
level: info
format: text
metrics:
enabled: true
endpoint: /metricsBuild up config.yaml section by section below. The complete file at
the end of this page is validated and ready to copy.
Define upstreams03
An upstream is a named backend made up of one or more instances. Define three services. The
order-service has two instances so it can be load-balanced:
upstreams:
- name: user-service
lb_policy: round_robin
instances:
- id: user-1
host: 127.0.0.1
port: 8081
health_check:
type: http
path: /health
interval: 10s
timeout: 2s
healthy_threshold: 2
unhealthy_threshold: 3
- name: order-service
lb_policy: least_connections
instances:
- id: order-1
host: 127.0.0.1
port: 8082
- id: order-2
host: 127.0.0.1
port: 8083
health_check:
type: http
path: /health
interval: 10s
timeout: 2s
- name: product-service
lb_policy: round_robin
instances:
- id: product-1
host: 127.0.0.1
port: 8084
health_check:
type: http
path: /health
interval: 10s
timeout: 2sWhen upstreams come from a static config file, the gateway currently uses round-robin
regardless of lb_policy, and health_check / circuit_breaker are parsed and validated but not
yet wired into the static path. Both are honored on the Kubernetes/operator-driven path. See
Upstreams, Load balancing, and
Health checks.
Configure routes04
A route maps an incoming request to an upstream by name. Each path must start with /, and each
upstream must exist in upstreams:
routes:
- path: /api/users
upstream: user-service
methods: [GET, POST, PUT, DELETE]
strip_prefix: /api
timeout: 30s
- path: /api/auth
upstream: user-service
methods: [POST]
strip_prefix: /api
skip_auth: true # public — no authentication
timeout: 10s
- path: /api/orders
upstream: order-service
methods: [GET, POST, PUT, DELETE]
strip_prefix: /api
require_roles: [orders] # authorization: caller must have this role
- path: /api/products
upstream: product-service
methods: [GET, POST, PUT, DELETE]
strip_prefix: /apistrip_prefix removes the prefix before proxying, so /api/users/123 reaches the upstream as
/users/123. Routes also support priority, add_prefix, per-route timeout, and per-route
auth fields — see Routes.
Add authentication05
Authentication is configured with named auth providers and a global auth section, not a
middleware list. Define a JWT provider, make it the default, and exclude public paths:
auth_providers:
primary-jwt:
type: jwt
secret: "${JWT_SECRET}" # HS256 by default
algorithm: HS256
issuer: my-gateway
auth:
default_provider: primary-jwt
global_enforce: true # all routes require auth unless skip_auth / skip_paths
skip_paths:
- /api/auth
- /livez
- /readyz
- /startupzA route can override the provider with auth_provider:, opt out with skip_auth: true, or require
roles/scopes with require_roles / require_scopes. See
Authentication & authorization and the Security
section for the request flow and other provider types (OIDC, API key, forward-auth, mTLS).
Compression06
Response compression runs in the request chain and is enabled by default. Tune it under
gateway.compression:
gateway:
listen: "0.0.0.0:8080"
compression:
enabled: true
level: 6
min_size: 1024
algorithms: ["br", "zstd", "gzip"]See Compression.
Compression and authentication are the middleware wired into the request chain today. CORS and
rate limiting are present in the schema (global cors, per-route cors/rate_limit) but not
yet enforced at request time. See Middleware for what is active.
Configure observability07
observability has three sub-sections: logging, metrics, and tracing.
observability:
logging:
level: info
format: text # or "json"
metrics:
enabled: true
endpoint: /metrics
tracing:
enabled: false
jaeger_endpoint: http://jaeger:4317Metrics are live on the /metrics endpoint. The JSON log format and distributed tracing are
parsed but not yet applied at runtime — see Observability.
Complete configuration08
Here's the complete, validated config.yaml:
gateway:
listen: "0.0.0.0:8080"
workers: 0
request_timeout: 30s
shutdown_timeout: 30s
compression:
enabled: true
level: 6
min_size: 1024
upstreams:
- name: user-service
lb_policy: round_robin
instances:
- id: user-1
host: 127.0.0.1
port: 8081
health_check:
type: http
path: /health
interval: 10s
timeout: 2s
healthy_threshold: 2
unhealthy_threshold: 3
- name: order-service
lb_policy: least_connections
instances:
- id: order-1
host: 127.0.0.1
port: 8082
- id: order-2
host: 127.0.0.1
port: 8083
health_check:
type: http
path: /health
interval: 10s
timeout: 2s
- name: product-service
lb_policy: round_robin
instances:
- id: product-1
host: 127.0.0.1
port: 8084
health_check:
type: http
path: /health
interval: 10s
timeout: 2s
routes:
- path: /api/users
upstream: user-service
methods: [GET, POST, PUT, DELETE]
strip_prefix: /api
timeout: 30s
- path: /api/auth
upstream: user-service
methods: [POST]
strip_prefix: /api
skip_auth: true
timeout: 10s
- path: /api/orders
upstream: order-service
methods: [GET, POST, PUT, DELETE]
strip_prefix: /api
require_roles: [orders]
- path: /api/products
upstream: product-service
methods: [GET, POST, PUT, DELETE]
strip_prefix: /api
auth_providers:
primary-jwt:
type: jwt
secret: "${JWT_SECRET}"
algorithm: HS256
issuer: my-gateway
auth:
default_provider: primary-jwt
global_enforce: true
skip_paths:
- /api/auth
- /livez
- /readyz
- /startupz
observability:
logging:
level: info
format: text
metrics:
enabled: true
endpoint: /metrics
tracing:
enabled: falseRunning the gateway09
Set environment variables
export JWT_SECRET=your-super-secret-key-change-thisStart backend services
For this example, use any HTTP servers on the configured ports (these won't answer /health, but
they let you see routing work):
python3 -m http.server 8081 # user-service
python3 -m http.server 8082 # order-service (instance 1)
python3 -m http.server 8083 # order-service (instance 2)
python3 -m http.server 8084 # product-serviceValidate and start Octopus
octopus validate -c config.yaml
octopus serve -c config.yamlTest the gateway
# Health probes (served on the listen port)
curl http://localhost:8080/livez
curl http://localhost:8080/readyz
# Public route (skip_auth) — reaches the upstream as /auth/login
curl -X POST http://localhost:8080/api/auth/login
# Authenticated route — requires a JWT signed with $JWT_SECRET
export TOKEN="eyJhbGciOi..."
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/orders/456
# Prometheus metrics
curl http://localhost:9090/metricsMonitoring10
The /metrics endpoint exposes Prometheus metrics. See Metrics for
the exact metric names Octopus emits and how to scrape them, and
Health probes for the probe semantics.
Production checklist11
Before deploying to production:
Terminate TLS (static
gateway.tlsor the Kubernetes path).Use a strong
JWT_SECRETsupplied via the environment, never committed.Scrape
/metricswith Prometheus and wire alerting.Wire Kubernetes liveness/readiness/startup probes to
/livez,/readyz,/startupz.Size
terminationGracePeriodSecondsagainstgateway.shutdown_timeoutandpre_stop_delay.Run
octopus validate -c config.yamlin CI.
See the full production checklist.
Next steps12
Common issues13
Upstream connection refused — confirm the backend is running and the instance host/port
are correct; for FARP-discovered services, check the discovery backend.
JWT authentication failing — verify JWT_SECRET is set, the token is sent as
Authorization: Bearer <token>, the algorithm matches the provider (HS256), and the token has
not expired.
Config rejected on startup — run octopus validate -c config.yaml; it names the exact field
that failed (for example a route referencing an upstream that doesn't exist).
For runtime debugging via logs and metrics, see Observability. For scaling guidance, see Scaling.