DevOps & Infrastructure
October 6, 2026
12 min read

Ditching Vercel for a $15 Hetzner Server with Dokploy

A practical breakdown of architecting multi-tenant data isolation, row-level security, and self-hosting production infrastructure behind a Cloudflare proxy on a $15/month VPS.

Jack Mkimbo
Jack Mkimbo
Software Engineer & AI Systems Developer

When building Patofy—a multi-tenant growth platform serving appointment-driven service businesses across East Africa, Europe, Latin America, and the Middle East—one of our earliest architectural crossroads was infrastructure:

  • Option A: The Standard Serverless Route. Deploy the application directly on Vercel, rely on serverless functions for API endpoints, bolt on third-party services for persistent WebSockets and cron jobs, and accept unpredictable consumption-based billing.
  • Option B: Modern Self-Hosting on Dedicated Metal. Pair Payload CMS v3 natively inside the Next.js 15 App Router, deploy containerized workloads onto high-performance Hetzner Cloud VPS hardware orchestrated via Dokploy, and shield the entire perimeter behind Cloudflare's global reverse proxy.

We initially launched on Option A, but as production traffic, background workflows, and real-time messaging expanded, we migrated completely to Option B.

In this article, I share the architectural blueprint behind Patofy's multi-tenant backend: how we enforce strict tenant isolation across 35+ collections, how we prevent data leakage when using internal database APIs, and why self-hosting delivered a 90%+ cost reduction with superior performance and zero cold starts.

For Business Owners Using Patofy — Why Does This Matter to Your Business? If you run a salon, clinic, spa, or studio, you shouldn't have to think about servers—you care about reliability, client privacy, and fair pricing. Here is how this setup directly benefits your business:

  1. Instant WhatsApp Replies: Because our servers run 24/7 instead of "waking up" per message, your AI receptionist responds to clients without lag.
  2. Strict Client List Privacy: Every business on Patofy operates inside an isolated database vault—your client phone numbers, booking notes, and revenue records can never be seen by another business.
  3. Predictable, Affordable Pricing: By cutting bloated cloud bills by over 90%, we pass those savings directly to you through flat, SME-friendly plans.
Multi-Tenant Cloud Infrastructure: Cloudflare Edge Proxy, Hetzner VPS, Dokploy, Traefik, Next.js + Payload CMS, and MongoDB Atlas
Multi-Tenant Cloud Infrastructure: Cloudflare Edge Proxy, Hetzner VPS, Dokploy, Traefik, Next.js + Payload CMS, and MongoDB Atlas

High-Level Architecture Overview#

A B2B SaaS operating system cannot afford downtime, noisy-neighbor bottlenecks, or cold-start latency when processing real-time customer webhooks.

Here is how traffic moves from the public internet through our edge perimeter down to our application containers and database:

System Flowchart
Loading system architecture diagram...

1. The Monorepo Strategy: Next.js 15 + Payload CMS v3#

Traditionally, building a headless SaaS involves running two completely separate codebases: a frontend web application (React/Next.js) and a standalone headless CMS or custom backend API (Express/NestJS/Strapi).

This split introduces significant engineering drag:

  • Duplicated TypeScript interfaces between client and server.
  • Double CI/CD deployment pipelines.
  • Network serialization overhead for internal data queries.

Payload CMS v3 eliminates this division. It runs natively inside the Next.js App Router as a unified monorepo. The admin control panel, custom dashboard pages, and database schema definitions share the same Node.js runtime, environment variables, and TypeScript types.

text
project-root/
├── app/
│   ├── (dashboard)/        # Next.js Business Client Portal
│   ├── (payload)/          # Payload CMS Admin & Control Plane
│   └── api/                # Custom Webhooks & Public Endpoints
├── collections/            # 35+ Multi-Tenant Collection Schemas (CRM, Bookings, Billing)
└── payload.config.ts       # Unified Configuration & Database Adapters

Because the CMS lives inside the same runtime as our application, our server components and API routes query data directly in-memory via Payload's Local API—bypassing HTTP overhead entirely.


2. Tenant Data Isolation: Partitioning Without Chaos#

In multi-tenant SaaS architecture, there are two primary database models:

  1. Database-per-Tenant: Every business gets an isolated physical database or schema.
  2. Shared Database with Row-Level Security (RLS): All tenants share a high-performance database cluster, with every document strictly scoped to a unique tenantId.

Database-per-tenant sounds clean on paper, but becomes an operational nightmare as you scale to hundreds of organizations. Managing migrations, connection pools, and backups across 500 separate databases burns engineering time.

In Patofy, we implemented Row-Level Security via Collection Access Control and beforeValidate Hooks in Payload CMS.

The Tenant Scoping Rule#

Every tenant-scoped collection (appointments, customers, campaigns, invoices, loyalty-ledger) includes a required relationship field pointing to the tenant organization, plus a hook that prevents tenant ID spoofing on creation:

typescript
// Production Multi-Tenant Collection Configuration
import type { CollectionConfig } from "payload";

