<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Alexandros Kyvrikoseos</title>
    <description>The latest articles on DEV Community by Alexandros Kyvrikoseos (@alexandroskyv).</description>
    <link>https://dev.to/alexandroskyv</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4118482%2F75848dfa-f398-4564-9566-5ca74f8944ed.jpg</url>
      <title>DEV Community: Alexandros Kyvrikoseos</title>
      <link>https://dev.to/alexandroskyv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alexandroskyv"/>
    <language>en</language>
    <item>
      <title>42 domains, one VPS, Cloudflare Free: the five things that actually broke</title>
      <dc:creator>Alexandros Kyvrikoseos</dc:creator>
      <pubDate>Thu, 10 Sep 2026 16:29:17 +0000</pubDate>
      <link>https://dev.to/alexandroskyv/42-domains-one-vps-cloudflare-free-the-five-things-that-actually-broke-2923</link>
      <guid>https://dev.to/alexandroskyv/42-domains-one-vps-cloudflare-free-the-five-things-that-actually-broke-2923</guid>
      <description>&lt;p&gt;description: "Running a network of sites on Cloudflare's Free plan and a single origin server. Not a setup guide: an honest list of what went wrong and the checks that now prevent it."&lt;/p&gt;

&lt;p&gt;I run 42 domains on Cloudflare's Free plan: tourism guides for my region in Greece, AI chat assistants, a couple of SaaS platforms and some client sites. Almost all of them point at &lt;strong&gt;one VPS&lt;/strong&gt;. The rest are answered at the edge by Workers and never touch the server.&lt;/p&gt;

&lt;p&gt;Cloudflare costs me nothing. What it costs is attention, because a free, powerful dashboard makes it very easy to end up with 42 zones configured 42 slightly different ways.&lt;/p&gt;

&lt;p&gt;This isn't a setup guide. It's the list of things that actually broke, or quietly leaked, and the check I now run so they don't happen again.&lt;/p&gt;

&lt;p&gt;Diagram: zones (proxied, DNS only, Worker routes, parked) pass through the Cloudflare edge (SSL modes, edge cache, WAF) to one origin VPS running nginx and Node services. Five numbered failure points are marked.&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6c4epvu9ot0w2gqbpll0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6c4epvu9ot0w2gqbpll0.png" alt="One Origin Behind Every Zone" width="800" height="397"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. One grey cloud exposes everyone
&lt;/h2&gt;

&lt;p&gt;Cloudflare's orange cloud hides your origin IP. The grey cloud ("DNS only") publishes it.&lt;/p&gt;

&lt;p&gt;When I inventoried every zone, some sites were proxied and some weren't. Even within one family of six near-identical sites the split was three and three, and nobody could tell me whether that was deliberate.&lt;/p&gt;

&lt;p&gt;That split matters more than it looks. &lt;strong&gt;All of those sites share the same IP.&lt;/strong&gt; One DNS-only record reveals the origin for every proxied site on the same box, and the proxy protection on the other 41 is only as strong as that one grey cloud.&lt;/p&gt;

&lt;p&gt;The quickest audit is to ask each domain who answered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.gr | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(server|cf-ray):'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;cf-ray&lt;/code&gt; header means the request went through Cloudflare. No &lt;code&gt;cf-ray&lt;/code&gt; means you just talked to your origin directly.&lt;/p&gt;

&lt;p&gt;The real fix goes further than flipping clouds to orange. An origin IP is discoverable from historical DNS anyway, so the server itself should only accept web traffic from Cloudflare's IP ranges. That's on my list, and it only becomes possible once no zone depends on a grey cloud.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Flexible SSL: a padlock with plain HTTP behind it
&lt;/h2&gt;

&lt;p&gt;The same inventory found four live, proxied sites on the &lt;strong&gt;Flexible&lt;/strong&gt; SSL mode.&lt;/p&gt;

&lt;p&gt;Flexible means the visitor gets HTTPS to Cloudflare, and Cloudflare talks to your origin over &lt;strong&gt;plain HTTP&lt;/strong&gt;. The browser shows a padlock. The second half of the trip is unencrypted. It works, it looks secure, and nothing ever warns you.&lt;/p&gt;

