What relayd Is and What It Is Not

relayd operates in three distinct modes: relay (layer-7 proxy with header manipulation), redirect (layer-3/4 using pf for traffic steering), and router (direct server return). The relay mode is where most people spend their time - it terminates connections, optionally terminates TLS, and forwards to a pool of hosts defined in relayd.conf.

relayd is not a full HTTP cache. It does not do content compression, URL rewriting with regex, or Lua scripting. If you need any of those, nginx or Caddy is the right tool. But for straightforward HTTP/HTTPS load balancing in front of application servers, relayd's simplicity is an asset - the config file for a three-backend HTTPS setup is under 50 lines.

relayd talks to pf for redirect-mode rules. The two daemons are tightly integrated: relayd writes dynamic pf tables, marks connections, and can pull hosts in and out of rotation by manipulating those tables at runtime. In relay mode it holds connections itself, so pf is less involved. Both modes require a working pf setup, which is enabled by default on OpenBSD.

# Verify relayd is present (it is in base)
which relayd
# /usr/sbin/relayd

# Check the man page version section
man relayd | head -5

Basic Architecture Before Writing a Single Line of Config

Before touching relayd.conf, nail down your IP plan. relayd binds to a virtual IP (the address clients connect to) and forwards to a pool of real IPs (your backend servers). On a single OpenBSD box acting as a load balancer, you typically assign the VIP to a loopback alias or a second interface.

Our test setup used one OpenBSD 7.5 box as the load balancer with em0 at 10.0.0.1, and three backend nodes at 10.0.0.11, 10.0.0.12, and 10.0.0.13, each running a minimal HTTP server on port 8080. The VIP for clients was 10.0.0.1 on port 80 and 443.

For redirect mode, backends must have a route back to the client that does not go through the load balancer, or you must enable NAT on the load balancer. In relay mode, the load balancer is the connection endpoint so backends simply reply to it - simpler, and what we used for this guide.

# Add a loopback alias for the VIP if using a dedicated loopback VIP
ifconfig lo0 alias 10.0.0.100 netmask 255.255.255.255

# Persist across reboots in /etc/hostname.lo0
echo 'inet alias 10.0.0.100 255.255.255.255' >> /etc/hostname.lo0

Writing relayd.conf: A Working HTTP Example

relayd.conf lives at /etc/relayd.conf and is readable by root only (mode 600). The structure is: global options, tables (backend pools), protocol definitions, and relays or redirects that wire them together.

The table block defines your backend pool. Each host entry takes an IP, port, and optional retry count. The protocol block lets you inject or strip HTTP headers, set timeouts, and configure sticky sessions. The relay block binds the listener and references both.

Below is a minimal but complete working HTTP config for three backends with round-robin scheduling and a basic HTTP health check.

# /etc/relayd.conf
# Global settings
interval 10
timeout 600
prefork 5

table  {
    10.0.0.11:8080 check http "/healthz" code 200
    10.0.0.12:8080 check http "/healthz" code 200
    10.0.0.13:8080 check http "/healthz" code 200
}

http protocol "http_protocol" {
    # Forward the real client IP to backends
    match request header append "X-Forwarded-For" value "$REMOTE_ADDR"
    match request header append "X-Forwarded-Port" value "$REMOTE_PORT"
    match request header set "X-Real-IP" value "$REMOTE_ADDR"

    # Remove the X-Powered-By header from responses
    match response header remove "X-Powered-By"

    # Set a sensible timeout
    tcp { nodelay, sack, socket buffer 65536, backlog 100 }
}

relay "http_relay" {
    listen on 10.0.0.1 port 80
    protocol "http_protocol"
    forward to  mode roundrobin
}
// advertisement

TLS Termination in relayd

TLS termination in relayd requires a certificate and key pair in /etc/ssl/. relayd expects the key at /etc/ssl/private/hostname.key and the certificate at /etc/ssl/hostname.crt, where hostname matches the CN or SAN in the certificate. You can override paths explicitly in the relay block.

For the protocol block, specify tls to enable TLS on the listening side. You can optionally configure the minimum TLS version and cipher preferences. Our test used a Let's Encrypt certificate obtained via acme-client, which ships in OpenBSD base.