export const Appointments: CollectionConfig = {
  slug: "appointments",
  admin: {
    useAsTitle: "clientName",
    defaultColumns: ["clientName", "appointmentDate", "status", "tenant"],
  },
  hooks: {
    beforeValidate: [
      ({ data, req, operation }) => {
        // Prevent tenant spoofing: bind new records strictly to the authenticated user's tenantId
        if (
          operation === "create" &&
          req.user &&
          !req.user.roles?.includes("super-admin")
        ) {
          return {
            ...data,
            tenant: req.user.tenantId,
          };
        }
        return data;
      },
    ],
  },
  access: {
    // Row-Level Security: Constrain queries to user's assigned organization
    read: ({ req: { user } }) => {
      if (!user) return false;
      if (user.roles?.includes("super-admin")) return true;

      return {
        tenant: { equals: user.tenantId },
      };
    },
    create: ({ req: { user } }) =>
      Boolean(user?.tenantId || user?.roles?.includes("super-admin")),
    update: ({ req: { user } }) => {
      if (!user) return false;
      if (user.roles?.includes("super-admin")) return true;
      return {
        tenant: { equals: user.tenantId },
      };
    },
    delete: ({ req: { user } }) => {
      if (!user) return false;
      if (user.roles?.includes("super-admin")) return true;
      // Only tenant admins can delete records within their own organization
      if (user.roles?.includes("admin")) {
        return {
          tenant: { equals: user.tenantId },
        };
      }
      return false;
    },
  },
  fields: [
    {
      name: "tenant",
      type: "relationship",
      relationTo: "tenants",
      required: true,
      index: true, // Indexed for high-speed scoped queries
      admin: {
        readOnly: true,
      },
    },
    { name: "clientName", type: "text", required: true },
    { name: "clientPhone", type: "text", required: true, index: true },
    { name: "appointmentDate", type: "date", required: true, index: true },
    {
      name: "status",
      type: "select",
      options: ["pending", "confirmed", "completed", "cancelled"],
      defaultValue: "pending",
    },
  ],
};

3. The Critical Security Gotcha: Local API Access Control#

When developers start building with Payload CMS, they quickly fall in love with the Local API:

typescript
// Direct in-memory database lookup
const appointments = await payload.find({
  collection: "appointments",
});

Here lies the most dangerous security trap in multi-tenant development:

By default, Payload CMS's Local API bypasses all collection-level access control rules.

If you pass a logged-in tenant user into payload.find({ user: someUser }), Payload still executes the query with administrative privileges unless you explicitly enforce access rules. If you forget this, a tenant could retrieve appointments belonging to a competing business.

The Immutable Rule#

Whenever you query data on behalf of a user or client session using the Local API, **always pass overrideAccess: false**:

typescript
// ❌ CRITICAL SECURITY BUG: Access control bypassed!
const leakedData = await payload.find({
  collection: "appointments",
  user: currentTenantUser, // Ignored by default! Runs with admin privileges.
});

// ✅ SECURE PATTERN: Enforces strict tenant boundaries
const secureData = await payload.find({
  collection: "appointments",
  user: currentTenantUser,
  overrideAccess: false, // MANDATORY: Enforces user.tenantId row-level security
  where: {
    status: { equals: "confirmed" },
  },
});

For administrative background jobs (such as our nightly retention engine identifying lapsed customers across all tenants), leaving overrideAccess: true is intentional—but every customer-facing route enforces overrideAccess: false.


4. The Cloud Cost Reality: Why We Transitioned Away from Serverless#

When we initially prototyped the platform, we hosted the application on Vercel. Serverless provides a convenient developer experience for getting static sites and early prototypes off the ground. However, as Patofy transitioned into an active B2B operations engine handling real-time WhatsApp traffic, serverless constraints became a clear bottleneck:

Operational MetricServerless Stack (Vercel Pro + Add-Ons)Self-Hosted Stack (Hetzner CPX22 + Dokploy)
Monthly Baseline Cost$150 – $600+ (scales with function invocations)~$15.00/mo flat (3 dedicated vCPUs, 4 GB RAM, 20 TB traffic)
WhatsApp Webhook Cold Starts600ms – 1,200ms on idle routes0ms (always-warm Node.js container behind Traefik)
Background Cron / Batch JobsHard 15s – 60s timeout capsUnlimited duration; native long-running worker daemons
Live Chat Inbox WebSocketsUnsupported natively; requires paid external pub/subNative persistent WebSockets via Cloudflare + Traefik
Build Memory & Deploy ControlOccasional OOM errors compiling Next.js + Payload v3Dedicated swap + multi-stage Docker builds
  1. No Persistent WebSockets: Live chat dashboards in our Conversations Inbox require persistent real-time streams so salon or clinic staff receive incoming client inquiries without polling. Serverless functions are ephemeral; supporting real-time sockets would have required bolting on paid third-party messaging infrastructure.
  2. Execution Timeouts on Batch Daemons: Nightly background jobs (calculating contact retention health scores, processing automated review queues, and preparing segmented broadcasts) frequently risk hitting serverless function duration caps.
  3. Heavy Build Memory Overhead: Compiling a large Next.js 15 application combined with a full Payload CMS v3 admin suite pushes serverless build memory to its limits, causing sporadic Out-Of-Memory (OOM) build failures on constrained runners.
  4. 24/7 Inbound Webhook Ingress: When thousands of WhatsApp messages, typing indicators, and delivery receipts arrive around the clock, serverless invocation counts compound rapidly.

