All Posts Architecture

Multi-Tenant n8n Architecture: Running Automations for Multiple Clients

You built an automation workflow for one client. It worked great. They loved it.

Multi-tenant n8n consolidates automation hosting from one server per client to one server for 10-15 clients, reducing infrastructure costs from $225/month to under $120/month while maintaining complete data isolation through Docker containers, separate PostgreSQL databases, and unique encryption keys per tenant. For IT consultancies and MSPs across Volusia County, this architecture cuts management overhead by 80% compared to per-client VPS deployments.

You built an automation workflow for one client. It worked great. They loved it. Then a second client wanted something similar. Then a third. You spun up a separate VPS for each one because that’s what you did for the first client, and now you’re managing six servers, six sets of updates, six SSL certificates, and six separate monitoring setups. Your automation practice is growing, but your infrastructure management is growing faster.

Multi-tenant n8n is an architecture where a single server or infrastructure runs separate n8n automation instances for multiple clients or business units. Each tenant gets their own isolated n8n container with dedicated database storage, credentials, and webhook endpoints, while sharing the underlying server hardware and networking infrastructure. This approach cuts your infrastructure costs dramatically while maintaining the data isolation your clients expect.

This is the problem every automation agency and managed service provider eventually hits. I see it with IT consultancies across Volusia County — businesses in Ormond Beach, Daytona Beach, Port Orange — that start building automations for clients and quickly realize that one-server-per-client doesn’t scale. The server costs add up. The management overhead multiplies. And the moment you have ten clients, you’re spending more time maintaining infrastructure than building automations.

In this guide, I’ll walk you through building a proper multi-tenant n8n platform using Docker Compose, Traefik as a reverse proxy, PostgreSQL for data isolation, and deployment scripts that let you onboard a new client in minutes instead of hours. Let’s get into it.

Why Per-Client Servers Don’t Scale

Let me quantify the problem. A basic VPS for running n8n costs $10 to $20 per month. Not much for one client. For ten clients, that’s $100 to $200 per month in server costs alone. But the real expense isn’t the servers — it’s the management time.

Each separate server needs its own SSL certificate management, its own update schedule, its own backup configuration, its own monitoring setup, and its own firewall rules. When n8n releases a security update, you need to update ten servers instead of one. When you improve a deployment pattern, you need to apply it ten times. When a server goes down at 2 AM, you need to figure out which client it belongs to, what workflows are affected, and how to restore service.

The multi-tenant approach consolidates all of this. One server, one SSL configuration (Traefik handles per-tenant certificates automatically), one update process, one backup job, one monitoring stack. You still maintain complete data isolation between clients — each client’s workflows, credentials, and execution history are in separate containers with separate databases. But the infrastructure management scales linearly instead of multiplicatively.

For a managed service provider in DeLand serving fifteen clients, the difference is substantial. Instead of fifteen servers at $15 each ($225/month) with fifteen times the management overhead, you run two or three servers at $40 each ($80-$120/month) with centralized management. The cost savings fund the automation work that actually generates revenue.

Architecture Overview

Here’s what we’re building. A single server runs Traefik as a reverse proxy at the front. Behind Traefik, each client gets their own n8n Docker container and their own PostgreSQL database container. Traefik routes requests based on subdomain — client1.automations.yourcompany.com goes to client1’s n8n container, client2.automations.yourcompany.com goes to client2’s container.

                    +-----------------------------+
                    |          Internet            |
                    +-------------+---------------+
                                  |
                    +-------------v---------------+
                    |       Traefik Proxy          |
                    |   (SSL, routing, load)       |
                    +--+------+------+------+------+
                       |      |      |      |
              +--------v--+ +-v----+ |  +---v-----+
              | Client A  | |Cli B | |  | Client N |
              |   n8n     | | n8n  | |  |   n8n    |
              | container | |cont. | |  |container |
              +-----+-----+ +--+---+ |  +----+----+
                    |          |     ...      |
              +-----v-----+ +--v----+    +---v-----+
              | Client A  | |Cli B |    |Client N  |
              | Postgres  | |Pg DB |    | Postgres |
              +-----------+ +------+    +---------+