&lt;p&gt;Other zones had drifted too: some on "Automatic" SSL mode, where Cloudflare picks for you and can change its mind, and six with &lt;strong&gt;Always Use HTTPS&lt;/strong&gt; switched off.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Switching to Full (strict) took four sites down
&lt;/h2&gt;

&lt;p&gt;The obvious fix for Flexible is &lt;strong&gt;Full (strict)&lt;/strong&gt;: Cloudflare connects to the origin over HTTPS and verifies its certificate. I switched the four sites over, and all four started returning a JSON &lt;code&gt;{"error":"not found"}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;These were static sites. They have no JSON anywhere.&lt;/p&gt;

&lt;p&gt;What happened: those domains had &lt;strong&gt;no server block on port 443&lt;/strong&gt; at all. Under Flexible that never mattered, because Cloudflare only ever knocked on port 80. Under Full (strict), Cloudflare knocked on 443, nginx found no matching &lt;code&gt;server_name&lt;/code&gt;, and handed the request to its default server for that port. With no explicit &lt;code&gt;default_server&lt;/code&gt;, that's simply the first &lt;code&gt;listen 443 ssl&lt;/code&gt; block nginx loads. On my machine it belonged to an API subdomain running Express, which politely replied that &lt;code&gt;/&lt;/code&gt; didn't exist.&lt;/p&gt;

&lt;p&gt;I switched them back to Flexible. The rule I use now, before touching any SSL mode, is to ask the origin directly, bypassing Cloudflare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sk&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resolve&lt;/span&gt; example.gr:443:ORIGIN_IP https://example.gr/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;200&lt;/code&gt; means go ahead. &lt;code&gt;404&lt;/code&gt; or &lt;code&gt;000&lt;/code&gt; means there's nothing real on 443 yet, and switching will break the site.&lt;/p&gt;

&lt;p&gt;The proper fix, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;strong&gt;Cloudflare Origin Certificate&lt;/strong&gt; per domain (it can last up to 15 years, so no renewal cron to forget)&lt;/li&gt;
&lt;li&gt;A real 443 server block, plus a redirect from 80 to 443&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nginx -t&lt;/code&gt;, reload, the &lt;code&gt;curl --resolve&lt;/code&gt; check again&lt;/li&gt;
&lt;li&gt;Only then, &lt;strong&gt;Full (strict)&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All eight hostnames came back with 200. One more thing I'd recommend: an explicit catch-all for unknown hostnames, so a missing block fails obviously instead of borrowing another site's backend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt; &lt;span class="s"&gt;default_server&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_reject_handshake&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. The change that didn't show up
&lt;/h2&gt;

&lt;p&gt;I updated the footer on six sites in one go. Most showed the change straight away. One didn't, and I spent a while convinced the write had failed.&lt;/p&gt;

&lt;p&gt;It hadn't. All six files had been written in the same second. That one zone was proxied and serving its HTML from Cloudflare's edge cache, so which site lagged wasn't random: it was predictable from how each zone was configured.&lt;/p&gt;

&lt;p&gt;Checking the origin's copy of a file isn't enough on a proxied site. Look at what the edge is actually serving:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.gr | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; cf-cache-status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DYNAMIC&lt;/code&gt; means you're seeing the origin. &lt;code&gt;HIT&lt;/code&gt; means you're seeing a copy, and you need a purge before you can judge the change.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. &lt;code&gt;nginx -t&lt;/code&gt; passed, and nothing changed
&lt;/h2&gt;

&lt;p&gt;I added a &lt;code&gt;location&lt;/code&gt; block, ran &lt;code&gt;nginx -t&lt;/code&gt;, got "syntax is ok", reloaded, and the new endpoint returned 404.&lt;/p&gt;

&lt;p&gt;The block had been pasted inside a section of the file that was commented out. &lt;code&gt;nginx -t&lt;/code&gt; tells you the configuration &lt;strong&gt;parses&lt;/strong&gt;. It says nothing about whether the lines you care about are active.&lt;/p&gt;