The Decision: Self-Hosting for Control, Predictability, and DevOps Mastery#

Rather than waiting for a surprise cloud bill, we made a deliberate architectural choice to self-host on a dedicated Hetzner Cloud VPS (CPX22 tier) orchestrated via Dokploy:

  • Flat, Predictable Cost: ~$15.00 flat per month for 3 dedicated vCPUs, 4 GB RAM, and 20 TB of included monthly traffic.
  • Complete Operational Control: Native support for persistent background workers, local Redis instances, long-lived WebSockets, and zero function timeout caps.
  • Hands-on DevOps Mastery: Direct control over container orchestration, Traefik reverse proxies, edge tunnels, and automated volume backup pipelines.

5. Production Orchestration: Dokploy & The Cloudflare Shield#

Self-hosting historically carried a bad reputation: managing brittle Bash scripts, debugging Nginx configs over SSH, and renewing SSL certificates manually.

We eliminated that friction by pairing Dokploy (an open-source self-hosted PaaS) with Cloudflare.

The Cloudflare Reverse Proxy Layer#

Both our core application (app.patofy.com) and marketing website (www.patofy.com) sit behind Cloudflare's global reverse proxy:

  1. Origin IP Cloaking: Cloudflare proxies all DNS records. The real IP address of our Hetzner server is never exposed to the public internet, neutralizing direct DDoS attacks.
  2. Global Edge Caching: Static assets, fonts, compiled CSS, and images are cached at Cloudflare edge points of presence worldwide. A visitor in Nairobi, London, or São Paulo downloads static UI files in under 25ms.
  3. Full (Strict) SSL Termination: Cloudflare manages edge SSL certificates, communicating with our server's internal Traefik proxy over an encrypted origin tunnel.
  4. WebSocket & Webhook Passthrough: Cloudflare natively supports long-lived WebSocket connections for our live chat inbox and high-throughput POST webhooks from Meta.

Automated Deployments via Dokploy#

Dokploy provides a clean control plane on top of our VPS:

  • Zero-Downtime Rolling Deploys: Pushing a commit to our GitHub repository triggers a webhook that builds an optimized Docker multi-stage container and swaps containers with zero traffic interruption.

  • Integrated Traefik Gateway: Routes traffic cleanly by hostname:

  • app.patofy.com → Next.js Core Platform container.

  • www.patofy.com → Marketing Web App container.

  • Nightly Automated Volume Backups: Automatically dumps database states and uploaded media assets to encrypted S3-compatible cold storage every night at 3:00 AM UTC.


Key Production Lessons & Best Practices#

After managing this multi-tenant architecture in production, here are the most important takeaways:

1. Always Index the Tenant Field#

Every collection query in a multi-tenant system filters by tenantId. If that field is not indexed in your database, your database performs full-collection scans on every single query. In Payload CMS, always add index: true to your tenant relationship field and create compound indexes for high-frequency lookups (such as { tenant: 1, appointmentDate: -1 }).

2. Isolate Upload Directories#

If your application allows tenants to upload brand logos, staff profile pictures, or customer files, ensure your file upload hook scopes the storage prefix: uploads/${tenantId}/${filename}. This prevents namespace collisions and makes tenant offboarding or data export simple.

3. Decouple Marketing from the Core App#

Deploying our marketing website as an independent container alongside the core platform container ensures that a marketing traffic surge (e.g., from a social media campaign) never impacts the database throughput or memory available to our operational booking engine.


Scaling Beyond the Pilot Phase#

As we onboard service businesses across new markets and request volumes grow, our architectural roadmap focuses on scaling smoothly:

  1. Dynamic Edge Load Balancing: Introducing external load balancers to distribute traffic across redundant VPS nodes across Europe and Africa as concurrency climbs.
  2. Microservice Worker Decoupling: Splitting heavy background retention daemons and WhatsApp webhook ingestors into independent containerized worker pools so they never compete for compute with user-facing appointment booking.
  3. Distributed Session Caching: Layering Redis between the application and MongoDB for instant tenant session verification and live WebSocket state.

This multi-tenant foundation ensures that as business volume scales 100x, our infrastructure scales predictably without requiring a costly rewrite.



Interested in seeing how Patofy can transform your service business operations? Explore the Patofy Platform, review our transparent tiers on the Pricing Page, or read our full Patofy Engineering Case Study.

Need assistance designing a multi-tenant SaaS architecture, Payload CMS backend, or self-hosted cloud deployment? Let's connect for an architecture consultation."

Payload CMSNext.jsMulti-TenancyDokployHetznerCloudflareSelf-HostingMongoDB
Share article:
X (Twitter)LinkedInWhatsApp
Jack Mkimbo
Written by Jack Mkimbo

Software engineer specializing in AI systems integration, multi-agent orchestration, and full-stack SaaS product development. Co-Founder & CTO at AcidCat Digital Marketing and creator of Patofy & Panda FPL.

Related Deep Dives

View all →