This guide covers three ways to deploy CrowdPay to production:
- Option A — Railway (recommended for most contributors)
- Option B — Render
- Option C — Self-hosted VPS (Ubuntu 22.04)
All three options end with the same running system: a Node.js backend on port 3001, a static React frontend, and a PostgreSQL 14+ database. A full environment variable reference is at the bottom.
You need two secrets before deploying anything.
JWT secret — a random 256-bit hex string:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Wallet encryption key (KEK) — a random 32-byte base64 string:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"Keep both values safe. Losing WALLET_SECRET_LOCAL_KEK means losing access to all campaign wallet private keys.
The backend needs a Stellar key pair that co-signs all campaign withdrawals.
Testnet (development)
# Install the Stellar SDK globally or run from backend/
node -e "
const StellarSdk = require('@stellar/stellar-sdk');
const pair = StellarSdk.Keypair.random();
console.log('Public:', pair.publicKey());
console.log('Secret:', pair.secret());
"Then fund it with Friendbot:
https://friendbot.stellar.org/?addr=<YOUR_PUBLIC_KEY>
Mainnet (production)
Generate the key pair the same way, but fund it by sending at least 1 XLM from an exchange or another wallet. Never use Friendbot on mainnet.
Railway runs the backend as a Node.js service and hosts PostgreSQL natively. Because the frontend's API client sends requests to /api on whatever origin it is served from, both the frontend build output and the backend must share a single origin. The approach below builds the frontend as part of the deploy and serves it from Express using the SERVE_FRONTEND=true environment variable.
Fork Savitura/crowdpay on GitHub so Railway can pull from your account.
- Go to railway.app and sign in.
- Click New Project → Deploy from GitHub repo and select your fork.
- Railway detects the repo but do not deploy yet — configure services first.
- Inside your project, click + New → Database → PostgreSQL.
- Once provisioned, open the PostgreSQL service and copy the
DATABASE_URLvalue from the Variables tab:postgresql://postgres:<password>@<host>.railway.internal:5432/railway
-
Click + New → GitHub Repo and select your fork.
-
In the service settings:
- Root directory: leave empty (use the project root)
- Build command:
cd frontend && npm install && npm run build && cd ../backend && npm install - Start command:
cd backend && npm start
-
Open the Variables tab and add every variable from the Environment Variable Reference. At minimum:
Variable Value DATABASE_URLPaste the value copied from the PostgreSQL service JWT_SECRETThe random hex string you generated STELLAR_NETWORKtestnetormainnetSTELLAR_HORIZON_URLhttps://horizon-testnet.stellar.orgorhttps://horizon.stellar.orgPLATFORM_PUBLIC_KEYYour platform account public key PLATFORM_SECRET_KEYYour platform account secret key WALLET_SECRET_PROVIDERlocalWALLET_SECRET_LOCAL_KEKThe random base64 KEK you generated USDC_ISSUERTestnet: GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5
| APP_URL | Your frontend/backend base URL (e.g. https://crowdpay.up.railway.app) |
| FRONTEND_URL | Your Railway app URL (set after first deploy, e.g. https://crowdpay.up.railway.app) |
| SERVE_FRONTEND | true |
| PORT | 3001 |
| VITE_STELLAR_NETWORK | testnet or public (used during the frontend build step) |
- Railway will build and deploy. Note the public URL.
Open the Railway shell for the service (Deploy → Shell) and run:
# Apply the base schema
psql $DATABASE_URL -f backend/db/schema.sql
# Apply all migrations in order
for f in backend/db/migrations/*.sql; do
echo "Applying $f"
psql $DATABASE_URL -f "$f"
doneAlternatively, from your local machine with psql installed:
# Export the DATABASE_URL from Railway's dashboard
export DATABASE_URL="postgresql://..."
psql $DATABASE_URL -f backend/db/schema.sql
for f in backend/db/migrations/*.sql; do psql $DATABASE_URL -f "$f"; doneVisit your Railway app URL. Create an account, create a campaign, and confirm that the campaign wallet is created on Stellar. Check the backend logs (Deploy → Logs) for any errors.
Render runs the app as a single Web Service with a managed PostgreSQL instance. Like Railway, the frontend and backend share one origin — the build step compiles the React app and Express serves the output via SERVE_FRONTEND=true.
- In the Render dashboard, click New → PostgreSQL.
- Choose a name (e.g.
crowdpay-db), region, and plan. - After provisioning, copy the Internal Database URL (for the app service) and the External Database URL (for running migrations from your laptop).
-
Click New → Web Service and connect your GitHub repo.
-
Configure:
- Root directory: leave empty (use the project root)
- Runtime: Node
- Build command:
cd frontend && npm install && npm run build && cd ../backend && npm install - Start command:
cd backend && npm start
-
Under Environment Variables, add all variables from the reference table. Key values:
Variable Value DATABASE_URLInternal Database URL from the PostgreSQL service JWT_SECRETThe random hex string you generated STELLAR_NETWORKtestnetormainnetSTELLAR_HORIZON_URLhttps://horizon-testnet.stellar.orgorhttps://horizon.stellar.orgPLATFORM_PUBLIC_KEYYour platform account public key PLATFORM_SECRET_KEYYour platform account secret key WALLET_SECRET_PROVIDERlocalWALLET_SECRET_LOCAL_KEKThe random base64 KEK you generated USDC_ISSUERTestnet: GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5
| APP_URL | Your Render service URL (e.g. https://crowdpay.onrender.com) |
| FRONTEND_URL | Your Render service URL (e.g. https://crowdpay.onrender.com) |
| SERVE_FRONTEND | true |
| VITE_STELLAR_NETWORK | testnet or public (used during the frontend build step) |
- Click Create Web Service. Note the public URL.
From your local machine using the External Database URL:
export DATABASE_URL="postgres://..." # External URL from Render dashboard
psql $DATABASE_URL -f backend/db/schema.sql
for f in backend/db/migrations/*.sql; do
echo "Applying $f"
psql $DATABASE_URL -f "$f"
doneOr use Render's Shell tab on the Web Service and run the same commands with the Internal URL.
Render's free tier spins down Web Services after 15 minutes of inactivity. The first request after a cold start takes ~30 seconds. Use a paid plan or a keep-alive ping for production.
This option gives you full control. The steps below install Node 20, PostgreSQL 14, nginx, and set up the backend as a systemd service with Let's Encrypt SSL.
Any Ubuntu 22.04 VPS works (DigitalOcean, Hetzner, Vultr, Linode). Minimum recommended spec: 1 vCPU, 1 GB RAM, 20 GB SSD. Point a domain name at the server's IP address before continuing.
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
node --version # should print v20.x.xsudo apt-get install -y postgresql postgresql-contrib
# Create the database and user
sudo -u postgres psql <<SQL
CREATE USER crowdpay WITH PASSWORD 'strong_password_here';
CREATE DATABASE crowdpay OWNER crowdpay;
SQLsudo mkdir -p /opt/crowdpay
sudo chown $USER:$USER /opt/crowdpay
git clone https://github.com/Savitura/crowdpay.git /opt/crowdpaycd /opt/crowdpay/backend
npm ci --omit=devcp /opt/crowdpay/backend/.env.example /opt/crowdpay/backend/.env
nano /opt/crowdpay/backend/.envFill in every variable. See the Environment Variable Reference for descriptions. The DATABASE_URL for a local PostgreSQL install looks like:
DATABASE_URL=postgresql://crowdpay:strong_password_here@localhost:5432/crowdpay
cd /opt/crowdpay/backend
# Export DATABASE_URL from the .env file
export DATABASE_URL=$(grep '^DATABASE_URL=' .env | cut -d '=' -f2-)
# Load the base schema
psql $DATABASE_URL -f db/schema.sql
# Apply all migrations in chronological order
for f in db/migrations/*.sql; do
echo "Applying $f"
psql $DATABASE_URL -f "$f"
donecd /opt/crowdpay/frontend
npm ci
# Create frontend .env
cat > .env <<EOF
VITE_API_URL=https://api.yourdomain.com
VITE_STELLAR_NETWORK=testnet
EOF
npm run build
# Output goes to /opt/crowdpay/frontend/distsudo nano /etc/systemd/system/crowdpay-backend.servicePaste the following (adjust User and WorkingDirectory if needed):
[Unit]
Description=CrowdPay Backend
After=network.target postgresql.service
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/crowdpay/backend
EnvironmentFile=/opt/crowdpay/backend/.env
ExecStart=/usr/bin/node src/index.js
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=crowdpay-backend
[Install]
WantedBy=multi-user.target# Fix ownership so www-data can read the files
sudo chown -R www-data:www-data /opt/crowdpay/backend
sudo systemctl daemon-reload
sudo systemctl enable crowdpay-backend
sudo systemctl start crowdpay-backend
sudo systemctl status crowdpay-backendsudo apt-get install -y nginxCreate a site configuration. Replace yourdomain.com with your actual domain.
sudo nano /etc/nginx/sites-available/crowdpayserver {
listen 80;
server_name yourdomain.com www.yourdomain.com;
# Frontend static files
root /opt/crowdpay/frontend/dist;
index index.html;
# Serve React app — fall back to index.html for client-side routing
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to backend
location /api/ {
proxy_pass http://127.0.0.1:3001;
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;
proxy_cache_bypass $http_upgrade;
}
}sudo ln -s /etc/nginx/sites-available/crowdpay /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginxsudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.comCertbot updates the nginx config automatically and sets up auto-renewal. Verify:
sudo systemctl status certbot.timer
sudo certbot renew --dry-run# Check backend is running
sudo systemctl status crowdpay-backend
journalctl -u crowdpay-backend -f
# Hit the health / docs endpoint
curl https://yourdomain.com/api/docsCrowdPay uses plain SQL migrations. There is no ORM migration runner — apply files manually in alphabetical (chronological) order.
Fresh install — apply the base schema, then all migrations:
psql $DATABASE_URL -f backend/db/schema.sql
for f in backend/db/migrations/*.sql; do
echo "Applying $f"
psql $DATABASE_URL -f "$f"
doneExisting install — check which migrations have already been applied and run only the new ones. A simple way is to keep a log:
# See which migration files exist
ls backend/db/migrations/
# Apply a single migration
psql $DATABASE_URL -f backend/db/migrations/20260429_add_soroban_contract_ids.sqlMigration files are named with dates (20260401_, 20260423_, etc.) so alphabetical order is chronological order.
| Setting | Testnet (development) | Mainnet (production) |
|---|---|---|
STELLAR_NETWORK |
testnet |
mainnet |
STELLAR_HORIZON_URL |
https://horizon-testnet.stellar.org |
https://horizon.stellar.org |
USDC_ISSUER |
GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 |
GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN |
VITE_STELLAR_NETWORK |
testnet |
public |
| Platform account funding | Friendbot (https://friendbot.stellar.org/?addr=<key>) |
Buy and send XLM from an exchange |
| KYC | Can disable with KYC_REQUIRED_FOR_CAMPAIGNS=false |
Must be enabled for compliance |
Never use testnet keys on mainnet. Generate a fresh key pair for each environment.
All variables go in backend/.env (or the equivalent dashboard UI for Railway/Render).
| Variable | Required | Description | Example |
|---|---|---|---|
STELLAR_NETWORK |
Yes | Network to connect to | testnet or mainnet |
STELLAR_HORIZON_URL |
Yes | Horizon API endpoint for the chosen network | https://horizon-testnet.stellar.org |
PLATFORM_PUBLIC_KEY |
Yes | Public key of the platform co-signer account | GDWUL... |
PLATFORM_SECRET_KEY |
Yes | Secret key of the platform co-signer account. Keep this out of version control. | SCVMQ... |
USDC_ISSUER |
Yes | Public key of the USDC issuer on the chosen network | GBBD47... (testnet) |
STELLAR_EXTRA_ASSETS |
No | JSON map of additional accepted assets {"CODE":"ISSUER_KEY"} |
{"AQUA":"GBNZ..."} |
| Variable | Required | Description | Example |
|---|---|---|---|
WALLET_SECRET_PROVIDER |
Yes | Where to store encrypted campaign wallet secrets | local (default) or aws-kms |
WALLET_SECRET_LOCAL_KEK |
Yes (if local) |
Base64-encoded 32-byte key-encryption key for AES-256-GCM. Changing this value breaks all existing campaign wallets. | +tLlh7nd... |
KMS_KEY_ID |
Yes (if aws-kms) |
AWS KMS key ARN used to encrypt wallet secrets in production | arn:aws:kms:us-east-1:... |
| Variable | Required | Description | Example |
|---|---|---|---|
DATABASE_URL |
Yes | PostgreSQL connection string | postgresql://user:pass@host:5432/crowdpay |
| Variable | Required | Description | Example |
|---|---|---|---|
JWT_SECRET |
Yes | Random secret for signing JWT access tokens. Use at least 32 random bytes. | a3f9... (hex) |
JWT_EXPIRES_IN |
No | Access token lifetime (default 15m) |
15m |
REFRESH_TOKEN_EXPIRES_IN |
No | Refresh token lifetime (default 7d) |
7d |
| Variable | Required | Description | Example |
|---|---|---|---|
PORT |
No | Port the Express server listens on (default 3001) |
3001 |
FRONTEND_URL |
Yes | Origin of the frontend app. Used for CORS and email links. | https://app.yourdomain.com |
SERVE_FRONTEND |
No | When true, Express serves the built frontend from frontend/dist/ and handles SPA routing. Set this on Railway/Render where both are deployed as a single service. Not needed on VPS (nginx handles it). |
true |
LOG_LEVEL |
No | Winston log verbosity (default info) |
info, debug, warn, error |
| Variable | Required | Description | Example |
|---|---|---|---|
PLATFORM_FEE_BPS |
No | Platform fee in basis points (100 bps = 1%). Defaults to 0. |
150 (1.5%) |
PLATFORM_APPROVER_USER_ID |
No | UUID of the user who may approve withdrawal requests. When unset, any authenticated user can call the approval endpoint. | 00000000-0000-0000-0000-000000000000 |
| Variable | Required | Description | Example |
|---|---|---|---|
KYC_REQUIRED_FOR_CAMPAIGNS |
No | Set to false to skip KYC checks on testnet |
true |
KYC_PROVIDER |
No | KYC provider integration | persona |
PERSONA_API_KEY |
No (if Persona) | Persona API key | persona_sandbox_... |
PERSONA_TEMPLATE_ID |
No (if Persona) | Persona inquiry template ID | itmpl_... |
APP_BASE_URL |
No (if Persona) | Public base URL of the backend, used for Persona callbacks | https://api.yourdomain.com |
| Variable | Required | Description | Example |
|---|---|---|---|
EMAIL_FROM |
No | Sender address for transactional emails | "CrowdPay" <noreply@yourdomain.com> |
SMTP_HOST |
No | SMTP server hostname | smtp.sendgrid.net |
SMTP_PORT |
No | SMTP port | 587 |
SMTP_USER |
No | SMTP username | apikey |
SMTP_PASS |
No | SMTP password or API key | SG.abc... |
APP_URL |
Yes | Base URL used for email verification links | https://api.yourdomain.com |
EMAIL_SERVICE_API_KEY |
No | Provider-level API key (SendGrid / Mailgun) | SG.abc... |
| Variable | Required | Description | Example |
|---|---|---|---|
ALERT_WEBHOOK_URL |
No | Slack-compatible incoming webhook URL for platform alerts. When unset, alerts are silently skipped. | https://hooks.slack.com/services/... |
| Variable | Required | Description | Example |
|---|---|---|---|
VITE_API_URL |
Yes (production) | Full URL of the deployed backend. Not needed in local dev (proxied by Vite). | https://api.yourdomain.com |
VITE_STELLAR_NETWORK |
No | Network label for Stellar explorer links | testnet or public |
VITE_KYC_REQUIRED_FOR_CAMPAIGNS |
No | Mirror of backend KYC setting for conditional UI | true or false |
Before going live, confirm each item:
-
JWT_SECRETis a unique random value not shared between environments -
WALLET_SECRET_LOCAL_KEKis backed up securely — losing it loses all wallet keys -
PLATFORM_SECRET_KEYis stored in a secrets manager, not in a.envfile committed to git -
STELLAR_NETWORK=mainnetandSTELLAR_HORIZON_URLpoints tohttps://horizon.stellar.org -
USDC_ISSUERis set to the mainnet USDC issuer key -
FRONTEND_URLmatches the actual frontend origin (CORS will block otherwise) - All database migrations have been applied in order
- SSL certificate is installed and auto-renewal is configured
-
LOG_LEVEL=info(usedebugonly in development) - KYC is enabled (
KYC_REQUIRED_FOR_CAMPAIGNS=true) for mainnet