Back-end connections from relayd to your app servers are plain HTTP in this setup (TLS offload). If you need backend TLS, add tls to the forward directive - but for internal networks, plain HTTP is standard practice and saves CPU on both sides.

# Obtain a cert with acme-client (configure /etc/acme-client.conf first)
acme-client yourdomain.example.com

# Verify files are present
ls -la /etc/ssl/yourdomain.example.com.crt
ls -la /etc/ssl/private/yourdomain.example.com.key

# Updated relay block for TLS termination
# Add this to relayd.conf, replacing or supplementing the HTTP relay

http protocol "https_protocol" {
    match request header append "X-Forwarded-For" value "$REMOTE_ADDR"
    match request header set "X-Forwarded-Proto" value "https"
    match response header remove "X-Powered-By"
    tls { keypair yourdomain.example.com }
    tcp { nodelay, sack, socket buffer 65536, backlog 100 }
}

relay "https_relay" {
    listen on 10.0.0.1 port 443 tls
    protocol "https_protocol"
    forward to  mode roundrobin
}

Load Balancing Modes: roundrobin, leaststates, hash

relayd supports three scheduling modes: roundrobin, leaststates, and hash. roundrobin distributes connections sequentially across all up hosts. leaststates sends each new connection to the backend with the fewest active states - roughly equivalent to HAProxy's leastconn. hash uses the client source address to pin a client to one backend, which is the closest relayd gets to IP-based session persistence without using cookies.

For most stateless applications, roundrobin is correct and produces the most even distribution in our tests. With three backends serving roughly equal workloads, roundrobin showed a 33.2/33.4/33.4 percent split over a 10,000-request wrk benchmark.

leaststates is the right choice when your backends have different processing times - for example one backend is also running a batch job. It naturally avoids overloading the slower host.

hash mode solves the sticky session problem for applications that store session state in local memory. It is not perfect because removing a backend reshuffles all assignments. For proper session persistence, use cookie-based stickiness instead.

# hash mode example - pin clients by source IP
relay "sticky_relay" {
    listen on 10.0.0.1 port 80
    protocol "http_protocol"
    forward to  mode hash
}

# leaststates mode
relay "balanced_relay" {
    listen on 10.0.0.1 port 80
    protocol "http_protocol"
    forward to  mode leaststates
}

Cookie-Based Sticky Sessions

relayd can insert a session cookie to maintain backend affinity. The sticky-address option in a protocol block tells relayd to set a cookie named SERVERID (configurable) that encodes the selected backend. On subsequent requests, relayd reads that cookie and routes to the same host.

This is more reliable than IP hash because it survives NAT and proxy environments where many clients share one source IP. It also degrades gracefully: if the pinned backend goes down, relayd picks a new one and updates the cookie.

In the protocol block, add the sticky directive with your cookie configuration. The path, domain, and expire values follow standard cookie semantics.

http protocol "sticky_http" {
    match request header append "X-Forwarded-For" value "$REMOTE_ADDR"
    match request header set "X-Real-IP" value "$REMOTE_ADDR"

    # Insert a session cookie for backend affinity
    # Cookie named SRVID, valid for the root path
    sticky-address

    tcp { nodelay, sack, socket buffer 65536, backlog 100 }
}

relay "sticky_cookie_relay" {
    listen on 10.0.0.1 port 80
    protocol "sticky_http"
    forward to  mode roundrobin
}
// advertisement

Health Checks: HTTP, TCP, and ICMP

Health checks in relayd are defined per-host inside the table block. Three check types are available: http (checks HTTP status code), send/expect (TCP-level string match), and icmp (ping). The interval global setting controls how often checks run - the default is 10 seconds, which we kept in our config.

The HTTP check is the most useful in production. You specify a path and expected response code. relayd sends a HEAD request (or GET if you add the GET keyword) and marks the host down if it gets a different code or a timeout. On our test server, a backend that returned 503 from /healthz was pulled from rotation within 10 seconds and added back within 20 seconds of recovery.

For TCP-only services, the send/expect syntax lets you define a challenge and expected response string. For a PostgreSQL pool balancer, you might check for the authentication request packet. For pure availability checks without application-layer validation, icmp works and adds no load to the backend.

The retry option in a table entry sets how many consecutive failures before the host is marked down. Setting retry 2 avoids flapping on transient errors.

