Note: This article provides a high-level architectural overview with simplified configuration examples. Production deployments require additional considerations for high availability, edge cases and error handling, automated certificate renewal, security hardening (DDoS protection, rate limiting, input validation etc), comprehensive observability and logging, database and secrets store optimization etc.
Multi-tenant Custom Domains Proxy On Kubernetes with Nginx(OpenResty)
At a previous company I worked on a feature where we needed to host user’s applications (running on serverless functions) on custom domains owned by users using our existing Kubernetes data plane cluster(s). Due to certificate limits on application load balancers on AWS and automation difficulties as well as non-scalable approach for using default Kubernetes Ingress (now deprecated) we decided to do L4 load balancing with a custom OpenResty gateway that allowed us to scale as well as offered more flexiblity in the gateway logic with Lua modules. Here’s a quick rundown of how (this does not resemble exactly what we did in production):
Control Plane
The control plane served the management APIs and handled the verification process where users would add custom domains and verify that they controlled the domain.
Verification and SSL Certificates Provisiong

After a user claimed that they owned the domain, we would provide them with two records, an A record to our anycast static IP and a custom TXT record for verifying that they owned the top level domain.
For e.g if a user owned the domain app.domain.com, the user would set up the following records:
app.domain.com A 172.122.1.5
_verify.domain.com TXT "some_securely_generated_random_verification_string"
Our verification service (notary) would then check the DNS records, and provision and store an SSL certificate for the domain. The verification process would look something like this (skipped error handling, retries, renewal etc for brevity):

- Check if a valid certificate already exists for added domain through wildcard certificates.
- Get the top level domain (TLD) from the domain added using the Public Suffix List (PSL). Deny verification if added domain is public.
- Check if there is a matching
TXTrecord for the domain every configurablenminutes. Verify ownership of the TLD if matching TXT record found. - Generate and store a 2048-bit RSA SSL private key in an external secrets store.
- Create a Certificate Signing Request (CSR) with the generated private key and ask for certificate creation from an external SSL provider.
Request a wild card certificate (
*.added.domain, added.domain) for the domain so that all subdomains can use this certificate and no certificate generation is required for subdomains. Use HTTP file validation method for validation (user must add theArecord already for validation and the backend proxy should serve the validation file on the validation route,/.well-known/pki-validation/or/.well-know/acme-challenge/). - Check validation status every configurable
nminutes. If certificate is ready store the created certificate in the store.
Data Plane
The data plane served the actual traffic for the custom domain.