The isolation model is “hard isolation at the container level.” Each client’s n8n process runs in its own container with its own filesystem, its own environment variables, and its own database connection. One client’s container cannot access another client’s data because they’re separate processes with separate storage. This is the same isolation model that cloud providers use for multi-tenant hosting — your AWS Lambda function can’t read another customer’s Lambda function, even though they might run on the same physical server.

Step 1: The Docker Compose Foundation

Here’s the Docker Compose file that sets up the shared infrastructure — Traefik and a shared network:

# docker-compose.base.yml
# Shared infrastructure for multi-tenant n8n platform

version: "3.8"

networks:
  n8n-public:
    name: n8n-public
    driver: bridge
  n8n-internal:
    name: n8n-internal
    driver: bridge
    internal: true # No external access

services:
  traefik:
    image: traefik:v3.1
    container_name: traefik
    restart: always
    command:
      - "--api.dashboard=true"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
      - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
      - "--certificatesresolvers.letsencrypt.acme.email=admin@yourcompany.com"
      - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
      - "--entrypoints.web.http.redirections.entryPoint.to=websecure"
      - "--entrypoints.web.http.redirections.entryPoint.scheme=https"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - traefik-certs:/letsencrypt
    networks:
      - n8n-public
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.dashboard.rule=Host(`traefik.automations.yourcompany.com`)"
      - "traefik.http.routers.dashboard.service=api@internal"
      - "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
      - "traefik.http.routers.dashboard.middlewares=auth"
      - "traefik.http.middlewares.auth.basicauth.users=admin:$$apr1$$xyz$$hashedpassword"

volumes:
  traefik-certs:

Traefik is doing heavy lifting here. It automatically discovers Docker containers through the Docker socket, routes traffic based on labels you attach to containers, and provisions Let’s Encrypt SSL certificates automatically for each subdomain. When you add a new client container with the right labels, Traefik detects it, generates an SSL certificate, and starts routing traffic — no manual certificate management.

The two networks serve different purposes. n8n-public is the network that Traefik and the n8n containers share for HTTP routing. n8n-internal is an internal-only network for database connections — it has no external access, which means the PostgreSQL containers are never exposed to the internet.

Step 2: The Per-Tenant Template

Each client gets their own Docker Compose file generated from a template. Here’s the template:

# docker-compose.tenant.yml.template
# Template for per-tenant n8n + PostgreSQL deployment
# Variables: TENANT_ID, TENANT_DOMAIN, DB_PASSWORD, ENCRYPTION_KEY

version: "3.8"

networks:
  n8n-public:
    external: true
  n8n-internal:
    external: true

services:
  n8n-${TENANT_ID}:
    image: n8nio/n8n:latest
    container_name: n8n-${TENANT_ID}
    restart: always
    environment:
      - N8N_HOST=${TENANT_DOMAIN}
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - WEBHOOK_URL=https://${TENANT_DOMAIN}/
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=postgres-${TENANT_ID}
      - DB_POSTGRESDB_PORT=5432
      - DB_POSTGRESDB_DATABASE=n8n_${TENANT_ID}
      - DB_POSTGRESDB_USER=n8n_${TENANT_ID}
      - DB_POSTGRESDB_PASSWORD=${DB_PASSWORD}
      - N8N_ENCRYPTION_KEY=${ENCRYPTION_KEY}
      - N8N_BASIC_AUTH_ACTIVE=true
      - N8N_BASIC_AUTH_USER=${TENANT_ID}_admin
      - N8N_BASIC_AUTH_PASSWORD=${ADMIN_PASSWORD}
      - EXECUTIONS_DATA_PRUNE=true
      - EXECUTIONS_DATA_MAX_AGE=168
      - GENERIC_TIMEZONE=America/New_York
    volumes:
      - n8n-data-${TENANT_ID}:/home/node/.n8n
    networks:
      - n8n-public
      - n8n-internal
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.n8n-${TENANT_ID}.rule=Host(`${TENANT_DOMAIN}`)"
      - "traefik.http.routers.n8n-${TENANT_ID}.tls=true"
      - "traefik.http.routers.n8n-${TENANT_ID}.tls.certresolver=letsencrypt"
      - "traefik.http.services.n8n-${TENANT_ID}.loadbalancer.server.port=5678"
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 1G
        reservations:
          cpus: "0.5"
          memory: 256M

  postgres-${TENANT_ID}:
    image: postgres:16-alpine
    container_name: postgres-${TENANT_ID}
    restart: always
    environment:
      - POSTGRES_DB=n8n_${TENANT_ID}
      - POSTGRES_USER=n8n_${TENANT_ID}
      - POSTGRES_PASSWORD=${DB_PASSWORD}
    volumes:
      - postgres-data-${TENANT_ID}:/var/lib/postgresql/data
    networks:
      - n8n-internal
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 128M

