- Rust 94.8%
- HTML 4.8%
- Dockerfile 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
With bodies streamed, reqwest's single .timeout() now bounds the whole client->ER->backend upload inside send(), so a large still-progressing push (multi-GB Docker layer) would 502 at proxy_timeout_seconds (30s default) — a regression vs the old buffer-then-send path. Switch to connect_timeout + read_timeout: a hung/slow backend is still caught, but an actively-transferring large body isn't killed. proxy_timeout_seconds now means connect + idle-read, not total. Also: a negative per-service max_request_body is now ignored (no cap) rather than clamped to a reject-everything 0. Full suite 199 green. |
||
| .aider.tags.cache.v4/7e/36 | ||
| .forgejo/workflows | ||
| assets | ||
| migrations | ||
| src | ||
| static/default | ||
| .env.example | ||
| .gitignore | ||
| build.rs | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| docker-entrypoint.sh | ||
| Dockerfile | ||
| edge-router.example.toml | ||
| README.md | ||
Edge Router
A lightweight, self-contained reverse proxy written in Rust. Services register themselves dynamically via API — no config file restarts required. Handles HTTP and WebSocket proxying, L4/SNI TCP passthrough, static site serving, and automatic HTTPS with wildcard certificates issued by Let's Encrypt via DNS-01 challenge — all behind secure-by-default network access control. Backed by SQLite with no external database dependency.
Designed for Docker-based environments where multiple services need to share a single entry point and hostname.
Features
- Dynamic service registration — services register and deregister via REST API; no restart needed
- Host and path routing — route by virtual host and path; when several services match, the most specific path wins (exact > longest prefix)
- Secure-by-default network access control — bindings, named listener trust zones, and per-resource access rules; services, the admin dashboard, and L4 routes are all unreachable until a rule grants them a listener
- Dual-stack IPv6 — listens on IPv4 and IPv6 by default (a v4 + v6 socket per port, v6 bound
IPV6_V6ONLYfor deterministic behaviour across platforms); access rules accept IPv6 addresses and CIDRs, and IPv6-literal backends are supported - L4 SNI passthrough — peek the TLS ClientHello and tunnel raw TCP to a backend by SNI, without terminating TLS at the edge
- Active health checking — configurable per-service probe interval; four-state health (healthy / unhealthy / warming / unmonitored) with fail-open routing that only withholds confirmed-unhealthy backends
- WebSocket proxying — transparent upgrade tunnelling
- Static site serving — serve files from a mounted directory or a MinIO/S3 bucket
- Automatic HTTPS — wildcard certificates via Let's Encrypt DNS-01 + Cloudflare; auto-renewed before expiry
- Multi-domain TLS — one router can terminate TLS for any number of apex domains simultaneously
- HTTP → HTTPS redirect — automatic 301 redirect to HTTPS when both ports are active
- Forwarding headers — injects
X-Forwarded-For,X-Real-IP,X-Forwarded-Proto,X-Forwarded-Hoston all proxied requests - Graceful shutdown — drains in-flight connections on SIGTERM / Ctrl-C before stopping
- Configurable proxy timeout — global default with per-service override
- Content-negotiated error pages — HTML for browsers, JSON for API clients, plain text otherwise
- Encrypted credential storage — Cloudflare tokens and private keys are AES-256-GCM encrypted at rest
- Request logging — optional per-service capture of full request/response for debugging
- API key authentication — master key + scoped service keys
- Build/version metadata — the image reports its release version, source commit, and CI build via
APP_VERSION/APP_COMMIT/APP_BUILD - TOML route config — seed services, static sites, and TLS domains from a file at startup
- SQLite storage — single-file database, no external dependencies
Getting Started
Docker Compose
services:
edge-router:
build: ./edge-router
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./edge-router/edge-router.toml:/app/edge-router.toml:ro
- edge_router_data:/app
environment:
HTTPS_PORT: "443"
networks:
- devnet
volumes:
edge_router_data: # persists the database, encryption key, and ACME account credentials
networks:
devnet:
external: true
On first boot, two secrets are printed to stdout and never stored in plaintext:
========================================
MASTER API KEY GENERATED
========================================
Key: er_AbCdEfGhIjKlMnOpQrStUvWxYz==
========================================
Save this key — it cannot be retrieved again.
========================================
ENCRYPTION KEY GENERATED
========================================
Key: base64encodedkey==
========================================
Save this key and set ENCRYPTION_KEY to use it in production.
The encryption key is auto-generated and written to {data_dir}/encryption.key on first boot. In production, pass it explicitly via ENCRYPTION_KEY so it is never stored on disk.
Local Development
cargo build --release
cargo run
# or with hot reload:
cargo watch -x run
Configuration
Settings can be provided via environment variables, a .env file, or a [config] section in edge-router.toml. Environment variables always take precedence over TOML values.
| Variable | Default | Description |
|---|---|---|
HTTP_PORT |
80 |
HTTP listen port |
HTTPS_PORT |
— | HTTPS listen port. Required to serve TLS. |
DATABASE_URL |
sqlite://edge-router.db |
SQLite database path |
DATA_DIR |
directory of DATABASE_URL |
Where the encryption key file is stored |
ENCRYPTION_KEY |
— | Base64-encoded 32-byte AES key. Auto-generated on first boot if absent. |
ENCRYPTION_KEY_FILE |
— | Path to a file holding the base64 key (e.g. a mounted secret). Read when ENCRYPTION_KEY is unset. |
ENCRYPTION_KEY_OLD |
— | Previous key for rotation. See Key Rotation. |
MASTER_API_KEY |
— | Pre-define the master API key (for automated/declarative deploys) instead of scraping the auto-generated one from first-boot logs. If set it is authoritative on every boot: it seeds the key on a fresh DB, and changing it rotates the key — the previous value stops working. Minimum 16 characters. Unset → auto-generate on first boot and print once. |
MASTER_API_KEY_FILE |
— | Path to a file holding the master key (e.g. a mounted secret). Read when MASTER_API_KEY is unset. |
LOG_RETENTION_COUNT |
1000 |
Max request log entries to keep |
LOG_RETENTION_HOURS |
24 |
Max age of request log entries |
DEFAULT_SITE_DIR |
— | Directory to serve when no service or static site matches |
DEFAULT_SITE_ENABLED |
true |
Whether to fall back to static serving (set false for pure proxy mode) |
TLS_FALLBACK_CERT |
true |
Serve a self-signed cert for IP/unknown-domain HTTPS connections. Set false to let the TLS handshake fail instead. |
REDIRECT_HTTP_TO_HTTPS |
true |
Issue 301 redirects from HTTP to HTTPS when HTTPS_PORT is configured. Set false to serve both protocols independently. |
PROXY_TIMEOUT_SECONDS |
30 |
Global upstream proxy timeout in seconds. Override per service with proxy_timeout_seconds in [[services]]. |
MAX_REQUEST_BODY |
— | Global max request body size (e.g. 2GB, 500MB, or a raw byte count). Unset = unlimited. Override per service with max_request_body in [[services]]; the effective limit is the minimum of the two. Request bodies are streamed (never buffered into RAM); an over-limit request gets 413. |
ROUTES_CONFIG |
edge-router.toml |
Path to the TOML routes config file |
RUST_LOG |
— | Log level, e.g. edge_router=info,tower_http=info |
APP_VERSION |
build value | Release version shown on the dashboard/status. Override at deploy to the release tag (e.g. v1.6.1). |
APP_COMMIT |
build value | Git commit the image was built from (set by CI). |
APP_BUILD |
local |
CI build that produced the image, e.g. forge-102 (set by CI). |
allow_ips in an access rule accepts bare IPs (127.0.0.1) and CIDR blocks (100.64.0.0/10), mixed freely. See Network Access Control.
DATABASE_URL and ENCRYPTION_KEY are infrastructure concerns and must be set via environment — they are intentionally not configurable from the TOML file.
Secrets: for ENCRYPTION_KEY and MASTER_API_KEY, prefer the *_FILE variant pointed at a Docker/Podman/Compose secret (mounted at /run/secrets/<name>) over the inline value — file-mounted secrets are not exposed via docker inspect, /proc/<pid>/environ, or to child processes. The inline env var takes precedence when both are set.
TOML-based config
Operational settings can be moved out of the environment and into edge-router.toml:
[config]
http_port = 80
https_port = 443
default_site_enabled = true
tls_fallback_cert = true # set false to reject TLS handshakes for unknown domains/IPs
redirect_http_to_https = true # set false to serve HTTP and HTTPS independently
proxy_timeout_seconds = 30 # global upstream timeout; override per service
[[admin]]
host = "router.oates.ws"
[[admin.access]]
via = "lan" # grant the dashboard via the "lan" listener — see Network Access Control
Environment variables override TOML values when both are present. The admin dashboard, like every service, is unreachable until an [[admin.access]] rule grants it a listener.
Secret injection with ER_ variables
Any value in edge-router.toml can reference an environment variable using ${VAR_NAME} syntax. EdgeRouter will look up ER_VAR_NAME and substitute the value at startup. If the variable is not set, EdgeRouter exits with a clear error.
[[tls]]
apex = "example.com"
email = "[email protected]"
cloudflare_api_token = "${CF_TOKEN}" # reads ER_CF_TOKEN from environment
# docker-compose.yml
environment:
ER_CF_TOKEN: "cf-token-abc"
The ER_ prefix deliberately namespaces pass-through secrets away from EdgeRouter's own config variables (HTTP_PORT, DATABASE_URL, etc.), preventing accidental cross-use. Secrets never touch disk — they live only in the environment and, once processed, in the encrypted database.
Automatic HTTPS
EdgeRouter manages the full TLS lifecycle: it requests wildcard certificates from Let's Encrypt via the DNS-01 challenge (handled by the Cloudflare API), serves them via SNI, and renews them automatically before they expire.
How it works
- You configure an apex domain (e.g.
example.com) with your Cloudflare API token. - EdgeRouter calls the Cloudflare API to create
_acme-challengeTXT records. - It polls Let's Encrypt until the challenge validates, then receives a certificate covering both
*.example.comandexample.com. - The certificate and private key are stored encrypted in SQLite and loaded into the live SNI resolver — HTTPS begins serving immediately, no restart required.
- Every 12 hours, EdgeRouter checks whether any certificate expires within 30 days. If so, it renews automatically using the stored ACME account credentials.
- Cleanup: the TXT records are deleted from Cloudflare whether the challenge succeeds or fails.
A single EdgeRouter instance can serve TLS for any number of apex domains simultaneously. Each domain has its own certificate and Cloudflare credentials; they are completely independent.
Configuration via TOML
# edge-router.toml
[[tls]]
apex = "example.com"
email = "[email protected]"
cloudflare_api_token = "${CF_TOKEN_EXAMPLE}" # reads ER_CF_TOKEN_EXAMPLE
# staging = true # use Let's Encrypt staging; flip to false for production
[[tls]]
apex = "another.io"
email = "[email protected]"
cloudflare_api_token = "${CF_TOKEN_ANOTHER}" # reads ER_CF_TOKEN_ANOTHER
TLS domains seeded from config behave like config-sourced services: they are upserted on startup and removed when deleted from the file.
Staging mode
Set staging = true on a [[tls]] entry (or pass "staging": true to POST /api/tls) to use the Let's Encrypt staging environment. Staging certificates are issued with no rate limits but are not browser-trusted — they are safe to use while testing DNS setup, Cloudflare credentials, and certificate issuance without risking a failed attempt against the production rate limit (5 certificates per registered domain per week).
Once staging works end-to-end, flip staging to false, delete the old domain record via the API, and re-add it (or restart to re-seed from TOML). The new certificate will be a production one. The staging flag is stored alongside the domain record so that automatic renewals always use the same Let's Encrypt environment — you will not accidentally renew a production cert against staging.
Configuration via API
# Register a new TLS domain and start cert acquisition (returns 202 immediately)
POST /api/tls
{
"apex": "example.com",
"email": "[email protected]",
"cloudflare_api_token": "cf-token-abc",
"staging": false
}
# List all TLS domains and their cert status
GET /api/tls
# Get a single domain
GET /api/tls/<id>
# Manually trigger renewal (e.g. after a failed auto-renewal)
POST /api/tls/<id>/renew
POST /api/tls/<id>/renew?staging=true # use Let's Encrypt staging
# Delete a domain (removes cert from live resolver immediately)
DELETE /api/tls/<id>
POST /api/tls returns 202 Accepted with the domain record. Certificate acquisition happens in the background and can take up to 60 seconds. Poll GET /api/tls/<id> and wait for has_cert: true and a populated expires_at.
Responses never include cloudflare_api_token, the private key, or the ACME account credentials — these are stored encrypted and never exposed via the API.
Cloudflare token permissions
The Cloudflare API token needs:
- Zone → DNS → Edit
- Scoped to the specific zone (recommended) or all zones
Encryption
All sensitive values written to the database — Cloudflare API tokens, TLS private keys, and ACME account credentials — are encrypted with AES-256-GCM before storage.
The encryption key is resolved in this order:
ENCRYPTION_KEYenvironment variable (base64-encoded 32-byte key)ENCRYPTION_KEY_FILEenvironment variable (path to a file containing the key){DATA_DIR}/encryption.key(key file auto-generated on first boot)
In production, set ENCRYPTION_KEY via a Docker secret or environment injection. If you rely on the auto-generated file, ensure the data volume is backed up.
Key Rotation
To rotate the encryption key without downtime:
- Set
ENCRYPTION_KEYto the new key. - Set
ENCRYPTION_KEY_OLDto the old key. - Restart EdgeRouter. On startup it re-encrypts all credentials with the new key.
- Remove
ENCRYPTION_KEY_OLDand restart again.
Route Config (TOML)
Services and static sites can be pre-seeded from a TOML file at startup. Config-sourced entries behave identically to API-registered ones and are upserted on each restart.
# edge-router.toml
[[services]]
name = "my-api"
target_host = "my-api" # Docker service name or IP
target_port = 3000
routing_hosts = ["api.example.com"]
routing_paths = ["/*"]
health_check_path = "/health"
health_check_interval_seconds = 30
health_check_timeout_seconds = 5
enable_logging = false
# proxy_timeout_seconds = 60 # override global default for slow services
[[services]]
name = "my-app"
target_host = "my-app"
target_port = 8080
routing_hosts = ["app.example.com"]
routing_paths = ["/*"]
# ── Static sites ──────────────────────────────────────────────────────────────
# Named MinIO instances — reference by name in static_sites, or inline the config
[minio.primary]
endpoint = "http://minio:9000"
# Directory-based: serve files from a mounted path
[[static_sites]]
host = "docs.example.com"
dir = "/app/static/docs"
# Bucket-based (named MinIO ref): files served from MinIO; no volume mount needed
[[static_sites]]
host = "marketing.example.com"
bucket = "marketing-site"
minio = "primary"
# Bucket-based (inline config): define MinIO endpoint directly on the site
[[static_sites]]
host = "landing.example.com"
bucket = "landing-page"
minio = { endpoint = "http://minio2:9000" }
# ── TLS ───────────────────────────────────────────────────────────────────────
[[tls]]
apex = "example.com"
email = "[email protected]"
cloudflare_api_token = "${CF_TOKEN}" # reads ER_CF_TOKEN from environment
Static site source types
| Type | Config | Best for |
|---|---|---|
dir |
dir = "/path" |
Local dev, content mounted into the container |
bucket |
bucket = "name" + minio |
Production — content lives in MinIO/S3, updated by CI/CD without touching the router |
For bucket-based sites, EdgeRouter makes unauthenticated GET requests to MinIO, so configure the bucket as public-read. CI/CD pipelines get a scoped write-only key per bucket — no worker can touch another site's storage.
Static fallback chain
When a request does not match any registered service, EdgeRouter falls back through:
- Per-domain static site (dir or bucket) matching the
Hostheader DEFAULT_SITE_DIR(if configured) — a global catch-all static directory- Embedded holding page (HTML requests only)
Network Access Control
EdgeRouter is secure by default: services, the admin dashboard, and L4 routes are unreachable from anywhere until an access rule grants them a listener. An empty rule list is a deny-all, not "open" — there is no legacy unrestricted state. Three layers, all configured in edge-router.toml:
Bindings are the OS-level socket binds — where the process actually listens:
[[bindings]]
name = "https"
bind = "0.0.0.0:443"
terminates_tls = true
Define no bindings and EdgeRouter binds dual-stack by default — a v4 (0.0.0.0) and a v6 ([::]) socket for each configured port (http/http6/https/https6; the v6 socket is IPV6_V6ONLY so the pair never collides). Both sockets of a port share one listener/trust-zone name (http/https), so an access rule via = "http" covers IPv4 and IPv6 alike. Resources still need an explicit access rule to be reachable. (If you declare bindings yourself but no listeners, each binding gets its own catch-all listener named after it — no implicit v4/v6 pairing.)
Listeners are named trust zones layered on a binding. A listener can match on the accepted connection's real local address (match_local_ip — e.g. to tell LAN from Tailscale; never a client-supplied value) and/or declare that forwarded client-IP headers are trustworthy on it (trust_forwarded_for):
[[listeners]]
name = "lan"
parent = "https"
match_local_ip = "192.168.0.247/32" # this host's LAN address
[[listeners]]
name = "tailscale"
parent = "https"
match_local_ip = "100.71.56.28/32" # this host's Tailscale address
# A binding reachable only from an internal network (e.g. a cloudflared
# sidecar). Trusting CF-Connecting-IP / X-Real-IP is sound here precisely
# because nothing else can reach this binding.
[[bindings]]
name = "cloudflare-internal"
bind = "0.0.0.0:8443"
terminates_tls = true
[[listeners]]
name = "cloudflare"
parent = "cloudflare-internal"
trust_forwarded_for = true
Bindings and listeners are startup-only — changing them requires a restart. Access rules (below) hot-reload.
Access rules grant a resource reachability through a listener. Each names a via listener and may further restrict to client IPs/CIDRs with allow_ips. Multiple rules are OR'd; an empty list means unreachable. The same [[…access]] mechanism gates services, the admin dashboard ([[admin.access]]), and L4 routes ([[l4_routes.access]]):
[[services.access]]
via = "cloudflare" # reachable from the internet via the tunnel
[[services.access]]
via = "tailscale" # and directly over Tailscale...
allow_ips = ["100.64.0.0/10"] # ...optionally narrowed to specific peers
# no "lan" rule → not reachable from the LAN directly
Rule changes on DB-backed resources (services, L4 routes) take effect immediately via the API, or within a few seconds when edited in the TOML file; admin rules refresh on the same config-file poll.
trust_forwarded_for and the client IP. For both access checks and forwarding headers, EdgeRouter uses the effective client IP: it honors a claimed CF-Connecting-IP / X-Real-IP only on a listener marked trust_forwarded_for (one structurally reachable solely through a trusted intermediary). On any other listener the real TCP peer address is used — so a client can't spoof its source IP to satisfy an allow_ips rule.
⚠
match_local_ipneeds the process to see the host's real interface addresses. Under Docker bridge networking, DNAT rewrites the destination before EdgeRouter sees it, somatch_local_ipnever matches and that traffic fails closed. Usenetwork_mode: host(or run Tailscale on the host), or use a single catch-all listener per binding. Seeedge-router.example.tomlfor the full annotated example.
L4 SNI Passthrough
For backends that must terminate their own TLS (mutual TLS, or a service with its own certificate), EdgeRouter can tunnel raw TCP by SNI without decrypting at the edge. On a TLS-terminating binding it peeks the ClientHello, reads the SNI, and:
- if the SNI matches an
[[l4_routes]]entry (exact match first, then a*.wildcard), it streams raw bytes straight to the backend — TLS is never terminated at the router; - otherwise it rewinds the connection and normal TLS termination proceeds unaffected.
[[l4_routes]]
name = "vault"
match_sni = "vault.example.com"
target_host = "vault"
target_port = 8200
# max_connections = 100
[[l4_routes.access]]
via = "tailscale"
Access rules are enforced on L4 routes too, using the real peer IP — L4 passthrough has no HTTP headers, so trust_forwarded_for does not apply at this layer. Connections that don't send a complete ClientHello within a few seconds are dropped (slow-loris guard).
API Reference
All endpoints require X-API-Key: <key> unless noted.
Admin endpoints
Admin endpoints are additionally gated by the [[admin]] host and its access rules — the request must arrive on a granted listener from a permitted IP (see Network Access Control).
Keys
POST /api/keys # Create a service key { "description": "my-service" }
GET /api/keys # List all keys
DELETE /api/keys/<id> # Revoke a key
Services
GET /api/services # List all services
GET /api/services/<name> # Get one service
GET /api/logs?service_id=<id>&limit=100 # Request logs (requires enable_logging on the service)
GET /api/status # Router status summary
GET /api/metrics # Uptime, request counts, error rate, latency
POST /api/services # Register a new service (see below)
PATCH /api/services/<name> # Update fields on an existing service (see below)
DELETE /api/services/<name> # Deregister a service
TLS domains
GET /api/tls # List all TLS domains
GET /api/tls/<id> # Get one domain (shows cert status, expiry)
DELETE /api/tls/<id> # Delete domain and remove cert from live resolver
POST /api/tls/<id>/renew # Trigger manual renewal (?staging=true for testing)
Dashboard
GET / (admin host only, no API key required)
Each [[admin]] host serves the dashboard at /, favicon, and logo. No API key required for the page itself; all /api/* calls from it still require auth.
Service registration endpoints
These require only a valid API key (master or service key), not admin IP.
# Register a service (upserts on name)
POST /api/services
{
"name": "my-api",
"target_host": "my-api",
"target_port": 3000,
"routing_hosts": ["api.example.com"],
"routing_paths": ["/*"],
"health_check_path": "/health", // optional
"health_check_interval_seconds": 30,
"health_check_timeout_seconds": 5,
"enable_logging": true, // required for /api/logs and dashboard stats
"proxy_timeout_seconds": 60, // optional; overrides global default
"max_request_body": 5368709120, // optional; per-service body cap in BYTES (min with global)
"skip_https_redirect": false // optional; set true when CDN terminates TLS
}
# Update specific fields on an existing service (all fields optional)
PATCH /api/services/<name>
{
"enable_logging": true,
"skip_https_redirect": true
// any subset of the POST fields above; omitted fields are unchanged
// pass null for health_check_path or proxy_timeout_seconds to clear them
}
# Deregister a service
DELETE /api/services/<name>
# Register a TLS domain and start cert acquisition
POST /api/tls
{
"apex": "example.com",
"email": "[email protected]",
"cloudflare_api_token": "cf-token-abc",
"staging": false
}
routing_paths supports exact matches (/health) and wildcard suffix (/api/*). Re-registering with the same name is an upsert.
Self-registration Pattern
Services register themselves at startup and deregister on shutdown. A typical entrypoint:
#!/bin/sh
set -e
# Wait for edge-router
until curl -sf http://edge-router/api/status -H "X-API-Key: $EDGE_ROUTER_KEY" > /dev/null; do
echo "Waiting for edge-router..."
sleep 2
done
# Register
curl -sf -X POST http://edge-router/api/services \
-H "Content-Type: application/json" \
-H "X-API-Key: $EDGE_ROUTER_KEY" \
-d "{
\"name\": \"$SERVICE_NAME\",
\"target_host\": \"$HOSTNAME\",
\"target_port\": $PORT,
\"routing_hosts\": [\"$VIRTUAL_HOST\"],
\"routing_paths\": [\"/*\"],
\"health_check_path\": \"/health\"
}"
# Run the service (this line becomes PID 1)
exec "$@"
HTTP → HTTPS Redirect
When both HTTP_PORT and HTTPS_PORT are configured, EdgeRouter automatically issues a 301 Moved Permanently redirect from HTTP to HTTPS for all requests. The redirect is port-aware: if HTTPS_PORT is 443 the Location header omits the port; otherwise it includes it.
To disable redirects and serve both protocols independently (e.g. for internal services that must stay on HTTP), set REDIRECT_HTTP_TO_HTTPS=false or redirect_http_to_https = false in [config].
To opt a single service out of the redirect while keeping it enabled globally, set skip_https_redirect = true on that service:
[[services]]
name = "my-service"
skip_https_redirect = true
# ...
This is useful when a CDN (e.g. Cloudflare) terminates TLS and forwards plain HTTP to EdgeRouter — the service sees HTTP but the client is already on HTTPS, so redirecting would create a loop.
Forwarding Headers
On every proxied request, EdgeRouter injects the following headers before forwarding to the upstream:
| Header | Value |
|---|---|
X-Forwarded-For |
Client IP (appended to any existing value) |
X-Real-IP |
Client IP |
X-Forwarded-Proto |
https or http |
X-Forwarded-Host |
Original Host header from the client |
Upstreams that need the real client IP (rate limiting, geolocation, audit logging) should read X-Real-IP or the rightmost entry in X-Forwarded-For.
The client IP here is the effective client IP: a forwarded CF-Connecting-IP / X-Real-IP is honored only on a listener marked trust_forwarded_for; on any other listener the real TCP peer is used, so a client can't spoof it. See Network Access Control.
Graceful Shutdown
EdgeRouter handles SIGTERM and Ctrl-C. On receipt it stops accepting new connections and waits for in-flight requests to complete before exiting. The HTTPS accept loop shuts down when the HTTP server finishes draining.
In Docker, docker stop sends SIGTERM and waits 10 seconds before SIGKILL, which is enough for almost all workloads. For long-running requests (video uploads, slow queries), increase stop_grace_period in your compose file.
Proxy Timeout
The global proxy timeout defaults to 30 seconds. A request that does not receive a complete response within this window returns a 504 Gateway Timeout error.
To change the global default:
[config]
proxy_timeout_seconds = 60
Or override per service:
[[services]]
name = "slow-export"
target_host = "export-svc"
target_port = 8080
routing_hosts = ["export.example.com"]
routing_paths = ["/*"]
proxy_timeout_seconds = 300 # 5 minutes for large exports
The per-service value takes precedence over the global default. Services without proxy_timeout_seconds use the global value.
Routing
Matching rules
A request matches a service when both are true:
- The
Hostheader starts with one of the service'srouting_hosts - The request path matches one of the service's
routing_paths
Path patterns:
| Pattern | Meaning |
|---|---|
/foo |
exact — only /foo |
/foo/? |
optional trailing slash — /foo and /foo/ |
/foo/* |
segment prefix — /foo/ and below (not /foobar, not bare /foo) |
/foo* |
raw prefix — /foo, /foobar, /foo/bar |
/* |
catch-all |
When several services match one request, the most specific path wins, independent of registration or name order: exact and /? matches outrank any prefix, and among prefixes the longest wins. The request path is forwarded to the backend unchanged. Backends confirmed unhealthy are skipped (see below); a service must also be granted a listener via an access rule to be reachable at all.
Health checking
Each service with a health_check_path is probed on its own health_check_interval_seconds. A shared checker ticks faster than any interval and probes a backend only once its interval has elapsed; every backend is probed once on startup so a restart doesn't serve stale health. Health is four-state:
| State | Dashboard | Meaning |
|---|---|---|
healthy |
green | last probe passed |
unhealthy |
red | last probe failed |
unknown |
amber | a health check is configured but hasn't run yet (warming) |
unmonitored |
grey | no health_check_path — the service is never probed |
Routing is fail-open: only a confirmed-unhealthy backend is withheld. A warming or unmonitored backend still receives traffic, so a freshly registered or just-restarted service isn't black-holed waiting on its first probe. /api/status and the dashboard report the tri/quad-state health field alongside the legacy boolean is_healthy.
Architecture
| Component | Role |
|---|---|
| Axum | HTTP framework, routing, middleware |
| SQLx + SQLite | Service registry, API keys, request logs, TLS domain storage |
| Reqwest | Outbound HTTP proxying, health checks, Cloudflare API calls |
| Hyper + hyper-util | WebSocket upgrade tunnelling, manual HTTPS accept loop |
| tokio-rustls | TLS termination |
| rustls | SNI cert resolver, ServerConfig |
| instant-acme | ACME protocol client (Let's Encrypt) |
| rcgen | CSR generation for ACME |
| x509-parser | Certificate expiry extraction |
| aes-gcm | AES-256-GCM encryption for credentials at rest |
| Tokio | Async runtime |
Licence
MIT