Every subsystem, with its actual config
omlb bundles reverse proxy, automatic HTTPS, load balancing, health-checked
failover, the edge security gate, VRRP self-HA, live config, caching, L4
proxying, self-healing managed pools, plugins, a web UI, and observability into
one Rust binary on Cloudflare's Pingora. Thirteen sections below, each with the plain-English version and the exact YAML
that turns it on.
First match wins, top to bottom
A route is a match plus a destination. The match side reads host, path prefix,
method, and exact headers; the first route that fits, top to bottom, takes the
request. The destination is either an upstream pool or a canned respond —
handy for health endpoints and redirects that have no business waking a backend.
Need to reshape the request on its way through? rewrite strips or
prepends a path prefix and can swap the Host header before the
backend ever sees it.
One thing we deliberately left out: regex. Hosts match exactly, paths match on
prefix, and that's the whole grammar. It keeps configs boring to read — but if
your routing genuinely needs a regex engine, omlb will frustrate
you today.
routes: # first match wins, top to bottom
- { name: api, match: { host: x.com, path_prefix: /api/, methods: [GET, POST] }, upstream: api_pool }
- name: legacy
match: { host: x.com, path_prefix: /old/ }
upstream: app
rewrite: { strip_prefix: /old, add_prefix: /v2, host: internal.x.com }
- { name: assets, match: { host: x.com, path_prefix: /assets/ }, upstream: app, encode: true, max_body: 10485760 }
- { name: healthz, match: { host: x.com, path_prefix: /healthz }, respond: { status: 200, body: "ok" } }
- { name: www, match: { host: www.x.com }, respond: { status: 308, headers: { location: "https://x.com" } } } ACME by default, bring your own when you need to
List your domains under tls.acme and certificates simply happen:
issued when omlb boots, renewed on schedule, and swapped into the
live SNI store mid-flight — a fresh cert starts serving without dropping a
single connection. There's no renewal cron to forget and no reload to schedule.
Every domain shares one HTTP-01 responder on http01_address, so
port 80 stays a solved problem.
The honest asterisk: HTTP-01 is the only challenge type today, and wildcards
need DNS-01 — so wildcard certs are bring-your-own under tls.certs. They'll serve fine; they just won't renew themselves. Static and ACME certs
share one store, and SNI resolution tries exact, then wildcard, then the default.
tls:
min_version: "tls1.2"
acme:
enabled: true
directory: "https://acme-v02.api.letsencrypt.org/directory" # swap to …/acme-staging-v02/… to test
contacts: ["mailto:admin@example.com"]
terms_of_service_agreed: true
cache_dir: /var/lib/omlb/acme # persist account + certs across restarts
http01_address: "0.0.0.0:80" # HTTP-01 challenge responder (port 80 must be reachable)
renew_before_days: 30
domains: [example.com, www.example.com, api.example.com]
listeners:
- { name: https, address: "0.0.0.0:443", tls: { managed: true }, http2: true } tls:
certs:
- { sni: ["*.example.com"], cert: /etc/ssl/wild.crt, key: /etc/ssl/wild.key } # single-label wildcard
- { sni: ["example.com"], cert: /etc/ssl/apex.crt, key: /etc/ssl/apex.key }
- { sni: [], cert: /etc/ssl/fallback.crt, key: /etc/ssl/fallback.key } # default/fallback
listeners:
- { name: https, address: "0.0.0.0:443", tls: { managed: true }, http2: true } Four algorithms, per pool
Every pool chooses its own algorithm: weighted round_robin, random, power_of_two (pick two backends at random,
keep the less-loaded one — the sensible default), or consistent Ketama hashing for when clients need to stick to a backend. Weights are
per-backend, and any backend can be dialed over TLS with its own SNI.
upstreams:
app:
algorithm: power_of_two # round_robin | random | power_of_two (p2c/least-conn) | consistent (ketama)
backends:
- { addr: "10.0.0.1:8080", weight: 3 }
- { addr: "10.0.0.2:8080" }
- { addr: "10.0.0.3:8080", tls: true, sni: app.internal } # TLS to the backend Failover that actually fails over
Two detection layers work together. Active checks probe each backend on an interval — a TCP connect or a real HTTP request — and pull it out of rotation after enough failures. Passive outlier detection doesn't wait for the next probe: a backend that starts answering 5xx on live traffic gets ejected on the spot and re-admitted later. And when a request does land on a dying backend, the retry policy moves it to a healthy one instead of surfacing the error. Both layers ship enabled, with thresholds that behave sanely out of the box.
Timeouts are per pool — connect, read, write — and 0s disables one
deliberately, which is exactly what you want in front of SSE and long-polls.
health_check: { kind: http, path: /healthz, interval: 2s, healthy_threshold: 2, unhealthy_threshold: 3 }
outlier: { enabled: true, consecutive_errors: 5, ejection_time: 30s } # passive circuit-break
retry: { enabled: true, max_retries: 1, retry_on_connect_error: true }
timeout: { connect: 5s, read: 30s, write: 30s } # 0s = disabled, e.g. for SSE/long-poll fail2ban's job, without fail2ban
The gate sits in front of routing, so a request gets judged before omlb even decides where it would go — which means scans against
hostnames you don't serve still get caught. It watches three things: request
rate per IP (a token bucket; over the limit means 429), trap paths
(touch /.env or /wp-login.php once and that IP eats a 403 on every host until ban_duration expires), and —
optionally — repeated 404s inside a strike window. Strikes ship off: a Matrix
homeserver or a REST API 404s legitimately all day, and we'd rather not ban
your users.
Bans track the real client address even behind Docker, survive config reloads,
and show up in metrics as omlb_abuse_*. Ranges on the allowlist are never touched — put your own infra there.
security:
enabled: true
allowlist: [127.0.0.0/8, "::1", 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16]
rate_limit_rps: 50 # per-IP token bucket; over-limit -> 429
rate_limit_burst: 100
ban_duration: 1h
max_strikes: 0 # strike-bans OFF — 404 is normal for Matrix/REST; enable only on static-only hosts
strike_window: 1m
strike_statuses: [404] # never 401/403 (app auth) or 5xx (backend outage)
ban_paths: [/.env, /.git/, /wp-login.php, /xmlrpc.php, /.aws/, /.ssh/] python3 bench/bench.py security measures the gate on your hardware.
VRRP and a floating VIP, without keepalived
Run omlb on two boxes, give both the same virtual_router_id and different priorities, and they hold an election over UDP heartbeats.
The winner claims the virtual_ip — the one address your DNS points
at. Go silent for dead_multiplier × advert_interval and the backup
takes the address over; preempt decides whether the old master gets
it back when it recovers. The only privilege it needs is CAP_NET_ADMIN, for moving the IP.
Worth knowing before you rely on it: the security gate's ban table stays local to each node, so a scanner banned on the master starts fresh after a failover.
ha:
enabled: true
virtual_router_id: 51 # MUST match on both nodes
priority: 150 # A = 150, B = 100
interface: eth0
virtual_ip: "10.0.0.100/24" # the floating address clients use
bind: "10.0.0.11:1821" # this node's heartbeat address
peers: ["10.0.0.12:1821"] # the other node's heartbeat address
advert_interval: 1s
dead_multiplier: 3 # promote after ~3s of master silence
preempt: true # reclaim MASTER when this node returns Hot reload that doesn't lie about what's live
One YAML document, three layers: compiled-in defaults underneath, your file on
top of them, and OMLB_* environment variables above both for the
knobs that differ per machine. The schema rejects unknown keys outright —
omlb --check fails on a typo'd key instead of quietly ignoring it,
which is the difference between a config language and a suggestion box. While
running, a file watcher applies edits atomically: routes, plugin chains,
rewrites, cache policy, and retry/timeout budgets all swap without dropping a
connection.
Structural edits — listeners, backend sets, algorithms, health checks, TLS,
HA — don't hot-apply, on purpose. omlb refuses the reload and keeps
serving the last good config, because half of a new topology is worse than all
of the old one.
omlb --check -c config.yaml # parse + cross-validate; non-zero exit on any error
omlb -c config.yaml # run
OMLB_LOG_LEVEL=debug OMLB_THREADS=8 omlb -c config.yaml # OMLB_* overrides scalar knobs
# hot reload: just edit config.yaml — the config-watcher swaps routes/plugins/rewrites/cache/timeouts
# atomically, with zero dropped connections (structural changes are rejected, not half-applied) ArcSwap),
so applying an edit is one pointer swap. A request in flight keeps whichever
config generation it started under; there's no moment of half-old, half-new.
Skip the upstream entirely on a hit
Add cache: { ttl, max_body } to a route and cacheable GET responses
start being answered straight from memory — no upstream round-trip, no filter
chain, just bytes out. "Cacheable" is strict: a 200 with no Authorization on the way in and no Set-Cookie or no-store on the
way out. The store is a bounded LRU sized by server.cache_max_entries, so it can't eat the host.
server: { cache_max_entries: 10000 } # bounded in-memory LRU; 0 disables
routes:
- { name: static, match: { host: x.com, path_prefix: /assets/ }, upstream: app, cache: { ttl: 5m, max_body: 1048576 } } Plain TCP, with optional TLS termination
Not everything at the edge speaks HTTP. l4 entries forward raw
TCP — an IRC daemon, a Postgres, anything — with two optional TLS twists: tls: true terminates client TLS using the same certificate store
as the HTTPS listeners, and backend_tls: true dials onward to the
backend over TLS.
l4:
- { name: irc-tls, address: "0.0.0.0:6697", backend: "ergo:6667", tls: true } # terminate TLS -> plaintext backend
- { name: pg, address: "0.0.0.0:5432", backend: "db.internal:5432", timeout: 300s } Self-healing backends, between compose and Kubernetes
For backends that are containers on the same host, a pool can manage them
instead of merely balancing across them. Label the containers, point the
pool's selector at the label, and omlb adopts them,
holds the pool at replicas healthy instances, and replaces any
container its own health checks give up on — including the hung-but-alive kind
that a Docker restart: policy will happily leave running forever.
Your compose file creates the containers exactly once (with restart: "no");
replacements are cloned from an adopted container's config, so there is no
second copy of the image spec to keep in sync.
upstreams:
app:
algorithm: round_robin # p2c is rejected for managed pools (see below)
health_check: { kind: http, path: /healthz, host: app, interval: 2s } # REQUIRED
managed:
provider: docker # only value in v1
selector: "omlb.pool=app" # adopt containers with this Docker label
port: 8080 # container port to route to
network: app_net # which network's container IP to use
replicas: 3
recreate_grace: 30s # MUST exceed a backend's startup+health time
restart_backoff: 5s # min gap between lifecycle actions
max_actions_per_min: 6 # circuit breaker: stops a crash-loop storm
min_healthy: 1 - Single host, adopt-only — no cross-node scheduling, no rollouts, no image/spec management.
- Needs the Docker socket, which is root-equivalent — scope it with a socket proxy in production.
recreate_gracemust exceed a backend's real startup+health time, or a slow-starting container gets churned.power_of_twois rejected for managed pools (in-flight weighting needs static backends), and passive outlier detection doesn't apply — active health checks and the reconciler cover them instead.
Native filters first, WASM when you need it
Six filters ship in-process, written in Rust against zero-copy accessors: rate limiting, basic auth, IP ACLs, CORS, header injection, request IDs. This is the always-on logic an edge actually runs, and at roughly 86% of unfiltered baseline throughput it's cheap enough to leave enabled everywhere it matters.
When you need custom logic, sandboxed WASM modules load and unload at runtime
through the admin API against a stable, versioned ABI. You pick the isolation
per plugin: per_request spins up a completely separate instance
for every call, so the worst a broken plugin can do is fail its own request —
while pooled recycles warm instances through a lock-free queue and
gets sandboxed code surprisingly close to native speed (a pooled header-inject
plugin holds ~61% of baseline versus ~24% per-request). The catch with
pooling: a guest's heap survives between requests, so save it for stateless
filters you trust.
It works, and the engineering is real — but it isn't the reason to run omlb today. Treat WASM as a serious experimental extension point; for anything
always-on, the native filters are the answer.
A dashboard that costs nothing on the data plane
Flip webui.enabled and a dashboard comes up on its own listener —
no second process to babysit, no npm build, no CDN assets phoning home. It reads
the same atomic counters and control-plane snapshots the proxy already maintains,
so watching it costs the data plane nothing; the only knob with a real
per-request price is opt-in log capture.
What it shows: live SVG charts (req/s, response classes, active requests, cache hit rate, bans enforced) plus stat cards; a services view of pools → backends with ready/ejected/inflight/weight state and a routes → upstream → plugin resolutions view; an opt-in live log tail; and the effective merged config with forms to add a domain or a service.
It binds 127.0.0.1 deliberately — this thing can edit your running
config. SSH-tunnel to it, or publish it through omlb itself behind
the built-in basic_auth filter. Its edits respect the same boundary
as hot reload: a new route onto an existing upstream applies immediately, while
a new upstream is staged to an overlay file and waits for a graceful restart —
and the UI tells you which of the two just happened.
webui:
enabled: true
address: "127.0.0.1:9095" # bind localhost; tunnel in or front via the proxy (see below)
capture_logs: false # opt-in live log tail — small per-request cost when on Metrics and logs, not a black box
Metrics are Prometheus on a dedicated listener (127.0.0.1:9091 by
default): requests and responses by class, upstream errors, backend ejections,
cache hits and misses, plus the omlb_abuse_* family for the gate —
bans applied, bans enforced, rate-limited requests, active bans. Logs come out
pretty for humans, JSON for shippers, or native journald — pick per deployment.
The admin REST API (127.0.0.1:9090 by default) is the control
plane: read status, drain or undrain a backend, trigger a reload, load or
unload a plugin. The web UI is a client of this same API — anything it does,
you can curl.