HTTP reverse proxy and static site server
  • Rust 94.8%
  • HTML 4.8%
  • Dockerfile 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
James Oates 519b3184b7
Some checks failed
Build Image (main) / build-arm64 (push) Failing after 1m24s
Build Image (main) / build-amd64 (push) Failing after 4m13s
Build Image (main) / manifest (push) Has been skipped
Use connect+read timeouts for streamed proxying, not a total timeout
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.
2026-08-13 17:18:52 +01:00
.aider.tags.cache.v4/7e/36 Stream request bodies instead of buffering into RAM 2026-08-13 16:58:56 +01:00
.forgejo/workflows Surface version/commit/build via generic runtime env vars 2026-08-07 10:25:17 +01:00
assets feat: add EdgeRouter logo and favicon, wire into dashboard 2026-04-09 17:48:24 +01:00
migrations Add per-service max_request_body override (#service body caps) 2026-08-13 17:14:11 +01:00
src Use connect+read timeouts for streamed proxying, not a total timeout 2026-08-13 17:18:52 +01:00
static/default feat: initial commit — edge-router standalone project 2026-04-08 17:37:14 +01:00
.env.example feat: initial commit — edge-router standalone project 2026-04-08 17:37:14 +01:00
.gitignore Document match_local_ip networking requirement; gitignore encryption keys 2026-08-03 11:54:53 +01:00
build.rs Surface version/commit/build via generic runtime env vars 2026-08-07 10:25:17 +01:00
Cargo.lock Hash API keys at rest instead of storing plaintext (#18) 2026-08-11 15:12:11 +01:00
Cargo.toml Hash API keys at rest instead of storing plaintext (#18) 2026-08-11 15:12:11 +01:00
CLAUDE.md Add per-service max_request_body override (#service body caps) 2026-08-13 17:14:11 +01:00
docker-compose.yml Support MASTER_API_KEY_FILE for file-mounted secrets (follow-up to #19) 2026-08-07 11:59:17 +01:00
docker-entrypoint.sh feat: initial commit — edge-router standalone project 2026-04-08 17:37:14 +01:00
Dockerfile Surface version/commit/build via generic runtime env vars 2026-08-07 10:25:17 +01:00
edge-router.example.toml Add per-service max_request_body override (#service body caps) 2026-08-13 17:14:11 +01:00
README.md Add per-service max_request_body override (#service body caps) 2026-08-13 17:14:11 +01:00

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_V6ONLY for 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-Host on 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

  1. You configure an apex domain (e.g. example.com) with your Cloudflare API token.
  2. EdgeRouter calls the Cloudflare API to create _acme-challenge TXT records.
  3. It polls Let's Encrypt until the challenge validates, then receives a certificate covering both *.example.com and example.com.
  4. The certificate and private key are stored encrypted in SQLite and loaded into the live SNI resolver — HTTPS begins serving immediately, no restart required.
  5. Every 12 hours, EdgeRouter checks whether any certificate expires within 30 days. If so, it renews automatically using the stored ACME account credentials.
  6. 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:

  1. ENCRYPTION_KEY environment variable (base64-encoded 32-byte key)
  2. ENCRYPTION_KEY_FILE environment variable (path to a file containing the key)
  3. {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:

  1. Set ENCRYPTION_KEY to the new key.
  2. Set ENCRYPTION_KEY_OLD to the old key.
  3. Restart EdgeRouter. On startup it re-encrypts all credentials with the new key.
  4. Remove ENCRYPTION_KEY_OLD and 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:

  1. Per-domain static site (dir or bucket) matching the Host header
  2. DEFAULT_SITE_DIR (if configured) — a global catch-all static directory
  3. 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_ip needs the process to see the host's real interface addresses. Under Docker bridge networking, DNAT rewrites the destination before EdgeRouter sees it, so match_local_ip never matches and that traffic fails closed. Use network_mode: host (or run Tailscale on the host), or use a single catch-all listener per binding. See edge-router.example.toml for 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 Host header starts with one of the service's routing_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