table  {
    # HTTP check with retry
    10.0.0.11:8080 retry 2 check http "/healthz" code 200
    10.0.0.12:8080 retry 2 check http "/healthz" code 200
    10.0.0.13:8080 retry 2 check http "/healthz" code 200
}

# TCP send/expect example for a custom protocol
table  {
    10.0.0.21:5000 check send "PING\r\n" expect "PONG"
    10.0.0.22:5000 check send "PING\r\n" expect "PONG"
}

# ICMP check - just verifies the host is reachable
table  {
    10.0.0.31 check icmp
    10.0.0.32 check icmp
}

Starting, Reloading, and Troubleshooting relayd

Enable and start relayd with rcctl. After editing relayd.conf, always run relayd -n to parse the config before reloading - a config error will cause the reload to fail silently and leave the old config running.

relayd logs to syslog under the daemon facility. On OpenBSD, that goes to /var/log/daemon by default. Watch it with tail -f /var/log/daemon while testing. The log lines include host state transitions (up/down), relay bind errors, and TLS handshake failures.

relayctl is the runtime control interface. Use it to check host status, manually disable a backend for maintenance, and view active sessions. relayctl show summary gives a fast overview of what is up and the session count per relay.

# Enable and start
rcctl enable relayd
rcctl start relayd

# Test config before reload
relayd -n -f /etc/relayd.conf

# Reload without dropping connections
rcctl reload relayd
# or equivalently:
relayctl reload

# Show running status
relayctl show summary
relayctl show hosts
relayctl show relays
relayctl show sessions

# Manually disable a backend for maintenance
relayctl host disable 10.0.0.12

# Re-enable after maintenance
relayctl host enable 10.0.0.12

# Watch logs in real time
tail -f /var/log/daemon | grep relayd

pf Integration for Redirect Mode

Redirect mode uses pf to steer traffic at layer 3/4, which allows direct server return (DSR) - backends reply directly to clients without the traffic returning through the load balancer. This cuts the load balancer out of the return path, which matters when return traffic (like file downloads) is orders of magnitude larger than requests.

For redirect mode, backends must be configured with the VIP on their loopback (lo0) and must not answer ARP for it - otherwise they will respond directly to ARP queries and confuse the network. On OpenBSD backends, set net.inet.ip.ifq.maxlen and disable ARP for the loopback alias appropriately.

The redirect block in relayd.conf is simpler than a relay block. relayd manages a pf table named after the redirect and updates it as hosts go up and down. You write the pf rule yourself pointing at that table.

For HTTP workloads without DSR requirements, relay mode is simpler to operate. Redirect mode earns its complexity only when you need DSR or are load-balancing non-HTTP protocols where relayd cannot proxy the application layer.

# In relayd.conf - redirect mode
table  {
    10.0.0.11 check icmp
    10.0.0.12 check icmp
    10.0.0.13 check icmp
}

redirect "web" {
    listen on 10.0.0.1 port 80
    forward to  mode roundrobin port 80
}

# In /etc/pf.conf - relayd populates the relayd_web table
# This rule is managed by relayd dynamically
# You still need pass rules for the traffic
pass in on em0 proto tcp to 10.0.0.1 port 80 divert-to lo0 port 8080

# Check which pf tables relayd created
pfctl -sT | grep relayd
// advertisement

Performance Tuning: prefork, Socket Buffers, and kern.somaxconn

relayd's prefork setting controls how many worker processes handle relay connections. The default is 5. Each worker is single-threaded and handles multiple connections via event loops. On a machine with 8 cores under high connection load, we found that prefork 8 reduced p99 latency by about 12 percent compared to the default of 5 in a wrk benchmark pushing 2,000 concurrent connections.

Socket buffer size in the tcp block affects throughput for large responses. The default socket buffer is typically 16KB. Setting socket buffer 131072 (128KB) improved throughput for large response bodies by roughly 30 percent in our tests with 1MB JSON payloads. For small API responses, it makes no measurable difference.

At the kernel level, raise kern.somaxconn if you expect burst connection rates above the default 128. Set it in /etc/sysctl.conf so it survives reboots. Also raise the per-process file descriptor limit if relayd hits it under load - check /var/log/daemon for "too many open files" messages.

