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=
# 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 ShippoGenerate 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)"; doneStep 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-bookworm
restart: unless-stopped
read_only: true
user: '999:999'
cap_drop:
- ALL
# Same credential as the quick start. The tenant server reads
# REDIS_PASSWORD from .env.selfhost through env_file, and sends AUTH
# whenever it is non-empty - so a Redis started without --requirepass
# here would refuse that AUTH and the server would never become ready.
# 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
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: 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}
# nginx below is the only way in and appends one X-Forwarded-For hop.
# Self-hosted trusts no proxy by default, so without this every caller
# is nginx's address: one shared bucket for every per-IP rate limit, and
# a probe-shield ban takes the whole install offline. Raise it if
# another appending proxy (a load balancer, Cloudflare) sits in front.
- TRUST_PROXY=${TRUST_PROXY:-1}
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
# The workers run in each replica. 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
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'
# The readiness check fails while the server shuts down, and autoheal
# restarts an unhealthy container with Docker's 10 s stop unless told
# otherwise, which would kill the jobs stop_grace_period lets finish.
- 'autoheal.stop.timeout=180'
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.30-alpine
# As nginx's own user, on an unprivileged port: with every capability
# dropped, a root master can neither write the tmpfs below nor switch
# its workers to that user, and exits at once.
user: '101:101'
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
tmpfs:
- /tmp:uid=101,gid=101
- /var/cache/nginx:uid=101,gid=101
- /var/run:uid=101,gid=101
ports:
- '${TENANT_SERVER_PORT:-4101}:8080'
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
# 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:
redis_data:
clamav_db:
# Network segmentation: databases isolated from frontends.
# Only tenant-server bridges both networks.
networks:
db:
driver: bridge
internal: true
app:
driver: bridgeStep 3: Nginx Configuration
Save as nginx-lb.conf in the same directory.
# Runs as nginx's own user (the compose sets user: 101), so its pid file
# goes where that user can write.
pid /tmp/nginx.pid;
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;
# The SDK API, per person: the address and the session the SDK sends, so
# an office behind one NAT is not one caller. An app start is nine calls.
# The caller picks the header, so only the two shapes the server mints
# (35 letters and digits, or an org id and then 35) count as a session;
# anything else keys on the address alone, and the key length stays
# bounded. A new id on every call still gets a new bucket, so api_sdk_ip
# is the real per-address cap.
map $http_x_session_id $sdk_session {
"~^([0-9a-f]{24})?[0-9A-Za-z]{35}$" $http_x_session_id;
default "";
}
limit_req_zone $binary_remote_addr$sdk_session zone=api_sdk_session:20m rate=20r/s;
limit_req_zone $binary_remote_addr zone=api_sdk_ip:10m rate=200r/s;
upstream app_servers {
# Sticky by client address: socket.io opens with polling and upgrades
# to a WebSocket, and the upgrade has to reach the replica that holds
# the session. Without this, two replicas drop every other connection.
hash $remote_addr consistent;
server tenant-server:3000;
}
server {
# Unprivileged: the container holds no capability to bind below 1024.
listen 8080;
client_max_body_size 500M;
# Reject oversized headers
large_client_header_buffers 4 8k;
# Rate limiting. Each location sets its own limit_req: one set here
# applies to every location without one, health checks included.
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). A load balancer's check
# must never get a 429: it comes from one address, often the same
# one as the traffic it fronts, and a refused check takes the
# replica out of service.
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;
}
# The SDK's sign-in steps (login, register, codes, reset, token) stay
# on the auth tier, as on cloud. The pattern above never matched
# /api/v1/auth, so without this they fell to the SDK zones below.
location /api/v1/auth {
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;
}
# The SDK API (customer apps): per person, with an address-wide guard.
location /api/v1/ {
limit_req zone=api_sdk_session burst=60 nodelay;
limit_req zone=api_sdk_ip burst=500 nodelay;
proxy_pass http://app_servers;
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;
}
location / {
limit_req zone=api_general burst=60 nodelay;
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 -dStep 5: SSL with Let's Encrypt
sudo certbot --nginx -d api.yourcompany.com -d app.yourcompany.com -d auth.yourcompany.comOr 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
| Endpoint | Purpose |
|---|---|
GET /api/ready | Readiness probe - answers 200 with {"ready": true, "mongo": "ok", "redis": "ready"} when MongoDB and Redis are connected (plus cell when CELL_ID is set), and 503 with "ready": false and the state of each connection when one is down |
GET /api/health | Health check - answers {"status", "timestamp"}, with 200 when MongoDB and Redis are connected and 503 when one is down. Send x-internal-api-key: <INSTALLATION_API_KEY> for the full report: database and Redis state, Redis latency, tenant connections and pool sizes, process memory and uptime |
Every replica shares one Redis, so a Redis outage fails every replica's probe at the same moment. READINESS_REDIS_FAIL_OPEN=true keeps the probe answering ready while Redis is down, reported as "redis": "degraded", so whatever acts on the probe does not take every replica out at once; the routes that need Redis then answer 503 on their own. It is off by default: Redis is required for readiness.
Updating
Back up MongoDB first (mongodump against your MONGO_CONNECTION_URL). Then save the compose file from these docs over yours: the Docker Compose file above, or the quick start's if you run the bundled MongoDB. The images are tested with the compose file of their release, and an update can change it. An install made before release 23 needs this one: its bundled MongoDB lacks CHOWN and crash-loops on its next restart, and saving the new file and running up -d recovers it with the data intact. Then pull the images and restart:
docker compose -f docker-compose.selfhost.yml --env-file .env.selfhost pull
docker compose -f docker-compose.selfhost.yml --env-file .env.selfhost up -dThe new tenant server runs its pending database migrations by itself as it starts, without holding up readiness, and logs one line when it is done: [Migrations] boot run: N ran, N already applied, N manual, not run (...). Existing users, sessions and data carry over.
Migrations marked manual are never run at boot; the log names them. List them, and run one by name when its release notes ask you to:
docker compose -f docker-compose.selfhost.yml --env-file .env.selfhost exec -T tenant-server node build/migrations/run.js --status
docker compose -f docker-compose.selfhost.yml --env-file .env.selfhost exec -T tenant-server node build/migrations/run.js <name>To move between known versions instead of whatever latest is today, replace latest in the three image: lines with a release tag.
Overage on Stripe Billing Meters
Overage now reaches the Stripe invoice through Billing Meters instead of Stripe's legacy usage records. Subscriptions created before the update keep billing through the legacy prices until they are moved, so if any of your plans bill overage, run the migration once after the new image is up. It is a dry run by default: it lists the plan versions and subscriptions it would move and writes nothing. Read the list, then run it again with --apply. It is safe to interrupt and run again, and makes a few Stripe calls per subscription, so run it off-peak.
# Dry run: lists what would move, writes nothing
docker compose -f docker-compose.selfhost.yml --env-file .env.selfhost exec -T tenant-server node build/migrations/migrate-overage-to-meters.js
# Move them
docker compose -f docker-compose.selfhost.yml --env-file .env.selfhost exec -T tenant-server node build/migrations/migrate-overage-to-meters.js --apply