tva
← Insights

Multi-Tenant Docker Without Accidental Shared Fate

This production-oriented follow-up turns current vendor guidance into operating controls, migration decisions, and verifiable release criteria.

What changed in 2026

We revisited this subject because the operating boundary has moved. The durable principles remain useful, but current versions make several old shortcuts incomplete or unsafe. This follow-up starts with the vendor documentation available on 14 July 2026, separates facts from local choices, and treats every configuration change as a controlled production intervention.

Current primary sources

How we use the update

Read the sources first, record the versions actually deployed, and define the observable result before changing a setting. Test the smallest reversible intervention in a representative environment. A successful command is not the acceptance criterion: service health, data integrity, latency, security boundaries, and rollback time are. Where the earlier guide offers a useful diagnostic sequence, we retain it below as an operating foundation; version-specific examples must still be checked against the current documentation.

Operating foundation

Learn how to create a template-based, multi-tenant development environment with 16 containerized services that works offline yet remains accessible online through subdomain-based routing. The configuration files, scripts, and command examples below are taken from the original guide and corrected where needed.

Managing development environments for multiple clients often means choosing between complex manual setups or expensive cloud solutions. Manual deployments are time-consuming and error-prone. Cloud platforms are convenient but create vendor lock-in and ongoing costs that scale with usage.

Today, we’ll walk through building a scalable multi-tenant development stack that gives you both: strong isolation between client environments with automated deployment capabilities, all while maintaining full control over your infrastructure. This approach builds on our philosophy of self-hosted solutions—similar to how we’ve shown you can self-host n8n for workflow automation and deploy Windmill with Docker for complete operational control.

The Tools We’re Using

Let’s start by understanding what each piece does in our comprehensive 16-container architecture:

Docker: Your Containerization Foundation

Docker provides the isolation and consistency we need for multi-tenant environments. Each client gets their own containers with identical configurations, which helps ensure that what works in development will also work in production. Think of it as having multiple separate servers running on the same hardware, although they still share its CPU, memory, disk, and kernel.

The key advantage? Strong isolation between clients. Each client’s data, configurations, and customizations live in separate containers, networks, and volumes, so they do not interfere with another’s. This matters when handling multiple business clients with different requirements and security needs.

Traefik: Smart Reverse Proxy and Load Balancer

Traefik acts as an intelligent traffic director, automatically routing requests to the correct client environment based on domain names. Instead of manually configuring complex Apache or Nginx rules, Traefik reads labels from your Docker containers and sets up routing automatically.

Think of Traefik as a smart receptionist who knows exactly which office (container) each visitor (request) should go to, without you having to give directions every time. In our setup, Traefik handles SSL termination, automatic service discovery, and provides detailed monitoring dashboards.

Cloudflare Tunnels: Secure External Access

Cloudflare Tunnels provide secure access to your local development stack without complex firewall configurations or VPNs. Each client domain gets its own tunnel, which keeps each client’s external access path separate.

Your development environments remain local and secure, but clients can access their specific services from anywhere with proper authentication—similar to how we configured secure external access in our n8n hosting guide.

The Complete Service Stack: Everything Your Clients Need

Our multi-tenant stack includes four core service categories across 16 containers per client:

Workflow Automation & Business Logic:

  • n8n: Complete workflow automation platform for business process automation
  • Authentik: Enterprise-grade single sign-on and identity management (3 containers: server, worker, Redis cache)

Database & Backend Services:

  • PostgreSQL: Robust database backend supporting all services with optimized connection pooling
  • Supabase Stack: Complete backend-as-a-service with 6 specialized containers (Studio, Meta, Auth, REST API, Realtime, Kong Gateway)
  • NocoDB: No-code database interface for client data management

AI & Intelligence:

  • Ollama: Local AI language models with GPU acceleration for intelligent automation
  • Qdrant (optional): Vector database for advanced AI workflows and similarity search

Infrastructure & Monitoring:

  • Cloudflare Tunnel: Secure external connectivity
  • Traefik: Reverse proxy with automatic SSL and monitoring dashboard

How It All Works Together

Here’s the complete flow when a client accesses their environment:

  • Client navigates to their custom domain (e.g., workflows.client-a.com)
  • Cloudflare Tunnel routes the request to your local Traefik instance
  • Traefik reads the domain, applies middleware (authentication, SSL, rate limiting), and forwards to the correct client container
  • Authentik handles SSO authentication across all services if configured
  • The client gets their own isolated environment with their data and configurations
  • Other clients’ environments are not reachable through this client’s routes

Everything stays organized and separated, with each client getting their own subdomain structure like auth.client-a.com, database.client-a.com, backend.client-a.com, etc.

Setting This Up: The Practical Steps

Getting the Foundation Ready

First, you’ll need Docker Desktop installed and a domain management setup. We recommend setting up a wildcard DNS structure for easy client onboarding:

# Install Docker Desktop (macOS)
brew install --cask docker

# Verify installation
docker --version
docker compose version

# Ensure sufficient resources for multi-container environments
# Recommended: 16GB RAM, 8+ CPU cores, 500GB+ SSD storage

Creating the Template System

Deployment uses a template-based approach. Instead of manually setting up each client, we create templates that can be instantly deployed with client-specific configurations.

Create the directory structure:

mkdir -p development-stack/{template,deployments}
cd development-stack/template

# Create service-specific configuration directories
mkdir -p {traefik,authentik,supabase,init}

Complete Multi-Service Template Configuration

Create a docker-compose.yml template with all 16 services:

networks:
  tenant:
    name: ${TENANT_NETWORK}
    driver: bridge

