BuildBaseBuildBase

Production Deployment

Deploy BuildBase for production with Nginx, SSL, replicas, and external MongoDB.

Production Deployment

This guide covers a production-ready deployment with Nginx load balancer, multiple app replicas, external MongoDB, SSL, and health monitoring.

Prerequisites

  • Linux (Ubuntu 20.04+ recommended), 2 GB+ RAM, 2 vCPU+
  • x86-64 (amd64) or 64-bit ARM (arm64) — ARM servers such as AWS Graviton and Ampere are fully supported
  • Docker Engine 20+ and Docker Compose v2
  • MongoDB 7+ (managed like Atlas, or self-hosted)
  • Domain name pointed to your server's public IP
  • SSL certificate (Let's Encrypt or custom)

Directory Structure

your-server/
  .env.selfhost              # Environment configuration
  docker-compose.selfhost.yml # Service definitions
  nginx-lb.conf              # Nginx load balancer config

Step 1: Environment File

Create a .env.selfhost file with your configuration. Generate secrets with openssl rand -hex 32.

# ═══════════════════════════════════════════════════════════════════
# Self-Hosted — Production Environment
# ═══════════════════════════════════════════════════════════════════

# ── Installation (from BuildBase dashboard) ──────────────────────
INSTALLATION_API_KEY=<INSTALLATION_API_KEY>
INSTALLATION_ID=<INSTALLATION_ID>

# ── Database (REQUIRED for production) ───────────────────────────
# The production compose uses an external MongoDB (Atlas or
# self-managed). Without this, the server falls back to localhost
# inside the container and never becomes ready.
# The quick-start compose bundles MongoDB and ignores this value.
MONGO_CONNECTION_URL=mongodb+srv://user:[email protected]/

# ── Public URLs ──────────────────────────────────────────────────
# For local testing use http://localhost:4100, :4101, :4103
CLIENT_URL=https://app.yourcompany.com
TENANT_SERVER_URL=https://api.yourcompany.com
AUTH_URL=https://auth.yourcompany.com

# ── Ports ────────────────────────────────────────────────────────
CLIENT_PORT=4100
TENANT_SERVER_PORT=4101
AUTH_PORT=4103

# ── Security (REQUIRED — run: openssl rand -hex 32) ──────────────
JWT_PASS=
DB_ENCRYPTION_KEY=
SECRET_KEY=
OAUTH2_SECRET=

# ── Optional services ────────────────────────────────────────────
# GOOGLE_AUTH_CLIENT_ID=
# GOOGLE_AUTH_CLIENT_SECRET=
# GOOGLE_STORAGE_ASSETS_BUCKET_NAME=
# MAILGUN_API_KEY=

Generate all secrets at once:

for i in JWT_PASS DB_ENCRYPTION_KEY SECRET_KEY OAUTH2_SECRET; do echo "$i=$(openssl rand -hex 32)"; done

Step 2: Docker Compose

# Production: external MongoDB, Nginx LB, replicas, all 3 services
# Requires: .env.selfhost + nginx-lb.conf in same directory

services:
  redis:
    image: redis:7.4-alpine
    restart: unless-stopped
    read_only: true
    cap_drop:
      - ALL
    cap_add:
      - SETUID
      - SETGID
    command: redis-server --appendonly yes --maxmemory-policy noeviction --maxmemory 256mb
    tmpfs:
      - /tmp
    volumes:
      - redis_data:/data
    healthcheck:
      test: ['CMD', 'redis-cli', 'ping']
      interval: 10s
      timeout: 5s
      retries: 5
    deploy:
      resources:
        limits:
          memory: 256M
          cpus: '0.5'
          pids: 64
    networks:
      - db

  # ── Backend (2 replicas behind Nginx LB) ──────────────────────
  tenant-server:
    image: buildbaseapp/tenant-server:latest
    read_only: true
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    tmpfs:
      - /tmp
      - /var/log
    env_file: .env.selfhost
    environment:
      - NODE_ENV=production
      - PORT=3000
      - REDIS_HOST=redis
      - REDIS_PORT=6379
      - NODE_OPTIONS=--max-old-space-size=768
      # Map the public URLs from .env.selfhost onto the names the server
      # reads. Without these the server falls back to localhost URLs and
      # CORS blocks the client and auth origins.
      - SERVER_URL=${TENANT_SERVER_URL}
      - APPLICATION_URL=${CLIENT_URL}
      - AUTH_SERVER_URL=${AUTH_URL}
      - CORS_WHITELISTED_DOMAINS=${CLIENT_URL},${AUTH_URL}
    depends_on:
      redis:
        condition: service_healthy
    restart: unless-stopped
    healthcheck:
      test: ['CMD', 'wget', '-qO-', 'http://127.0.0.1:3000/api/ready']
      interval: 15s
      timeout: 5s
      start_period: 45s
      retries: 3
    deploy:
      replicas: 2
      resources:
        limits:
          memory: 1024M
          cpus: '1.0'
          pids: 256
    labels:
      - 'autoheal=true'
    networks:
      - db
      - app

  # ── Frontend ──────────────────────────────────────────────────
  client:
    image: buildbaseapp/client:latest
    # NOTE: read_only must NOT be set on client or auth. Their entrypoints
    # rewrite __NEXT_PUBLIC_*__ URL placeholders in the JS bundles at
    # startup; a read-only filesystem makes that rewrite fail silently and
    # the app calls the literal placeholder string instead of your URL.
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    tmpfs:
      - /tmp
      - /var/cache/nginx
      - /var/run
    ports:
      - '${CLIENT_PORT:-4100}:3000'
    environment:
      - NEXT_PUBLIC_SERVER_URL=${TENANT_SERVER_URL}
      - NEXT_PUBLIC_DEFAULT_TENANT_SERVER_URL=${TENANT_SERVER_URL}
      - NEXT_PUBLIC_INSTALLATION_ID=${INSTALLATION_ID}
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 256M
          cpus: '0.5'
          pids: 128
    networks:
      - app

  auth:
    image: buildbaseapp/auth:latest
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    tmpfs:
      - /tmp
      - /var/cache/nginx:uid=1001,gid=1001
      - /var/run:uid=1001,gid=1001
    ports:
      - '${AUTH_PORT:-4103}:3000'
    environment:
      - NEXT_PUBLIC_SERVER_URL=${TENANT_SERVER_URL}
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 256M
          cpus: '0.5'
          pids: 128
    networks:
      - app

  # ── Load Balancer (tenant server only) ────────────────────────
  nginx:
    image: nginx:1.27-alpine
    read_only: true
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    tmpfs:
      - /tmp
      - /var/cache/nginx:uid=101,gid=101
      - /var/run:uid=101,gid=101
    ports:
      - '${TENANT_SERVER_PORT:-4101}:80'
    volumes:
      - ./nginx-lb.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - tenant-server
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 128M
          cpus: '0.25'
          pids: 64
    networks:
      - app

  autoheal:
    image: willfarrell/autoheal:1.2.0
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    environment:
      - AUTOHEAL_CONTAINER_LABEL=autoheal
      - AUTOHEAL_INTERVAL=30
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 64M
          cpus: '0.1'
          pids: 32

volumes:
  redis_data:

# Network segmentation: databases isolated from frontends.
# Only tenant-server bridges both networks.
networks:
  db:
    driver: bridge
    internal: true
  app:
    driver: bridge

Step 3: Nginx Configuration

Save as nginx-lb.conf in the same directory.

events {
    worker_connections 1024;
}

http {
    # Hide server version
    server_tokens off;

    # Rate limiting
    limit_req_zone $binary_remote_addr zone=api_general:10m rate=30r/s;
    limit_req_zone $binary_remote_addr zone=api_auth:10m rate=5r/s;

    upstream app_servers {
        server tenant-server:3000;
    }

    server {
        listen 80;
        client_max_body_size 500M;

        # Reject oversized headers
        large_client_header_buffers 4 8k;

        # Rate limiting
        limit_req zone=api_general burst=60 nodelay;
        limit_req_status 429;

        # Timeouts
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
        client_body_timeout 60s;
        client_header_timeout 30s;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Health checks (no rate limit, no logging)
        location /ready  { access_log off; proxy_pass http://app_servers/api/ready; }
        location /health { access_log off; proxy_pass http://app_servers/api/health; }

        # Stricter rate limit on auth endpoints
        location ~ ^/api/(auth|oauth|login|register|password) {
            limit_req zone=api_auth burst=10 nodelay;
            proxy_pass http://app_servers;
            proxy_http_version 1.1;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location / {
            proxy_pass http://app_servers;
            proxy_next_upstream error timeout http_502 http_503;
            proxy_next_upstream_tries 2;
            proxy_buffering off;
        }
    }
}

Step 4: Deploy

docker compose -f docker-compose.selfhost.yml --env-file .env.selfhost up -d

Step 5: SSL with Let's Encrypt

sudo certbot --nginx -d api.yourcompany.com -d app.yourcompany.com -d auth.yourcompany.com

Or use Caddy for automatic HTTPS with zero configuration.

Step 6: Connect

Enter your public URL (e.g. https://api.yourcompany.com) in the setup wizard and complete the setup.

Health Checks

EndpointPurpose
GET /api/readyReadiness probe — returns {"ready": true} when DB and Redis are connected
GET /api/healthFull health check — DB status, Redis latency, worker status

Updating

Pull the latest images and restart:

docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml --env-file .env.selfhost up -d