eBeacon is a fault-tolerant, high-performance reverse proxy for Ethereum Beacon API nodes. It provides intelligent load balancing, response caching, canonical fork detection, request multiplexing, and multi-upstream reliability for consensus layer infrastructure.
⚠️ Not for validators. eBeacon is a load balancer and cache for read-heavy Beacon API workloads (RPC clients, indexers, dashboards). It is not intended to sit between a validator client and its beacon node — caching and load balancing can violate the freshness and correctness guarantees a validator needs. If you want a fault-tolerant, validator-safe Beacon API load balancer, look at Vero.
- Score-based, round-robin, random, and least-connections load balancing
- Canonical fork detection (majority-vote block tracking, auto-excludes forked nodes)
- Auto-detection of consensus client type (Lighthouse, Prysm, Teku, Nimbus, Lodestar, Grandine, Caplin)
- Response caching with finality-aware TTL promotion (in-memory LRU or Redis)
- Request multiplexing (deduplicates identical concurrent requests)
- Sequential retry with configurable backoff and jitter
- Hedged requests (parallel requests after configurable delay)
- Per-upstream circuit breaker (closed/open/half-open states)
- Consensus verification policy (query N upstreams, require M agreement)
- Per-IP and global rate limiting with token bucket
- Per-upstream rate limit auto-tuner (adapts to 429 responses)
- Sticky sessions with automatic rebalancing
- Upstream priority tiers for preferred and backup upstreams
- Archive upstream routing (serve historical queries pruned nodes can't)
- Per-method/path failsafe overrides
- gzip compression (client-proxy and proxy-upstream)
- Configurable CORS for browser clients and frontend apps
- Dugtrio-style client routing (path prefix to specific upstreams)
- Network routing with API key authentication
- SSE (Server-Sent Events) relay with reconnect and deduplication
- Synthetic health endpoints for load balancers:
/healthzand/eth/v1/node/health - Ethereum consensus header preservation
- Web UI dashboard (health, upstreams, cache, sessions, fork visualization)
- Prometheus metrics with pre-built Grafana dashboard
- Shared state for horizontal scaling (Redis pub/sub)
- Docker support
For multi-page docs, start in docs/README.md:
Create a minimal ebeacon.yaml with three mainnet upstreams:
logLevel: info
server:
host: "0.0.0.0"
port: 5555
maxTimeout: 60s
networks:
- id: mainnet
upstreams:
- id: lighthouse
url: "http://127.0.0.1:5052"
- id: prysm
url: "http://127.0.0.1:3500"
- id: teku
url: "http://127.0.0.1:5051"
routing:
loadBalancing: score
stickySession: true
cache:
enabled: true
maxSize: 4096Build and run:
go build -o ebeacon .
./ebeacon -config ebeacon.yamlThe proxy listens on the configured server.port (default 5555). Beacon API paths are served under /{networkId}/eth/v1/... (or prefix-free when only one network is defined).
For readiness checks, eBeacon also exposes a global /healthz, a per-network /{networkId}/healthz, and synthetic /eth/v1/node/health responses that reflect upstream health instead of proxy process liveness alone.
Build locally:
docker build -t ebeacon .
docker run -p 5555:5555 -v $(pwd)/ebeacon.yaml:/app/ebeacon.yaml ebeaconOr pull a published release image from GHCR (image names must be lowercase):
docker pull ghcr.io/mysticryuujin/ebeacon:latest
docker run -p 5555:5555 -v $(pwd)/ebeacon.yaml:/app/ebeacon.yaml ghcr.io/mysticryuujin/ebeacon:latestThe image exposes port 5555 and expects a config file at /app/ebeacon.yaml (override by mounting your own file as shown).
For the bundled local monitoring stack:
cp ebeacon.example.yaml ebeacon.local.yaml
cp .env.example .env
# Edit ebeacon.local.yaml with upstream URLs reachable from the container.
docker compose up --buildCompose publishes eBeacon at 127.0.0.1:5555, Prometheus at 127.0.0.1:19090, and Grafana at 127.0.0.1:13000. It also reserves 127.0.0.1:6060 for pprof, which remains disabled unless enabled in ebeacon.local.yaml; use host: "0.0.0.0" inside the container when enabling it. The host-side loopback bindings keep the developer stack local by default. Set non-default UI and Grafana secrets in .env before starting it.
Local git hooks are provided in .githooks/:
pre-commit: runsgofmton staged.gofiles, re-stages them, tests changed packages, and runsgolangci-lintif installed locally.pre-push: verifies repo-widegofmt, runsgo vet, and runsgo test -race ./....
Install hooks:
./scripts/install-hooks.shOptional Makefile shortcuts:
make fmt
make lint
make test
make ci-localThe repository includes two long-running validation tools under scripts/:
go run ./scripts/loadtest/ -base http://127.0.0.1:5555/mainnet -concurrency 50 -duration 60Generates mixed Beacon API traffic — including SSE, bothgzipandidentityrequest paths, and POSTs whose responses are validated against the request body.go run ./scripts/reliability/ -duration 30m -report 1mContinuously checks immutable responses, cache accuracy, cache encoding compatibility, SSE health, and metrics invariants (active connections must return to zero at quiesce), and captures pprof snapshots. Exits non-zero on any detected issue, on eBeacon request errors, or when a checker completed zero checks, so it can gate CI or cron runs.
Both tools target a live eBeacon instance. The reliability harness can also compare against direct upstream CL REST APIs via -upstream.
CI (.github/workflows/ci.yml) enforces:
gofmtverification- module tidiness
go vetgolangci-lintgo test -race ./...
Release tags (v*) trigger the separate release workflow, which builds and publishes multi-architecture Docker images and creates the GitHub Release. make vuln remains available as an explicit local dependency scan.
Dependabot config is included at .github/dependabot.yml for weekly updates of:
- Go modules (
gomod) - GitHub Actions
- Docker base images
Configuration is YAML. See ebeacon.example.yaml for a full, commented reference covering top-level networks, Redis cache/state, and advanced routing.
Unknown YAML fields are rejected to catch misspellings. Network and upstream IDs must match [A-Za-z0-9][A-Za-z0-9._-]* because they are used in URL routing, metrics labels, and dashboard identifiers.
Key sections:
| Section | Purpose |
|---|---|
| server | host, port, maxTimeout, enableGzip, maxResponseBodyBytes, trustedProxies |
| cors | allowedOrigins, allowedMethods, allowedHeaders, exposedHeaders, allowCredentials, maxAge for browser access |
| failsafe | timeout, retry, hedge, circuitBreaker, consensus — global defaults merged per network/upstream |
| health | checkInterval, finalityInterval, maxSyncDistance, followDistance, maxHeadDistance — sync/finality polling and degradation thresholds |
| rateLimiting | perIP, global — token-bucket limit and burst |
| metrics | enabled, path — Prometheus scrape endpoint on the proxy port |
| state | driver: local or redis — shared pub/sub for multi-instance deployments |
| ui | enabled, basePath — embedded web UI (default base path /webui) |
| networks | id, upstreams, routing, cache, failsafeOverrides — per-chain pools and overrides for a single eBeacon deployment |
Example skeleton:
logLevel: info
server:
host: "0.0.0.0"
port: 5555
maxTimeout: 60s
cors:
allowedOrigins: ["*"]
failsafe:
timeout: { duration: 30s }
retry:
maxAttempts: 3
delay: 100ms
backoff: 2.0
jitter: 50ms
hedge: { delay: 500ms, maxCount: 1 }
circuitBreaker:
failureThreshold: 5
successThreshold: 2
halfOpenAfter: 30s
health:
checkInterval: 15s
finalityInterval: 60s
maxSyncDistance: 10
rateLimiting:
perIP: { limit: 100, burst: 1000 }
metrics:
enabled: true
path: /metrics
state:
driver: local
ui:
enabled: true
basePath: /webui
auth: # required when ui.enabled is true
keys:
- id: dashboard
secret: "${EBEACON_UI_SECRET}"
networks:
- id: mainnet
upstreams:
- id: lh
url: "http://localhost:5052"
priority: 0
routing:
loadBalancing: score
scoreWeights:
errorRate: 4.0
latency: 8.0
headLag: 2.0
syncDistance: 1.0
cache:
enabled: true
driver: memory
maxSize: 4096eBeacon supports four strategies:
| Strategy | Behavior |
|---|---|
| round-robin | Cycles upstreams in order for each new request. |
| random | Chooses a uniform random upstream per request. |
| least-conn | Prefers upstreams with fewer active in-flight connections. |
| score | Ranks upstreams by a composite quality score (higher is better) using rolling metrics. |
Before load balancing is applied, eBeacon narrows the candidate set in this order:
- Ready upstreams on the canonical fork.
- Ready upstreams regardless of canonical-fork status.
- Healthy upstreams.
- All upstreams as a last resort.
Then it sorts that set by priority first, and only compares load-balancing strategy inside the same priority tier. Lower priority numbers always win over higher numbers.
Score-based routing computes this raw score, where higher is better:
score = (1 - errorRate) * errorWeight
+ latencyTerm * latencyWeight
+ (1 / (1 + headLag)) * headLagWeight
+ (1 / (1 + syncDistance)) * syncDistanceWeight
latencyTerm = 1 when there are no latency samples yet
latencyTerm = 1 / (1 + p90Seconds) otherwise
The score inputs are:
errorRate: rolling fraction of recent upstream interactions that failed, including internal health probes.p90Seconds: rolling 90th percentile latency of successful upstream interactions, including internal health probes.headLag: how many slots this upstream is behind the pool's current canonical head.syncDistance: the upstream's reported sync distance.
Within a priority tier, eBeacon now applies upstream.weight as an actual routing bias:
round-robin: weighted round-robin for the primary pick.random: weighted random selection without replacement.least-conn: loweractiveConnections / weightwins.score: weighted sampling without replacement usingrawScore * weight.
Normal requests still use a single upstream: the first entry in the ordered candidate list. With loadBalancing: score, that means primary traffic is influenced by both the raw score and weight inside the lowest available priority tier. Lower-ranked upstreams still receive traffic when:
- the preferred upstream becomes unready or non-canonical,
retry.maxAttempts > 1and eBeacon walks down the ordered list,hedge.maxCount > 0and eBeacon fires parallel requests to the top few candidates,consensusmode queries multiple upstreams.
This means score routing is no longer pure highest-score-wins ranking. It is weighted by rawScore * weight within a priority tier, with retries and hedges still able to fan out further down the ordered list.
Use priority for hard primary / backup separation, and weight for softer traffic shaping inside a single priority tier.
For a common primary / backup layout, put self-hosted nodes in priority: 0 and paid or public fallback providers in priority: 1 or higher. Example:
networks:
- id: mainnet
upstreams:
- id: local-lighthouse-a
url: "http://10.0.0.11:5052"
priority: 0
- id: local-lighthouse-b
url: "http://10.0.0.12:5052"
priority: 0
weight: 3
- id: paid-backup
url: "https://beacon.example-provider.com"
priority: 1
weight: 1
routing:
loadBalancing: score
failsafe:
retry:
maxAttempts: 3With that setup, eBeacon prefers the local tier for primary traffic and only reaches the backup tier when the local tier is exhausted by health, fork exclusion, or retry / hedge behavior.
The live composite score is exposed in both the embedded dashboard (/webui, Upstreams table) and Prometheus. The main metrics are ebeacon_upstream_score, ebeacon_upstream_score_error_rate, ebeacon_upstream_score_p90_latency_seconds, and ebeacon_upstream_score_head_lag, and the bundled Grafana overview dashboard includes them in the per-upstream table.
Beacon nodes prune historical state by default (about 8 days of full historical states and roughly 18 days of blob sidecars for typical clients — blocks are retained longer but still finite). If every upstream in a pool is pruned, a client asking for /eth/v2/beacon/blocks/2000000 or a 6-month-old blob sidecar gets a 404 and nothing else eBeacon can do. Marking specific upstreams as archive lets eBeacon route those historical-data requests to upstreams that can actually serve them.
networks:
- id: mainnet
upstreams:
- id: lighthouse
url: "http://lh:5052"
priority: 0 # local pruned
- id: quicknode
url: "https://..."
priority: 10
archive: true # serves historical data when the local tier can'tThe default is archive: false, matching the CL default. Only mark upstreams you have verified to retain full history.
Two mechanisms decide when archive upstreams get used:
-
Proactive classification. Request paths that carry a numeric slot or epoch are classified up front. If the target is older than the per-endpoint retention window (blob sidecars 4096 epochs, blocks 33024 epochs, states 8192 slots, duties 1 epoch), eBeacon skips pruned upstreams on the first attempt and routes to archive-capable ones directly. The epoch-based thresholds use the network's configured
slotsPerEpoch. Named identifiers (head,finalized,justified,genesis) and root-based lookups cannot be classified this way because their slot is unknown, so they flow through normal routing. -
Error-driven fallthrough. If a pruned upstream returns a 404 for a historical-id path — or a blob endpoint returns a recognized PeerDAS custody-related 400 — eBeacon promotes the remaining retry budget to archive upstreams and continues the request without exposing that response when an archive upstream succeeds. This covers by-root lookups and requests that sat just inside the conservative retention threshold but outside the client's actual retention.
Priority still applies within the archive subset. A priority: 0 local archive beats a priority: 10 cloud archive. If no upstream is marked archive: true, behavior matches pre-archive releases: pruning-shaped 404s propagate to the client unchanged, and ebeacon_pruning_error_no_archive_total ticks so operators can see the signal that adding an archive upstream would help.
Relevant Prometheus metrics:
ebeacon_upstream_archive{network,upstream}— 1 if archive, 0 if prunedebeacon_archive_promotion_total{network,reason}— counter,reasonisproactiveorpruning_errorebeacon_pruning_error_no_archive_total{network}— counter of pruning-shaped responses returned because no archive upstream exists
The bundled Grafana overview dashboard has an Archive Routing row covering all three.
When hedge is enabled (failsafe.hedge) and proactive archive routing fires, multiple parallel requests go to archive upstreams simultaneously. For metered providers like QuickNode or Alchemy, either disable hedge for that network or set per-upstream rateLimiting.autoTune to stay inside your plan.
Responses are cached per network using path patterns and TTLs. Finality-aware promotion: when a request targets data at a finalized slot (derived from network finality checkpoints), the effective TTL is promoted so finalized data can be cached effectively forever regardless of a shorter policy TTL. Unfinalized head-dependent data uses the configured TTL so clients do not see stale head state for long.
Cache behavior is representation-aware:
- transport encoding is interchangeable: cached responses can be served as plain or
gzipdepending onAccept-Encoding - JSON and SSZ /
application/octet-streamare not interchangeable: they use distinct cache keys because eBeacon does not transcode between those representations
Backends:
- memory — LRU with
maxSizeentries (default driver). - redis — shared cache across proxy instances; configure
cache.driver: redisandcache.redis.url.
Traffic flows through layered components:
Client → Proxy / HTTP router → Network context
For each request, roughly:
- Blocked paths — regex denylist (e.g. debug, pool submission) if configured.
- Rate limiting — per-IP and optional global token buckets.
- Routing rules — Dugtrio-style
clientRoutes, orderedrouteRules, sticky sessions, load-balancing pick. - Cache lookup — on cacheable methods/paths, return if hit.
- Execute — multiplex identical in-flight requests; apply retry, hedge, circuit breaker, optional consensus policy; forward to chosen upstream(s).
- Response — preserve Ethereum consensus headers, optional gzip, SSE handling where applicable.
eBeacon was inspired by eRPC (Apache 2.0) and Dugtrio. See the NOTICE file for full attribution details.
Licensed under the Apache License, Version 2.0.