services:
  # External Connectivity (public hostnames point to https://traefik:443 in the Cloudflare dashboard)
  cloudflare-tunnel:
    image: cloudflare/cloudflared:2026.7.2
    container_name: ${TENANT_PREFIX}-tunnel
    command: tunnel --no-autoupdate run --token ${CLOUDFLARE_TOKEN}
    networks:
      - tenant
    restart: unless-stopped

  # Reverse Proxy & Load Balancer
  # No host ports 80/443: client traffic arrives through the tunnel, so several
  # clients can share one host. Certificates use the Cloudflare DNS challenge.
  traefik:
    image: traefik:v3.7.9
    container_name: ${TENANT_PREFIX}-traefik
    command:
      - "--api.dashboard=true"
      - "--ping=true"
      - "--providers.docker=true"
      - "--providers.docker.network=${TENANT_NETWORK}"
      - "--providers.docker.exposedbydefault=false"
      - "--providers.docker.constraints=Label(`com.docker.compose.project`,`${TENANT_PREFIX}`)"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--entrypoints.websecure.asdefault=true"
      - "--entrypoints.traefik.address=:8080"
      - "--certificatesresolvers.letsencrypt.acme.email=${ADMIN_EMAIL}"
      - "--certificatesresolvers.letsencrypt.acme.storage=/acme.json"
      - "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare"
    environment:
      CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN}
    ports:
      # Dashboard on localhost only (http://localhost:PORT/dashboard/)
      - "127.0.0.1:${TRAEFIK_DASHBOARD_PORT}:8080"
    networks:
      - tenant
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik/acme.json:/acme.json
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.dashboard.rule=PathPrefix(`/`)"
      - "traefik.http.routers.dashboard.entrypoints=traefik"
      - "traefik.http.routers.dashboard.service=api@internal"
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "traefik", "healthcheck", "--ping"]
      interval: 30s
      timeout: 10s
      retries: 3

  # Database Backend
  postgres:
    image: postgres:15.18-alpine
    container_name: ${TENANT_PREFIX}-postgres
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init:/docker-entrypoint-initdb.d
    networks:
      - tenant
    ports:
      # Localhost only, for admin tools on the host
      - "127.0.0.1:${POSTGRES_PORT}:5432"
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 30s
      timeout: 10s
      retries: 5
    deploy:
      resources:
        limits:
          memory: 2G
        reservations:
          memory: 1G

  # Workflow Automation
  n8n:
    image: n8nio/n8n:2.31.7
    container_name: ${TENANT_PREFIX}-n8n
    environment:
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_DATABASE: n8n
      DB_POSTGRESDB_USER: ${POSTGRES_USER}
      DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
      N8N_PROTOCOL: ${N8N_PROTOCOL}
      N8N_HOST: ${N8N_DOMAIN}
      N8N_PORT: 5678
      N8N_SECURE_COOKIE: ${N8N_SECURE_COOKIE}
      WEBHOOK_URL: https://${N8N_DOMAIN}
      N8N_EDITOR_BASE_URL: https://${N8N_DOMAIN}
      EXECUTIONS_DATA_PRUNE: "true"
      EXECUTIONS_DATA_MAX_AGE: 168
    volumes:
      - n8n_data:/home/node/.n8n
    networks:
      - tenant
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.n8n.rule=Host(`${N8N_DOMAIN}`)"
      - "traefik.http.routers.n8n.tls.certresolver=letsencrypt"
      - "traefik.http.services.n8n.loadbalancer.server.port=5678"
      - "traefik.http.routers.n8n.middlewares=${AUTH_MIDDLEWARE}"
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy

  # No-Code Database Interface
  nocodb:
    image: nocodb/nocodb:2026.07.0
    container_name: ${TENANT_PREFIX}-nocodb
    environment:
      NC_DB: "pg://postgres:5432?u=${POSTGRES_USER}&p=${POSTGRES_PASSWORD}&d=nocodb"
      NC_PUBLIC_URL: https://${NOCODB_DOMAIN}
      NC_DISABLE_TELE: "true"
      NC_ADMIN_EMAIL: ${ADMIN_EMAIL}
      NC_ADMIN_PASSWORD: ${NOCODB_ADMIN_PASSWORD}
    volumes:
      - nocodb_data:/usr/app/data
    networks:
      - tenant
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.nocodb.rule=Host(`${NOCODB_DOMAIN}`)"
      - "traefik.http.routers.nocodb.tls.certresolver=letsencrypt"
      - "traefik.http.services.nocodb.loadbalancer.server.port=8080"
      - "traefik.http.routers.nocodb.middlewares=${AUTH_MIDDLEWARE}"
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy

  # Supabase Backend Stack (5 containers)
  supabase-studio:
    image: supabase/studio:2026.07.27-sha-cbb076d
    container_name: ${TENANT_PREFIX}-supabase-studio
    environment:
      STUDIO_PG_META_URL: http://supabase-meta:8080
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      DEFAULT_ORGANIZATION_NAME: ${CLIENT_NAME}
      DEFAULT_PROJECT_NAME: ${CLIENT_NAME} Project
      SUPABASE_PUBLIC_URL: https://${SUPABASE_DOMAIN}
    networks:
      - tenant
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.supabase-studio.rule=Host(`${SUPABASE_DOMAIN}`)"
      - "traefik.http.routers.supabase-studio.tls.certresolver=letsencrypt"
      - "traefik.http.services.supabase-studio.loadbalancer.server.port=3000"
      - "traefik.http.routers.supabase-studio.middlewares=${AUTH_MIDDLEWARE}"
    restart: unless-stopped
    healthcheck:
      disable: true
    depends_on:
      postgres:
        condition: service_healthy

  supabase-meta:
    image: supabase/postgres-meta:v0.96.6
    container_name: ${TENANT_PREFIX}-supabase-meta
    environment:
      PG_META_PORT: 8080
      PG_META_DB_HOST: postgres
      PG_META_DB_PORT: 5432
      PG_META_DB_NAME: supabase
      PG_META_DB_USER: ${POSTGRES_USER}
      PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
    networks:
      - tenant
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy

  supabase-auth:
    image: supabase/gotrue:v2.193.1
    container_name: ${TENANT_PREFIX}-supabase-auth
    environment:
      GOTRUE_API_HOST: 0.0.0.0
      GOTRUE_API_PORT: 9999
      API_EXTERNAL_URL: https://api.${CLIENT_DOMAIN}
      GOTRUE_DB_DRIVER: postgres
      GOTRUE_DB_DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/supabase
      GOTRUE_SITE_URL: https://${SUPABASE_DOMAIN}
      GOTRUE_JWT_SECRET: ${SUPABASE_JWT_SECRET}
      GOTRUE_JWT_EXP: 3600
      GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
    networks:
      - tenant
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy

  supabase-rest:
    image: postgrest/postgrest:v13.0.8
    container_name: ${TENANT_PREFIX}-supabase-rest
    environment:
      PGRST_DB_URI: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/supabase
      PGRST_DB_SCHEMAS: public
      PGRST_DB_ANON_ROLE: anon
      PGRST_JWT_SECRET: ${SUPABASE_JWT_SECRET}
      PGRST_DB_USE_LEGACY_GUCS: "false"
    networks:
      - tenant
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy

  supabase-realtime:
    image: supabase/realtime:v2.120.3
    container_name: ${TENANT_PREFIX}-supabase-realtime
    environment:
      PORT: 4000
      DB_HOST: postgres
      DB_PORT: 5432
      DB_USER: ${POSTGRES_USER}
      DB_PASSWORD: ${POSTGRES_PASSWORD}
      DB_NAME: supabase
      DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
      DB_ENC_KEY: supabaserealtime
      API_JWT_SECRET: ${SUPABASE_JWT_SECRET}
      SECRET_KEY_BASE: ${SUPABASE_JWT_SECRET}
      METRICS_JWT_SECRET: ${SUPABASE_JWT_SECRET}
      ERL_AFLAGS: -proto_dist inet_tcp
      DNS_NODES: "''"
      APP_NAME: realtime
      SEED_SELF_HOST: "true"
    networks:
      - tenant
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy

  supabase-kong:
    image: kong:3.9.1
    container_name: ${TENANT_PREFIX}-supabase-kong
    environment:
      KONG_DATABASE: "off"
      KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml
      KONG_DNS_ORDER: LAST,A,CNAME
      KONG_PLUGINS: request-size-limiting,cors,key-auth,rate-limiting
      KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
      KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
    volumes:
      - ./supabase/kong.yml:/var/lib/kong/kong.yml:ro
    networks:
      - tenant
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.kong.rule=Host(`api.${CLIENT_DOMAIN}`)"
      - "traefik.http.routers.kong.tls.certresolver=letsencrypt"
      - "traefik.http.services.kong.loadbalancer.server.port=8000"
      - "traefik.http.routers.kong.middlewares=${AUTH_MIDDLEWARE}"
    restart: unless-stopped

  # Local AI Language Models
  ollama:
    image: ollama/ollama:0.32.3
    container_name: ${TENANT_PREFIX}-ollama
    environment:
      OLLAMA_HOST: 0.0.0.0:11434
      OLLAMA_ORIGINS: "*"
    volumes:
      - ollama_data:/root/.ollama
    networks:
      - tenant
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.ollama.rule=Host(`ai.${CLIENT_DOMAIN}`)"
      - "traefik.http.routers.ollama.tls.certresolver=letsencrypt"
      - "traefik.http.services.ollama.loadbalancer.server.port=11434"
      - "traefik.http.routers.ollama.middlewares=${AUTH_MIDDLEWARE}"
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 16G
        reservations:
          memory: 8G
    healthcheck:
      test: ["CMD", "ollama", "list"]
      interval: 30s
      timeout: 10s
      retries: 3

  # Enterprise SSO Authentication (3 containers)
  authentik-redis:
    image: redis:7.4.9-alpine
    container_name: ${TENANT_PREFIX}-authentik-redis
    command: --save 60 1 --loglevel warning
    networks:
      - tenant
    volumes:
      - authentik_redis_data:/data
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
      interval: 30s
      timeout: 3s
      retries: 3

  authentik-server:
    image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG}
    container_name: ${TENANT_PREFIX}-authentik-server
    command: server
    environment:
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
      AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
      AUTHENTIK_POSTGRESQL__HOST: postgres
      AUTHENTIK_POSTGRESQL__USER: ${POSTGRES_USER}
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_POSTGRESQL__PASSWORD: ${POSTGRES_PASSWORD}
      AUTHENTIK_REDIS__HOST: authentik-redis
    volumes:
      - authentik_media:/media
      - authentik_templates:/templates
    networks:
      - tenant
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.authentik.rule=Host(`auth.${CLIENT_DOMAIN}`)"
      - "traefik.http.routers.authentik.tls.certresolver=letsencrypt"
      - "traefik.http.services.authentik.loadbalancer.server.port=9000"
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
      authentik-redis:
        condition: service_healthy

  authentik-worker:
    image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG}
    container_name: ${TENANT_PREFIX}-authentik-worker
    command: worker
    environment:
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
      AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
      AUTHENTIK_POSTGRESQL__HOST: postgres
      AUTHENTIK_POSTGRESQL__USER: ${POSTGRES_USER}
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_POSTGRESQL__PASSWORD: ${POSTGRES_PASSWORD}
      AUTHENTIK_REDIS__HOST: authentik-redis
    volumes:
      - authentik_media:/media
      - authentik_templates:/templates
      - /var/run/docker.sock:/var/run/docker.sock
    networks:
      - tenant
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
      authentik-redis:
        condition: service_healthy

  # Test Service for Health Monitoring
  whoami:
    image: traefik/whoami:v1.11.0
    container_name: ${TENANT_PREFIX}-whoami
    networks:
      - tenant
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.whoami.rule=Host(`test.${CLIENT_DOMAIN}`)"
      - "traefik.http.routers.whoami.tls.certresolver=letsencrypt"
      - "traefik.http.services.whoami.loadbalancer.server.port=80"
    restart: unless-stopped

