Around this time last summer, I was working on okiyani.com. More specifically, in July, I was focused on a set of multi-tenant architecture challenges that felt both unique and fun, and I thought I’d share them.
A bit of context
Okiynai is a startup that I and my friend, @golden, were working on for the majority of 2025. It was an AI-powered e-commerce platform where you can build your storefront just like Shopify, and have a dashboard to manage your stocks, SKUs, and the usual e-commerce stuff, and you had AI in all of those parts. Like an AI builder for the storefront. And an AI store manager. Now unfortunately, it did fail, and we decided to open source most of the stuff on GitHub. But they did not include the cool infrastructure parts, and that’s what this blog post is about.
Domains setup overview
okiynai.com was the main platform, there you have your dashboard. api.okiynai.com is obvious1. *.okiynai.shop was where storefronts of tenants lived. custom domain were connected through a CNAME pointing to shops.okiynai.shop and routed to the correct storefront based on the incoming Host header.
A little cookie trait
When I first started working on authentication, tenant storefronts still lived under *.okiynai.com, before we eventually moved them to *.okiynai.shop. This was one of the first pieces of our multi-tenant infrastructure, and at the time, we had no idea how many browser and cookie behaviours would come with it.
So, to reproduce that setup locally, we ran the Nextjs app on localhost:3000 and accessed each storefront through *.localhost:3000.
To route those tenant subdomains to storefronts, we could've used nginx as a reverse proxy, but we went with a simple solution from Nextjs middleware:
// src/middleware.ts
if subdomain Either way, the browser will see that we are sending requests from tenantX.localhost:3000.
Now you might think that this setup is correct, mirroring what we have in on the prod, but in reality it's far from the truth.
See, there are two cookie properties that we care about here: Domain and SameSite.
Cookies are host-only by default. So if api.okiynai.com sets a cookie without a Domain property, that cookie will only be sent back to api.okiynai.com. But if it sets Domain=okiynai.com, the cookie can be sent to okiynai.com and all of its subdomains.
This does not mean that sibling subdomains can directly set cookies for each other. hossam.okiynai.com cannot set a cookie specifically for golden.okiynai.com. What it can do is set one for their shared parent, okiynai.com, which causes the browser to send it to both of them.
Then there is SameSite, which controls whether the browser sends a cookie when a request originates from another site.
Strict only sends the cookie in same-site contexts. Lax, which is the default in modern browsers, also allows it during some cross-site top-level GET navigations, but not regular cross-site API calls. None allows the cookie to be sent in cross-site contexts, but it requires the cookie to also be marked as Secure.
A site is determined using the scheme and the publicly registerable domain. Subdomains and ports do not make separate sites. But that did not exist in this setup, because localhost is not a publicly registerable domain. localhost and tenant.localhost were therefore treated as different sites.
And that caused two issues:
- In production, all of our original
*.okiynai.comtenants shared the same parent. A cookie scoped toDomain=okiynai.comwas sent to every tenant, whileapi.okiynai.com, being a sibling, could not set a cookie for one specific tenant. Locally, everytenant.localhostappeared to have isolated authentication, so I completely missed this behaviour. - Since
tenant.localhostand the API onlocalhostwere different sites,SameSite=LaxandSameSite=Strictcookies were not sent with normal API requests. So the local setup could not test the authentication flow at all.
A Production-Like Local Setup
As we saw, keeping every storefront under *.okiynai.com meant they all shared the same parent domain, which made tenant-scoped authentication difficult. This eventually led us to get okiynai.shop and move the storefronts to *.okiynai.shop.
Now that production had two separate sites: okiynai.com for the platform and API, and okiynai.shop for the storefronts, we needed our local environment to reproduce that same boundary.
So we stopped using localhost and switched to reserved testing domains. Nginx would receive requests for those domains and proxy them to the applications already running locally:
# Redirect the main app and API from HTTP to HTTPS
# Redirect storefronts from HTTP to HTTPS
# Main Next.js app
# Express API
# Tenant storefronts
So our production domains mapped to the local setup like this:
okiynai.com => devokiynai.test:8443
api.okiynai.com => api.devokiynai.test:8443
*.okiynai.shop => *.devokiynai.example:8443Authentication Across Tenant Domains
With the local environment finally matching production, we could start developing our auth.
Email and password
For email-password authentication, the user submits a form directly to the expressjs backend, including a hidden field that has the tenant domain as a value. The Expressjs backend then verfies the user credentials then generates a short-lived exchange token23. Then it returns an auto-submitting HTML form, the idea of using this here is to perform a browser top-level navigation, so that when go to the tenant's Nextjs backend's callback endpoint. After that it's a simple exchnge token and verify it server-to-server communication between the Nextjs and Expressjs backend, and we just return the response from the Nextjs backend that we are on that has the Set-Cookie header.
The Expressjs side handled the initial sign-in verfication, token generation, the browser navigation. And the later token exchange:
// routes/sso.ts
"/signin", ;
"/exchange", ;The callback endpoint on the tenant's Nextjs backend completed the flow:
// api/auth/callback/route.ts
Google OAuth
Google OAuth only changed the first half of the flow.
Google requires the callback URL to be registered beforehand, so we couldn't use a different callback for every tenant subdomain and custom domain. The OAuth flow had to begin and end at the centralized Expressjs API.
The storefront therefore sent the browser to the central Google sign-in route with sso=true and its tenant domain:
// components/auth/index.tsx
Expressjs generated the usual CSRF state and PKCE code verifier. In SSO mode, it also encoded the tenant domain into the OAuth state and stored the SSO context temporarily in cookies:
// routes/auth.ts
"/signin/google", ;The browser then went through the normal Google OAuth flow and returned to the centralized callback:
Once Expressjs verified Google's response and identified the user, the rest was identical to email-password authentication. It generated the same exchange token and returned the same auto-submitting form:
// routes/auth.ts
"/callback/google", ;From here, we performed that same browser navigation, and the rest is the same as username-password auth.
This flow worked, but it left Nextjs sitting between the browser and Expressjs for the final exchange, adding what now appears to be an unnecessary extra round trip, which brings us to how I would build it today
Looking back at it now
While writing this blog, I was discussing this solution with Codex and suggesting alternatives, and we found out that this flow could've been waaay simpler. For example, we could've used an Nginx routing rule like this:
So that we could talk directly to the Expressjs backend. That way, we wouldn't need to use the Nextjs backend as a proxy. Nginx would act as the reverse proxy instead.
That would've made the username-password authentication waaay simpler.
Though Google OAuth would still need one extra hop. Because Google requires the OAuth callback URL to be registered beforehand. We cannot register every tenant subdomain and arbitrary custom domains, so the callback still had to go through one stable, centralized URL.
But even then, we could've kept the flow only until the centralized callback returned the auto-submitting form. That form would perform a top-level navigation to something like https:// tenant.okiynai.shop/api/oauth/complete. Nginx would route that request straight back to the same Expressjs backend, which could verify and consume the short-lived exchange code, create the session, and respond directly with a Set-Cookie header and a redirect to /. So we would still have the browser navigation required to reach the tenant domain, but without the additional Nextjs-to-Expressjs exchange.
Custom Domains Onboarding
This is more of an honorable mention part. Unlike the authentication flow, this one did not require the same kind of architectural thinking. It was mostly a known sequence of steps: verify DNS, serve the ACME challenge, issue the certificate, write the final Nginx config, and reload.
Also, by the time I worked on this part, I had already learned a decent bit about DNS, SSL, Nginx, Cloudflare, and the general cloud infrastructure side of things. So unlike the earlier auth days, this was less of a "learn as you go" situation, and more of just applying the pieces step by step.
The start is the user-facing flow, it was simple:
- The merchant adds their custom domain.
- We tell them to create a
CNAMErecord in their domain registrar4. - That
CNAMEshould point toshops.okiynai.shop. - Once DNS is verified, we provision SSL.
- Then Nginx starts serving that domain as a real storefront domain.
The backend first verified that the domain actually pointed to us:
// services/domain/domainService.ts
;
Once DNS was verified, we kicked off the SSL provisioning workflow in the background:
// routes/manager/domain.ts
;
if dnsResult.verified The infrastructure part was basically a small Nginx config generator.
The first config was HTTP-only. This existed before the SSL certificate was issued, because Let's Encrypt needs to hit /.well-known/acme-challenge/... over HTTP to verify that we actually control the domain.
// services/domain/infrastructureService.ts
So while SSL was still being provisioned, the custom domain was not fully active yet. It could answer the ACME challenge, and everything else was temporarily redirected back to the normal *.okiynai.shop storefront.
After writing that config, we reloaded Nginx, then ran Certbot:
// services/domain/infrastructureService.ts
One important detail here is how the backend was controlling the Nginx and Certbot containers.
The Expressjs backend itself was running inside Docker, but it still needed to reload the custom-domains Nginx container and run Certbot commands. So the backend container had access to the Docker CLI, and on the VPS we mounted the host Docker socket into it.
In docker-compose.yml, it looked roughly like this:
nerv: # our Expressjs backend
volumes:
- ./custom-domains/nginx/conf.d:/home/avramcohen/apps/okiynai/custom-domains/nginx/conf.d
- /var/run/docker.sock:/var/run/docker.sock
environment:
CUSTOM_DOMAINS_BASE: /home/avramcohen/apps/okiynai/custom-domains
NGINX_CONTAINER: nginx-custom-domains
CERTBOT_CONTAINER: certbot-custom-domainsThis pattern is called Docker-outside-of-Docker. The backend was not running another Docker daemon inside itself. It was just using the mounted Docker socket to talk to the host Docker daemon.
The VPS structure looked roughly like this:
apps/okiynai/
├── docker-compose.yml
└── custom-domains/
├── nginx/
│ ├── nginx.conf
│ └── conf.d/
│ └── shop.example.com.conf
└── certbot/
├── www/
└── conf/
├── live/
├── archive/
└── renewal/The backend wrote one Nginx config file per custom domain under custom-domains/nginx/conf.d.
Once Certbot succeeded, we replaced the temporary HTTP config with the real HTTPS config:
// services/domain/infrastructureService.ts
And the full workflow was just those steps glued together:
// services/domain/infrastructureService.ts
Here's it in a diagram:
User adds custom domain
↓
User creates CNAME to shops.okiynai.shop
↓
Backend verifies DNS
↓
Backend writes temporary HTTP Nginx config
↓
Nginx reloads
↓
Certbot verifies the ACME challenge
↓
Backend writes HTTPS Nginx config
↓
Nginx reloads again
↓
Domain becomes activeSome core parts were missing here, like renewals, but this was mainly a proof-of-concept, and it came after we decided to shutdown okiynai. I only did it because I thought this is gonig to be a fun technical exercise.