I started out as a photographer and art director. Today I run the infrastructure behind 42 domains from Diakopto, a small town on the north coast of the Peloponnese in Greece. I'm self-taught, I don't have a CS degree, and I'll say it plainly: I work as a production manager who directs and reviews AI-assisted code.
That is exactly why I care about architecture. The code can be generated. The boundaries, the trade-offs and the "what happens when this fails" have to be mine.
This post is about ShopOS, a multi-tenant e-shop platform I'm building for small businesses in Greece: many shops on one core, each with its own domain, products and customers, and data that never leaks between them.
Where it actually stands
I don't like dashboards with fake numbers, so here is the honest status:
- Written: tenants, JWT auth with roles, plan gating, catalog with variants, an inventory ledger, orders, customers, analytics, and an AI agent with tool calling.
- Not yet: audited against this design, or running a live shop. The first shop on it will be a small local business near me.
- In the diagram: solid boxes exist (in code or in my current infrastructure), dashed boxes are planned.
Why multi-tenant at all
The alternative is one codebase per shop. That starts faster, but every bug gets fixed N times and every new shop is a new project. With a shared core, a new shop is a new row and a domain, a fix ships once, and the product itself can be sold as a subscription.
The price is real: a bug in the core hits every shop at once, and one shop's data is only a missing WHERE clause away from another's. Most of the design below is about paying that price safely.
L1 — Edge: Cloudflare
Every shop domain sits behind Cloudflare for DNS, TLS and WAF rate limiting. Nginx restores the real client IP from Cloudflare's headers, so logs and rate limits see the shopper, not a Cloudflare edge node. Turnstile will guard login and checkout.
L2 — One VPS, on purpose
Everything runs on a single Ubuntu VPS (4 vCPU, 8 GB, NVMe): Nginx serves the built frontends and proxies /api to a Node/Express process managed by PM2. A second PM2 process runs background jobs.
One machine is a single point of failure, and I accept that at this stage. What I don't accept is running it without nightly database dumps, provider snapshots before risky changes, and a rollback plan written before the change.
The request pipeline
Every API request passes the same five steps, in the same order:
-
resolveTenant — which shop is this? (from the
Hostheader) - requireAuth — who are you, and does your token belong to this shop?
- requireRole — OWNER, ADMIN or STAFF
- requirePlanFeature — does this shop's plan include this?
- handler — a query that can only see this shop's rows
Simplified versions (not production code):
// middleware/resolveTenant.js
import { prisma } from '../lib/prisma.js';
const cache = new Map(); // host -> { tenantId, expires }
const TTL_MS = 60_000;
export async function resolveTenant(req, res, next) {
const host = (req.hostname || '').toLowerCase();
const hit = cache.get(host);
if (hit && hit.expires > Date.now()) {
req.tenantId = hit.tenantId;
return next();
}
const domain = await prisma.domain.findUnique({
where: { host },
select: { tenantId: true },
});
if (!domain) return res.status(404).json({ error: 'SHOP_NOT_FOUND' });
cache.set(host, { tenantId: domain.tenantId, expires: Date.now() + TTL_MS });
req.tenantId = domain.tenantId;
next();
}
The important line in auth is the tenant match. A valid token from shop A must be useless on shop B's domain:
// middleware/requireAuth.js
import jwt from 'jsonwebtoken';
export function requireAuth(req, res, next) {
const header = req.get('authorization') || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: 'AUTH_REQUIRED' });
try {
const claims = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
if (claims.tid !== req.tenantId) {
return res.status(403).json({ error: 'TENANT_MISMATCH' });
}
req.user = { id: claims.uid, role: claims.role };
next();
} catch {
return res.status(401).json({ error: 'TOKEN_INVALID' });
}
}
Roles have a small JavaScript trap worth showing:
// middleware/requireRole.js
const RANK = { STAFF: 1, ADMIN: 2, OWNER: 3 };
export const requireRole = (min) => (req, res, next) => {
const have = RANK[req.user?.role];
// `undefined < 2` is false in JS, so a naive `if (have < RANK[min])`
// would let an unknown or missing role straight through.
if (have === undefined || have < RANK[min]) {
return res.status(403).json({ error: 'ROLE_NOT_ALLOWED' });
}
next();
};
Unknown means deny. Always.
Plans live in one file
Limits and features come from a single plans.js. Middleware, the admin UI and the billing page all read from it, so there is no second copy to drift out of sync.
// config/plans.js — numbers are illustrative, pricing isn't final
export const PLANS = {
STARTER: { maxProducts: 100, maxUsers: 2, agent: false, analytics: false },
PRO: { maxProducts: 1_000, maxUsers: 5, agent: true, analytics: true },
BUSINESS: { maxProducts: 10_000, maxUsers: 15, agent: true, analytics: true },
};
export const requirePlanFeature = (feature) => (req, res, next) => {
const plan = PLANS[req.subscription?.plan];
if (!plan) return res.status(403).json({ error: 'NO_ACTIVE_PLAN' });
if (!plan[feature]) {
return res.status(402).json({ error: 'PLAN_UPGRADE_REQUIRED', feature });
}
next();
};
L3 — Data: one database, shared schema
I considered a schema or a database per shop. Both give stronger isolation, but every migration then runs N times, and with a solo operator that's where things break at 2 a.m.
So: one PostgreSQL database, a shared schema, a tenantId on every business row, and composite indexes that start with it (@@index([tenantId, ...]) in Prisma).
The weakness is obvious: isolation depends on application code never forgetting the filter. Two planned layers cover that:
Isolation tests that run before every commit. Tenant A logs in and tries to read, update and delete tenant B's products, orders and customers. Every attempt must fail. I already run this pattern on another platform of mine.
Row-Level Security as a second wall:
ALTER TABLE "Product" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Product" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "Product"
USING ("tenantId" = current_setting('app.tenant_id', true))
WITH CHECK ("tenantId" = current_setting('app.tenant_id', true));
With Prisma, this means setting app.tenant_id inside each transaction (SELECT set_config('app.tenant_id', $1, true)), and making sure the app's database role can't bypass RLS. That's the part I'd most like feedback on.
The AI agent can't choose a tenant
Each shop gets an assistant that can answer "what's low on stock?" or "top customers this month?" and perform a few write actions. The model never receives a tenantId as a parameter. The tools close over it on the server:
// agent/tools.js
export function makeTools({ tenantId, prisma }) {
return {
lowStock: async ({ threshold = 5 }) =>
prisma.productVariant.findMany({
where: { tenantId, stock: { lte: threshold } },
select: { sku: true, name: true, stock: true },
take: 50,
}),
// recentOrders, salesSummary, adjustStock, ...
};
}
The model sees a tool called lowStock with one optional number. However cleverly a prompt is written, there is no argument it can pass to reach another shop. Write tools also respect role and plan, and agent messages are metered per billing period.
Background jobs without Redis
Renewals, low-stock alerts, the email outbox and usage rollovers run in a separate worker. The queue is a Postgres table:
UPDATE "Job"
SET status = 'running', "lockedAt" = now()
WHERE id = (
SELECT id FROM "Job"
WHERE status = 'queued' AND "runAt" <= now()
ORDER BY "runAt"
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING *;
SKIP LOCKED lets more than one worker pull jobs without taking the same one twice. A sweep puts jobs back in the queue if they've been running for too long. One less service to run, monitor and back up.
L4 — External services, HTTPS only
My VPS provider blocks outbound SMTP ports, which turned out to be a useful constraint: everything external is an HTTPS API.
- Email: Resend
- AI: Gemini (I'm consolidating my AI traffic there, away from OpenAI)
- Payments: card, IRIS (Greece's instant payments) and cash on delivery; provider not chosen yet
- Shipping: parcel lockers and courier vouchers; provider not chosen yet
- myDATA (Greek tax e-invoicing): deliberately not in the core, handled through a certified provider
Rules I work by
These matter more to me than any framework choice:
- No auto-deploy. AI agents write to disk and report. Publishing is a human decision.
- Proposal before apply. Every change to production starts as a written proposal with a rollback plan.
- Backups live outside the web root. I've found exposed backup files on my own servers before. Once is enough.
- Real numbers only. If a metric isn't measured, the UI shows "—", never a made-up value.
What's next
- Audit the existing code against this diagram, box by box
- Host-based tenant resolution and the tenant-match check
- The isolation test suite, then RLS
- Checkout, and the first live shop
I'd like your take
- Have you run Postgres RLS with Prisma in production? Worth it, or more pain than protection?
- Custom domains for tenants: Cloudflare for SaaS custom hostnames, or one zone per shop?
- Postgres as a job queue: where did it stop being enough for you?
I'll post the audit results next in this series.
Alexandros · Web Host Pro · Diakopto, Greece

Top comments (0)