Configuration
Everything omlb does is declared in one YAML document — routing, TLS,
balancing, health, security, HA, all of it. There's no directive language to learn
and no second config file conspiring against the first. This page walks through
every block with a working example you can paste.
The design rules
A few deliberate choices keep the file readable at 2 a.m., which is when configs get read:
- One document. The whole edge fits in one mental model — nothing lives in a second daemon's config that can drift out of sync with the first.
--checkis a contract. It parses and cross-validates: routes must reference real upstreams, addresses must parse, cert files must exist, ACME settings must cohere. Non-zero exit on any failure, so CI can be the gate.- Unknown keys are errors. The schema is
deny_unknown_fields— a typo fails--checkloudly instead of being ignored for six months. - Reloads can't half-apply. Editing the file live-swaps routes, filters, rewrites, cache, and timeout budgets atomically; anything structural (listeners, backends, algorithms, health, TLS, HA) is refused, and the last good config keeps serving.
- Defaults do the right thing. Outlier ejection and retries are on from the start, and the thresholds ship with working values — a ten-line config behaves sensibly.
- Durations read like you'd say them.
30s,1h,500ms. AndOMLB_*environment variables override scalar knobs, so one image deploys everywhere.
Curious how the same job looks in nginx or Caddy? We keep that side-by-side — with an honest verdict that gives Caddy its due — on the comparison page.
Configure every mode
Every block below is a complete, working slice of an omlb.yaml — combine what you need in one file.
Automatic HTTPS (ACME), multi-domain
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 } Certificates issue when the process boots, renew on their own schedule, and swap into the live SNI store mid-flight — a renewal is invisible to clients.
Bring-your-own / wildcard certificates
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 } Static certs and ACME certs share the one store. SNI resolution tries an exact match first, then a wildcard, then falls back to the default entry.
Load balancing & failover
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
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 Routing, rewrites, static responses, compression
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" } } }
A route matches on host, path prefix, methods, and exact headers, and carries exactly
one of upstream (proxy it) or respond (answer it here — a
health-check 200, a redirect, a static body). rewrite reshapes host and path before the backend sees the request.
Edge security — rate limit + scanner bans
security:
enabled: true
allowlist: [127.0.0.0/8, "::1", 10.0.0.0/8, 172.16.0.0/12] # exempt internal/trusted
rate_limit_rps: 50 # per-IP token bucket -> 429 over-limit
rate_limit_burst: 100
ban_duration: 1h
max_strikes: 0 # OFF — 404s are legitimate for Matrix/REST apps (would lock out real users)
strike_window: 1m
strike_statuses: [404]
ban_paths: [/.env, /.git/, /wp-login.php, /xmlrpc.php, /.aws/, /.ssh/]
The gate runs before routing, so probes against hostnames you don't even serve still
get caught. One touch of a trap path bans that IP on every host until ban_duration runs out — after that it's an instant 403 with no upstream work at all.
Response caching
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 } }
Only unambiguously cacheable GETs get stored — a 200 without Authorization, Set-Cookie, or no-store anywhere near it. A hit bypasses the upstream and the filter chain both.
L4 (TCP/stream) proxy, with optional 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 } tls: true terminates client TLS with the same cert store the HTTPS
listeners use; backend_tls: true dials the backend over TLS instead.
Managed pools (self-healing container backends)
Instead of listing backend addresses, hand the pool a Docker label. omlb adopts the containers that carry it, keeps replicas of them healthy, and
replaces any container that fails its health checks — which catches the
hung-but-alive case a Docker restart: policy never notices.
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
Create the containers once from compose, with restart: "no" and the
label — from then on their lifecycle belongs to omlb, and replacements
are cloned from an adopted container's own config, so nothing about the image is
repeated here. It does need the Docker socket, which is root-equivalent — scope it
with a socket proxy in production.
High availability (VRRP + floating VIP), 2 nodes
Node A — higher priority is MASTER while healthy:
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 Node B — same virtual router, lower priority, heartbeat addresses swapped:
ha:
enabled: true
virtual_router_id: 51 # MUST match node A
priority: 100 # lower than node A -> BACKUP until A fails
interface: eth0
virtual_ip: "10.0.0.100/24" # same VIP as node A
bind: "10.0.0.12:1821" # THIS node's heartbeat address
peers: ["10.0.0.11:1821"] # node A's heartbeat address
advert_interval: 1s
dead_multiplier: 3
preempt: true
Clients only ever know virtual_ip. When the master goes quiet, the
backup claims that address (this needs CAP_NET_ADMIN). A complete
working pair ships in the repo as config/ha-node-a.yaml and config/ha-node-b.yaml.
Operate it: validate, env overrides, hot reload, systemd
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)
systemd units for both standalone and HA layouts — with the capabilities for :80/:443 and the VIP already declared — live in examples/systemd/. The operations page walks through the whole thing.