volumes:
  n8n-data-${TENANT_ID}:
  postgres-data-${TENANT_ID}:

Several important configuration decisions here. The N8N_ENCRYPTION_KEY is unique per tenant — this is the key that encrypts stored credentials (API keys, OAuth tokens, database passwords). If a tenant’s database is compromised, the encrypted credentials can’t be decrypted without this key. Each tenant gets a different key so compromising one tenant’s key doesn’t expose other tenants’ credentials.

The deploy.resources block sets CPU and memory limits per container. This is critical for multi-tenant environments — one client’s runaway workflow shouldn’t consume all the server’s resources and starve other clients. The limits I’ve set here (2 CPU cores, 1 GB RAM for n8n; 1 core, 512 MB for Postgres) work well for typical small business automation loads. Adjust based on your clients’ workflow complexity.

The EXECUTIONS_DATA_PRUNE and EXECUTIONS_DATA_MAX_AGE settings automatically clean up old execution data after seven days. Without this, n8n’s execution history grows indefinitely and eventually fills the disk. I’ve seen this happen with clients in Deltona — the database grew to 15 GB of execution logs before anyone noticed.

Step 3: The Tenant Provisioning Script

This Python script automates the process of onboarding a new client. It generates credentials, creates the Docker Compose file from the template, and deploys the tenant:

#!/usr/bin/env python3
"""
provision_tenant.py
Provisions a new n8n tenant with isolated container and database.

Usage:
    python provision_tenant.py <tenant_id> <subdomain>

Example:
    python provision_tenant.py acme acme.automations.yourcompany.com
"""





from pathlib import Path


TENANTS_DIR = Path("/opt/n8n-platform/tenants")
TEMPLATE_FILE = Path("/opt/n8n-platform/docker-compose.tenant.yml.template")


def generate_secure_string(length=32):
    """Generate a cryptographically secure random string."""
    alphabet = string.ascii_letters + string.digits
    return "".join(secrets.choice(alphabet) for _ in range(length))


def provision_tenant(tenant_id, domain):
    """Create and deploy a new tenant."""
    tenant_dir = TENANTS_DIR / tenant_id
    tenant_dir.mkdir(parents=True, exist_ok=True)

    # Generate unique credentials
    db_password = generate_secure_string(24)
    encryption_key = generate_secure_string(48)
    admin_password = generate_secure_string(16)

    # Save credentials securely
    creds = {
        "tenant_id": tenant_id,
        "domain": domain,
        "db_password": db_password,
        "encryption_key": encryption_key,
        "admin_user": f"{tenant_id}_admin",
        "admin_password": admin_password,
    }

    creds_file = tenant_dir / ".credentials"
    creds_file.write_text(
        "\n".join(f"{k}={v}" for k, v in creds.items())
    )
    creds_file.chmod(0o600)

    # Read template and substitute variables
    template = TEMPLATE_FILE.read_text()
    compose_content = template.replace(
        "${TENANT_ID}", tenant_id
    ).replace(
        "${TENANT_DOMAIN}", domain
    ).replace(
        "${DB_PASSWORD}", db_password
    ).replace(
        "${ENCRYPTION_KEY}", encryption_key
    ).replace(
        "${ADMIN_PASSWORD}", admin_password
    )

    compose_file = tenant_dir / "docker-compose.yml"
    compose_file.write_text(compose_content)

    # Deploy the tenant
    print(f"Deploying tenant: {tenant_id}")
    result = subprocess.run(
        ["docker", "compose", "-f", str(compose_file), "up", "-d"],
        capture_output=True,
        text=True,
    )

    if result.returncode != 0:
        print(f"Deployment failed: {result.stderr}")
        return False

    print(f"Tenant {tenant_id} deployed successfully!")
    print(f"  URL:      https://{domain}")
    print(f"  Username: {tenant_id}_admin")
    print(f"  Password: {admin_password}")
    print(f"  Creds:    {creds_file}")
    return True


