End-to-end guide to ship Vendo to a single VPS, served via Cloudflare Tunnel (no open ports, automatic TLS, free DDoS protection).
Total time: ~30 minutes if you have the VPS, domain, and Cloudflare account ready.
- VPS with 2 vCPU, 4 GB RAM, 40 GB SSD minimum (Hetzner CX22 / DO 4 GB / Linode 4 GB). Ubuntu 24.04 LTS recommended.
- Domain on Cloudflare (free plan is fine). You'll use two subdomains:
portal.example.com— the portal (what doctors and clinic staff type)medplum.example.com— the Medplum API (referenced from portal config; not customer-facing)
- SSH access to the VPS as root or a sudoer.
ssh root@your-vps
# Install Docker + compose plugin
apt update && apt install -y ca-certificates curl gnupg jq git openssl
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" \
| tee /etc/apt/sources.list.d/docker.list > /dev/null
apt update && apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
systemctl enable --now docker
# Tighten firewall — Cloudflare Tunnel is outbound, no inbound ports needed.
ufw allow OpenSSH
ufw --force enablemkdir -p /opt && cd /opt
git clone <your-repo-url> vendo
cd vendo/infra/compose
cp .env.prod.example .env.prod
chmod 600 .env.prodGenerate the two secrets and put them into .env.prod:
echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> .env.prod
echo "SESSION_SECRET=$(openssl rand -hex 32)" >> .env.prod
# Then edit .env.prod and fill in the URL fields + CF_TUNNEL_TOKEN (next step).
nano .env.prodRequired values in .env.prod:
| Var | What | Where it comes from |
|---|---|---|
APP_BASE_URL |
https://portal.example.com |
Subdomain you'll point at the portal |
MEDPLUM_BASE_URL |
https://medplum.example.com |
Subdomain for the Medplum API |
SUPPORT_EMAIL |
support@example.com |
Address shown in Medplum templates |
POSTGRES_PASSWORD |
random 48-hex | Already filled by command above |
SESSION_SECRET |
random 64-hex | Already filled by command above |
CF_TUNNEL_TOKEN |
eyJhIjoi… |
Step 3 below |
In the Cloudflare dashboard:
- Zero Trust → Networks → Tunnels → Create tunnel.
- Connector: Cloudflared. Tunnel name:
vendo-prod. Save. - Install connector → Docker. Copy the long token from the displayed
command (the part after
--token). Paste into.env.prodasCF_TUNNEL_TOKEN. Skip running their command — our compose handles it. - Public hostnames → Add a public hostname:
- Subdomain:
portal· Domain:example.com· Service:http://portal:3000
- Subdomain:
- Add a public hostname:
- Subdomain:
medplum· Domain:example.com· Service:http://medplum-server:8103
- Subdomain:
- Save. Both subdomains now resolve through Cloudflare to the tunnel.
Note: the service hostnames (
portal,medplum-server) are the Docker service names fromdocker-compose.prod.yml— Cloudflared resolves them via the Compose network because it runs in the same network.
# Still in /opt/vendo/infra/compose
./scripts/gen-prod-config.sh .env.prod
# → writes medplum.config.prod.json (chmod 600). Back this file up — losing it
# invalidates every existing user session and Binary signing URL.docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build
docker compose -f docker-compose.prod.yml ps # all should be healthy
docker compose -f docker-compose.prod.yml logs -f portal cloudflaredWhen cloudflared shows Connection registered, browse to
https://portal.example.com/login — the password sign-in form should load
over Cloudflare's TLS cert.
The first admin must be created against a fresh Medplum instance:
# Create the super-admin via Medplum's bootstrap endpoint.
# Replace the email/password — store the password in your password manager.
curl -X POST https://medplum.example.com/auth/newproject \
-H 'content-type: application/json' \
-d '{
"firstName":"Vendo",
"lastName":"Admin",
"email":"admin@example.com",
"password":"<strong-password>",
"projectName":"Vendo Demo Production",
"recaptchaToken":"dev-bypass"
}'Then run the seed (creates referring doctors + front-desk staff + Schedule + Slots) by
SSHing into the box and running, from /opt/vendo:
# One-time: seed the production project. Edit the seed env first to point at
# the prod admin you just created.
cp infra/compose/seed/.env.example infra/compose/seed/.env.prod
nano infra/compose/seed/.env.prod # set MEDPLUM_BASE_URL + SEED_ADMIN_EMAIL/PASSWORD
docker run --rm --network vendo-prod_default \
-v /opt/vendo:/work -w /work \
-e DOTENV_FILE=/work/infra/compose/seed/.env.prod \
node:22-alpine sh -c 'corepack enable && pnpm -C infra/compose/seed start'You should see seed complete with the user count. Each seeded user uses the
shared password ClinicPass!2026 — change these immediately via the
Medplum admin app or send each user a password-reset link.
Add a daily Postgres dump to crontab (encrypted at rest, off-host weekly):
cat > /opt/vendo/scripts/backup.sh <<'BASH'
#!/usr/bin/env bash
set -euo pipefail
ts=$(date -u +%Y%m%dT%H%M%SZ)
mkdir -p /opt/vendo/backups
docker exec vendo-prod-postgres-1 pg_dump -U medplum medplum \
| gzip -9 > "/opt/vendo/backups/medplum-${ts}.sql.gz"
# Keep last 14 daily.
find /opt/vendo/backups -name 'medplum-*.sql.gz' -mtime +14 -delete
BASH
chmod +x /opt/vendo/scripts/backup.sh
( crontab -l 2>/dev/null; echo "30 3 * * * /opt/vendo/scripts/backup.sh" ) | crontab -For off-host: rsync /opt/vendo/backups weekly to S3/B2/another VPS.
Restore drill should be tabletop-tested before touching real PHI.
# Tail logs of one service
docker compose -f docker-compose.prod.yml logs -f portal
# Update portal after a git pull
cd /opt/vendo && git pull
cd infra/compose
docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build portal
# Restart cloudflared without touching the app
docker compose -f docker-compose.prod.yml restart cloudflared
# Manually trigger a backup
/opt/vendo/scripts/backup.sh
# Restore from backup (DESTRUCTIVE — replaces current DB)
gunzip -c /opt/vendo/backups/medplum-20260501T033000Z.sql.gz \
| docker exec -i vendo-prod-postgres-1 psql -U medplum medplum- External pen-test
- Multi-region failover, hot-standby DB
- Centralised log shipping (use
journalctlon the VPS for now) - TOTP MFA, self-service forgot-password
- Error aggregation (Sentry / Honeybadger / similar)
See TODO.md for the full deferred-work list.