volumes:
  postgres_data:
  n8n_data:
  nocodb_data:
  ollama_data:
  authentik_redis_data:
  authentik_media:
  authentik_templates:

Complete Environment Template

Create .env.template for client-specific variables:

# Client Configuration
CLIENT_NAME=CLIENT_NAME_PLACEHOLDER
CLIENT_DOMAIN=CLIENT_DOMAIN_PLACEHOLDER
TENANT_PREFIX=CLIENT_PREFIX_PLACEHOLDER
TENANT_NETWORK=CLIENT_NETWORK_PLACEHOLDER
# Compose project name: keeps volume names and Traefik's container filter per client
COMPOSE_PROJECT_NAME=CLIENT_PREFIX_PLACEHOLDER

# Service Domains (Subdomain-based routing)
N8N_DOMAIN=workflows.CLIENT_DOMAIN_PLACEHOLDER
NOCODB_DOMAIN=database.CLIENT_DOMAIN_PLACEHOLDER
SUPABASE_DOMAIN=backend.CLIENT_DOMAIN_PLACEHOLDER
AUTHENTIK_DOMAIN=auth.CLIENT_DOMAIN_PLACEHOLDER

# Host Ports (localhost only, offset per client by the deployment script)
TRAEFIK_DASHBOARD_PORT=8080
POSTGRES_PORT=5432

