Docs

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.
  • --check is 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 --check loudly 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. And OMLB_* 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.