Skip to content
BuildBase

Quick Start

Get a self-hosted BuildBase stack running in minutes with Docker Compose.

Quick Start

This guide gets you from zero to a running stack in minutes. Everything is bundled - MongoDB, Redis, tenant server, client app, and auth portal.

Tip

Rather clone than copy? The self-hosted starter is this same stack as a repo. In Claude Code, the buildbase-selfhost skill walks the deploy with you.

Prerequisites

  • Docker and Docker Compose installed
  • An amd64 or arm64 host - images run natively on both, including Apple Silicon
  • A BuildBase account (console.buildbase.app)

Step 1: Create an Organization & Installation

  1. Log in to console.buildbase.app
  2. Open Account, then Installations, and click Create installation. (Choosing Self-hosted while creating an organization takes you to the same page.)
  3. Copy your Installation API Key and Installation ID. The key is shown once; if you lose it, regenerate it on the same page.

The organization itself comes later, in Step 6, once the server is running: it is created on an installation that has checked in.

Note

A new account offers Shared cloud only. If Self-hosted is not among the hosting options when you create an organization, contact support to have self-hosting enabled for your account.

Step 2: Create your environment file

Save this as .env.selfhost:

# ═══════════════════════════════════════════════════════════════════
# 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=
# Redis auth. Both compose files require this and both start Redis with
# --requirepass, so it must match on the server and the container.
REDIS_PASSWORD=

# ── Optional services ────────────────────────────────────────────
# GOOGLE_AUTH_CLIENT_ID=
# GOOGLE_AUTH_CLIENT_SECRET=
# GOOGLE_STORAGE_ASSETS_BUCKET_NAME=
# Direct uploads: up to 100 MB, form file fields and SDK uploads. Unset keeps
# every upload on the 5 MB server route, and those two answer 501.
# GOOGLE_STORAGE_STAGING_BUCKET_NAME=
# GOOGLE_STORAGE_FINALIZE_TOKEN=     # optional: finalize notifications
# GOOGLE_STORAGE_USAGE_LOG_BUCKET=   # optional: measures bandwidth
# GOOGLE_STORAGE_USAGE_LOG_PREFIX=assets
# EXPORTS_BUCKET=                    # owner data exports; unset, they are off
# TENANT_STORAGE_GATE=               # default: follows TENANT_FEATURE_GATE (off)
# TENANT_STORAGE_BILLING=off         # no effect self-hosted: storage is not billed
# TENANT_STORAGE_INTEGRITY=shadow    # default: shadow - weekly report, no changes
# TENANT_STORAGE_PURGE=off           # no effect self-hosted: never lapses
# GOOGLE_WEB_RISK_API_KEY=
# URL_SAFETY_ENABLED=true            # default: true
# URL_SAFETY_LOG_ONLY=false          # default: false - a malicious destination
#                                    # is refused as soon as the key is set
# URL_SAFETY_CLEAN_TTL_SECONDS=3600
# WEB_RISK_TIMEOUT_MS=5000
# BREACHED_PASSWORD_CHECK=on         # default: on; off when the server cannot
#                                    # reach api.pwnedpasswords.com
# TURNSTILE_SITE_KEY=                # bot protection on your auth domain;
# TURNSTILE_SECRET_KEY=              # unset leaves it to each org's own keys
# SMS_NUMBER_HOURLY_MAX=5            # phone codes to one number an hour
# SMS_NUMBER_DAILY_MAX=10            # phone codes to one number a day
# SMS_IP_HOURLY_MAX=20               # phone codes from one IP an hour
# SMS_IP_DAILY_MAX=50                # phone codes from one IP a day
# Commerce: selling through Stripe Connect. Unset keeps each one off.
# TENANT_MARKETPLACE=off             # on (keys follow the environment) or off;
#                                    # test is legacy (test keys only)
# AFFILIATES_PAYPAL=off              # on lets affiliates be paid by PayPal
# GOOGLE_STORAGE_DELIVERABLES_BUCKET_NAME=  # sellers' files; public access
#                                    # prevention on
# CLAMAV_HOST=                       # clamd that scans sellers' files; unset
# CLAMAV_PORT=3310                   # files sell only while the switch is test.
#                                    # The compose file ships one: start it
#                                    # with --profile scanner, set clamav here
# SHIPPING_EASYPOST=off              # on lets an app owner connect EasyPost
# SHIPPING_SHIPPO=off                # on lets an app owner connect Shippo

Generate all secrets at once:

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

Step 3: Save the Docker Compose file

Save this as docker-compose.selfhost.yml:

# Self-Hosted: MongoDB + Redis + Server + Client + Auth
# Save as docker-compose.selfhost.yml
# Usage: docker compose -f docker-compose.selfhost.yml --env-file .env.selfhost up -d