# Admin Configuration
ADMIN_EMAIL=admin@CLIENT_DOMAIN_PLACEHOLDER

# Database Configuration
POSTGRES_DB=main_db
POSTGRES_USER=postgres
POSTGRES_PASSWORD=SECURE_PASSWORD_PLACEHOLDER

# Service-Specific Passwords
NOCODB_ADMIN_PASSWORD=NOCODB_PASSWORD_PLACEHOLDER
SUPABASE_JWT_SECRET=SUPABASE_JWT_PLACEHOLDER

# Authentik SSO Configuration
AUTHENTIK_SECRET_KEY=AUTHENTIK_SECRET_PLACEHOLDER
AUTHENTIK_TAG=2025.8.6

# n8n Configuration
N8N_PROTOCOL=https
N8N_SECURE_COOKIE=true

# Cloudflare Integration (tunnel token, and an API token with Zone:DNS:Edit for certificates)
CLOUDFLARE_TOKEN=CLOUDFLARE_TOKEN_PLACEHOLDER
CF_DNS_API_TOKEN=CF_DNS_API_TOKEN_PLACEHOLDER

# Authentication Middleware (set to 'auth-global' for SSO, leave empty for no auth)
AUTH_MIDDLEWARE=

Database Initialization Scripts

Create database initialization scripts in init/01-create-multiple-databases.sql:

-- Create databases for all services
CREATE DATABASE n8n;
CREATE DATABASE nocodb;
CREATE DATABASE supabase;
CREATE DATABASE authentik;

-- Enable required extensions for Supabase
-- (pgjwt is not part of the official postgres image, and a failing statement aborts the init)
\c supabase
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";

-- Schemas and role the Supabase services expect
CREATE SCHEMA IF NOT EXISTS auth;
CREATE SCHEMA IF NOT EXISTS _realtime;
CREATE ROLE anon NOLOGIN;
GRANT USAGE ON SCHEMA public TO anon;

-- Enable required extensions for Authentik
\c authentik
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";

\echo 'Multiple databases and extensions created successfully'

Supabase Kong Gateway Configuration

Create supabase/kong.yml for API gateway routing:

_format_version: "3.0"

services:
  - name: auth-v1-open
    url: http://supabase-auth:9999/verify
    plugins:
      - name: cors
    routes:
      - name: auth-v1-open
        strip_path: true
        paths:
          - /auth/v1/verify
        methods:
          - POST
          - OPTIONS

  - name: auth-v1-open-callback
    url: http://supabase-auth:9999/callback
    plugins:
      - name: cors
    routes:
      - name: auth-v1-open-callback
        strip_path: true
        paths:
          - /auth/v1/callback
        methods:
          - GET
          - POST
          - OPTIONS

  - name: auth-v1
    _comment: "GoTrue: /auth/v1/* -> http://supabase-auth:9999/*"
    url: http://supabase-auth:9999/
    plugins:
      - name: cors
      - name: key-auth
        config:
          hide_credentials: false
    routes:
      - name: auth-v1-all
        strip_path: true
        paths:
          - /auth/v1/
        methods:
          - GET
          - POST
          - PUT
          - PATCH
          - DELETE
          - OPTIONS

  - name: rest-v1
    _comment: "PostgREST: /rest/v1/* -> http://supabase-rest:3000/*"
    url: http://supabase-rest:3000/
    plugins:
      - name: cors
      - name: key-auth
        config:
          hide_credentials: true
    routes:
      - name: rest-v1-all
        strip_path: true
        paths:
          - /rest/v1/
        methods:
          - GET
          - POST
          - PUT
          - PATCH
          - DELETE
          - OPTIONS

  - name: realtime-v1
    _comment: "Realtime: /realtime/v1/* -> ws://supabase-realtime:4000/socket/*"
    url: http://supabase-realtime:4000/socket/
    plugins:
      - name: cors
      - name: key-auth
        config:
          hide_credentials: false
    routes:
      - name: realtime-v1-all
        strip_path: true
        paths:
          - /realtime/v1/
        methods:
          - GET
          - POST
          - PUT
          - PATCH
          - DELETE
          - OPTIONS

consumers:
  - username: anon
    keyauth_credentials:
      - key: your-anon-key-here
  - username: service_role
    keyauth_credentials:
      - key: your-service-role-key-here

plugins:
  - name: cors
    config:
      origins:
        - "*"
      methods:
        - GET
        - POST
        - PUT
        - PATCH
        - DELETE
        - OPTIONS
      headers:
        - Accept
        - Accept-Version
        - Content-Length
        - Content-MD5
        - Content-Type
        - Date
        - X-Auth-Token
        - Authorization
        - X-Forwarded-For
        - X-Forwarded-Proto
        - X-Forwarded-Port
      exposed_headers:
        - X-Auth-Token
      credentials: true
      max_age: 3600

Automated Deployment Script

Use the deployment script to create new client environments in minutes:

#!/bin/bash
# deploy-client.sh - Complete Multi-Tenant Deployment
set -euo pipefail

CLIENT_DOMAIN=${1:-}
CLIENT_NAME=${2:-}
CLOUDFLARE_TOKEN=${3:-}

