Sign inSign up

zunohan/zunik

By zunohan

•Updated 7 days ago

⚡ High-performance reverse proxy with Auto-SSL, HTTP/2, and 6 LB algorithms. Built with Rust.

Image
Networking
Developer tools
Operating systems
0

10K+

zunohan/zunik repository overview

⁠Zunik - High-Performance Reverse Proxy

Zunik⁠ is a lightweight reverse proxy & load balancer written in Rust, configured with plain TOML, with built-in Let's Encrypt and hot-reload.

Features

  • HTTP/1.1 + HTTP/2 auto-detection, http:// and https:// backends with connection pooling
  • Fully streamed request/response bodies: large uploads, downloads and compressed responses use constant memory
  • Auto-SSL via Let's Encrypt (ACME HTTP-01) with automatic renewal, or bring your own PEM cert
  • 6 load balancing algorithms: round_robin, weighted_round_robin, random, ip_hash, least_connections, least_response_time
  • Routing by host (wildcards), path, path prefix, regex, method and headers, with route priority
  • Health checks (HTTP/HTTPS) with healthy/unhealthy thresholds
  • Per-route timeout and retry across backends
  • Middlewares: CORS, response headers, Basic Auth, rate limit, strip/add prefix, regex path rewrite, compression (br/gzip/deflate), HTTPS redirect
  • Static file serving (streamed, Range/206, 304 Not Modified) and WebSocket proxying
  • Slowloris protection: header read timeout, TLS handshake timeout, upload idle timeout, HTTP/2 stream limit
  • trusted_proxies: only trusted upstreams (Cloudflare, load balancers) can set X-Forwarded-For / X-Forwarded-Proto
  • Prometheus /metrics with per-route latency histograms and per-backend stats
  • Hot-reload of routes on file change or SIGHUP, lock-free config swap
  • Graceful shutdown on SIGTERM/SIGINT (drains in-flight requests)
  • Multi-file config (include or --config-dir) and zunik -t config test
  • Admin dashboard with JSON API and optional Basic Auth

⁠Quick Start

Create config/zunik.toml:

[server]
http_listen = "0.0.0.0:80"

[dashboard]
listen = "0.0.0.0:8080"

[routes.app.match_rule]
path_prefix = "/"

[[routes.app.backends]]
url = "http://your-backend:3000"

Run it:

docker run -d --name zunik \
  -p 80:80 -p 8080:8080 \
  -v $(pwd)/config/:/app/config/:ro \
  zunohan/zunik:latest

Open the dashboard at http://localhost:8080.

A single file works too: -v $(pwd)/config.toml:/app/config.toml:ro. When /app/config.toml is absent, Zunik loads every .toml file in /app/config/.

Validate a config before deploying:

docker run --rm -v $(pwd)/config/:/app/config/:ro zunohan/zunik:latest -t

⁠Docker Compose

services:
  zunik:
    image: zunohan/zunik:latest
    ports:
      - 80:80
      - 443:443
      - 127.0.0.1:8080:8080   # keep the dashboard private
    volumes:
      - ./config/:/app/config/:ro
      - zunik-certs:/app/certs
    ulimits:
      nofile:
        soft: 65536
        hard: 65536
    stop_grace_period: 35s     # > graceful_shutdown_secs (default 30)
    restart: unless-stopped

  backend:
    image: your-app:latest
    expose:
      - 3000

volumes:
  zunik-certs:

⁠Auto-SSL (Let's Encrypt)

[server]
http_listen = "0.0.0.0:80"
https_listen = "0.0.0.0:443"

[server.auto_ssl]
domains = ["example.com", "www.example.com"]
email = "[email protected]"
cert_dir = "/app/certs"
staging = false
renew_before_days = 30
  • Port 80 must be reachable from the internet and every domain must resolve to the server.
  • One certificate covers all listed domains. It is stored as <cert_dir>/<first-domain>.cert.pem / .key.pem. Persist /app/certs in a volume.
  • Expiry is checked every 12 hours and renewed certificates are loaded without restart.
  • Delete the stored cert when switching from staging = true to false, or when adding domains.

Manual certificates (key must be PKCS#8):

[server]
https_listen = "0.0.0.0:443"

[server.tls]
cert_file = "/app/certs/fullchain.pem"
key_file = "/app/certs/privkey.pem"

⁠Routing & Load Balancing

[routes.api]
load_balancing = "least_connections"
priority = 10
timeout_secs = 15
max_body_size = 10485760

[routes.api.match_rule]
host = "api.example.com"     # or "*.example.com"
path_prefix = "/v1"
methods = ["GET", "POST"]

[[routes.api.backends]]
url = "http://api-1:3000"
weight = 3

[[routes.api.backends]]
url = "http://api-2:3000"
weight = 1

[routes.api.health_check]
path = "/health"
interval_secs = 10
unhealthy_threshold = 3
healthy_threshold = 2

[routes.api.retry]
attempts = 3
delay_ms = 200
retry_on_statuses = [502, 503, 504]

Higher priority wins. With equal priority, the most specific match wins (exact path > host > regex > longest prefix > method > headers).

⁠Middlewares

[[routes.api.middlewares]]
type = "cors"
allowed_origins = ["https://app.example.com"]

[[routes.api.middlewares]]
type = "rate_limit"
requests_per_second = 100
burst = 20

[[routes.api.middlewares]]
type = "strip_prefix"
prefix = "/v1"

[[routes.api.middlewares]]
type = "compress"
algorithms = ["br", "gzip"]

[[routes.admin.middlewares]]
type = "basic_auth"
[routes.admin.middlewares.users]
admin = "change-me"

Available: cors, add_headers (response headers), basic_auth, rate_limit, strip_prefix, add_prefix, rewrite_path, compress, redirect_https.

redirect_https only redirects plain HTTP requests. HTTPS requests and requests with X-Forwarded-Proto: https (TLS terminated upstream) pass through, so it is safe on routes served over both.

⁠Static Files

[routes.assets.match_rule]
path_prefix = "/assets"

[routes.assets.static_files]
root = "/app/public"
index = "index.html"
directory_listing = false
cache_secs = 3600

⁠Multi-file Config

config/
├── 00-server.toml    # [server], [dashboard]
├── 10-users.toml     # [routes.users]
└── 20-orders.toml    # [routes.orders]

Files load in alphabetical order. Routes are merged (later files win on name clashes). Editing any .toml file in the directory reloads routes automatically, or force a reload with docker kill -s HUP zunik. Listener, TLS and dashboard settings require a restart.

Config lookup order: --config-dir → --config file (if it exists) → ./config/ (if it has .toml files) → ./config.toml. An existing but invalid config makes Zunik exit with code 1 instead of starting with no routes.

⁠Behind a CDN or Load Balancer

By default Zunik trusts nobody's forwarding headers: the client IP is the TCP peer. Declare your upstream proxies so the real client IP and scheme are used for ip_hash, rate_limit, X-Real-IP and redirect_https:

[server]
trusted_proxies = ["173.245.48.0/20", "10.0.0.0/8"]   # IPs or CIDRs

⁠Connection Limits

[server]
max_connections = 10000
header_read_timeout_secs = 10      # close clients that don't send headers in time
tls_handshake_timeout_secs = 10
http2_max_concurrent_streams = 250

⁠Image Details

Basedebian:bookworm-slim, runs as non-root user zunik
Working dir/app
Entrypoint/app/zunik --config /app/config.toml
Volumes/app/config (config), /app/certs (certificates)
Ports80 HTTP, 443 HTTPS, 8080 dashboard
Healthcheckcurl -sf http://localhost:8080/api/health (requires the dashboard on port 8080)

⁠Dashboard API

EndpointDescription
GET /Web dashboard
GET /api/metricsRequests, connections, bytes, RPS, error rate, status codes, route hits, per-backend stats
GET /api/configRunning configuration (JSON)
GET /api/healthLiveness status (always public)
GET /metricsPrometheus metrics: zunik_requests_total, zunik_responses_total{code}, zunik_request_duration_seconds, zunik_route_request_duration_seconds{route}, zunik_backend_up{backend}, ...

Protect it with Basic Auth:

[dashboard]
listen = "0.0.0.0:8080"
username = "admin"
password = "a-long-random-password"

Secrets in /api/config (dashboard and basic_auth passwords) are always redacted. For access over the internet, prefer binding to 127.0.0.1 and using an SSH tunnel.

⁠Environment Variables

VariableDefaultDescription
RUST_LOGzunik=infoLog level, e.g. zunik=debug, zunik=warn

⁠Documentation

Full documentation and source code: https://github.com/zuno90/zunik⁠

⁠Support

Buy Me A Coffee

⁠License

MIT

Tag summary

Content type

Image

Digest

sha256:f54b945b8…

Size

37.5 MB

Last updated

8 days ago

docker pull zunohan/zunik