One binary. Every protocol. Zero dependencies.
A ~5,000-line C server that replaces nginx + HAProxy + CoreDNS + socat โ all from a single process with a single JSON config file. Static files, reverse proxy, WebSocket passthrough, TCP/UDP forwarding, protocol translation, built-in authoritative DNS, TLS termination, gzip compression, and five load-balancing algorithms. Built on epoll/kqueue with sendfile(2) zero-copy I/O.
Because sometimes you don't need a container orchestrator. You need a Swiss Army knife.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ npserver โ
โ โ
HTTP โโโโถโ static files ยท reverse proxy โ
HTTPS โโโโถโ WebSocket ยท gzip compression โ
TCP โโโโถโ port forward ยท load balancing โ
UDP โโโโถโ DNS server ยท protocol translate โ
Unix โโโโถโ TLS terminate ยท sendfile(2) โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Build
cmake . && make
# Serve static files on port 8080
./npserver http://:8080
# HTTPS + HTTP with TLS termination
sh make_test_cert.sh
./npserver https://:443 http://:80 -cert testsite.crt -key testsite.key
# Port forward (ssh -L style)
./npserver tcp://:3307 -to dbserver.internal:3306
# Full config-driven setup
./npserver -config config.jsonsendfile(2)zero-copy on Linux and macOS (fallback for TLS)- Byte-range requests (
Range: bytes=START-END,206 Partial Content) HEADmethod support (headers only, no body)- Gzip compression for text, JS, CSS, JSON, SVG (via zlib, compile-time optional)
Vary: Accept-Encodingfor correct cache behaviorindex.htmldirectory fallback- Path traversal protection (
realpath+ prefix check) - Per-location root mapping
- Non-blocking upstream connect with full bidirectional backpressure
- WebSocket passthrough โ
Upgrade: websocketis detected,Connection: Upgradeis forwarded, and the connection becomes a raw bidirectional tunnel - Header rewriting:
Host,X-Forwarded-For,X-Real-IP,X-Forwarded-Proto - Streaming request bodies (chunked and Content-Length)
- Named upstream pools with five load-balancing algorithms:
round-robinโ sequential rotationrandomโ random selectionip-hashโ sticky sessions by client IP (djb2 hash)conn-hashโ per-connection stickinessleast-connโ route to the backend with fewest active connections
- Per-pool health check configuration
- IPv4 + IPv6 upstream resolution
- Bidirectional relay with buffered backpressure
- Protocol translation: TCPโUDP, TCPโUnix, UDPโTCP
- Framing modes:
stream(byte-oriented) anddatagram(message-oriented) - Configurable idle timeout, max connections, and buffer size
- Like
ssh -Lorsocat, but declarative
- Authoritative DNS responder over UDP
- Record types: A, AAAA, CNAME, MX, TXT, SRV, NS, PTR
ANYquery support (returns all records for a name)- CNAME pass-through (included in answers regardless of query type)
- Per-record TTL overrides
- DNS name compression on responses
- Case-insensitive matching with compression-loop protection
- All configured in JSON โ no zone files
- OpenSSL-based TLS 1.2/1.3 termination
- Scheme-driven:
https://andssl://listeners get TLS automatically - Non-blocking handshake integrated into the event loop
- Hand-rolled incremental state-machine parser (no library dependencies)
- HTTP/1.1 with keep-alive and chunked transfer encoding
- Streaming body support for large uploads
- Configurable limits:
MAX_BODY_SIZE(64 MiB),MAX_URL_SIZE(4096),MAX_HEADERS(128)
- Single process, single thread โ no locking, no shared state
- epoll (Linux) + kqueue (macOS/BSD) dual backend
- Header-only libraries:
http.h,socket_server.h,dns.h,config.h,stringbuf.h,dict.h - Everything compiles from one
server.cโ total build time under 1 second - URI-based socket configuration:
http://,https://,tcp://,udp://,ssl://,unix://,unixgram:// - IPv4 + IPv6 dual-stack on all listeners
# Serve files from ./public on port 8080
./npserver http://:8080
# HTTPS on 443 + HTTP on 80
./npserver https://:443 http://:80 -cert fullchain.pem -key privkey.pem
# TCP port forward: local 3307 โ remote MySQL 3306
./npserver tcp://:3307 -to dbserver.internal:3306
# UDP relay: local 5353 โ Google DNS
./npserver udp://127.0.0.1:5353 -to udp://8.8.8.8:53
# Unix socket listener
./npserver unix:///tmp/myapp.sock
# Listen on a specific interface
./npserver http://192.168.1.10:8080
# Custom backlog
./npserver http://:8080 -c 4096
# Use a config file
./npserver -config production.jsonnpserver uses a single JSON config file. All features โ listeners, static serving, proxying, forwarding, DNS, upstream pools โ are declared in one place.
| Handler | What it does | Key fields |
|---|---|---|
static |
Serve files from a directory | root, index |
proxy |
HTTP reverse proxy (+ WebSocket auto-detect) | proxy_pass, path |
forward |
Raw TCP/UDP/Unix bidirectional relay | forward_to, mode, timeout, buffer_size |
dns |
Authoritative DNS from the dns config block |
(none โ uses the top-level dns section) |
| Algorithm | Config value | Behavior |
|---|---|---|
| Round-robin | round-robin |
Sequential rotation across healthy targets |
| Random | random |
Random selection with health-aware fallback |
| IP hash | ip-hash |
Sticky sessions โ same client IP โ same backend |
| Conn hash | conn-hash |
Per-connection stickiness via fd hash |
| Least connections | least-conn |
Route to the backend with fewest active conns |
One binary replaces your entire ingress stack. Serve your blog, proxy Jellyfin, forward SSH, and resolve .local hostnames โ all from one config:
{
"listen": [
{ "name": "web", "uri": "https://:443" },
{ "name": "dns", "uri": "udp://192.168.1.1:53" }
],
"tls": { "cert": "/etc/letsencrypt/live/mysite/fullchain.pem",
"key": "/etc/letsencrypt/live/mysite/privkey.pem" },
"dns": {
"jellyfin.home": { "A": "192.168.1.50" },
"nas.home": { "A": "192.168.1.10" },
"pi.home": { "A": "192.168.1.20" }
},
"dispatch": [
{ "listener": "web", "handler": "static", "root": "/var/www/blog" },
{ "listener": "web", "handler": "proxy", "path": "/jellyfin/**",
"proxy_pass": "http://192.168.1.50:8096" },
{ "listener": "dns", "handler": "dns" }
]
}Deploy as a sidecar that terminates TLS, proxies HTTP to your app, and forwards metrics to StatsD โ no service mesh required:
./npserver -config sidecar.json
# Listens: https://:443 โ http://localhost:8000 (your app)
# tcp://:9000 โ udp://statsd:8125 (metrics)
# unix:///tmp/app.sock โ your app's unix socketTranslate between UDP game clients and a TCP game server, with a web dashboard on the side:
{
"listen": [
{ "name": "game", "uri": "udp://:27015" },
{ "name": "admin", "uri": "http://:8080" }
],
"dispatch": [
{ "listener": "game", "handler": "forward",
"forward_to": "tcp://gameserver.internal:27015", "mode": "stream" },
{ "listener": "admin", "handler": "static", "root": "./dashboard" }
]
}Expose a remote database locally โ like ssh -L but declarative and persistent:
# MySQL on a remote host, available locally on :3307
./npserver tcp://:3307 -to dbserver.staging:3306Distribute WebSocket connections across a pool of backend servers with least-connections balancing:
{
"listen": [{ "name": "web", "uri": "http://:8080" }],
"upstreams": {
"ws_pool": {
"targets": ["http://10.0.0.1:3000", "http://10.0.0.2:3000", "http://10.0.0.3:3000"],
"balance": "least-conn"
}
},
"dispatch": [
{ "listener": "web", "handler": "proxy", "path": "/ws/**",
"proxy_pass": "upstream://ws_pool" }
]
}Serve different DNS answers on your internal network while also hosting the website:
{
"listen": [
{ "name": "web", "uri": "http://:80" },
{ "name": "dns", "uri": "udp://10.0.0.1:53" }
],
"dns": {
"api.company.com": { "A": "10.0.0.50" },
"db.company.com": { "A": "10.0.0.51" },
"search.company.com": { "A": ["10.0.0.60", "10.0.0.61"] },
"company.com": {
"A": "10.0.0.100",
"MX": [{ "priority": 10, "host": "mx.company.com" }],
"TXT": ["v=spf1 include:company.com ~all"]
}
},
"dispatch": [
{ "listener": "web", "handler": "static", "root": "/var/www/company" },
{ "listener": "dns", "handler": "dns" }
]
}cmake . && makeDependencies:
- OpenSSL โ required for TLS (
https://,ssl://listeners) - zlib โ optional, enables gzip compression (auto-detected by CMake)
Both are auto-detected. If zlib isn't found, npserver builds without compression. Override compile-time limits with -D flags:
cmake . -DCMAKE_C_FLAGS="-DMAX_BODY_SIZE=134217728 -DNUM_CLIENTS=50000 -DGZIP_MAX_SIZE=10485760"| Define | Default | What it controls |
|---|---|---|
NUM_CLIENTS |
10,000 | Max concurrent connections |
MAX_BODY_SIZE |
64 MiB | Max HTTP request body |
MAX_HEADERS |
128 | Max HTTP headers per request |
MAX_URL_SIZE |
4,096 | Max URI length |
GZIP_MAX_SIZE |
5 MiB | Max file size for in-memory compression |
GZIP_MIN_SIZE |
256 | Min file size to bother compressing |
npserver is a sharp tool, not a padded one. Know the edges:
If a chosen upstream fails to connect, the request gets a 502 Bad Gateway. There is no automatic retry to the next backend in the pool. A failed health check marks a target as unhealthy, but active health checking is not yet implemented โ the config fields exist as placeholders.
Header, body, and idle timeouts are enforced on all incoming connections (configurable via the "timeouts" config section). Per-IP connection limits provide basic DoS defense. Proxy upstream connect timeouts are also enforced. Forward relay idle timeouts are parsed but not yet enforced.
There is no per-IP or per-path rate limiting. Every request is served. Pair with an external firewall (iptables, nftables, pf) if you need throttling.
The HTTP parser is HTTP/1.1 only. No ALPN negotiation, no binary framing, no stream multiplexing. This is by design โ the goal is simplicity, not feature parity with Envoy.
The reverse proxy only connects to http:// backends. TLS-to-backend (https:// upstream) is not yet supported. Use this server for TLS termination and talk plaintext to your backends.
No built-in Let's Encrypt. Bring your own certs. Works well with certbot or acme.sh in a cron job.
One thread, one event loop, one core. Throughput scales vertically with CPU clock speed, not core count. For most workloads this is fine โ the bottleneck is usually I/O, not CPU.
Changing config.json requires a restart. No SIGHUP reload, no zero-downtime config swap.
One global cert/key pair for all TLS listeners. No per-domain certificate selection via SNI.
The DNS server is authoritative-only, plain UDP, no EDNS(0). Good enough for .local service discovery. Not a replacement for a public-facing nameserver.
server.c Main entry point, config wiring, event loop setup
request.h HTTP handlers: static files, reverse proxy, load balancer, forwarding
http.h Incremental HTTP/1.1 parser and response builder
socket_server.h Event loop (epoll/kqueue), TLS, connection management
dns.h Authoritative DNS responder
config.h JSON config loader
dict.h JSON parser (hand-rolled, no dependencies)
stringbuf.h Safe dynamic string buffer
stb_ds.h Hash map / dynamic array (stb single-header lib)
Because I wanted a small, high-performance, C-only mantlepiece.
One process. One config. One binary you can scp to a server and run. No YAML, no plugins, no runtime, no 200MB Docker image. Just a program that listens on sockets and moves bytes where they need to go.
npserver โ one proxy to serve them all.
{ // โโ Listeners โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ "listen": [ { "name": "web", "uri": "http://:8080" }, { "name": "tls", "uri": "https://:8443" }, { "name": "dns-in", "uri": "udp://127.0.0.1:5353" }, { "name": "db-local", "uri": "tcp://:3307" }, { "name": "sidecar", "uri": "tcp://:9000" }, { "name": "gameproxy", "uri": "udp://:27015" }, "unix:///tmp/app.sock" ], // โโ TLS โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ "tls": { "cert": "fullchain.pem", "key": "privkey.pem" }, "backlog": 2048, // โโ DNS Zone โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ "dns_ttl": 300, "dns": { "redis.local": { "A": "192.168.11.2", "AAAA": "fd00::2" }, "postgres.local": { "A": "192.168.11.3" }, "web.local": { "A": ["10.0.0.10", "10.0.0.11"], "AAAA": "fd00::10" }, "app.local": { "CNAME": "web.local" }, "cache.local": { "A": "192.168.11.4", "TTL": 60 }, "example.com": { "A": "93.184.216.34", "MX": [ { "priority": 10, "host": "mx1.example.com" }, { "priority": 20, "host": "mx2.example.com" } ], "TXT": ["v=spf1 include:example.com ~all"], "NS": ["ns1.example.com", "ns2.example.com"] }, "_http._tcp.web.local": { "SRV": { "priority": 0, "weight": 100, "port": 8080, "target": "web.local" } } }, // โโ Upstream Pools (load balancing) โโโโโโโโโโโโโโโโโโโโโโโ "upstreams": { "app_pool": { "targets": [ "http://127.0.0.1:8001", "http://127.0.0.1:8002", "http://127.0.0.1:8003" ], "balance": "round-robin", "health_check": { "path": "/healthz", "interval": 10 } }, "ws_pool": { "targets": ["http://127.0.0.1:8010", "http://127.0.0.1:8011"], "balance": "least-conn" } }, // โโ Dispatch (routing rules) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ "dispatch": [ // Static files { "listener": "web", "handler": "static", "root": "./public", "index": "index.html" }, { "listener": "tls", "handler": "static", "root": "./public" }, // Reverse proxy to backend API { "listener": "web", "handler": "proxy", "path": "/api/**", "proxy_pass": "http://127.0.0.1:8000" }, { "listener": "tls", "handler": "proxy", "path": "/api/**", "proxy_pass": "http://127.0.0.1:8000" }, // Load-balanced proxy via named upstream pool { "listener": "web", "handler": "proxy", "path": "/app/**", "proxy_pass": "upstream://app_pool" }, // WebSocket proxy (detected automatically from Upgrade header) { "listener": "web", "handler": "proxy", "path": "/ws/**", "proxy_pass": "upstream://ws_pool" }, // TCP forwarding: local :3307 โ remote MySQL { "listener": "db-local", "handler": "forward", "forward_to": "tcp://dbserver.internal:3306", "timeout": 300, "max_connections": 20, "buffer_size": 65536 }, // TCP โ UDP protocol translation (StatsD sidecar) { "listener": "sidecar", "handler": "forward", "forward_to": "udp://statsd.internal:8125", "mode": "datagram" }, // UDP โ TCP protocol translation (game traffic) { "listener": "gameproxy", "handler": "forward", "forward_to": "tcp://gameserver.internal:27015", "mode": "stream" }, // Unix socket forwarding (PHP-FPM, etc.) { "listener": "sidecar", "handler": "forward", "forward_to": "unix:///var/run/php-fpm.sock" }, // Built-in DNS { "listener": "dns-in", "handler": "dns" } ] }