services:
  # ── Infrastructure ──────────────────────────────────────────
  mongodb:
    image: mongo:7.0
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - SETUID
      - SETGID
      - DAC_OVERRIDE
      # The healthcheck runs mongosh as root, which leaves root-owned files
      # in /data/db/.mongodb; without CHOWN the entrypoint cannot hand them
      # back to mongodb and every restart after the first crash-loops.
      - CHOWN
    volumes:
      - mongodb_data:/data/db
    networks:
      - db
    healthcheck:
      # mongosh's own cold start can take most of 5s on a first boot, which
      # failed a first `up` with "container mongodb is unhealthy" while
      # mongod was fine. Same probe as the repo's docker-compose.selfhost.yml.
      test: ['CMD', 'mongosh', '--quiet', '--eval', "db.adminCommand('ping')"]
      interval: 10s
      timeout: 15s
      start_period: 60s
      retries: 5
    deploy:
      resources:
        limits:
          memory: 1024M
          cpus: '1.0'
          pids: 256
    logging:
      driver: json-file
      options:
        max-size: '100m'
        max-file: '3'

  redis:
    image: redis:7.4-bookworm
    restart: unless-stopped
    read_only: true
    user: '999:999'
    cap_drop:
      - ALL
    # maxmemory stays well under the 256M limit below: Redis needs memory
    # beyond its data (allocator overhead, fragmentation, the AOF rewrite's
    # fork), and a Redis that reaches the limit is killed instead of refusing
    # writes. To give Redis more, raise both numbers together.
    command: redis-server --appendonly yes --maxmemory-policy noeviction --maxmemory 150mb --requirepass ${REDIS_PASSWORD:?REDIS_PASSWORD is required — run the generate-secrets command from the quick start}
    tmpfs:
      - /tmp
    volumes:
      - redis_data:/data
    networks:
      - db
    healthcheck:
      test:
        [
          'CMD',
          'redis-cli',
          '-a',
          '${REDIS_PASSWORD:?REDIS_PASSWORD is required — run the generate-secrets command from the quick start}',
          'ping',
        ]
      interval: 10s
      timeout: 5s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 256M
          cpus: '0.5'
          pids: 64
    logging:
      driver: json-file
      options:
        max-size: '50m'
        max-file: '3'

  # ── Backend ─────────────────────────────────────────────────
  tenant-server:
    image: buildbaseapp/tenant-server:latest
    restart: unless-stopped
    # The workers run in this process. Compose's default of 10 s would kill
    # a running job on every upgrade or restart; the server exits by itself
    # within 150 s, and long jobs hand themselves back as it closes.
    stop_grace_period: 180s
    read_only: true
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    tmpfs:
      - /tmp
      - /var/log
    ports:
      - '${TENANT_SERVER_PORT:-4101}:3000'
    environment:
      - NODE_ENV=production
      - PORT=3000
      # Keep the Node heap below the 1024M container limit — the image
      # default is sized for larger hosts and would get OOM-killed here.
      - NODE_OPTIONS=--max-old-space-size=768
      - SERVER_URL=${TENANT_SERVER_URL:-http://localhost:4101}
      - APPLICATION_URL=${CLIENT_URL:-http://localhost:4100}
      - AUTH_SERVER_URL=${AUTH_URL:-http://localhost:4103}
      - MONGO_CONNECTION_URL=mongodb://mongodb:27017/
      - REDIS_HOST=redis
      - REDIS_PORT=6379
      - REDIS_PASSWORD=${REDIS_PASSWORD:?REDIS_PASSWORD is required — run the generate-secrets command from the quick start}
      - INSTALLATION_API_KEY=${INSTALLATION_API_KEY:-}
      - INSTALLATION_ID=${INSTALLATION_ID:-}
      - JWT_PASS=${JWT_PASS:?JWT_PASS is required — run the generate-secrets command from the quick start}
      - OAUTH2_SECRET=${OAUTH2_SECRET:?OAUTH2_SECRET is required — run the generate-secrets command from the quick start}
      - DB_ENCRYPTION_KEY=${DB_ENCRYPTION_KEY:?DB_ENCRYPTION_KEY is required (64 hex chars) — run the generate-secrets command from the quick start}
      - SECRET_KEY=${SECRET_KEY:?SECRET_KEY is required — run the generate-secrets command from the quick start}
      - GOOGLE_AUTH_CLIENT_ID=${GOOGLE_AUTH_CLIENT_ID:-}
      - GOOGLE_AUTH_CLIENT_SECRET=${GOOGLE_AUTH_CLIENT_SECRET:-}
      - GOOGLE_STORAGE_ASSETS_BUCKET_NAME=${GOOGLE_STORAGE_ASSETS_BUCKET_NAME:-}
      # Direct uploads (up to 100 MB, form file fields, SDK uploads). Empty
      # keeps every upload on the 5 MB server route.
      - GOOGLE_STORAGE_STAGING_BUCKET_NAME=${GOOGLE_STORAGE_STAGING_BUCKET_NAME:-}
      - GOOGLE_STORAGE_FINALIZE_TOKEN=${GOOGLE_STORAGE_FINALIZE_TOKEN:-}
      - GOOGLE_STORAGE_USAGE_LOG_BUCKET=${GOOGLE_STORAGE_USAGE_LOG_BUCKET:-}
      - GOOGLE_STORAGE_USAGE_LOG_PREFIX=${GOOGLE_STORAGE_USAGE_LOG_PREFIX:-}
      # Owner data exports. Empty keeps them off (the routes answer 503).
      - EXPORTS_BUCKET=${EXPORTS_BUCKET:-}
      # Storage switches. Empty keeps each one's code default.
      - TENANT_STORAGE_GATE=${TENANT_STORAGE_GATE:-}
      - TENANT_STORAGE_BILLING=${TENANT_STORAGE_BILLING:-}
      - TENANT_STORAGE_INTEGRITY=${TENANT_STORAGE_INTEGRITY:-}
      - TENANT_STORAGE_PURGE=${TENANT_STORAGE_PURGE:-}
      # URL safety. Unset means no reputation checking and links keep working;
      # each tuning var below falls back to its code default when empty.
      - GOOGLE_WEB_RISK_API_KEY=${GOOGLE_WEB_RISK_API_KEY:-}
      - URL_SAFETY_ENABLED=${URL_SAFETY_ENABLED:-}
      - URL_SAFETY_LOG_ONLY=${URL_SAFETY_LOG_ONLY:-}
      - URL_SAFETY_CLEAN_TTL_SECONDS=${URL_SAFETY_CLEAN_TTL_SECONDS:-}
      - WEB_RISK_TIMEOUT_MS=${WEB_RISK_TIMEOUT_MS:-}
      # Breached-password check. Unset means on; off for an install that
      # cannot reach api.pwnedpasswords.com.
      - BREACHED_PASSWORD_CHECK=${BREACHED_PASSWORD_CHECK:-}
      # Bot protection (Cloudflare Turnstile) on this install's sign-in
      # domain. Unset means no challenge unless an org saves its own keys.
      - TURNSTILE_SITE_KEY=${TURNSTILE_SITE_KEY:-}
      - TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY:-}
      # Phone sign-in code limits per number and per IP. Unset means the
      # defaults (5 and 10 per number, 20 and 50 per IP, an hour and a day).
      - SMS_NUMBER_HOURLY_MAX=${SMS_NUMBER_HOURLY_MAX:-}
      - SMS_NUMBER_DAILY_MAX=${SMS_NUMBER_DAILY_MAX:-}
      - SMS_IP_HOURLY_MAX=${SMS_IP_HOURLY_MAX:-}
      - SMS_IP_DAILY_MAX=${SMS_IP_DAILY_MAX:-}
      # Commerce (selling through Stripe Connect). All off or empty by
      # default: unset keeps selling, PayPal payouts and label providers
      # closed, and file items unavailable.
      - TENANT_MARKETPLACE=${TENANT_MARKETPLACE:-}
      - AFFILIATES_PAYPAL=${AFFILIATES_PAYPAL:-}
      - GOOGLE_STORAGE_DELIVERABLES_BUCKET_NAME=${GOOGLE_STORAGE_DELIVERABLES_BUCKET_NAME:-}
      - CLAMAV_HOST=${CLAMAV_HOST:-}
      - CLAMAV_PORT=${CLAMAV_PORT:-}
      - SHIPPING_EASYPOST=${SHIPPING_EASYPOST:-}
      - SHIPPING_SHIPPO=${SHIPPING_SHIPPO:-}
      - CORS_WHITELISTED_DOMAINS=${CLIENT_URL:-http://localhost:4100},${AUTH_URL:-http://localhost:4103}
      # Optional: point APM/log telemetry at YOUR OWN New Relic account.
      # Leave unset (the default) and no telemetry is collected or sent.
      - NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
      - NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-}
      - LOG_LEVEL=${LOG_LEVEL:-}
      # The job dashboard's password. Unset, it falls back to the
      # installation API key, and the server warns at every boot.
      - BULLMQ_DASHBOARD_PASSWORD=${BULLMQ_DASHBOARD_PASSWORD:-}
    depends_on:
      mongodb:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - db
      - app
    healthcheck:
      test: ['CMD', 'wget', '-qO-', 'http://127.0.0.1:3000/api/ready']
      interval: 15s
      timeout: 5s
      start_period: 45s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 1024M
          cpus: '1.0'
          pids: 256
    logging:
      driver: json-file
      options:
        max-size: '100m'
        max-file: '5'

  # ── Frontend ────────────────────────────────────────────────
  client:
    image: buildbaseapp/client:latest
    restart: unless-stopped
    # 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:-http://localhost:4101}
      - NEXT_PUBLIC_DEFAULT_TENANT_SERVER_URL=${TENANT_SERVER_URL:-http://localhost:4101}
      - NEXT_PUBLIC_INSTALLATION_ID=${INSTALLATION_ID:-}
      # Optional: browser monitoring against YOUR OWN New Relic account.
      # Account ID and browser key are shared; the app ID is per app.
      - NEXT_PUBLIC_NEW_RELIC_ACCOUNT_ID=${NEW_RELIC_BROWSER_ACCOUNT_ID:-}
      - NEXT_PUBLIC_NEW_RELIC_BROWSER_LICENSE_KEY=${NEW_RELIC_BROWSER_LICENSE_KEY:-}
      - NEXT_PUBLIC_NEW_RELIC_APP_ID=${NEW_RELIC_BROWSER_APP_ID_CLIENT:-}
    networks:
      - app
    healthcheck:
      test: ['CMD', 'wget', '-qO-', 'http://127.0.0.1:3000/']
      interval: 15s
      timeout: 5s
      start_period: 30s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 256M
          cpus: '0.5'
          pids: 128
    logging:
      driver: json-file
      options:
        max-size: '50m'
        max-file: '3'

  auth:
    image: buildbaseapp/auth:latest
    restart: unless-stopped
    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:-http://localhost:4101}
      # Optional: browser monitoring against YOUR OWN New Relic account.
      - NEXT_PUBLIC_NEW_RELIC_ACCOUNT_ID=${NEW_RELIC_BROWSER_ACCOUNT_ID:-}
      - NEXT_PUBLIC_NEW_RELIC_BROWSER_LICENSE_KEY=${NEW_RELIC_BROWSER_LICENSE_KEY:-}
      - NEXT_PUBLIC_NEW_RELIC_APP_ID=${NEW_RELIC_BROWSER_APP_ID_AUTH:-}
    networks:
      - app
    healthcheck:
      test: ['CMD', 'wget', '-qO-', 'http://127.0.0.1:3000/health']
      interval: 15s
      timeout: 5s
      start_period: 30s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 256M
          cpus: '0.5'
          pids: 128
    logging:
      driver: json-file
      options:
        max-size: '50m'
        max-file: '3'

  # Optional virus scanner for the files sellers sell. Off unless you start
  # the "scanner" profile:
  #   docker compose ... --profile scanner up -d
  # and set CLAMAV_HOST=clamav in .env.selfhost. It needs about 3 GB of
  # memory (clamd keeps its signatures in memory) and outbound internet,
  # since freshclam downloads the signatures; the first start takes a few
  # minutes. Port 3310 is reachable inside the app network only.
  clamav:
    image: clamav/clamav:1.4.6
    profiles: ['scanner']
    restart: unless-stopped
    volumes:
      - clamav_db:/var/lib/clamav
    networks:
      - app
    healthcheck:
      test: ['CMD', '/usr/local/bin/clamdcheck.sh']
      interval: 60s
      timeout: 15s
      start_period: 600s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 3072M
    logging:
      driver: json-file
      options:
        max-size: '20m'
        max-file: '3'

volumes:
  mongodb_data:
  redis_data:
  clamav_db:

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

Step 4: Start the stack

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

Step 5: Verify

# Check all services are running
docker compose -f docker-compose.selfhost.yml --env-file .env.selfhost ps

# Server should answer {"ready":true,"mongo":"ok","redis":"ready"}
curl http://localhost:4101/api/ready

Step 6: Connect

  1. In the dashboard, create an organization with the Self-hosted hosting mode and choose your installation. It shows Connected once the server has checked in, within a few minutes of starting.
  2. Open the organization's setup, enter your server URL (http://localhost:4101 for local testing), click Test connection, then Complete setup.

A local or private address can show Not checked from this browser: the dashboard page may not be allowed to reach it. Complete setup anyway if the server is running. BuildBase never connects to a private address itself, so the features where its servers call yours need a public HTTPS URL - see the Production guide.

Step 7: Let people sign in

Your end users sign in through the auth portal, and the organization needs three things from you in the console first:

  1. An email sender (Messaging, then Sender accounts). Sign-up verification codes, password resets and invitations go through it. Without one, the hosted page refuses password sign-up.
  2. A sign-in method, such as email and password.
  3. That method enabled on your app, the client your application signs in with.

Your services are now running at:

What's Next