if [ -z "$CLIENT_DOMAIN" ] || [ -z "$CLIENT_NAME" ] || [ -z "$CLOUDFLARE_TOKEN" ] || [ -z "${CF_DNS_API_TOKEN:-}" ]; then
    echo "Usage: CF_DNS_API_TOKEN=... ./deploy-client.sh example.com 'Client Name' 'cloudflare-tunnel-token'"
    echo ""
    echo "Example: ./deploy-client.sh client-a.com 'Client A Corporation' 'your-cloudflare-token'"
    echo "CF_DNS_API_TOKEN: Cloudflare API token with Zone:DNS:Edit, used for certificates"
    exit 1
fi

# Run from the template directory, wherever the script is called from
cd "$(dirname "$0")"

CLIENT_PREFIX=$(echo "$CLIENT_DOMAIN" | sed 's/[.-]//g' | tr '[:upper:]' '[:lower:]')
TIMESTAMP=$(date +%Y%m%d_%H%M%S)

echo "🚀 Deploying complete multi-tenant environment..."
echo "📋 Configuration:"
echo "   Domain: $CLIENT_DOMAIN"
echo "   Name: $CLIENT_NAME"
echo "   Prefix: $CLIENT_PREFIX"
echo "   Timestamp: $TIMESTAMP"
echo ""

# Create deployment directory
DEPLOY_DIR="../deployments/$CLIENT_DOMAIN"
mkdir -p "$DEPLOY_DIR"/{traefik,authentik,supabase,init,logs}

echo "📁 Created deployment directory structure"

# Copy template files
cp docker-compose.yml "$DEPLOY_DIR/"
cp -R traefik authentik supabase init "$DEPLOY_DIR/"

echo "📋 Copied configuration templates"

# Generate secure passwords and secrets
POSTGRES_PASSWORD=$(openssl rand -hex 16)
NOCODB_PASSWORD=$(openssl rand -hex 12)
SUPABASE_JWT_SECRET=$(openssl rand -hex 32)
AUTHENTIK_SECRET=$(openssl rand -hex 32)

echo "🔐 Generated secure credentials"

# Offset the localhost-only host ports per client (80/443 are not published at all)
PORT_OFFSET=$(($(echo "$CLIENT_PREFIX" | cksum | cut -f1 -d' ') % 1000))
TRAEFIK_DASHBOARD_PORT=$((8080 + PORT_OFFSET))
POSTGRES_PORT=$((5432 + PORT_OFFSET))

# Create comprehensive environment file (readable by the owner only)
umask 077
sed -e "s/CLIENT_NAME_PLACEHOLDER/$CLIENT_NAME/g" \
    -e "s/CLIENT_DOMAIN_PLACEHOLDER/$CLIENT_DOMAIN/g" \
    -e "s/CLIENT_PREFIX_PLACEHOLDER/$CLIENT_PREFIX/g" \
    -e "s/CLIENT_NETWORK_PLACEHOLDER/${CLIENT_PREFIX}-network/g" \
    -e "s/SECURE_PASSWORD_PLACEHOLDER/$POSTGRES_PASSWORD/g" \
    -e "s/NOCODB_PASSWORD_PLACEHOLDER/$NOCODB_PASSWORD/g" \
    -e "s/SUPABASE_JWT_PLACEHOLDER/$SUPABASE_JWT_SECRET/g" \
    -e "s/AUTHENTIK_SECRET_PLACEHOLDER/$AUTHENTIK_SECRET/g" \
    -e "s/CLOUDFLARE_TOKEN_PLACEHOLDER/$CLOUDFLARE_TOKEN/g" \
    -e "s/CF_DNS_API_TOKEN_PLACEHOLDER/$CF_DNS_API_TOKEN/g" \
    -e "s/^TRAEFIK_DASHBOARD_PORT=.*/TRAEFIK_DASHBOARD_PORT=$TRAEFIK_DASHBOARD_PORT/" \
    -e "s/^POSTGRES_PORT=.*/POSTGRES_PORT=$POSTGRES_PORT/" \
    .env.template > "$DEPLOY_DIR/.env"

echo "⚙️  Generated environment configuration"

# Create ACME file for Traefik SSL
touch "$DEPLOY_DIR/traefik/acme.json"
chmod 600 "$DEPLOY_DIR/traefik/acme.json"

# Initialize deployment
cd "$DEPLOY_DIR"

echo "🐋 Starting Docker containers..."
echo "   This may take several minutes for first-time image downloads"

# Start all services (each waits for the database health check)
docker compose up -d

echo ""
echo "✅ Multi-tenant environment deployed successfully!"
echo ""
echo "🌐 Access URLs:"
echo "   🔧 Traefik Dashboard: http://localhost:$TRAEFIK_DASHBOARD_PORT/dashboard/"
echo "   🔀 Workflows (n8n): https://workflows.$CLIENT_DOMAIN"
echo "   🗄️  Database (NocoDB): https://database.$CLIENT_DOMAIN"
echo "   🔧 Backend (Supabase): https://backend.$CLIENT_DOMAIN"
echo "   🤖 AI (Ollama): https://ai.$CLIENT_DOMAIN"
echo "   🔐 Authentication: https://auth.$CLIENT_DOMAIN"
echo "   🔌 API Gateway: https://api.$CLIENT_DOMAIN"
echo "   🧪 Test Service: https://test.$CLIENT_DOMAIN"
echo ""
echo "📊 Container Status:"
docker compose ps
echo ""
echo "🔑 Generated credentials are stored in $DEPLOY_DIR/.env (mode 600)"
echo ""
echo "📝 Next Steps:"
echo "   1. In the Cloudflare tunnel, route *.${CLIENT_DOMAIN} to https://traefik:443 (No TLS Verify)"
echo "   2. Wait 2-3 minutes for all services to initialize"
echo "   3. Access services via the URLs above"
echo "   4. Configure SSO via auth.$CLIENT_DOMAIN if needed"
echo ""
echo "📚 Documentation: Visit tva.sg for setup guides and troubleshooting"
echo "💬 Support: Contact us via tva.sg/contact for assistance"

Using Your Multi-Tenant Stack

Deploying New Clients

Create a new client environment with the deployment script:

# Set once per shell: Cloudflare API token with Zone:DNS:Edit (used for certificates)
export CF_DNS_API_TOKEN="your-cloudflare-dns-token"

# Deploy Client A with full enterprise stack
./deploy-client.sh client-a.com "Client A Corporation" "your-cloudflare-token"

# Deploy Client B with different domain
./deploy-client.sh client-b.org "Client B Industries" "your-cloudflare-token"

# Deploy Startup C
./deploy-client.sh startup-c.io "Startup C" "your-cloudflare-token"

Each deployment creates:

  • Dedicated Docker network with 16 containers
  • Separate data volumes for persistent storage
  • Unique service containers with health monitoring
  • Individual Cloudflare tunnel configuration
  • Custom domain routing with SSL certificates
  • Enterprise-grade SSO infrastructure ready for activation

Managing Multiple Environments

Monitor all client environments from a central location:

# Check all running environments across clients
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep -E "(client|startup)"

# View comprehensive logs for specific client
cd deployments/client-a.com
docker compose logs -f --tail=50 n8n

# Health check all services for a client
docker compose ps
docker compose exec postgres pg_isready

# Restart specific services
docker compose restart nocodb supabase-studio

# Update all services to the image versions now pinned in docker-compose.yml
docker compose pull && docker compose up -d

Container Architecture Deep Dive

Our complete 16-container architecture per client includes:

Infrastructure Layer (4 containers):

  • cloudflare-tunnel: Secure external connectivity
  • traefik: Reverse proxy with automatic SSL and service discovery
  • postgres: Central database with connection pooling
  • whoami: Health monitoring and routing verification

Application Layer (7 containers):

  • n8n: Workflow automation with PostgreSQL backend
  • nocodb: No-code database interface
  • supabase-studio: Backend development dashboard
  • supabase-meta: Database introspection service
  • supabase-auth: Authentication and user management
  • supabase-rest: Auto-generated REST API
  • supabase-realtime: Real-time subscriptions and updates

AI & Gateway Layer (2 containers):

  • ollama: Local AI with GPU acceleration support
  • supabase-kong: API gateway with rate limiting and CORS

Enterprise Security Layer (3 containers):

  • authentik-server: SSO authentication server
  • authentik-worker: Background tasks and notifications
  • authentik-redis: Session management and caching

Scaling Resources Per Client

Adjust resources based on client needs and usage patterns:

# High-performance client configuration (docker-compose.override.yml)
services:
  n8n:
    deploy:
      resources:
        limits:
          cpus: '4.0'
          memory: 8G
        reservations:
          cpus: '2.0'
          memory: 4G

  postgres:
    command: postgres -c max_connections=200 -c shared_buffers=1GB -c effective_cache_size=3GB
    deploy:
      resources:
        limits:
          cpus: '2.0'
          memory: 4G
        reservations:
          cpus: '1.0'
          memory: 2G

  ollama:
    deploy:
      resources:
        limits:
          memory: 32G
        reservations:
          memory: 16G
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]

Real Benefits for Your Business

Complete Client Isolation with Enterprise Features

Each client gets a complete environment with enterprise-grade SSO, AI capabilities, and full backend infrastructure. Data, configurations, customizations, and security policies stay separated. An application-level problem in one client’s environment stays within that environment, but because all clients share one host, host-level issues such as exhausted CPU, memory, or disk can still affect everyone. This is similar to the isolation we achieve with our individual n8n deployments.

Rapid Client Onboarding with Full Feature Set

New clients can be up and running with a complete development and automation stack in under 10 minutes. The deployment script automates configuration, credential generation, and service initialization; Cloudflare DNS records and optional SSO remain short manual steps.

Predictable Enterprise Costs

After initial setup, there are no per-client hosting costs beyond your base infrastructure. Unlike SaaS solutions that charge per seat, per workflow, or per API call, you pay a fixed amount for the server and can run as many client environments with full enterprise features as its resources allow.

Professional Brand Consistency

Each client gets their own branded domains with professional subdomains (workflows.client.com, auth.client.com, etc.) and can customize their environments completely. No “powered by” footers or shared interfaces that dilute their brand identity.

The n8n Integration: Enterprise Workflow Automation at Scale

Just as we’ve shown you how to self-host n8n for workflow automation, this multi-tenant setup gives each client their own complete n8n instance integrated with a full enterprise stack.

Each client can build sophisticated workflows that:

  • Connect to their own databases (NocoDB, Supabase PostgreSQL)
  • Use their own AI models (Ollama) for intelligent automation
  • Authenticate through enterprise SSO (Authentik)
  • Integrate with their specific business tools and APIs
  • Process their data within their own isolated environment

The combination creates a powerful client delivery platform where you can:

  • Deploy standardized automation capabilities rapidly
  • Customize workflows per client without affecting others
  • Scale your service delivery without linear cost increases
  • Maintain complete data sovereignty for each client
  • Offer SSO-based access control as a foundation for security and compliance work

This approach builds on the same principles we used in our Windmill Docker setup guide, but extends it to a complete multi-tenant architecture.

Advanced Configuration Options

Implementing Enterprise SSO with Authentik

Enable single sign-on across all client services by configuring Authentik forward authentication:

# Add to the authentik-server labels in docker-compose.yml
- "traefik.http.middlewares.auth-global.forwardauth.address=http://authentik-server:9000/outpost.goauthentik.io/auth/traefik"
- "traefik.http.middlewares.auth-global.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.auth-global.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name,X-authentik-uid"

Then update your service labels to use the middleware:

labels:
  - "traefik.http.routers.n8n.middlewares=auth-global"
  - "traefik.http.routers.nocodb.middlewares=auth-global"
  - "traefik.http.routers.supabase-studio.middlewares=auth-global"

Adding a Vector Database for Advanced AI

Enhance AI capabilities with the Qdrant vector database:

qdrant:
  image: qdrant/qdrant:v1.18.3
  container_name: ${TENANT_PREFIX}-qdrant
  environment:
    QDRANT__SERVICE__HTTP_PORT: 6333
    QDRANT__SERVICE__GRPC_PORT: 6334
  volumes:
    - qdrant_data:/qdrant/storage
  networks:
    - tenant
  labels:
    - "traefik.enable=true"
    - "traefik.http.routers.qdrant.rule=Host(`vector.${CLIENT_DOMAIN}`)"
    - "traefik.http.routers.qdrant.tls.certresolver=letsencrypt"
    - "traefik.http.services.qdrant.loadbalancer.server.port=6333"
    - "traefik.http.routers.qdrant.middlewares=${AUTH_MIDDLEWARE}"
# Also add qdrant_data: under the top-level volumes:

Implementing Hybrid AI Architecture

For optimal performance, consider a hybrid approach combining containerized and native AI:

# Install Ollama natively on host for GPU acceleration
brew install ollama

# Configure containers to use native Ollama
# In docker-compose.yml, services can access via host.docker.internal:11434
n8n:
  environment:
    - OLLAMA_HOST=host.docker.internal:11434

On macOS this matters because Docker Desktop does not support GPUs in containers, while native Ollama uses GPU acceleration directly. In our setup, this gave roughly a 5-6x performance improvement while the other services stayed containerized.

Monitoring and Observability Stack

Add comprehensive monitoring per client:

prometheus:
  image: prom/prometheus:v3.13.1
  container_name: ${TENANT_PREFIX}-prometheus
  volumes:
    - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
    - prometheus_data:/prometheus
  networks:
    - tenant
  labels:
    - "traefik.enable=true"
    - "traefik.http.routers.prometheus.rule=Host(`metrics.${CLIENT_DOMAIN}`)"
    - "traefik.http.routers.prometheus.tls.certresolver=letsencrypt"
    - "traefik.http.routers.prometheus.middlewares=${AUTH_MIDDLEWARE}"

grafana:
  image: grafana/grafana:13.1.1
  container_name: ${TENANT_PREFIX}-grafana
  environment:
    GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD}
    GF_USERS_ALLOW_SIGN_UP: "false"
  volumes:
    - grafana_data:/var/lib/grafana
    - ./monitoring/dashboards:/var/lib/grafana/dashboards
  networks:
    - tenant
  labels:
    - "traefik.enable=true"
    - "traefik.http.routers.grafana.rule=Host(`monitoring.${CLIENT_DOMAIN}`)"
    - "traefik.http.routers.grafana.tls.certresolver=letsencrypt"
    - "traefik.http.routers.grafana.middlewares=${AUTH_MIDDLEWARE}"
# Also add prometheus_data: and grafana_data: under the top-level volumes:

Common Issues and Solutions

“Service Unavailable” or HTTP 502 Errors

This usually means Traefik can’t reach the target container. Check that:

# Verify container is running and healthy
docker compose ps
docker compose logs traefik --tail=20

# Check container is on correct network
docker network ls
docker network inspect ${CLIENT_PREFIX}-network

# Verify Traefik labels are correct
docker compose config --services

DNS Resolution Problems

Wildcard DNS setup is crucial for subdomain routing:

# Correct Cloudflare DNS configuration
*.client-a.com CNAME tunnel-uuid.cfargotunnel.com
*.client-b.org CNAME tunnel-uuid2.cfargotunnel.com

# Test DNS resolution
nslookup workflows.client-a.com
dig workflows.client-a.com

Resource Exhaustion Across Multiple Clients

Monitor resource usage across all client environments:

# Check overall system resource usage
docker stats --no-stream
htop

# Check disk usage per client
du -sh deployments/*/
df -h

# Monitor container memory usage
docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}"

Database Connection Pool Exhaustion

PostgreSQL connection limits can be hit with many clients. Configure per deployment:

# Connect to client database
docker compose exec postgres psql -U postgres

-- Increase connection limit
ALTER SYSTEM SET max_connections = 300;
ALTER SYSTEM SET shared_buffers = '256MB';
ALTER SYSTEM SET effective_cache_size = '1GB';

-- Apply: effective_cache_size reloads, max_connections and shared_buffers need a restart
SELECT pg_reload_conf();
\q

# Restart PostgreSQL
docker compose restart postgres

Authentik SSO Configuration Issues

Common SSO setup problems and solutions:

# Check Authentik containers are running
docker compose ps | grep authentik

# Verify database initialization
docker compose exec postgres psql -U postgres -d authentik -c "\dt"

# Check Authentik logs for startup issues
docker compose logs authentik-server --tail=50

# Get a recovery link to log in as admin if needed
docker compose exec authentik-worker ak create_recovery_key 10 akadmin

Cloudflare Tunnel Connection Issues

Debug tunnel connectivity problems:

# Check tunnel status
docker compose logs cloudflare-tunnel --tail=20

# Verify tunnel configuration in Cloudflare dashboard
# Ensure wildcard routing: *.client-domain.com

# Test tunnel connectivity
curl -I https://test.client-domain.com

Infrastructure Considerations

Sizing Your Infrastructure for Multiple Clients

For a typical setup handling 10-15 clients simultaneously with full 16-container stacks:

Minimum Requirements:

  • CPU: 16-24 cores (2 cores per active client environment)
  • RAM: 64-128GB (4-8GB per client depending on AI usage)
  • Storage: NVMe SSD with 2TB+ (databases, AI models, and logs grow over time)
  • Network: Gigabit connection for responsive client access

Recommended for Production:

  • Server: Hetzner CCX62 or similar (48 vCPU, 192GB RAM)
  • Storage: 4TB NVMe with automated backup system
  • Network: Multiple redundant connections
  • Monitoring: Full observability stack with alerting

Backup Strategy for Multi-Client Environments

Implement automated backups per client:

#!/bin/bash
# backup-all-clients.sh - Comprehensive backup solution
set -euo pipefail
umask 077

BACKUP_DIR="/opt/backups"
BACKUP_DATE=$(date +%Y%m%d_%H%M%S)
# Path to development-stack/deployments
DEPLOYMENTS_DIR="/opt/development-stack/deployments"
mkdir -p "$BACKUP_DIR"

for client_dir in "$DEPLOYMENTS_DIR"/*/; do
    CLIENT_DOMAIN=$(basename "$client_dir")
    # Volume names start with the compose project name (TENANT_PREFIX in .env)
    PROJECT=$(grep '^COMPOSE_PROJECT_NAME=' "$client_dir/.env" | cut -d= -f2)
    echo "📦 Backing up client: $CLIENT_DOMAIN"

    # Subshell: the cd applies to this client only; set -e stops the script on any failure
    (
        cd "$client_dir"

        # Backup databases with compression
        docker compose exec -T postgres pg_dumpall -U postgres | gzip > "${BACKUP_DIR}/${CLIENT_DOMAIN}_db_${BACKUP_DATE}.sql.gz"

        # Backup persistent volumes
        docker run --rm \
            -v "${BACKUP_DIR}":/backup \
            -v "${PROJECT}_n8n_data":/data/n8n:ro \
            -v "${PROJECT}_nocodb_data":/data/nocodb:ro \
            -v "${PROJECT}_ollama_data":/data/ollama:ro \
            alpine:3.22 tar czf "/backup/${CLIENT_DOMAIN}_volumes_${BACKUP_DATE}.tar.gz" -C /data .

        # Backup configuration files
        tar czf "${BACKUP_DIR}/${CLIENT_DOMAIN}_config_${BACKUP_DATE}.tar.gz" \
            docker-compose.yml .env traefik/ supabase/ authentik/ init/
    )

    echo "✅ Backup completed for $CLIENT_DOMAIN"