DNS And Load Balancing
We provisioned a static IP and did anycast geolocation routing to the nearest cluster based on the query origin. Users would set up an A record pointing their custom domains to this static IP.
As we provisioned and managed SSL certificates certificates ourselves, we had to terminate SSL in our own clusters which required L4 load balancing that would target the OpenResty gateway deployed within the cluster.
Nginx (OpenResty) Gateway
We wrote custom modules in Lua to handle SSL termination and serving the right certificate for the domain.
The gateway itself is deployed as DaemonSets (Kubernetes makes sure at least one pod is running in each node) exposing an HTTP and HTTPs port. The configuration file(s) and lua modules are provided through Kubernetes ConfigMaps and mounted in a volume. Default SSL certificates (required for nginx to start) are mounted from a Kubernetes Secret.
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: openresty-gateway
spec:
selector:
matchLabels:
app: openresty
template:
metadata:
labels:
app: openresty
spec:
containers:
- name: openresty-gateway
image: some.container.registry.com/openresty-gateway:{version}
ports:
- name: http
containerPort: 80
hostPort: 80
- name: https
containerPort: 443
hostPort: 443
volumeMounts:
- mountPath: /etc/nginx/conf.d
name: conf-d
- mountPath: /etc/nginx/modules
name: modules
- mountPath: /etc/nginx/certs
name: default-certs
readOnly: true
volumes:
- name: conf-d
configMap:
name: openresty-config-conf.d
- name: modules
configMap:
name: openresty-config-modules
- name: default-certs
secret:
secretName: openresty-default-certs
items:
- key: tls.crt
path: default.crt
- key: tls.key
path: default.key
The default certificates are required by Nignx for listening on port 443. We will not use the cert however still need to create it. The secret can be created with:
# Generate self-signed certificate for nginx startup
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout default.key -out default.crt \
-subj "/CN=default/O=default"
# Create Kubernetes Secret
kubectl create secret generic openresty-default-certs \
--from-file=tls.crt=default.crt \
--from-file=tls.key=default.key \
-n data-plane
The dockerfile (simplified) starts OpenResty with a main.conf:
FROM openresty/openresty:jammy
EXPOSE 80 443
CMD ["/usr/local/openresty/bin/openresty", "-c", "/etc/nginx/conf.d/main.conf", "-g", "daemon off;"]
The relevant parts of the main.conf:
http {
# load lua modules from this path
lua_package_path "/etc/nginx/modules/?.lua;;";
# tls session resumption
ssl_session_cache shared:SSL:10m; # 10MB = ~40,000 sessions
ssl_session_timeout 1d; # sessions valid for 24 hours
ssl_session_tickets off; # for better security
# certificate cache - stores actual SSL certs
lua_shared_dict ssl_certs_cache 10m; # 10MB = ~2000 certs
# modern SSL protocols only
ssl_protocols TLSv1.2 TLSv1.3;
# cipher suites - prioritize TLSv1.3, strong ciphers for TLSv1.2
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers off;
# OSCP stapling
ssl_stapling on;
ssl_stapling_verify on;
# use kubernetes internal DNS resolver for dynamic hostname resolution
# update this IP to match your cluster's DNS service IP
# default is usually 10.96.0.10, verify with: kubectl get svc -n kube-system kube-dns
resolver 10.96.0.10 valid=10s ipv6=off;
resolver_timeout 5s;
# include server conf that handles server name configuration
include /etc/nginx/conf.d/server.conf;
}
The relevant parts of server.conf:
# https handling
server {
listen 443 ssl;
# default/fallback certificates (required by nginx to start)
# these are mounted from a Kubernetes Secret via volumeMount in the DaemonSet
# the default certs allow nginx to start, but will be cleared during SNI processing
# if no valid certificate is found, the SSL handshake will fail with an error
ssl_certificate /etc/nginx/certs/default.crt;
ssl_certificate_key /etc/nginx/certs/default.key;
# kubernetes service hostnames
# structured as {service_name}.{service_namespace}.svc.cluster.local:{service_port}
# invoker routes domains to right serverless functions
set $service "invoker.data-plane.svc.cluster.local:9000";
ssl_certificate_by_lua_block {
local ssl = require "ssl_certs"
ssl.get_cert()
}
location / {
proxy_pass http://$service;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
# http handling
server {
set $service "invoker.data-plane.svc.cluster.local:9000";
# notary responsible for file validation
set $validation_service = "notary.data-plane.svc.cluster.local:9001";
listen 80;
location ~ ^/\.well-known/(pki-validation|acme-challenge)/ {
proxy_pass http://$validation_service;
}
# still allow http requests as users would handle https redirect if needed
location / {
proxy_pass http://$service;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto http;
proxy_set_header X-Forwarded-Port 80;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
The ssl_certs lua module is provided as ConfigMap to the Daemonset. The module loads and sets ssl certificates from the lua_shared_dict or from the db and secrets store using Server Name Indication(SNI).
For this article, implementations of the db and secrets_store is not provided but these are modules that provide APIs to get the certificate and the private key respectively.
local db = require("db")
local secrets_store = require("secrets_store")
local ssl = require("ngx.ssl")
local _M = {}
-- Cache configuration
local CERT_CACHE_TTL = 3600 -- 1 hour
local KEY_CACHE_TTL = 3600 -- 1 hour
local NEGATIVE_CACHE_TTL = 60 -- Cache "not found" results for 1 minute
-- Get shared dictionaries
local ssl_certs_cache = ngx.shared.ssl_certs_cache
-- Validate cache availability
if not ssl_certs_cache then
ngx.log(ngx.EMERG, "ssl_certs_cache shared dictionary not configured")
end
-- End session with error
local function end_session_with_error(err_msg)
ngx.log(ngx.ERR, "SSL handshake failed: ", err_msg)
-- Clear default certificates to force SSL handshake failure
local ok, clear_err = ssl.clear_certs()
if not ok then
ngx.log(ngx.ERR, "Failed to clear certificates: ", clear_err)
end
-- Exit with error to terminate SSL handshake
return ngx.exit(ngx.ERROR)
end
-- Normalize domain for consistent caching
local function normalize_domain(domain)
-- Convert to lowercase for case-insensitive matching
return domain:lower()
end
-- Load certificate chain with caching
local function load_cert_chain()
local domain, _ = ssl.server_name()
if not domain then
return nil, "no_sni"
end
-- Normalize domain
domain = normalize_domain(domain)
-- Check negative cache first (domain not found previously)
local cache_key_negative = "cert:notfound:" .. domain
local is_cached_negative = ssl_certs_cache:get(cache_key_negative)
if is_cached_negative then
return nil, "cached_not_found"
end
-- Check certificate cache
local cache_key = "cert:chain:" .. domain
local cached_cert = ssl_certs_cache:get(cache_key)
if cached_cert then
return cached_cert, nil
end
-- Cache miss - fetch from database
-- the db API also manages getting the right certificate that satisfy wildcard certificates
local certs, db_err = db.get_ssl_cert(domain)
if db_err then
return nil, "db_error"
end
if not certs or #certs < 1 then
-- Cache negative result to avoid repeated DB queries
ssl_certs_cache:set(cache_key_negative, true, NEGATIVE_CACHE_TTL)
return nil, "not_found"
end
local cert = certs[1]
-- Validate certificate data
if not cert.ssl_cert or cert.ssl_cert == "" then
return nil, "empty_cert"
end
-- Build full certificate chain
local cert_chain = cert.ssl_cert
if cert.ca_bundle_cert and cert.ca_bundle_cert ~= "" then
cert_chain = cert_chain .. cert.ca_bundle_cert
end
-- Cache the certificate chain (as PEM, will convert to DER later)
local ok, _, _ = ssl_certs_cache:set(cache_key, cert_chain, CERT_CACHE_TTL)
return cert_chain, nil
end
-- Load private key from secrets store with caching
local function load_priv_key(domain)
if not domain then
return nil, "no_domain"
end
domain = normalize_domain(domain)
-- Check cache first
local cache_key = "key:priv:" .. domain
local cached_key = ssl_certs_cache:get(cache_key)
if cached_key then
return cached_key, nil
end
-- The secrets store API also manages getting the right private key that satisfy wildcard certificates
local pem_key, err = secrets_store.get_private_key(domain)
if err then
return nil, "secrets_store_error"
end
if not pem_key or pem_key == "" then
return nil, "empty_key"
end
-- Validate key format (basic check)
if not pem_key:match("BEGIN") or not pem_key:match("PRIVATE KEY") then
return nil, "invalid_key_format"
end
-- Cache the private key (as PEM, will convert to DER later)
local ok, _ , _ = ssl_certs_cache:set(cache_key, pem_key, KEY_CACHE_TTL)
return pem_key, nil
end
-- Main function to get and set certificate
function _M.get_cert()
-- Get SNI domain early for logging
local domain = ssl.server_name()
if domain then
domain = normalize_domain(domain)
end
-- Load certificate chain
local pem_cert_chain, cert_err = load_cert_chain()
if not pem_cert_chain then
-- No valid certificate found - fail the SSL handshake
local err_msg = string.format("Certificate not found for domain '%s': %s",
domain or "unknown", cert_err or "unknown error")
return end_session_with_error(err_msg)
end
-- Clear fallback certificates and private keys
local ok, clear_err = ssl.clear_certs()
if not ok then
local err_msg = string.format("Failed to clear default certificates: %s", clear_err or "unknown error")
return end_session_with_error(err_msg)
end
-- Convert PEM certificate to DER, format required by ngx.ssl APIs
local der_cert_chain, cert_conv_err = ssl.cert_pem_to_der(pem_cert_chain)
if not der_cert_chain then
local err_msg = string.format("Failed to convert certificate to DER format: %s",
cert_conv_err or "unknown error")
return end_session_with_error(err_msg)
end
-- Set DER certificate
local ok, set_cert_err = ssl.set_der_cert(der_cert_chain)
if not ok then
local err_msg = string.format("Failed to set certificate: %s", set_cert_err or "unknown error")
return end_session_with_error(err_msg)
end
-- Load private key
local pem_pkey, key_err = load_priv_key(domain)
if not pem_pkey then
local err_msg = string.format("Failed to load private key: %s", key_err or "unknown error")
return end_session_with_error(err_msg)
end
-- Convert PEM private key to DER
local der_pkey, key_conv_err = ssl.priv_key_pem_to_der(pem_pkey)
if not der_pkey then
local err_msg = string.format("Failed to convert private key to DER format: %s",
key_conv_err or "unknown error")
return end_session_with_error(err_msg)
end
-- Set DER private key
local ok, set_key_err = ssl.set_der_priv_key(der_pkey)
if not ok then
local err_msg = string.format("Failed to set private key: %s", set_key_err or "unknown error")
return end_session_with_error(err_msg)
end
-- SSL handshake will proceed successfully with the loaded certificate
return ngx.exit(ngx.OK)
end
return _M

Conclusion
This setup provides a scalable and flexible solution for hosting user applications on their own domains. By combining L4 load balancing with Lua-based SSL termination, this architecture bypasses the limitations of traditional ingress controllers and cloud load balancer certificate quotas.
Domain verification, automated certificate provisioning, and dynamic SSL certificate loading via SNI work together to create a system that can handle thousands of custom domains. OpenResty’s programmability through Lua modules enables custom caching strategies and logic that would be difficult or impossible with standard nginx or ingress controllers.
The article focuses on the core architecture only, production deployments demand additional attention to operational concerns.