Feature Flags
    A/B Testing

    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

    ProjectGrowthBook (Next.js + Express + Python stats)
    LicenseMIT
    Recommended PlanRamNode Cloud VPS 2 vCPU / 4 GB
    Hostnamesapp.example.com + api.example.com
    Data storeMongoDB 7 (private Docker network)
    1

    Base Server + Firewall

    Update + ufw
    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 enable

    Configure ufw before Docker — Docker can punch holes in iptables that bypass ufw if you reorder this.

    2

    Install Docker Engine

    Docker repo + daemon hardening
    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-plugin
    3

    Project Layout + Secrets

    Generate strong 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 .env

    If you change ENCRYPTION_KEY later, you must run GrowthBook's encryption-key migration script or existing data-source credentials become unreadable.

    4

    Compose Stack (Mongo private, app on 127.0.0.1)

    /opt/growthbook/docker-compose.yml
    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 }
    Bring it up
    cd /opt/growthbook && docker compose up -d
    curl -sf http://127.0.0.1:3100/healthcheck
    5

    Nginx Reverse Proxy + Let's Encrypt

    /etc/nginx/sites-available/growthbook.conf
    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;
        }
    }
    Enable + cert
    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 --redirect

    GrowthBook requires UI and API on separate hostnames — the front end has its own /api/* routes that conflict with the back end.

    6

    Hardening

    Disable open signups (after creating admin)
    # Add to /opt/growthbook/.env
    DISABLE_REGISTRATION=true
    fail2ban login filter
    # /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 = 3600

    If 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.

    7

    Backups (MongoDB + uploads)

    /opt/growthbook/backup.sh
    #!/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 -delete
    Cron + offsite
    chmod +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 storage
    8

    Updates

    Controlled upgrade
    cd /opt/growthbook
    ./backup.sh
    docker compose pull growthbook
    docker compose up -d growthbook
    docker compose logs -f growthbook

    Pin to a release tag (e.g. growthbook/growthbook:3.6.0) instead of latest once stable. Schema migrations run automatically on startup.