ferryman 0.2.1

Small L7 reverse proxy: prefix routing, per-upstream circuit breakers, active health checks, hot-reloaded TOML config, TLS termination.
Documentation

ferryman

Small L7 reverse proxy in Rust — prefix routing, per-upstream circuit breakers, active health checks, hot-reloaded TOML config. Pingora-pattern at miniature scale. Built to be readable in one sitting.

ci crates.io docs.rs license

The problem

Typical self-hosted setups expose services on direct ports or tunnels with no L7 in front. Real teams put L7 proxies between clients and origin: routing, health checks, observability, TLS termination. ferryman is a hyper-based reverse proxy that does the boring parts well and exposes the interesting tradeoffs — circuit-breaker state machine, atomic config hot-reload, prefix-longest-match routing — in code small enough to read top-to-bottom.

Architecture

                    +-----------------------------+
                    |    ferryman :443/:80        |
                    |  hyper 1.x + tower stack    |
                    +--+--------------------------+
                       |
                       |  1. parse req.uri.path
                       |  2. longest prefix match on a path-segment
                       |     boundary (no match -> 404)
                       |  3. ask the upstream's breaker (open -> 503)
                       |  4. rebuild URI, stream via hyper client
                       v
       +---------------+---------+----------+
       |               |         |          |
   svc-A:8001     svc-B:8002  svc-C:8003  ...
       ^               ^         ^
       |               |         |
       +-------+-------+---------+
               |
        Background task:
        - active /health every 5s (any answer < 500 = up)
        - lock-free closed / open / half-open breaker
        - N consecutive failures: open for cooldown (30s)
        - after cooldown: exactly one half-open probe
        - health success: close immediately

Routing rules: TOML, hot-reloaded via `notify` filesystem watch + arc-swap.

Stack

Layer Crate / Tool
Async runtime tokio 1.47 (full)
HTTP server hyper 1.x + hyper-util auto (HTTP/1.1 + HTTP/2)
HTTP client (proxy) hyper-util legacy Client<HttpConnector>
HTTP client (health) reqwest 0.12 (plain HTTP, no TLS stack)
TLS termination rustls 0.23 (ring) + tokio-rustls, ALPN h2 / http/1.1
Config serde + toml + notify (FS watch)
Hot-swap arc-swap (atomic RouteTable swap)
Observability tracing + metrics-exporter-prometheus
CLI clap 4
Container build cargo-chef multi-stage, FROM scratch
Deploy Fly.io 2-region (sin + iad)
CI GHA (stable + beta) + cargo-deny + cargo-nextest + wrk2

Full pinned versions live in Cargo.toml. Default routing rules: config.toml.

Quick start

# Install the binary from crates.io:
cargo install --locked ferryman
ferryman --config config.toml

# Build and run against the example config (2 upstreams, 5s health interval).
cargo run -p ferryman -- --config config.toml

# Optional: local upstreams that answer 200 on every path.
cargo run --release -p ferryman --example echo_upstream -- 127.0.0.1:8001 &

# In another shell, hit a route:
curl -i http://localhost:8080/svc-a/hello
# HTTP/1.1 502 bad gateway          (no svc-a:8001 listening yet)
# HTTP/1.1 503 upstream unavailable (once its circuit has opened)
curl -i http://localhost:8080/no-such-route
# HTTP/1.1 404 no route

# Optional TLS termination (HTTP/2 via ALPN):
cargo run -p ferryman -- --config config.toml \
    --tls-cert cert.pem --tls-key key.pem

# Scrape Prometheus metrics:
curl -s http://localhost:9090/metrics | head

Edit config.toml while ferryman is running — the routing table reloads atomically without dropping live connections.

Examples

Reference examples live under examples/ (index: examples/README.md):

Example What it shows
examples/full-stack/ Full Docker Compose deployment: ferryman, TLS, three demo upstreams, Prometheus + Grafana, an asserted demo.sh tour.
examples/embedded/ Embedding ferryman::serve as a library inside your own async Rust app, with its own admin server.
crates/core/examples/guarded_client.rs ferryman-core's circuit breaker guarding any fallible async call, no proxy involved.

Configuration

See config.toml. Unknown keys are rejected, so typos fail loudly instead of silently falling back to defaults.

Key Default Meaning
health_interval_secs 5 Active /health probe interval (restart to change).
default_cooldown_secs 30 How long an open circuit refuses traffic before one probe.
failure_threshold 3 Consecutive failures that open a closed circuit.
upstream_timeout_secs 30 Time allowed for an upstream to send response headers.
[[routes]] prefix — Path prefix, matched on segment boundaries (/a ≠ /ab).
[[routes]] upstream — http://host:port — no path, no query, no https.
[[routes]] cooldown_secs default Per-route cooldown override.

