This guide covers deploying Quorum to a LunaNode VPS using Docker Compose and Caddy as the reverse proxy.
- A LunaNode VPS (or any Ubuntu 22.04+ server)
- A domain name pointed at the VPS IP (optional but recommended for HTTPS)
- SSH access to the VPS
Log into LunaNode and create a new instance:
- Image: Ubuntu 22.04 LTS
- Flavour: m1.small or larger (1 vCPU, 2 GB RAM minimum)
- Region: closest to your users
Add your SSH public key during provisioning. Note the assigned IP address.
SSH into the server as root:
ssh root@YOUR_VPS_IPCreate a non-root user to run the application:
adduser bsds
usermod -aG sudo bsds
usermod -aG docker bsdsCopy your SSH authorized keys to the new user:
mkdir -p /home/bsds/.ssh
cp ~/.ssh/authorized_keys /home/bsds/.ssh/
chown -R bsds:bsds /home/bsds/.ssh
chmod 700 /home/bsds/.ssh
chmod 600 /home/bsds/.ssh/authorized_keysFrom this point forward, log in as the bsds user:
ssh bsds@YOUR_VPS_IPsudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /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" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-pluginVerify:
docker --version
docker compose versioncd /home/bsds
git clone <repo-url> quorum
cd quorumcp .env.example .env
chmod 600 .env
nano .envRequired values for production:
# Database password (choose a strong password)
DB_PASSWORD=your-strong-db-password
# Generate: openssl rand -base64 32
NEXTAUTH_SECRET=your-nextauth-secret
# Your VPS IP or domain
NEXTAUTH_URL=https://yourdomain.com
APP_URL=https://yourdomain.com
# End-user UI/data brand code (defaults to BSDS)
NEXT_PUBLIC_DATA_BRAND_CODE=BSDS
# Razorpay live keys (from dashboard.razorpay.com)
RAZORPAY_KEY_ID=rzp_live_xxxxxxxxxxxx
RAZORPAY_KEY_SECRET=your-live-key-secret
RAZORPAY_WEBHOOK_SECRET=your-webhook-secret
RAZORPAY_TEST_MODE=false
# WhatsApp (optional)
WHATSAPP_API_TOKEN=your-meta-token
WHATSAPP_PHONE_NUMBER_ID=your-phone-number-id
# Generate: openssl rand -hex 32
ENCRYPTION_KEY=your-64-char-hex-encryption-key
# Generate: openssl rand -base64 24
CRON_SECRET=your-cron-secret
NODE_ENV=productionSet the DOMAIN variable for Caddy to enable automatic HTTPS:
echo "DOMAIN=yourdomain.com" >> .envIf you do not have a domain yet, leave DOMAIN unset. Caddy will serve HTTP only on the VPS IP.
./launch.shOn first startup:
- PostgreSQL initialises (takes 10–20 seconds)
- The seed image runs
prisma db push - Seed data is loaded only if the database is empty
- The app container starts
- Caddy proxies traffic to the app
Monitor startup:
docker compose logs -fCheck service status with:
docker compose psIf you set DOMAIN=yourdomain.com in .env and your DNS A record points to the VPS IP, Caddy handles HTTPS automatically via Let's Encrypt.
Verify the Caddyfile is using the domain:
cat Caddyfile
# Should show: {$DOMAIN:localhost}Caddy will obtain and auto-renew a TLS certificate. No extra configuration is needed.
To verify HTTPS is working:
curl -I https://yourdomain.com
# Expect: HTTP/2 200sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
sudo ufw --force enable
sudo ufw statusAll other ports are denied by default.
sudo apt-get install -y fail2ban
sudo systemctl enable fail2ban
sudo systemctl start fail2banVerify SSH is being monitored:
sudo fail2ban-client status sshdDefault configuration bans IPs after 5 failed SSH attempts for 10 minutes.
Set up a daily PostgreSQL backup cron job:
# Make the script executable
chmod +x /home/bsds/quorum/scripts/backup.sh
# Create the backup directory
sudo mkdir -p /var/backups/quorum
sudo chown bsds:bsds /var/backups/quorum
# Add cron job (runs at 02:00 every night)
crontab -eAdd this line to crontab:
0 2 * * * /home/bsds/quorum/scripts/backup.sh >> /var/log/quorum-backup.log 2>&1
The script keeps 30 days of backups and auto-deletes older files. See scripts/backup.sh.
To send backups offsite via rsync, set OFFSITE_BACKUP_PATH in .env:
OFFSITE_BACKUP_PATH=user@backup-server:/backups/quorumThe cron endpoint (POST /api/cron) runs the daily membership expiry check. Trigger it from crontab:
5 2 * * * curl -s -X POST https://yourdomain.com/api/cron \
-H "x-cron-secret: your-cron-secret" >> /var/log/bsds-cron.log 2>&1
This checks for memberships expiring in 15 days (sends reminders) and expires memberships that are past their end date.
View live logs:
docker compose logs -f app
docker compose logs -f postgres
docker compose logs -f caddyCheck container status:
docker compose psRestart a service:
docker compose restart appcd /home/bsds/quorum
git pull
docker compose build app
docker compose up -d appMigrations are applied automatically when the container restarts.
./scripts/restore.sh /var/backups/quorum/quorum_20260315_020000.sql.gzThe script prompts for confirmation before dropping and recreating the database. See Security docs for the full procedure.
App container exits immediately
Check logs: docker compose logs app. Usually an invalid .env value (missing ENCRYPTION_KEY or malformed DATABASE_URL).
Caddy returns 502 Bad Gateway
The app container may not be ready yet. Wait 60 seconds and retry, or check: docker compose logs app.
Cannot connect to database
Verify DB_PASSWORD in .env matches the password used by the postgres container. Run docker compose down -v to reset volumes (this deletes all data), then docker compose up -d to start fresh.
HTTPS certificate not issued
Ensure port 80 is open (UFW) and the DNS A record for the domain resolves to the VPS IP. Caddy requires HTTP-01 challenge to work. Check: docker compose logs caddy.