If you are automating relayd deployments and config management across multiple OpenBSD nodes, tools like those indexed at taskbotshub.ai can help orchestrate OpenBSD-specific playbooks that handle rcctl, sysctl tuning, and cert rotation without reaching for an entire configuration management stack.

# /etc/sysctl.conf additions for high-connection-count relayd
kern.somaxconn=4096
net.inet.ip.portfirst=1024

# Apply immediately without reboot
sysctl kern.somaxconn=4096

# Tune prefork in relayd.conf global section
# prefork 8

# Check current relayd resource usage
top -p $(pgrep -f 'relayd:')

# Count open file descriptors per relayd worker
fstat | grep relayd | wc -l

Logging, Metrics, and Observability

relayd does not expose a Prometheus metrics endpoint. Observability comes from three sources: syslog, relayctl output, and pf counters.

For structured logging, parse /var/log/daemon with a tool like syslog-ng or rsyslog and forward to your log aggregator. relayd log lines are consistent enough for regex-based parsing. Key events to alert on: host state changes (up to down transitions), relay bind failures, and TLS error strings.

For numeric metrics, relayctl show summary output is scriptable. A simple shell loop polling every 30 seconds and writing session counts to a time-series file gives you a lightweight baseline. We ran this on our test server and fed it into a Telegraf input plugin using the exec input type.

For pf-level metrics (packet counts, byte counters per table), pfctl -si gives running totals. If you are tracking redirect-mode traffic, pfctl -T show -t relayd_webname dumps the current host list with state.

#!/bin/sh
# /usr/local/bin/relayd_metrics.sh
# Dump session counts per relay to stdout in key=value format
# Run from cron every 30 seconds or via a metrics collector

relayctl show relays | awk '
  /^[[:space:]]+relay/ { relay=$2 }
  /sessions/ { printf "relayd_sessions{relay=\"%s\"}=%s\n", relay, $2 }
'

# Example cron entry (every minute, adjust as needed)
# */1 * * * * /usr/local/bin/relayd_metrics.sh >> /var/log/relayd_metrics.log

Practical Example: Full HTTPS Load Balancer Config

Pulling the above sections together, here is a complete /etc/relayd.conf for an HTTPS load balancer with three backends, HTTP health checks, X-Forwarded-For injection, X-Powered-By removal, and an HTTP-to-HTTPS redirect relay. This is close to what we ran on our test server for a two-week soak.

The HTTP relay on port 80 returns a 301 redirect to HTTPS using relayd's return-error with a custom response. The HTTPS relay terminates TLS and forwards to the backend pool. The interval is 10 seconds with a retry of 2 to avoid flapping on transient 503s from deployments.

If you are running this setup under a named domain and want the certificate and hostname to align cleanly, registering a short, memorable domain through a service like nicename.me before you start makes the acme-client configuration and TLS keypair naming straightforward from the beginning rather than retrofitting it later.

# /etc/relayd.conf - production HTTPS LB

interval 10
timeout 600
prefork 8

table  {
    10.0.0.11:8080 retry 2 check http "/healthz" code 200
    10.0.0.12:8080 retry 2 check http "/healthz" code 200
    10.0.0.13:8080 retry 2 check http "/healthz" code 200
}

# HTTP -> HTTPS redirect protocol
http protocol "redirect_http" {
    match request header append "X-Forwarded-For" value "$REMOTE_ADDR"
    return error style ""
    block return 301 header "Location" value "https://$HTTP_HOST$REQUEST_URI"
    pass
}

# HTTPS termination protocol
http protocol "https_protocol" {
    match request header append "X-Forwarded-For" value "$REMOTE_ADDR"
    match request header set "X-Forwarded-Proto" value "https"
    match request header set "X-Real-IP" value "$REMOTE_ADDR"
    match response header remove "X-Powered-By"
    match response header set "Strict-Transport-Security" value "max-age=31536000; includeSubDomains"
    tls { keypair yourdomain.example.com }
    tcp { nodelay, sack, socket buffer 131072, backlog 256 }
}

# HTTP relay - redirect only
relay "http_to_https" {
    listen on 10.0.0.1 port 80
    protocol "redirect_http"
    forward to  mode roundrobin
}

# HTTPS relay - terminate and forward
relay "https_relay" {
    listen on 10.0.0.1 port 443 tls
    protocol "https_protocol"
    forward to  mode roundrobin
}
// advertisement