[{"data":1,"prerenderedAt":728},["ShallowReactive",2],{"blog-posts":3},[4],{"id":5,"title":6,"body":7,"date":715,"description":716,"extension":717,"image":17,"meta":718,"navigation":238,"path":719,"seo":720,"stem":721,"tags":722,"__hash__":727},"blog\u002Fblog\u002Fmulti-tenant-custom-domains.md","Multi-Tenant Custom Domains, without paying for Cloudflare for SaaS",{"type":8,"value":9,"toc":702},"minimark",[10,18,34,37,42,77,98,102,133,137,156,163,169,291,296,302,312,322,450,454,460,474,491,494,498,504,515,519,522,556,559,612,615,618,657,671,675,698],[11,12,13],"p",{},[14,15],"img",{"alt":16,"src":17},"Multi-Tenant Custom Domains","\u002Fimages\u002Fblog\u002Fbanner-custom-domains-light.png",[11,19,20,21,28,29,33],{},"I run ",[22,23,27],"a",{"href":24,"rel":25},"https:\u002F\u002Fcountdownbuilder.com",[26],"nofollow","Countdown Builder",", a SaaS that serves live countdown timer images for emails. Customers wanted ",[30,31,32],"code",{},"timer.theirbrand.com"," instead of our domain, which meant issuing and renewing TLS certs for domains I don't know in advance.",[11,35,36],{},"Cloudflare for SaaS solves this, but I wanted to keep it on the single server I already run. So I built it with Nginx and Caddy. This is the loop I went through: what I tried, what broke, and what the final setup looks like.",[38,39,41],"h2",{"id":40},"the-final-architecture","The final architecture",[43,44,49],"pre",{"className":45,"code":46,"language":47,"meta":48,"style":48},"language-mermaid shiki shiki-themes github-light github-dark","flowchart LR\n    Client[Client] -->|HTTPS :443| N[\"Nginx stream (L4)\"]\n    N -->|\"SNI: known SaaS domain\"| App[\"Nginx app\"]\n    N -->|\"SNI: custom domain\"| Caddy[\"Caddy on-demand TLS\"]\n","mermaid","",[30,50,51,59,65,71],{"__ignoreMap":48},[52,53,56],"span",{"class":54,"line":55},"line",1,[52,57,58],{},"flowchart LR\n",[52,60,62],{"class":54,"line":61},2,[52,63,64],{},"    Client[Client] -->|HTTPS :443| N[\"Nginx stream (L4)\"]\n",[52,66,68],{"class":54,"line":67},3,[52,69,70],{},"    N -->|\"SNI: known SaaS domain\"| App[\"Nginx app\"]\n",[52,72,74],{"class":54,"line":73},4,[52,75,76],{},"    N -->|\"SNI: custom domain\"| Caddy[\"Caddy on-demand TLS\"]\n",[78,79,80,92],"ul",{},[81,82,83,91],"li",{},[84,85,86,87,90],"strong",{},"Nginx ",[30,88,89],{},"stream"," (Layer 4)"," listens on 443. It never decrypts anything. It reads the SNI from the TLS handshake and routes the raw connection.",[81,93,94,97],{},[84,95,96],{},"Caddy (Layer 7)"," handles unknown customer domains. It terminates TLS, gets Let's Encrypt certificates on the fly, and proxies to the backend.",[38,99,101],{"id":100},"attempt-1-plain-nginx","Attempt 1: Plain Nginx",[11,103,104,107,108,111,112,115,116,119,120,124,125,128,129,132],{},[84,105,106],{},"Try:"," one ",[30,109,110],{},"server"," block with ",[30,113,114],{},"server_name _"," and a wildcard cert.\n",[84,117,118],{},"Result:"," a wildcard cert only covers ",[121,122,123],"em",{},"my"," domain. Customer domains throw certificate warnings, and Gmail's image proxy refuses to fetch the timer.\n",[84,126,127],{},"Learned:"," Nginx needs a cert ",[121,130,131],{},"before"," it knows which site you wanted. Dynamic domains need something that can get certs at handshake time.",[38,134,136],{"id":135},"attempt-2-caddy-on-demand-tls-alone","Attempt 2: Caddy on-demand TLS, alone",[11,138,139,141,142,145,146,149,150,152,153,155],{},[84,140,106],{}," Caddy on 443 with ",[30,143,144],{},"on_demand"," and an ",[30,147,148],{},"ask"," endpoint.\n",[84,151,118],{}," it works, but now Caddy fronts my main app too, and I didn't want to migrate a working Nginx setup.\n",[84,154,127],{}," I wanted the main app untouched, with only unknown domains going to Caddy.",[38,157,159,160,162],{"id":158},"attempt-3-nginx-stream-as-an-sni-router","Attempt 3: Nginx ",[30,161,89],{}," as an SNI router",[11,164,165,166,168],{},"Nginx's ",[30,167,89],{}," module can read the SNI from the TLS ClientHello without decrypting anything:",[43,170,174],{"className":171,"code":172,"language":173,"meta":48,"style":48},"language-nginx shiki shiki-themes github-light github-dark","stream {\n    map $ssl_preread_server_name $https_backend {\n        app.example.com  127.0.0.1:8081;   # main app (Nginx http block)\n        example.com      127.0.0.1:8081;\n        default          127.0.0.1:8443;   # everything else -> Caddy\n    }\n\n    server {\n        listen 443;\n        ssl_preread on;\n        proxy_pass $https_backend;\n    }\n}\n","nginx",[30,175,176,185,200,209,214,227,233,240,248,260,271,280,285],{"__ignoreMap":48},[52,177,178,181],{"class":54,"line":55},[52,179,89],{"class":180},"szBVR",[52,182,184],{"class":183},"sVt8B"," {\n",[52,186,187,190,193,197],{"class":54,"line":61},[52,188,189],{"class":180},"    map",[52,191,192],{"class":183}," $",[52,194,196],{"class":195},"s4XuR","ssl_preread_server_name",[52,198,199],{"class":183}," $https_backend {\n",[52,201,202,205],{"class":54,"line":67},[52,203,204],{"class":183},"        app.example.com  127.0.0.1:8081;   ",[52,206,208],{"class":207},"sJ8bj","# main app (Nginx http block)\n",[52,210,211],{"class":54,"line":73},[52,212,213],{"class":183},"        example.com      127.0.0.1:8081;\n",[52,215,217,221,224],{"class":54,"line":216},5,[52,218,220],{"class":219},"sj4cs","        default",[52,222,223],{"class":183},"          127.0.0.1:8443;   ",[52,225,226],{"class":207},"# everything else -> Caddy\n",[52,228,230],{"class":54,"line":229},6,[52,231,232],{"class":183},"    }\n",[52,234,236],{"class":54,"line":235},7,[52,237,239],{"emptyLinePlaceholder":238},true,"\n",[52,241,243,246],{"class":54,"line":242},8,[52,244,245],{"class":180},"    server",[52,247,184],{"class":183},[52,249,251,254,257],{"class":54,"line":250},9,[52,252,253],{"class":180},"        listen ",[52,255,256],{"class":219},"443",[52,258,259],{"class":183},";\n",[52,261,263,266,269],{"class":54,"line":262},10,[52,264,265],{"class":180},"        ssl_preread ",[52,267,268],{"class":219},"on",[52,270,259],{"class":183},[52,272,274,277],{"class":54,"line":273},11,[52,275,276],{"class":180},"        proxy_pass ",[52,278,279],{"class":183},"$https_backend;\n",[52,281,283],{"class":54,"line":282},12,[52,284,232],{"class":183},[52,286,288],{"class":54,"line":287},13,[52,289,290],{"class":183},"}\n",[11,292,293,295],{},[84,294,118],{}," HTTPS worked. Known domains hit the app, unknown ones hit Caddy and got certs on demand. I thought I was done.",[38,297,299,300],{"id":298},"port-80-routing-doesnt-work-in-stream","Port 80 routing doesn't work in ",[30,301,89],{},[11,303,304,305,308,309,311],{},"My first draft routed port 80 with ",[30,306,307],{},"$http_host"," inside a ",[30,310,89],{}," map. That variable doesn't exist there, because stream is Layer 4 and never parses HTTP. You get a config error or an empty variable, depending on version.",[11,313,314,317,318,321],{},[84,315,316],{},"Fix:"," keep port 80 in a normal ",[30,319,320],{},"http"," block, where Host-based routing works:",[43,323,325],{"className":171,"code":324,"language":173,"meta":48,"style":48},"http {\n    server {\n        listen 80 default_server;\n        server_name app.example.com example.com;\n        location \u002F { proxy_pass http:\u002F\u002F127.0.0.1:5000; }\n    }\n\n    server {\n        listen 80;\n        server_name _;\n        location \u002F {\n            proxy_pass http:\u002F\u002F127.0.0.1:8082;   # Caddy HTTP (ACME + redirect)\n            proxy_set_header Host $host;\n        }\n    }\n}\n",[30,326,327,333,339,351,359,377,381,385,391,399,406,415,426,434,440,445],{"__ignoreMap":48},[52,328,329,331],{"class":54,"line":55},[52,330,320],{"class":180},[52,332,184],{"class":183},[52,334,335,337],{"class":54,"line":61},[52,336,245],{"class":180},[52,338,184],{"class":183},[52,340,341,343,346,349],{"class":54,"line":67},[52,342,253],{"class":180},[52,344,345],{"class":219},"80",[52,347,348],{"class":219}," default_server",[52,350,259],{"class":183},[52,352,353,356],{"class":54,"line":73},[52,354,355],{"class":180},"        server_name ",[52,357,358],{"class":183},"app.example.com example.com;\n",[52,360,361,364,368,371,374],{"class":54,"line":216},[52,362,363],{"class":180},"        location",[52,365,367],{"class":366},"sScJk"," \u002F ",[52,369,370],{"class":183},"{",[52,372,373],{"class":180}," proxy_pass ",[52,375,376],{"class":183},"http:\u002F\u002F127.0.0.1:5000; }\n",[52,378,379],{"class":54,"line":229},[52,380,232],{"class":183},[52,382,383],{"class":54,"line":235},[52,384,239],{"emptyLinePlaceholder":238},[52,386,387,389],{"class":54,"line":242},[52,388,245],{"class":180},[52,390,184],{"class":183},[52,392,393,395,397],{"class":54,"line":250},[52,394,253],{"class":180},[52,396,345],{"class":219},[52,398,259],{"class":183},[52,400,401,403],{"class":54,"line":262},[52,402,355],{"class":180},[52,404,405],{"class":183},"_;\n",[52,407,408,410,412],{"class":54,"line":273},[52,409,363],{"class":180},[52,411,367],{"class":366},[52,413,414],{"class":183},"{\n",[52,416,417,420,423],{"class":54,"line":282},[52,418,419],{"class":180},"            proxy_pass ",[52,421,422],{"class":183},"http:\u002F\u002F127.0.0.1:8082;   ",[52,424,425],{"class":207},"# Caddy HTTP (ACME + redirect)\n",[52,427,428,431],{"class":54,"line":287},[52,429,430],{"class":180},"            proxy_set_header ",[52,432,433],{"class":183},"Host $host;\n",[52,435,437],{"class":54,"line":436},14,[52,438,439],{"class":183},"        }\n",[52,441,443],{"class":54,"line":442},15,[52,444,232],{"class":183},[52,446,448],{"class":54,"line":447},16,[52,449,290],{"class":183},[38,451,453],{"id":452},"the-gatekeeper-endpoint","The gatekeeper endpoint",[11,455,456,457,459],{},"Without an ",[30,458,148],{}," endpoint, anyone can point a domain at your IP and make Caddy burn through your Let's Encrypt rate limits. Caddy calls your backend before issuing:",[43,461,464],{"className":462,"code":463,"language":320,"meta":48,"style":48},"language-http shiki shiki-themes github-light github-dark","GET \u002Finternal\u002Fdomain-check?domain=clientdomain.com\n",[30,465,466],{"__ignoreMap":48},[52,467,468,471],{"class":54,"line":55},[52,469,470],{"class":180},"GET",[52,472,473],{"class":183}," \u002Finternal\u002Fdomain-check?domain=clientdomain.com\n",[78,475,476,485],{},[81,477,478,484],{},[84,479,480,483],{},[30,481,482],{},"200 OK",":"," your database recognizes the domain. Caddy gets the cert, caches it, and serves the request.",[81,486,487,490],{},[84,488,489],{},"Anything else:"," Caddy aborts the handshake.",[11,492,493],{},"Keep it to one indexed DB lookup, since it sits on the handshake path.",[38,495,497],{"id":496},"gotcha-acme-challenges-and-scanner-noise","Gotcha: ACME challenges and scanner noise",[11,499,500,501,503],{},"HTTP-01 needs port 80 to reach Caddy for the customer domain, which the ",[30,502,320],{}," block from Bug 1 handles. If issuance fails, check first that the customer's DNS actually points at your server.",[11,505,506,507,510,511,514],{},"You may also see ",[30,508,509],{},"tls-alpn challenge ... no information found to solve challenge for identifier:"," in Caddy's logs every few days. Look at the ",[30,512,513],{},"server_name"," field: when it's empty, something connected with the ACME ALPN protocol but sent no hostname. Real validations carry the domain, so this is almost certainly a scanner, and Caddy is correctly refusing it.",[38,516,518],{"id":517},"how-i-verified-it","How I verified it",[11,520,521],{},"A page that loads proves nothing. I checked each path separately, from a machine other than the server:",[43,523,527],{"className":524,"code":525,"language":526,"meta":48,"style":48},"language-bash shiki shiki-themes github-light github-dark","curl -sI https:\u002F\u002Ftimer.customer.com\u002F        # customer-domain path\ncurl -sI https:\u002F\u002Fapp.example.com\u002F           # main-app path\n","bash",[30,528,529,544],{"__ignoreMap":48},[52,530,531,534,537,541],{"class":54,"line":55},[52,532,533],{"class":366},"curl",[52,535,536],{"class":219}," -sI",[52,538,540],{"class":539},"sZZnC"," https:\u002F\u002Ftimer.customer.com\u002F",[52,542,543],{"class":207},"        # customer-domain path\n",[52,545,546,548,550,553],{"class":54,"line":61},[52,547,533],{"class":366},[52,549,536],{"class":219},[52,551,552],{"class":539}," https:\u002F\u002Fapp.example.com\u002F",[52,554,555],{"class":207},"           # main-app path\n",[11,557,558],{},"Both should answer over HTTPS with no certificate warnings. To inspect the certificate Caddy actually issued:",[43,560,562],{"className":524,"code":561,"language":526,"meta":48,"style":48},"echo | openssl s_client -connect timer.customer.com:443 -servername timer.customer.com 2>\u002Fdev\u002Fnull | openssl x509 -noout -issuer -dates\n",[30,563,564],{"__ignoreMap":48},[52,565,566,569,572,575,578,581,584,587,590,593,596,598,600,603,606,609],{"class":54,"line":55},[52,567,568],{"class":219},"echo",[52,570,571],{"class":180}," |",[52,573,574],{"class":366}," openssl",[52,576,577],{"class":539}," s_client",[52,579,580],{"class":219}," -connect",[52,582,583],{"class":539}," timer.customer.com:443",[52,585,586],{"class":219}," -servername",[52,588,589],{"class":539}," timer.customer.com",[52,591,592],{"class":180}," 2>",[52,594,595],{"class":539},"\u002Fdev\u002Fnull",[52,597,571],{"class":180},[52,599,574],{"class":366},[52,601,602],{"class":539}," x509",[52,604,605],{"class":219}," -noout",[52,607,608],{"class":219}," -issuer",[52,610,611],{"class":219}," -dates\n",[11,613,614],{},"That should show Let's Encrypt as the issuer, with dates that make sense.",[11,616,617],{},"Last, issue a certificate for a domain that has never touched the server and read Caddy's log:",[43,619,621],{"className":524,"code":620,"language":526,"meta":48,"style":48},"sudo journalctl -u caddy --since \"5 min ago\" --no-pager | grep -iE 'obtaining|obtained|error'\n",[30,622,623],{"__ignoreMap":48},[52,624,625,628,631,634,637,640,643,646,648,651,654],{"class":54,"line":55},[52,626,627],{"class":366},"sudo",[52,629,630],{"class":539}," journalctl",[52,632,633],{"class":219}," -u",[52,635,636],{"class":539}," caddy",[52,638,639],{"class":219}," --since",[52,641,642],{"class":539}," \"5 min ago\"",[52,644,645],{"class":219}," --no-pager",[52,647,571],{"class":180},[52,649,650],{"class":366}," grep",[52,652,653],{"class":219}," -iE",[52,655,656],{"class":539}," 'obtaining|obtained|error'\n",[11,658,659,660,663,664,667,668,670],{},"You want ",[30,661,662],{},"obtaining new certificate"," followed by ",[30,665,666],{},"certificate obtained successfully",". (If journalctl prints \"No entries\" without ",[30,669,627],{},", that's a permissions issue, not an empty log.)",[38,672,674],{"id":673},"what-id-tell-past-me","What I'd tell past me",[676,677,678,685,692],"ol",{},[81,679,680,681,684],{},"Prototype with Let's Encrypt ",[84,682,683],{},"staging"," to avoid rate limits while debugging.",[81,686,687,688,691],{},"Test with ",[30,689,690],{},"curl --resolve"," before touching real DNS.",[81,693,694,695,697],{},"Add a per-domain issuance cap in your ",[30,696,148],{}," endpoint.",[699,700,701],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":48,"searchDepth":61,"depth":61,"links":703},[704,705,706,707,709,711,712,713,714],{"id":40,"depth":61,"text":41},{"id":100,"depth":61,"text":101},{"id":135,"depth":61,"text":136},{"id":158,"depth":61,"text":708},"Attempt 3: Nginx stream as an SNI router",{"id":298,"depth":61,"text":710},"Port 80 routing doesn't work in stream",{"id":452,"depth":61,"text":453},{"id":496,"depth":61,"text":497},{"id":517,"depth":61,"text":518},{"id":673,"depth":61,"text":674},"2026-10-23","How Countdown Builder serves customer domains with their own TLS certs — Nginx stream SNI routing and Caddy on-demand TLS on a single server.","md",{},"\u002Fblog\u002Fmulti-tenant-custom-domains",{"title":6,"description":716},"blog\u002Fmulti-tenant-custom-domains",[723,724,725,726],"Nginx","Caddy","TLS","Infrastructure","bVLS3blVkr8FVauTe72DYy7ztLCZkwJ4MEc8giBDFeY",1792778368447]