done

# Cleanup old backups (keep 30 days)
find "$BACKUP_DIR" -name "*.gz" -mtime +30 -delete

# Optional: Upload to cloud storage
# rclone sync "$BACKUP_DIR" s3:backup-bucket/multi-tenant/

Security Hardening for Production

Implement comprehensive security best practices:

# Enhanced Traefik security configuration
traefik:
  command:
    - "--api.dashboard=true"
    - "--ping=true"
    - "--log.level=WARN"
    - "--accesslog=true"
    - "--providers.docker=true"
    - "--providers.docker.network=${TENANT_NETWORK}"
    - "--providers.docker.exposedbydefault=false"
    - "--providers.docker.constraints=Label(`com.docker.compose.project`,`${TENANT_PREFIX}`)"
    - "--entrypoints.web.address=:80"
    - "--entrypoints.websecure.address=:443"
    - "--entrypoints.websecure.asdefault=true"
    - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
    - "--entrypoints.web.http.redirections.entrypoint.scheme=https"
    - "--entrypoints.traefik.address=:8080"
    - "--certificatesresolvers.letsencrypt.acme.email=${ADMIN_EMAIL}"
    - "--certificatesresolvers.letsencrypt.acme.storage=/acme.json"
    - "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare"
  labels:
    # Security headers middleware
    - "traefik.http.middlewares.security.headers.customRequestHeaders.X-Forwarded-Proto=https"
    - "traefik.http.middlewares.security.headers.customResponseHeaders.X-Frame-Options=DENY"
    - "traefik.http.middlewares.security.headers.customResponseHeaders.X-Content-Type-Options=nosniff"
    - "traefik.http.middlewares.security.headers.customResponseHeaders.Strict-Transport-Security=max-age=31536000"
    - "traefik.http.middlewares.security.headers.customResponseHeaders.Content-Security-Policy=default-src 'self'"

    # Rate limiting middleware
    - "traefik.http.middlewares.ratelimit.ratelimit.burst=100"
    - "traefik.http.middlewares.ratelimit.ratelimit.average=50"

Apply security middleware to all client services:

labels:
  # Without SSO
  - "traefik.http.routers.n8n.middlewares=security,ratelimit"
  # With SSO enabled (AUTH_MIDDLEWARE=auth-global)
  # - "traefik.http.routers.n8n.middlewares=security,ratelimit,auth-global"

Cost Analysis: The Numbers That Matter

Traditional SaaS Costs (10 enterprise clients with full feature sets)

Per-client monthly costs:

  • n8n Pro: $50/month per client = $500/month
  • Supabase Pro: $25/month per client = $250/month
  • NoCode platform (Airtable): $20/month per client = $200/month
  • Enterprise SSO (Auth0): $23/month per client = $230/month
  • AI API costs (OpenAI): $50/month per client = $500/month
  • Total: $1,680/month = $20,160/year

Self-Hosted Multi-Tenant Enterprise Stack Costs

Annual infrastructure costs:

  • Cloud server with dedicated vCPUs (Hetzner CCX62): $350/month = $4,200/year
  • Domain costs (10 clients): $120/year
  • Cloudflare Pro (optional): $240/year
  • Total: $4,560/year

Annual savings: $15,600 (77% cost reduction)

These figures come from the original August 2025 guide. As of October 2026, Auth0’s B2C Essentials plan starts at $35/month, and Hetzner no longer lists the CCX62; its 48-vCPU, 192 GB successor, the CCX63, lists at €853.99/month excluding VAT, including a primary IPv4 address. Recalculate both sides with current prices before you decide.

Plus you get:

  • Complete data sovereignty and privacy
  • Unlimited customization and white-labeling
  • No vendor lock-in or SaaS plan rate limits
  • Security and compliance controls you manage yourself
  • Ability to offer reseller services
  • Full control over updates and features

This is particularly powerful when you consider that our setup provides enterprise features that would typically cost much more in SaaS subscriptions, similar to the cost benefits we demonstrated in our n8n self-hosting analysis.

From configuration to an operating decision

The practical question is not whether a platform can be configured. It is whether the team can explain ownership, detect drift, recover without improvisation, and demonstrate that the change improved the intended outcome. We therefore pair configuration with an owner, a baseline, a rollback path, and a readback window. That turns a one-off fix into an operating capability. The production record should include the reason for the change, the evidence reviewed, the exact scope, and the person authorized to stop or reverse it. After release, compare the agreed indicators with the baseline and keep the result with the change record. If the expected improvement is absent, rollback is a valid outcome rather than a failure. This discipline matters more than any individual configuration value. The same record gives the next operator a trustworthy starting point and makes later optimization a measured decision rather than another round of guesswork.

Related Insights

Further Reading