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