Hi, I'm Brett

Marketing guru by day, FOSS dev by night.

Lua, Redis, and the Art of Never Reloading Nginx


If you’ve been following along, you’ll know I’ve been on a bit of a journey with CDN infrastructure. From Building a Better CDN to speeding things up to playing with gRPC. This time around, the whole thing got torn down and rebuilt from scratch. The goal: a reverse proxy where I never have to touch an nginx config file to add a new site.

The Problem With Config Files

Every time you wanted to add a backend to the old setup, you’d write a server block, add SSL certs, reload nginx, and pray nothing broke. It works, sure. But it doesn’t scale well when you’re managing dozens of domains across multiple edge nodes. One typo in a config and the whole thing refuses to start. Not ideal.

What if the proxy could figure out where to send traffic at request time, pulling its configuration from a database instead of flat files?

Enter OpenResty and Lua

OpenResty is nginx with LuaJIT bolted on. It lets you hook into virtually every phase of the request lifecycle, from the TLS handshake all the way through to the response. Instead of static upstream blocks and proxy_pass directives, you write Lua scripts that run inline during request processing.

The architecture is simple on paper:

  1. A TLS connection comes in
  2. Lua extracts the SNI hostname from the ClientHello
  3. It fetches the matching SSL certificate from Redis
  4. The request arrives, Lua looks up the backend for that hostname in Redis
  5. DNS resolution happens (following CNAME chains if needed)
  6. The resolved IP gets handed to the balancer, traffic flows upstream

No server blocks per domain. No reloads. Add a new site by writing a couple keys to Redis, and the next request just works.

Dynamic SSL Without Restarts

This was the part that surprised me most. OpenResty’s ssl_certificate_by_lua hook fires during the TLS handshake, before HTTP even enters the picture. You can intercept it, look at what hostname the client is asking for, and serve the right certificate dynamically:

local key = ssl.server_name() or var.host or var.server_name
local cert_table = ssl_cache:get(key, nil, fetch_pem_from_redis, key)
ssl.set_cert(cert_table.cert)
ssl.set_priv_key(cert_table.pkey)

The SNI value drives everything. If a client doesn’t send SNI (rare these days, but it happens), it falls back to the Host header or the server_name variable. Certificates and private keys live in Redis as PEM strings, get parsed into OpenSSL structures on first access, and then stay cached in the Lua VM.

Three Levels of Cache

Hitting Redis on every request would defeat the purpose, so there’s a multi-level cache sitting in front of everything using lua-resty-mlcache:

  • L1: In-process LRU cache inside the Lua VM. Sub-microsecond lookups. This is where most hits land.
  • L2: Shared memory dictionary across nginx workers. Still fast, avoids duplicate Redis calls from different workers.
  • L3: Redis itself. The source of truth, only hit on cold starts or cache expiry.

Zone configs cache for 30 seconds on hit, 10 seconds on miss. SSL certs get a larger shared dict (8MB) since they’re chunkier. In practice, after the first request to a given hostname, everything serves from L1, Redis barely sees any read traffic from the proxy.

CNAME Chain Resolution

One thing I ran into early: not every backend hostname resolves to an A record directly. Some sit behind their own CDNs or load balancers with CNAME chains. The DNS resolver follows these chains up to 10 levels deep before giving up, which handles the vast majority of real-world setups without hardcoding IPs.

Compression Negotiation

The proxy negotiates compression with the client, preferring Zstandard over Brotli over gzip. Modern clients that advertise zstd in their Accept-Encoding get the best ratio; older browsers fall back gracefully. There’s some user-agent sniffing involved to avoid double-compressing responses from backends that already handle their own compression. Turns out that compressing an already-compressed response is a great way to melt a single-core VM.

The Build

The whole thing ships as a multi-stage Docker image. The builder stage compiles OpenResty from source with OpenSSL 3, PCRE2 with JIT, and the Brotli and Zstandard nginx modules. The runtime stage is a clean Ubuntu 24.04 image with just the compiled binaries copied in. Redis runs alongside as a replica of a remote master, so configuration state is distributed without any custom sync tooling.

Host networking mode keeps things simple and fast, no Docker bridge overhead to worry about on the edge.

Was It Worth It?

Absolutely. The old setup worked, but it was brittle. Adding a domain meant SSH-ing into edge nodes, editing configs, and reloading. Now it’s a Redis write. SSL renewals update Redis, and every edge node picks up the new cert within 30 seconds without anyone touching anything.

The Lua layer adds negligible overhead, most of the request lifecycle is still handled by nginx’s event loop. You get the flexibility of application-level routing logic with the performance of a purpose-built proxy. OpenResty sits in a sweet spot that very few other tools occupy.

If you’re running more than a handful of sites behind a reverse proxy and tired of managing config files, I’d highly recommend giving this approach a look. The learning curve for Lua in OpenResty is surprisingly gentle, and the payoff is immediate.