def list_tenants():
    """List all deployed tenants."""
    if not TENANTS_DIR.exists():
        print("No tenants directory found.")
        return

    for tenant_dir in sorted(TENANTS_DIR.iterdir()):
        if not tenant_dir.is_dir():
            continue

        tenant_id = tenant_dir.name
        creds_file = tenant_dir / ".credentials"
        status = "configured"

        # Check container status
        result = subprocess.run(
            ["docker", "inspect", "-f", "{{.State.Status}}",
             f"n8n-{tenant_id}"],
            capture_output=True, text=True,
        )

        if result.returncode == 0:
            status = result.stdout.strip()

        domain = "unknown"
        if creds_file.exists():
            for line in creds_file.read_text().splitlines():
                if line.startswith("domain="):
                    domain = line.split("=", 1)[1]
                    break

        print(f"  {tenant_id:20s} {status:12s} {domain}")


def remove_tenant(tenant_id):
    """Stop and remove a tenant's containers and data."""
    tenant_dir = TENANTS_DIR / tenant_id
    compose_file = tenant_dir / "docker-compose.yml"

    if not compose_file.exists():
        print(f"Tenant {tenant_id} not found.")
        return False

    print(f"Stopping tenant: {tenant_id}")
    subprocess.run(
        ["docker", "compose", "-f", str(compose_file), "down", "-v"],
        capture_output=True, text=True,
    )

    print(f"Tenant {tenant_id} removed.")
    print(f"  Config preserved at: {tenant_dir}")
    print(f"  Delete manually if no longer needed.")
    return True


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage:")
        print("  provision: python provision_tenant.py add <id> <domain>")
        print("  list:      python provision_tenant.py list")
        print("  remove:    python provision_tenant.py remove <id>")
        sys.exit(1)

    command = sys.argv[1]

    if command == "add" and len(sys.argv) == 4:
        provision_tenant(sys.argv[2], sys.argv[3])
    elif command == "list":
        list_tenants()
    elif command == "remove" and len(sys.argv) == 3:
        remove_tenant(sys.argv[2])
    else:
        print("Invalid arguments. Run without args for usage.")
        sys.exit(1)

The provisioning workflow is straightforward. You run the script with a tenant ID and domain, it generates unique credentials, creates the Docker Compose file, and deploys the containers. The whole process takes under a minute. Compare that to manually provisioning a new VPS, installing Docker, configuring n8n, setting up SSL certificates, and configuring the database — which takes one to two hours. We cover this in more detail in Automate Customer Follow-Up Emails: Free n8n Workflow You Can Deploy Today.

The credentials file is stored with 600 permissions (owner read/write only) in the tenant’s directory. In production, you’d want to store these in a secret manager like HashiCorp Vault or Azure Key Vault. But for a small multi-tenant setup with five to fifteen clients, file-based credential storage with proper permissions is a reasonable starting point.

Step 4: Backup and Disaster Recovery

Multi-tenant architecture concentrates risk. If your server goes down, all clients lose their automation service simultaneously. This makes backup and disaster recovery non-negotiable.

Here’s a backup script that dumps each tenant’s database and n8n data:

#!/usr/bin/env python3
"""
backup_tenants.py
Backs up all tenant databases and n8n configuration data.
Run daily via cron: 0 2 * * * /usr/bin/python3 /opt/n8n-platform/backup_tenants.py
"""