Routes pointing at the same host:port share one circuit breaker. A hot reload keeps each surviving upstream's breaker, so an open circuit stays open across a config edit. An invalid config on reload is logged and the old table stays live.

CLI flags (env var in brackets): --config (FERRYMAN_CONFIG), --bind (FERRYMAN_BIND, 0.0.0.0:8080), --metrics-bind (FERRYMAN_METRICS_BIND, 0.0.0.0:9090), --tls-cert / --tls-key (FERRYMAN_TLS_CERT / FERRYMAN_TLS_KEY).

Behaviour

Situation Response Counts against breaker
No route matches 404 —
Circuit open 503 —
Upgrade / CONNECT (e.g. WebSocket) 501 —
Connect / transport error 502 yes
No response headers within upstream_timeout_secs 504 only for bodyless requests
Client's request body fails mid-upload 400 no
Upstream answers 502/503/504 passed through yes
Anything else from upstream passed through success

Request and response bodies are streamed, never buffered. Hop-by-hop headers are stripped both ways; x-forwarded-for and x-forwarded-proto are set; the client's Host is kept (HTTP/2 :authority becomes Host). Upstreams always get HTTP/1.1. Slow clients are cut off after 10s of header reading or TLS handshake. SIGINT/SIGTERM stop accepting and drain in-flight connections for up to 25s.

Metrics endpoints

The Prometheus exporter binds a separate listener (default :9090). Surface:

Metric Labels Description
ferryman_requests_total route, upstream, status ("none" on 404) Counter of inbound requests.
ferryman_request_duration_seconds route, upstream Histogram (buckets le), time to upstream response headers.
ferryman_upstream_alive upstream Gauge: 1 = circuit closed, 0 otherwise.
ferryman_circuit_state upstream Gauge: 0 closed / 1 open / 2 half-open.

route is the configured prefix and upstream is host:port, so label cardinality is bounded by the config, never by client input.

Bench targets (per projects-l3-l4.md § P2)

Run with benches/wrk2.lua:

wrk2 -c 1000 -t 16 -R 50000 -d 60s \
     -s benches/wrk2.lua \
     http://localhost:8080/svc-a/echo
Metric Target
Throughput 50,000 rps
p50 latency < 1 ms
p99 latency < 5 ms
p999 latency < 20 ms
RSS at 50k rps idle < 20 MB
Failover recovery after upstream kill < 5 s (one health interval)
CPU vs NGINX on same hardware >= 50% reduction (Pingora story: ~70%)

Repository layout

ferryman/
  Cargo.toml                 # workspace
  config.toml                # example routing config
  crates/
    core/                    # breaker, RouteTable, health loop, TOML schema
      benches/lookup.rs      # criterion bench for RouteTable::lookup
      examples/guarded_client.rs  # breaker guarding a fallible async call
    server/                  # serve loop, proxy handler, TLS, reload watcher
      tests/proxy.rs         # end-to-end tests on real sockets
  examples/                  # reference examples, see examples/README.md
    full-stack/              # Docker Compose: ferryman + upstreams + Prometheus/Grafana
    embedded/                # ferryman::serve embedded as a library
  benches/wrk2.lua           # wrk2 harness for the throughput target
  benches/bench.toml         # routing config for the compose fixture
  docker-compose.bench.yml   # ferryman + two http-echo upstreams
  scripts/wrk2-smoke.sh      # boots the compose fixture and runs wrk2
  Dockerfile                 # cargo-chef multi-stage, scratch final (musl)
  fly.toml                   # Fly.io 2-region (sin + iad)
  deny.toml                  # cargo-deny config
  rust-toolchain.toml        # stable channel
  .github/workflows/ci.yml   # nextest + clippy + fmt + deny + bench + wrk2 + examples
  docs/
    architecture.md          # internals: request path, breaker, reload
    specs/2026-05-28-ferryman-design.md
    plans/2026-05-28-ferryman-phase-1-scaffold.md
    plans/2026-09-26-ferryman-phase-2.md
  PROGRESS.md

Limitations

  • Upstreams are plain HTTP only; https:// upstreams are rejected.
  • No WebSocket / Upgrade passthrough (501).
  • No per-upstream load balancing: one route, one upstream.
  • Kubernetes ConfigMap mounts update via a ..data symlink swap that the file watcher does not see; restart the pod after a ConfigMap edit.
  • The response body has no idle timeout once headers have arrived.

Roadmap

How it works inside: docs/architecture.md.

Phases and bench numbers are tracked in PROGRESS.md. P4 (ferryman-edge) layers mTLS + JWT + cert hot-reload on top of this base; see projects-l3-l4.md § P4.

License

Dual-licensed under either of

at your option.