Deploy GrowthBook on a VPS
Open-source feature flagging and experimentation. Single VPS, Docker Compose, MongoDB on a private network, Nginx + TLS, backups — production-ready.
At a Glance
| Project | GrowthBook (Next.js + Express + Python stats) |
| License | MIT |
| Recommended Plan | RamNode Cloud VPS 2 vCPU / 4 GB |
| Hostnames | app.example.com + api.example.com |
| Data store | MongoDB 7 (private Docker network) |
Base Server + Firewall
apt update && apt upgrade -y
apt install -y ca-certificates curl gnupg ufw fail2ban htop apache2-utils jq pwgen unattended-upgrades
dpkg-reconfigure --priority=low unattended-upgrades
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enableConfigure ufw before Docker — Docker can punch holes in iptables that bypass ufw if you reorder this.
Install Docker Engine
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list
apt update && apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-pluginProject Layout + Secrets
mkdir -p /opt/growthbook/{data,uploads,mongo}
cd /opt/growthbook
JWT_SECRET=$(openssl rand -hex 32)
ENCRYPTION_KEY=$(openssl rand -hex 32)
MONGO_USER=growthbook
MONGO_PASS=$(pwgen -s 40 1)
cat > .env <<EOF
JWT_SECRET=${JWT_SECRET}
ENCRYPTION_KEY=${ENCRYPTION_KEY}
MONGO_USER=${MONGO_USER}
MONGO_PASS=${MONGO_PASS}
MONGO_INITDB_ROOT_USERNAME=${MONGO_USER}
MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASS}
APP_ORIGIN=https://app.example.com
API_HOST=https://api.example.com
NODE_ENV=production
EOF
chmod 600 .envIf you change ENCRYPTION_KEY later, you must run GrowthBook's encryption-key migration script or existing data-source credentials become unreadable.
Compose Stack (Mongo private, app on 127.0.0.1)
services:
mongo:
image: mongo:7
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD}
volumes: [./mongo:/data/db]
networks: [gb_internal]
command: ["--bind_ip","0.0.0.0","--wiredTigerCacheSizeGB","1"]
healthcheck:
test: ["CMD","mongosh","--quiet","--eval","db.runCommand({ ping: 1 }).ok"]
interval: 30s
growthbook:
image: growthbook/growthbook:latest
restart: unless-stopped
depends_on: { mongo: { condition: service_healthy } }
environment:
NODE_ENV: ${NODE_ENV}
MONGODB_URI: mongodb://${MONGO_USER}:${MONGO_PASS}@mongo:27017/growthbook?authSource=admin
JWT_SECRET: ${JWT_SECRET}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
APP_ORIGIN: ${APP_ORIGIN}
API_HOST: ${API_HOST}
UPLOAD_METHOD: local
volumes:
- ./uploads:/usr/local/src/app/packages/back-end/uploads
ports:
- "127.0.0.1:3000:3000" # UI — Nginx only
- "127.0.0.1:3100:3100" # API — Nginx only
networks: [gb_internal]
networks:
gb_internal: { driver: bridge }cd /opt/growthbook && docker compose up -d
curl -sf http://127.0.0.1:3100/healthcheckNginx Reverse Proxy + Let's Encrypt
server {
listen 80; server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
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;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
client_max_body_size 50M;
}
}
server {
listen 80; server_name api.example.com;
location / {
proxy_pass http://127.0.0.1:3100;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
client_max_body_size 50M;
}
}apt install -y nginx python3-certbot-nginx
ln -s /etc/nginx/sites-available/growthbook.conf /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
certbot --nginx -d app.example.com -d api.example.com \
--non-interactive --agree-tos -m admin@example.com --redirectGrowthBook requires UI and API on separate hostnames — the front end has its own /api/* routes that conflict with the back end.
Hardening
# Add to /opt/growthbook/.env
DISABLE_REGISTRATION=true# /etc/fail2ban/filter.d/growthbook-login.conf
[Definition]
failregex = ^<HOST> -.*"POST /api/auth/login HTTP/.*" 401
# /etc/fail2ban/jail.d/growthbook.conf
[growthbook-login]
enabled = true
filter = growthbook-login
logpath = /var/log/nginx/access.log
maxretry = 8
findtime = 600
bantime = 3600If your SDKs only hit a CDN cache of /api/features/*, restrict api.example.com at Nginx with allow rules for your office and CI ranges.
Backups (MongoDB + uploads)
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR=/var/backups/growthbook; STAMP=$(date +%Y%m%d-%H%M%S)
mkdir -p "$BACKUP_DIR"
cd /opt/growthbook && set -a && source .env && set +a
docker compose exec -T mongo mongodump \
--username "$MONGO_USER" --password "$MONGO_PASS" \
--authenticationDatabase admin --db growthbook --archive --gzip \
> "$BACKUP_DIR/mongo-${STAMP}.archive.gz"
tar -czf "$BACKUP_DIR/uploads-${STAMP}.tar.gz" -C /opt/growthbook uploads
find "$BACKUP_DIR" -type f -mtime +14 -deletechmod +x /opt/growthbook/backup.sh
echo "15 3 * * * /opt/growthbook/backup.sh >> /var/log/growthbook-backup.log 2>&1" | crontab -
# Then ship $BACKUP_DIR offsite with rclone or restic to S3-compatible storageUpdates
cd /opt/growthbook
./backup.sh
docker compose pull growthbook
docker compose up -d growthbook
docker compose logs -f growthbookPin to a release tag (e.g. growthbook/growthbook:3.6.0) instead of latest once stable. Schema migrations run automatically on startup.