from datetime import datetime
from pathlib import Path

TENANTS_DIR = Path("/opt/n8n-platform/tenants")
BACKUP_DIR = Path("/opt/n8n-platform/backups")
RETENTION_DAYS = 14


def backup_tenant(tenant_id):
    """Backup a single tenant's database and data."""
    date_stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    tenant_backup_dir = BACKUP_DIR / tenant_id / date_stamp
    tenant_backup_dir.mkdir(parents=True, exist_ok=True)

    # Dump PostgreSQL database
    db_dump = tenant_backup_dir / "database.sql.gz"
    dump_cmd = (
        f"docker exec postgres-{tenant_id} "
        f"pg_dump -U n8n_{tenant_id} n8n_{tenant_id} "
        f"| gzip > {db_dump}"
    )

    result = subprocess.run(
        dump_cmd, shell=True, capture_output=True, text=True,
    )

    if result.returncode != 0:
        print(f"  DB backup failed for {tenant_id}: {result.stderr}")
        return False

    # Copy n8n data volume
    data_tar = tenant_backup_dir / "n8n-data.tar.gz"
    tar_cmd = (
        f"docker run --rm "
        f"-v n8n-data-{tenant_id}:/data:ro "
        f"-v {tenant_backup_dir}:/backup "
        f"alpine tar czf /backup/n8n-data.tar.gz -C /data ."
    )

    result = subprocess.run(
        tar_cmd, shell=True, capture_output=True, text=True,
    )

    if result.returncode != 0:
        print(f"  Data backup failed for {tenant_id}: {result.stderr}")
        return False

    # Copy credentials
    creds_src = TENANTS_DIR / tenant_id / ".credentials"
    if creds_src.exists():
        creds_dst = tenant_backup_dir / ".credentials"
        creds_dst.write_text(creds_src.read_text())
        creds_dst.chmod(0o600)

    print(f"  {tenant_id}: backup complete -> {tenant_backup_dir}")
    return True


def cleanup_old_backups():
    """Remove backups older than retention period."""
    cutoff = datetime.now().timestamp() - (RETENTION_DAYS * 86400)

    for tenant_dir in BACKUP_DIR.iterdir():
        if not tenant_dir.is_dir():
            continue
        for backup_dir in tenant_dir.iterdir():
            if not backup_dir.is_dir():
                continue
            if backup_dir.stat().st_mtime < cutoff:
                subprocess.run(
                    ["rm", "-rf", str(backup_dir)],
                    capture_output=True,
                )
                print(f"  Cleaned: {backup_dir}")


def main():
    """Backup all tenants."""
    print(f"Tenant backup started: {datetime.now()}")
    BACKUP_DIR.mkdir(parents=True, exist_ok=True)

    success = 0
    failed = 0

    for tenant_dir in sorted(TENANTS_DIR.iterdir()):
        if not tenant_dir.is_dir():
            continue

        tenant_id = tenant_dir.name
        if backup_tenant(tenant_id):
            success += 1
        else:
            failed += 1

    cleanup_old_backups()

    print(f"\nBackup complete: {success} succeeded, {failed} failed")
    sys.exit(1 if failed > 0 else 0)


if __name__ == "__main__":
    main()

Schedule this with cron to run nightly at 2 AM. The script dumps each tenant’s PostgreSQL database, archives the n8n data volume, and copies the credential files. Old backups are automatically cleaned up after the retention period.

For disaster recovery, sync the backup directory to an off-server location — an S3 bucket, Azure Blob storage, or a second VPS. If the primary server fails, you can spin up a new server, restore the backup, and have all client instances running within an hour.

Step 5: Monitoring and Health Checks

With multiple tenants on one server, you need monitoring that covers both the infrastructure layer and the per-tenant application layer:

#!/usr/bin/env python3
"""
health_check.py
Monitors all n8n tenant instances and alerts on failures.
Run every 5 minutes via cron.
"""




from datetime import datetime
from pathlib import Path
from urllib.request import urlopen, Request
from urllib.error import URLError

