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)"; 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-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: bridgeStep 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 -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 — returns {"ready": true} when DB and Redis are connected |
GET /api/health | Full 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