This guide walks through building a working config.yaml for Spooky as a production reverse proxy, explaining every section and its trade-offs.
Use Configuration Reference for the canonical field-by-field schema and Configuration Examples for complete deployment templates.
Config File Location
Spooky loads config from the path given to --config:
spooky --config /etc/spooky/config.yaml
If --config is not provided, it falls back to /etc/spooky/config.yaml. Spooky exits at startup if the file is missing, unreadable, or fails validation.
Minimal Working Config
The absolute minimum to get Spooky running as a reverse proxy:
version: 1
listen:
tls:
cert: /etc/spooky/certs/fullchain.pem
key: /etc/spooky/certs/privkey.pem
upstream:
default:
route:
path_prefix: "/"
backends:
- id: "backend1"
address: "127.0.0.1:8080"
This listens on 0.0.0.0:9889, forwards all traffic to 127.0.0.1:8080 over HTTPS (cleartext backend needs http:// prefix — see below), and uses all defaults.
Section-by-Section Guide
version
Always set to 1. Future schema changes bump this number.
version: 1
listen
Defines where Spooky accepts client connections.
listen:
protocol: http3 # only supported value
address: "0.0.0.0" # bind all interfaces; use "127.0.0.1" for localhost-only
port: 443 # use 9889 for unprivileged; 443 requires root or CAP_NET_BIND_SERVICE
tls:
cert: /etc/spooky/certs/fullchain.pem
key: /etc/spooky/certs/privkey.pem
Spooky also automatically starts a bootstrap TLS listener on the same address/port for HTTP/1.1 and HTTP/2 clients. This is how browsers connect before they learn about HTTP/3 via the Alt-Svc header. You do not configure it separately — it shares the same cert/key.
Protocol boundary:
- native ingress is HTTP/3 only
- HTTP/3 Upgrade / Connection: upgrade requests are rejected explicitly
- bootstrap HTTP/1.1 may proxy WebSocket upgrades
- native H3 does not currently support WebSocket-style upgrade semantics
Binding port 443:
# Option A: run as root (drops privileges after binding — see security section)
sudo spooky --config /etc/spooky/config.yaml
# Option B: grant CAP_NET_BIND_SERVICE
sudo setcap cap_net_bind_service=+ep /usr/bin/spooky
spooky --config /etc/spooky/config.yaml
mTLS (client certificates):
listen:
tls:
cert: /etc/spooky/certs/fullchain.pem
key: /etc/spooky/certs/privkey.pem
client_auth:
enabled: true
require_client_cert: true # reject connections without a client cert
ca_file: /etc/spooky/certs/client-ca.pem
listeners (multi-listener)
Use listeners instead of listen when you need multiple independent ports:
listeners:
- protocol: http3
address: "0.0.0.0"
port: 443
tls:
cert: /etc/spooky/certs/public-fullchain.pem
key: /etc/spooky/certs/public-privkey.pem
- protocol: http3
address: "10.0.0.1"
port: 8443
tls:
cert: /etc/spooky/certs/internal-fullchain.pem
key: /etc/spooky/certs/internal-privkey.pem
When listeners is set, the top-level listen block is ignored. Each listener gets its own worker group and bootstrap TLS listener. All listeners share the same upstream routing table.
upstream_tls
Controls how Spooky verifies backends' TLS certificates. Defaults are safe — keep them unless your backends use a private CA.
upstream_tls:
verify_certificates: true # always verify backend TLS (default)
strict_sni: true # send backend hostname as SNI (default)
ca_file: null # set if backends use a private CA
ca_dir: null # directory of PEM CA bundles (alternative to ca_file)
Semantics:
- Hostname backends are verified against the configured backend hostname.
- IP-literal backends are verified against the configured IP identity.
- strict_sni: false disables only the SNI extension; certificate verification still stays enabled unless verify_certificates: false.
- verify_certificates: false disables upstream certificate validation entirely and should only be used in trusted environments.
To trust a private CA:
upstream_tls:
verify_certificates: true
strict_sni: true
ca_file: /etc/spooky/certs/internal-ca.pem
For cleartext HTTP backends, use http:// in the backend address instead of disabling verification:
backends:
- id: "backend1"
address: "http://127.0.0.1:8080" # explicit cleartext opt-out
upstream
The core of the config. Each key is a named upstream pool.
upstream:
api_pool: # pool name (any string, no spaces)
load_balancing:
type: round-robin # algorithm for this pool
route:
host: "api.example.com" # optional: match Host header
path_prefix: "/api" # match requests starting with /api
host_policy:
mode: pass-through # how to set Host on the upstream request
forwarded_headers:
mode: append # how to handle X-Forwarded-For
backends:
- id: "api-01"
address: "10.0.1.10:8443"
weight: 100
health_check:
path: "/health"
interval: 5000
Route matching
Routes are matched by longest path prefix. Ties are broken by: host-specific > wildcard host > host-agnostic, then method-specific > any-method, then lexicographic upstream name. Ambiguous routes (same host + path + method) are rejected at startup.
# Most specific — both host and path
route:
host: "api.example.com"
path_prefix: "/v2"
# Wildcard host — matches any subdomain
route:
host: "*.example.com"
path_prefix: "/api"
# Path only — matches any host
route:
path_prefix: "/static"
# Catch-all — use "/" as last resort
route:
path_prefix: "/"
Load balancing algorithms
| Type | When to use |
|---|---|
round-robin |
Equal backends, stateless requests |
random |
Simple equal distribution |
least-connections |
Backends with variable latency |
consistent-hash |
Sticky routing by header/cookie/query |
latency-aware |
Automatically shift traffic to faster backends |
sticky-cid |
Sticky routing by QUIC Connection ID |
For consistent-hash and sticky-cid, specify the key source:
load_balancing:
type: consistent-hash
key: "header:x-user-id" # or: cookie:session, query:user_id, path, authority
Backend address formats
backends:
- address: "10.0.1.10:8443" # HTTPS (default, verified)
- address: "https://10.0.1.10:8443" # explicit HTTPS
- address: "http://10.0.1.10:8080" # cleartext HTTP (insecure, logs warning)
- address: "backend.internal.example" # HTTPS on port 443 (hostname only)
- address: "[::1]:8443" # IPv6 with brackets
Backend weights
Weights are relative. A backend with weight 200 gets twice the traffic of one with weight 100.
backends:
- id: "primary"
address: "10.0.1.10:8443"
weight: 200 # ~67% of traffic
- id: "secondary"
address: "10.0.1.11:8443"
weight: 100 # ~33% of traffic
Health checks
Without a health_check block, the backend starts healthy and stays healthy (no active polling). Add one to detect failures:
health_check:
path: "/health" # GET this path
interval: 5000 # poll every 5 seconds
timeout_ms: 1000 # fail after 1 second with no response
failure_threshold: 3 # 3 consecutive failures → mark unhealthy
success_threshold: 2 # 2 consecutive successes → mark healthy again
cooldown_ms: 5000 # wait 5 seconds after marking unhealthy before re-polling
Host policy
Controls the Host/:authority header sent to the upstream backend.
host_policy:
mode: pass-through # forward the client's Host as-is (default)
host_policy:
mode: rewrite
host: "internal-api.example.com" # replace with this static value
host_policy:
mode: upstream # use the backend's own hostname
Forwarded headers
Controls X-Forwarded-For and related forwarding headers.
forwarded_headers:
mode: overwrite # replace with client IP only (default — trust no inbound chain)
forwarded_headers:
mode: append # append client IP to existing chain (use behind another trusted proxy)
forwarded_headers:
mode: preserve # pass inbound chain unchanged (no client IP added)
Request validation and protocol semantics
:authorityandHostmust match when both are present.CONNECTrequireshost:portauthority and is denied unless explicitly allowed by policy.HEADresponses are headers-only downstream, even if the upstream emitted a body.- HTTP/3 rejects
Upgrade-style requests; use the bootstrap HTTP/1.1 path for WebSocket upgrades.
Example protocol policy:
resilience:
protocol:
allow_0rtt: false
early_data_safe_methods: ["GET", "HEAD"]
enforce_authority_host_match: true
allow_connect: true
connect_allowed_ports: [443]
connect_allowed_authorities:
- "proxy.internal.example:443"
Per-upstream TLS override
Override the global upstream_tls for one upstream:
api_pool:
tls:
verify_certificates: true
strict_sni: true
ca_file: /etc/spooky/certs/api-internal-ca.pem
log
log:
level: info # trace | debug | info | warn | error | off
format: json # json (structured, for log collectors) | plain (human-readable)
file:
enabled: false # set true to write to a file instead of stderr
path: /var/log/spooky/spooky.log
Use json format in production (parseable by Loki, Elasticsearch, Datadog). Use plain during development.
performance
Key values to tune for production:
performance:
worker_threads: 4 # match CPU cores (or cores - 1)
reuseport: true # required when worker_threads > 1
global_inflight_limit: 8192 # max concurrent requests across all upstreams
per_backend_inflight_limit: 128
backend_timeout_ms: 5000 # total upstream response timeout
backend_connect_timeout_ms: 1000
max_request_body_bytes: 10485760 # 10 MiB
max_response_body_bytes: 104857600 # 100 MiB
Timeout ordering constraint (validated at startup):
backend_connect_timeout_ms
<= backend_timeout_ms
<= backend_body_idle_timeout_ms
<= backend_body_total_timeout_ms
<= backend_total_request_timeout_ms
observability
observability:
metrics:
enabled: true
address: "127.0.0.1" # expose only on loopback (safer)
port: 9901
path: "/metrics" # Prometheus scrape endpoint
control_api:
enabled: true
address: "127.0.0.1"
port: 9902
auth_token: "replace-with-strong-token" # required when enabled
health_path: "/health"
ready_path: "/ready"
runtime_path: "/admin/runtime"
restart_path: "/admin/runtime/restart"
reload_certs_path: "/admin/runtime/reload-certs"
Check readiness:
curl http://127.0.0.1:9902/ready
curl http://127.0.0.1:9902/health
Reload listener certificates without restarting the process:
curl -X POST \
-H 'Authorization: Bearer replace-with-strong-token' \
https://127.0.0.1:9902/admin/runtime/reload-certs
security
When Spooky starts as root (for port 443), it drops privileges after binding:
security:
privileges:
enabled: true
user: "spooky" # drop to this user after binding
group: "spooky" # drop to this group after binding
Create the system user:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin spooky
sudo chown -R spooky:spooky /etc/spooky /var/log/spooky
Validating Your Config
Spooky runs full validation on startup and exits with a clear error message. To validate without starting:
spooky --config /etc/spooky/config.yaml --validate
Common validation errors and fixes:
| Error | Fix |
|---|---|
listen.tls requires either cert/key or certificates entries |
Add cert and key paths |
Ambiguous route matcher detected |
Two upstreams have same host+path+method — make them distinct |
backend_connect_timeout_ms must be <= backend_timeout_ms |
Fix timeout ordering |
worker_threads > 1 requires reuseport=true |
Add reuseport: true |
Cannot open listen.tls.cert |
Wrong path or file missing |
Upstream has no backends |
Add at least one backend per upstream |