TENANTS_DIR = Path("/opt/n8n-platform/tenants")
ALERT_WEBHOOK = ""  # Set your Slack/Teams webhook URL


def check_container_health(tenant_id):
    """Check if a tenant's containers are running."""
    checks = {}

    for service in [f"n8n-{tenant_id}", f"postgres-{tenant_id}"]:
        result = subprocess.run(
            ["docker", "inspect", "-f",
             "{{.State.Status}}:{{.State.Health.Status}}",
             service],
            capture_output=True, text=True,
        )

        if result.returncode != 0:
            checks[service] = "not_found"
        else:
            checks[service] = result.stdout.strip()

    return checks


def check_http_health(domain):
    """Check if n8n responds to HTTP requests."""
    try:
        req = Request(
            f"https://{domain}/healthz",
            headers={"User-Agent": "n8n-health-check"},
        )
        response = urlopen(req, timeout=10)
        return response.status == 200
    except (URLError, Exception):
        return False


def check_disk_usage():
    """Check server disk usage."""
    result = subprocess.run(
        ["df", "-h", "/"],
        capture_output=True, text=True,
    )
    for line in result.stdout.splitlines()[1:]:
        parts = line.split()
        usage_pct = int(parts[4].rstrip("%"))
        return usage_pct
    return 0


def send_alert(message):
    """Send alert to notification webhook."""
    if not ALERT_WEBHOOK:
        print(f"ALERT: {message}")
        return

    payload = json.dumps({"text": f"n8n Platform Alert: {message}"})
    req = Request(
        ALERT_WEBHOOK,
        data=payload.encode(),
        headers={"Content-Type": "application/json"},
    )
    try:
        urlopen(req, timeout=10)
    except URLError:
        print(f"Failed to send alert: {message}")


def main():
    """Run health checks across all tenants."""
    issues = []

    # Check disk usage
    disk_pct = check_disk_usage()
    if disk_pct > 85:
        issues.append(f"Disk usage at {disk_pct}%")

    # Check each tenant
    for tenant_dir in sorted(TENANTS_DIR.iterdir()):
        if not tenant_dir.is_dir():
            continue

        tenant_id = tenant_dir.name
        creds_file = tenant_dir / ".credentials"

        # Container health
        containers = check_container_health(tenant_id)
        for name, status in containers.items():
            if "running" not in status:
                issues.append(f"{name} status: {status}")

        # HTTP health
        if creds_file.exists():
            domain = None
            for line in creds_file.read_text().splitlines():
                if line.startswith("domain="):
                    domain = line.split("=", 1)[1]
            if domain and not check_http_health(domain):
                issues.append(f"{tenant_id} HTTP check failed: {domain}")

    # Report
    if issues:
        alert_msg = "\n".join(f"- {i}" for i in issues)
        send_alert(f"Issues detected:\n{alert_msg}")
        print(f"ISSUES ({len(issues)}):")
        for issue in issues:
            print(f"  - {issue}")
        sys.exit(1)
    else:
        print(f"All tenants healthy at {datetime.now()}")
        sys.exit(0)


if __name__ == "__main__":
    main()

The health check validates three layers: container status (are the Docker containers running), HTTP health (does n8n respond to requests), and disk usage (is the server running out of space). Run it every five minutes via cron and alert on any failure.

Resource Planning and Capacity

The question I get most from IT consultancies in Ormond Beach and across Volusia County is “how many clients can I run on one server?” The answer depends on workflow complexity, but here are the baselines I use:

Per-tenant resource baseline:

  • n8n container: 200-500 MB RAM idle, up to 1 GB during heavy execution
  • PostgreSQL container: 100-200 MB RAM
  • Disk: 500 MB to 2 GB depending on execution history retention

Server sizing:

  • 4 CPU / 8 GB RAM: 5-8 tenants with moderate workloads
  • 8 CPU / 16 GB RAM: 10-15 tenants with moderate workloads
  • 16 CPU / 32 GB RAM: 20-25 tenants with moderate workloads

“Moderate workload” means 10-50 workflow executions per hour per tenant, with workflows that complete in under 30 seconds. If a client runs heavy data processing workflows — pulling thousands of records from APIs, transforming large datasets, generating complex reports — they need more resources and might warrant their own dedicated server.