&lt;p&gt;Even &lt;code&gt;nginx -T&lt;/code&gt;, which dumps every loaded file, prints the comments along with everything else, so a careless grep will "find" a block that nginx is ignoring. The only proof that a change is live is a request that hits it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://example.gr/new-endpoint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bonus: what a sweep found in the web roots
&lt;/h2&gt;

&lt;p&gt;A read-only sweep across every vhost found two things that no dashboard ever showed me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.git/config&lt;/code&gt; publicly downloadable on seven sites&lt;/li&gt;
&lt;li&gt;about 88 backup files (&lt;code&gt;index.html.bak-…&lt;/code&gt; and friends) sitting inside document roots&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are now blocked by one shared snippet included in every server block, and the backups moved outside the web roots entirely. Mine looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;/\.(?!well-known)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.(bak(-[^/]*)?|old|orig|swp|sql)&lt;/span&gt;$ &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a smaller one: static sites using &lt;code&gt;try_files $uri $uri/ /index.html&lt;/code&gt; return &lt;strong&gt;200 with the homepage for every wrong URL&lt;/strong&gt;. That hides broken links from you and serves duplicate pages to search engines. For sites that aren't single-page apps, &lt;code&gt;try_files $uri $uri/ =404&lt;/code&gt; is the honest version.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist I run per zone now
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Proxied, or deliberately DNS only? (&lt;code&gt;cf-ray&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;SSL mode set explicitly, never "Automatic"&lt;/li&gt;
&lt;li&gt;A real 443 block on the origin (&lt;code&gt;curl --resolve&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Always Use HTTPS on&lt;/li&gt;
&lt;li&gt;On proxied zones: &lt;code&gt;cf-cache-status&lt;/code&gt; before judging any change&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;.git&lt;/code&gt;, no backups, no soft-404s&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this needed a paid plan. It needed looking at all 42 zones side by side, once, and writing down what each one actually does.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Do you run many zones on one origin?&lt;/strong&gt; I'm especially curious how people handle locking the origin down to Cloudflare's IPs without losing direct access for maintenance.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Alexandros · Web Host Pro · Diakopto, Greece&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>devops</category>
      <category>webdev</category>
      <category>cloudflarechallenge</category>
    </item>
    <item>
      <title>"How I let AI agents work on my production server without breaking it"</title>
      <dc:creator>Alexandros Kyvrikoseos</dc:creator>
      <pubDate>Thu, 10 Sep 2026 16:19:45 +0000</pubDate>
      <link>https://dev.to/alexandroskyv/how-i-let-ai-agents-work-on-my-production-server-without-breaking-it-i56</link>
      <guid>https://dev.to/alexandroskyv/how-i-let-ai-agents-work-on-my-production-server-without-breaking-it-i56</guid>
      <description>&lt;p&gt;description: "A solo operator's rulebook: agents propose, a human applies. Real incidents and the rules they produced, from a small town in Greece."&lt;br&gt;
tags: ai, devops, productivity, webdev&lt;br&gt;
cover_image: &lt;br&gt;
[Image description]&lt;/p&gt;
&lt;h2&gt;
  
  
  [Human Gate]
&lt;/h2&gt;

&lt;p&gt;I'm not a classic programmer. I started as a photographer and art director, and today I run a network of 42 domains, a handful of SaaS platforms and a fleet of AI chat assistants from Diakopto, a small town in Greece. Most of that code was written by AI agents working inside my editor over SSH, on the same server that serves real users.&lt;/p&gt;

&lt;p&gt;The agents are faster than me at almost everything. That's exactly the problem. A fast agent with shell access can take down in thirty seconds what took a month to build.&lt;/p&gt;

&lt;p&gt;So I don't try to make the agents smarter. I make the path to production narrower. This post is the rulebook, and most of the rules exist because something went wrong once.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgr0xs1c38yup34fbnqoa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgr0xs1c38yup34fbnqoa.png" alt="The human gate: agents audit and propose, a human gives an explicit GO, a snapshot is taken, the agent applies and stops, the human builds and restarts, the agent verifies and reports." width="800" height="435"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Rule 1: Agents propose, I apply
&lt;/h2&gt;

&lt;p&gt;Every change to production starts as a file, not a command. The agent audits first (read-only: configs, logs, row counts, what's actually deployed) and then writes a &lt;code&gt;PROPOSED.md&lt;/code&gt; with four parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The change:&lt;/strong&gt; which files, which tables, which services&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blast radius:&lt;/strong&gt; what else could break, and for whom&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rollback:&lt;/strong&gt; the exact steps back, written &lt;em&gt;before&lt;/em&gt; the change&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checks:&lt;/strong&gt; how we'll know it worked&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I read it, ask questions, and only then say &lt;strong&gt;GO&lt;/strong&gt;. Silence is not a GO. "Looks fine" in the middle of a different conversation is not a GO.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The incident behind the rule:&lt;/strong&gt; once, my editor froze while I was still reading a proposal. The agent didn't wait. It applied the change, which happened to pass every check. Nothing broke, and that's what scared me: next time the change might not be one that passes. Since then, the agent's instructions end every proposal with &lt;em&gt;write it, report, stop&lt;/em&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rule 2: Some commands are never the agent's
&lt;/h2&gt;

&lt;p&gt;Agents write files to disk. Unless I give an explicit GO for that specific step, they don't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;run &lt;code&gt;npm run build&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;restart processes (&lt;code&gt;pm2 restart&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;change nginx&lt;/li&gt;
&lt;li&gt;run database migrations&lt;/li&gt;
&lt;li&gt;&lt;code&gt;git push&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;publish anything&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The build one surprises people. On my server, several frontends are served straight from their &lt;code&gt;dist/&lt;/code&gt; folder. That means &lt;strong&gt;building is deploying&lt;/strong&gt;. An agent that "just runs a quick build to check for errors" has shipped whatever was on disk to every visitor.&lt;/p&gt;

&lt;p&gt;Two of my repos also had GitHub Actions that deployed on every push to &lt;code&gt;main&lt;/code&gt;. I switched both off. If a push can deploy, then anything that can push can deploy.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rule 3: Never &lt;code&gt;prisma migrate dev&lt;/code&gt; on the server
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;migrate dev&lt;/code&gt; is a development tool. When it detects drift between your migration history and the real database, its answer can be to reset the database. On a laptop that's a shrug. On production it's a very bad afternoon.&lt;/p&gt;

&lt;p&gt;My database also has partial unique indexes that I created with raw SQL, because Prisma's schema doesn't express them the way I needed. Prisma doesn't know they exist, so any tool that "syncs" the schema is a tool that can quietly drop them. For the same reason, &lt;code&gt;prisma db push&lt;/code&gt; is banned too: it bypasses migration history completely.&lt;/p&gt;

&lt;p&gt;Schema changes on the server follow one fixed sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pg_dump &lt;span class="nt"&gt;-Fc&lt;/span&gt; mydb &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /var/backups/mydb-before-change.dump
psql mydb &lt;span class="nt"&gt;-f&lt;/span&gt; 2026-09-10-add-column.sql
npx prisma generate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Snapshot at the provider, dump, handwritten SQL, update &lt;code&gt;schema.prisma&lt;/code&gt; to match, regenerate the client, and then a restart that I approve. It's boring. Boring is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 4: Every command has an address
&lt;/h2&gt;

&lt;p&gt;I work with three places that can run commands: the terminal on my Mac, a terminal on the server, and the agent itself. Every command I receive is labeled with where it runs, and I get &lt;strong&gt;one command at a time&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This sounds bureaucratic until you paste &lt;code&gt;scp ~/Downloads/logo.png ...&lt;/code&gt; into the server terminal, or a &lt;code&gt;sudo&lt;/code&gt; command into your Mac. I've done both. My rule of thumb now: if it starts with &lt;code&gt;cd ~/Downloads&lt;/code&gt; or &lt;code&gt;scp&lt;/code&gt;, it belongs on the Mac. If it starts with &lt;code&gt;cd /var/www&lt;/code&gt; or &lt;code&gt;sudo&lt;/code&gt;, it belongs on the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 5: A test that has never failed has proven nothing
&lt;/h2&gt;

&lt;p&gt;One of my platforms caches data per client. I asked for a test that proves client A can never see client B's data. The test passed.&lt;/p&gt;

&lt;p&gt;Then we broke the code on purpose: made the cache global, so every client shared it. If the test still passed, it was decoration. It failed, loudly, which is the only reason I trust it now.&lt;/p&gt;

&lt;p&gt;When an agent writes a security test, make it show you the test failing against broken code before you believe it passes against working code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 6: Silent fallbacks are lies
&lt;/h2&gt;

&lt;p&gt;My AI chat gateway was configured with a primary model provider and a fallback. Everything "worked". An audit showed that the primary had &lt;strong&gt;never&lt;/strong&gt; worked on that gateway: first a dead key, then retired model names. For weeks every conversation had silently gone through the fallback, and nothing anywhere said so.&lt;/p&gt;

&lt;p&gt;Now every fallback is counted and visible. When the gateway serves a prompt from its fallback path, a counter goes up and the logs say why. A fallback you can't see is a fault that hasn't been reported yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 7: Real numbers only
&lt;/h2&gt;

&lt;p&gt;A mockup for my company's homepage once had a lovely "live console": a site count slowly drifting upward, a made-up hostname, ping values from &lt;code&gt;Math.random()&lt;/code&gt;. It looked great. It was also lying to every visitor.&lt;/p&gt;

&lt;p&gt;The rule now covers every screen I own: if a number isn't measured, the UI shows &lt;strong&gt;"—"&lt;/strong&gt;. No placeholder that looks real, no "estimated" metric that nobody estimated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 8: Ground truth beats screenshots
&lt;/h2&gt;

&lt;p&gt;I was once sure a redesign was already live, because I had a screenshot of it. The agent disagreed, and instead of arguing it proved it with four checks: which docroot nginx actually serves, the hash of the file on disk versus what the edge returned, the MIME types, and which stylesheet loaded last. The design had never been live. My screenshot was the prototype, open in another tab.&lt;/p&gt;

&lt;p&gt;Memory, mine or the agent's, is not evidence. The server is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The small rules
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backups live outside the web root.&lt;/strong&gt; I once found a backup file sitting in a public folder on one of my own sites, downloadable by anyone who guessed the name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets never go into chat.&lt;/strong&gt; The agent never gets admin passwords. If a check needs a real login in a browser, I do that part myself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two editor windows, maximum.&lt;/strong&gt; Power cuts happen here, and every cut leaves dead SSH sessions behind that eat connections.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two files per project.&lt;/strong&gt; &lt;code&gt;JOURNAL.md&lt;/code&gt; is append-only and written by the agent. &lt;code&gt;PENDINGS.md&lt;/code&gt; is the to-do list, and only I edit it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every session closes with three lines:&lt;/strong&gt; what closed, what's blocked, what's next.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it costs, what it buys
&lt;/h2&gt;

&lt;p&gt;It's slower. A change I could "just do" in two minutes takes fifteen: audit, proposal, questions, GO, apply, verify.&lt;/p&gt;

&lt;p&gt;What it buys is that I can sleep. A recent audit showed four long-running services on the server that had gone more than two weeks without a single restart. That's not because the agents never make mistakes. They make plenty. It's because their mistakes land in a proposal, where they cost me a question instead of an outage.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;How do you work with AI agents near production?&lt;/strong&gt; Do you let them run builds or migrations, or do you keep a gate like this? I'd genuinely like to know where other people draw the line.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Alexandros · Web Host Pro · Diakopto, Greece&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>devops</category>
    </item>
    <item>
      <title>ShopOS: designing a multi-tenant e-shop SaaS on a single VPS</title>
      <dc:creator>Alexandros Kyvrikoseos</dc:creator>
      <pubDate>Thu, 10 Sep 2026 13:02:49 +0000</pubDate>
      <link>https://dev.to/alexandroskyv/shopos-designing-a-multi-tenant-e-shop-saas-on-a-single-vps-25kl</link>
      <guid>https://dev.to/alexandroskyv/shopos-designing-a-multi-tenant-e-shop-saas-on-a-single-vps-25kl</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This post is about &lt;strong&gt;ShopOS&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it actually stands
&lt;/h2&gt;

&lt;p&gt;I don't like dashboards with fake numbers, so here is the honest status:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Written:&lt;/strong&gt; tenants, JWT auth with roles, plan gating, catalog with variants, an inventory ledger, orders, customers, analytics, and an AI agent with tool calling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not yet:&lt;/strong&gt; audited against this design, or running a live shop. The first shop on it will be a small local business near me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In the diagram:&lt;/strong&gt; solid boxes exist (in code or in my current infrastructure), dashed boxes are planned.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fykhnh0s1kdgmu2hacsrl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fykhnh0s1kdgmu2hacsrl.png" alt="ShopOS architecture diagram in five layers: clients, Cloudflare edge, a single VPS with the API request pipeline and modules, PostgreSQL, and external services over HTTPS. Solid boxes exist, dashed boxes are planned." width="800" height="681"&gt;&lt;/a&gt;[ShopOS Αrchitecture Diagram]&lt;/p&gt;

&lt;h2&gt;
  
  
  Why multi-tenant at all
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The price is real: &lt;strong&gt;a bug in the core hits every shop at once&lt;/strong&gt;, and one shop's data is only a missing &lt;code&gt;WHERE&lt;/code&gt; clause away from another's. Most of the design below is about paying that price safely.&lt;/p&gt;

&lt;h2&gt;
  
  
  L1 — Edge: Cloudflare
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  L2 — One VPS, on purpose
&lt;/h2&gt;

&lt;p&gt;Everything runs on a single Ubuntu VPS (4 vCPU, 8 GB, NVMe): Nginx serves the built frontends and proxies &lt;code&gt;/api&lt;/code&gt; to a Node/Express process managed by PM2. A second PM2 process runs background jobs.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;before&lt;/em&gt; the change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The request pipeline
&lt;/h2&gt;

&lt;p&gt;Every API request passes the same five steps, in the same order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;resolveTenant&lt;/strong&gt; — which shop is this? (from the &lt;code&gt;Host&lt;/code&gt; header)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;requireAuth&lt;/strong&gt; — who are you, and does your token belong to &lt;em&gt;this&lt;/em&gt; shop?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;requireRole&lt;/strong&gt; — OWNER, ADMIN or STAFF&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;requirePlanFeature&lt;/strong&gt; — does this shop's plan include this?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;handler&lt;/strong&gt; — a query that can only see this shop's rows&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Simplified versions (not production code):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// middleware/resolveTenant.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../lib/prisma.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// host -&amp;gt; { tenantId, expires }&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TTL_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expires&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHOP_NOT_FOUND&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expires&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;TTL_MS&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important line in auth is the tenant match. A valid token from shop A must be useless on shop B's domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// middleware/requireAuth.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jsonwebtoken&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requireAuth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bearer &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AUTH_REQUIRED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;algorithms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HS256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tid&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TENANT_MISMATCH&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TOKEN_INVALID&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Roles have a small JavaScript trap worth showing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// middleware/requireRole.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RANK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;STAFF&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ADMIN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;OWNER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requireRole&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;have&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;RANK&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="c1"&gt;// `undefined &amp;lt; 2` is false in JS, so a naive `if (have &amp;lt; RANK[min])`&lt;/span&gt;
  &lt;span class="c1"&gt;// would let an unknown or missing role straight through.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;have&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;have&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;RANK&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ROLE_NOT_ALLOWED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unknown means deny. Always.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plans live in one file
&lt;/h2&gt;

&lt;p&gt;Limits and features come from a single &lt;code&gt;plans.js&lt;/code&gt;. Middleware, the admin UI and the billing page all read from it, so there is no second copy to drift out of sync.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/plans.js — numbers are illustrative, pricing isn't final&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PLANS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;STARTER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;maxProducts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="na"&gt;maxUsers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;PRO&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;maxProducts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;maxUsers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;BUSINESS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;maxProducts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;maxUsers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requirePlanFeature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;PLANS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscription&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NO_ACTIVE_PLAN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;402&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PLAN_UPGRADE_REQUIRED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;feature&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  L3 — Data: one database, shared schema
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So: one PostgreSQL database, a shared schema, a &lt;code&gt;tenantId&lt;/code&gt; on every business row, and composite indexes that start with it (&lt;code&gt;@@index([tenantId, ...])&lt;/code&gt; in Prisma).&lt;/p&gt;

&lt;p&gt;The weakness is obvious: isolation depends on application code never forgetting the filter. Two planned layers cover that:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Isolation tests&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Row-Level Security&lt;/strong&gt; as a second wall:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"Product"&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"Product"&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="nv"&gt;"Product"&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"tenantId"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"tenantId"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Prisma, this means setting &lt;code&gt;app.tenant_id&lt;/code&gt; inside each transaction (&lt;code&gt;SELECT set_config('app.tenant_id', $1, true)&lt;/code&gt;), and making sure the app's database role can't bypass RLS. That's the part I'd most like feedback on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI agent can't choose a tenant
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;tenantId&lt;/code&gt; as a parameter. The tools close over it on the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// agent/tools.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;makeTools&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;lowStock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productVariant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;threshold&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;take&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="c1"&gt;// recentOrders, salesSummary, adjustStock, ...&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model sees a tool called &lt;code&gt;lowStock&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background jobs without Redis
&lt;/h2&gt;

&lt;p&gt;Renewals, low-stock alerts, the email outbox and usage rollovers run in a separate worker. The queue is a Postgres table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="nv"&gt;"Job"&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'running'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"lockedAt"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"Job"&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'queued'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"runAt"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nv"&gt;"runAt"&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;
  &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SKIP LOCKED&lt;/code&gt; 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 &lt;code&gt;running&lt;/code&gt; for too long. One less service to run, monitor and back up.&lt;/p&gt;

&lt;h2&gt;
  
  
  L4 — External services, HTTPS only
&lt;/h2&gt;

&lt;p&gt;My VPS provider blocks outbound SMTP ports, which turned out to be a useful constraint: everything external is an HTTPS API.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Email:&lt;/strong&gt; Resend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI:&lt;/strong&gt; Gemini (I'm consolidating my AI traffic there, away from OpenAI)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payments:&lt;/strong&gt; card, IRIS (Greece's instant payments) and cash on delivery; provider not chosen yet&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shipping:&lt;/strong&gt; parcel lockers and courier vouchers; provider not chosen yet&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;myDATA&lt;/strong&gt; (Greek tax e-invoicing): deliberately &lt;em&gt;not&lt;/em&gt; in the core, handled through a certified provider&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Rules I work by
&lt;/h2&gt;

&lt;p&gt;These matter more to me than any framework choice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No auto-deploy.&lt;/strong&gt; AI agents write to disk and report. Publishing is a human decision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proposal before apply.&lt;/strong&gt; Every change to production starts as a written proposal with a rollback plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups live outside the web root.&lt;/strong&gt; I've found exposed backup files on my own servers before. Once is enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real numbers only.&lt;/strong&gt; If a metric isn't measured, the UI shows "—", never a made-up value.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Audit the existing code against this diagram, box by box&lt;/li&gt;
&lt;li&gt;Host-based tenant resolution and the tenant-match check&lt;/li&gt;
&lt;li&gt;The isolation test suite, then RLS&lt;/li&gt;
&lt;li&gt;Checkout, and the first live shop&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  I'd like your take
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Have you run &lt;strong&gt;Postgres RLS with Prisma&lt;/strong&gt; in production? Worth it, or more pain than protection?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom domains&lt;/strong&gt; for tenants: Cloudflare for SaaS custom hostnames, or one zone per shop?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres as a job queue:&lt;/strong&gt; where did it stop being enough for you?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'll post the audit results next in this series.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Alexandros · Web Host Pro · Diakopto, Greece&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>webdev</category>
      <category>saas</category>
      <category>postgress</category>
    </item>
  </channel>
</rss>
