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

verification

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):

verification-seq

  1. Check if a valid certificate already exists for added domain through wildcard certificates.
  2. Get the top level domain (TLD) from the domain added using the Public Suffix List (PSL). Deny verification if added domain is public.
  3. Check if there is a matching TXT record for the domain every configurable n minutes. Verify ownership of the TLD if matching TXT record found.
  4. Generate and store a 2048-bit RSA SSL private key in an external secrets store.
  5. 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 the A record 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/).
  6. Check validation status every configurable n minutes. If certificate is ready store the created certificate in the store.

Data Plane

The data plane served the actual traffic for the custom domain.

architecture

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

ssl-sequence

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.