The resource limits in the Docker Compose template prevent one client’s workflows from starving other clients. If tenant A’s workflow tries to consume 4 GB of RAM, Docker kills it instead of letting it eat into tenant B’s resources. This isn’t gentle — the workflow fails — but it protects the platform. You’ll see the failure in n8n’s execution history and can adjust the resource limits or move the client to a dedicated instance.

Security Considerations

Multi-tenancy introduces security requirements that single-tenant deployments don’t have. Here’s the minimum security posture I recommend:

Network isolation. The PostgreSQL containers should only be accessible from the n8n containers on the internal network. Never expose database ports to the public internet. The internal Docker network in our configuration handles this — n8n-internal has no external connectivity.

Credential encryption. Each tenant’s N8N_ENCRYPTION_KEY must be unique. This ensures that even if an attacker gains access to one tenant’s database, they can’t decrypt another tenant’s stored credentials.

Update strategy. When n8n releases a security update, update all tenant containers simultaneously. Don’t leave some tenants on old versions — the vulnerability affects all of them equally.

Webhook isolation. Each tenant’s webhook URLs are on their own subdomain. This means webhook traffic for one client can’t accidentally trigger another client’s workflows. The Traefik routing enforces this at the proxy level.

Access logging. Enable Traefik access logs and n8n audit logs. When a client asks “who accessed my automations last Tuesday at 3 PM?” you need to be able to answer. This isn’t just good practice — it’s increasingly a compliance requirement for businesses handling sensitive data.

When to Call a Professional

Building a basic multi-tenant n8n platform is doable for anyone comfortable with Docker and Linux system administration. The complexity increases when you need high availability (active-passive failover between servers), automated scaling (adding server capacity when tenant count grows), compliance isolation (tenants that require dedicated infrastructure for regulatory reasons), or complex networking (VPN connections to client networks for on-premises integrations).

For automation agencies and MSPs across Volusia County — in New Smyrna Beach, Deltona, Daytona Beach — we design and deploy multi-tenant automation platforms as part of our automation practice. We handle the architecture, deployment, monitoring, and ongoing maintenance so you can focus on building automations for your clients instead of managing infrastructure.

If you’re running separate servers for each client and the management overhead is eating into your margins, let’s consolidate your platform.

FAQ

What is multi-tenant n8n?

Multi-tenant n8n is an architecture where a single server or infrastructure runs separate n8n automation instances for multiple clients or business units. Each tenant gets their own isolated n8n container with dedicated database storage, credentials, and webhook endpoints, while sharing the underlying server hardware and networking infrastructure.

Can n8n run multiple instances on one server?

Yes. Using Docker Compose with a reverse proxy like Traefik, you can run multiple n8n containers on a single server. Each instance gets its own subdomain, database, and encrypted credential store. A server with 8 GB of RAM can typically support 5 to 10 n8n instances depending on workflow complexity and execution frequency.

How do you isolate client data in multi-tenant n8n?

The strongest approach is per-tenant container isolation — each client gets their own n8n container and their own PostgreSQL database. This provides complete data separation at the process and storage level. Credentials, workflow data, execution history, and webhook URLs are all isolated.

What are the hardware requirements for multi-tenant n8n?

A single VPS with 4 CPU cores and 8 GB RAM can run 5 to 8 n8n instances with moderate workflow loads. Each n8n container typically uses 200 to 500 MB of RAM at idle and spikes during workflow execution. Plan for 1 GB per tenant as a baseline.

Is n8n multi-tenancy included in the free version?

n8n Community Edition (free, open-source) supports running multiple separate instances via Docker, which provides multi-tenancy through container isolation. n8n Enterprise adds LDAP, SAML SSO, and audit logging that simplify multi-tenant management.

Related Posts

Free Discovery Call

Start With a Conversation, Not a Commitment

Every engagement begins with a free 30-minute discovery call. We'll map what's slowing your business down and tell you exactly what we'd fix first – no pitch deck, no obligation.