Self-Hosting VeloCMS
Why self-host VeloCMS
Most people just want the managed cloud — and that's fine. But if you have data residency requirements, a compliance mandate (GDPR, HIPAA, SOC 2 internal policy), cost-at-scale concerns, or simply the principle that your data should live on your own iron, this guide gets you to a running production instance in under 30 minutes on a $6/mo VPS. Self-hosting means full control over schema, storage, backups, and the upgrade cycle. The tradeoff is that you own the ops. VeloCMS tries to make that tradeoff genuinely worth taking.
System requirements
VeloCMS is lighter than it looks. PocketBase is a single Go binary with embedded SQLite, and the Next.js container idles at around 150MB RSS. You don't need a beefy server for a typical blog deployment.
| Profile | Tenants | RAM | Disk | CPU | Notes |
|---|---|---|---|---|---|
| Minimum | 1 (single-instance mode) | 1 GB | 10 GB SSD | 1 vCPU | Personal blog or small agency site. SQLite fits comfortably. |
| Comfortable | Up to ~20 tenants | 2 GB | 25 GB SSD | 1–2 vCPU | Small multi-tenant deployment. Allocate ~500 MB per additional PB instance. |
| Production | Up to ~100 tenants | 4 GB | 50 GB SSD | 2 vCPU | Multi-tenant SaaS. R2 for media offloads disk pressure significantly. |
| High-scale | 100+ tenants | 8+ GB | 100 GB SSD + R2 | 4+ vCPU | Consider splitting master PB and tenant PB across separate services. |
Docker Compose quickstart
VeloCMS ships two pre-built Docker images: `velocms/velocms:latest` (Next.js 16 app) and `velocms/pocketbase:latest` (PocketBase 0.36 with the VeloCMS schema pre-loaded). You don't need the source code to run it. The Compose template at `public/install/docker-compose.yml` wires the two services together with sensible defaults — shared volume, internal DNS, and health-check ordering so PocketBase is ready before the Next.js process attempts a schema migration.
# 1. Create a working directory
mkdir velocms && cd velocms
# 2. Download the official Compose template + env example
curl -O https://velocms.org/install/docker-compose.yml
curl -O https://velocms.org/install/.env.example
cp .env.example .env
# 3. Edit .env — fill in the required secrets (see Environment Variables table below)
nano .env # or: vim .env / code .env
# 4. Pull images and start in the background
docker compose up -d
# Verify both containers are running and healthy
docker compose psThe full Compose file is maintained in the repo at `public/install/docker-compose.yml`. Here is the canonical structure as of the Q3 2026 release:
version: "3.9"
services:
pocketbase:
image: velocms/pocketbase:latest
restart: unless-stopped
volumes:
- pb_data:/pb/pb_data
ports:
- "8090:8090" # Expose only internally in prod; reverse-proxy to 3000
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8090/api/health"]
interval: 10s
timeout: 5s
retries: 5
velocms:
image: velocms/velocms:latest
restart: unless-stopped
depends_on:
pocketbase:
condition: service_healthy
ports:
- "3000:3000"
env_file: .env
environment:
POCKETBASE_URL: http://pocketbase:8090 # Internal Docker DNS — do not change
volumes:
pb_data:Environment variables reference
All configuration goes through environment variables — no config files, no UI settings panel for server behavior. The `.env.example` in the install bundle mirrors this table exactly. Required variables are enforced at startup; missing ones will cause the app to log a clear error and refuse to serve requests.
| Variable | Required | Default | Purpose | Security note |
|---|---|---|---|---|
| VELOCMS_MODE | Yes | single | single = one blog, one PocketBase. multi = SaaS multi-tenant with master DB. | — |
| POCKETBASE_URL | Yes | http://localhost:8090 | Internal URL of the PocketBase service. In Docker, use http://pocketbase:8090. | Never expose PB directly on a public port in production. |
| POCKETBASE_ADMIN_EMAIL | Yes | — | PocketBase superuser email. Created on first PB boot. | Use a non-guessable address. Not used for reader/blog-owner auth. |
| POCKETBASE_ADMIN_PASSWORD | Yes | — | PocketBase superuser password. | Minimum 12 chars. Store in a password manager; not recoverable without DB access. |
| ENCRYPTION_KEY | Yes | — | 32-byte hex key (64 chars) for AES-256-GCM. Encrypts all tenant API secrets (Stripe keys, AI keys). Generate: openssl rand -hex 32 | CRITICAL. Loss = all tenant secrets unreadable. Rotation requires a migration. Back up separately from the DB. |
| NEXT_PUBLIC_SITE_URL | Yes | http://localhost:3000 | Public-facing base URL. Used for sitemap, OG image absolute URLs, canonical links, and magic-link emails. | Must be HTTPS in production or email links will be insecure. |
| NEXT_PUBLIC_PLATFORM_DOMAIN | multi only | velocms.org | Apex domain for subdomain-based tenant routing (e.g. alice.yourdomain.com). | — |
| RESEND_API_KEY | Yes | — | Resend send-only API key (re_...). Transactional email for magic links, newsletter blasts, unsubscribe flows. | Use a send-only restricted key — it cannot list domains or manage API keys if leaked. |
| RESEND_FROM_EMAIL | Yes | — | From address for all outbound email. Must match a verified domain in your Resend account. | — |
| CLOUDFLARE_ACCOUNT_ID | Yes (media) | — | Cloudflare account ID for R2 presigned upload URLs. | — |
| CLOUDFLARE_API_TOKEN | Yes (media) | — | Cloudflare API token scoped to R2 Object Read & Write on your bucket. | Scope tightly — the token only needs R2 access, not DNS or Zone edit. |
| R2_BUCKET_NAME | Yes (media) | velocms-media | Name of the Cloudflare R2 bucket for all uploaded media. | — |
| R2_PUBLIC_URL | Yes (media) | — | Public base URL of the bucket (r2.dev URL or custom domain). | — |
| STRIPE_SECRET_KEY | No | — | Stripe restricted key (sk_live_...). Required only for platform-level membership billing. | Never put the full secret key here — use a restricted key with only the webhook + customer permissions your flows need. |
| NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | No | — | Stripe publishable key (pk_live_...). Required only if using platform Stripe. | — |
| STRIPE_WEBHOOK_SECRET | No | — | Webhook signing secret (whsec_...) from the Stripe Dashboard endpoint config. | The CLI local secret (stripe listen) is DIFFERENT from the Dashboard secret. Do not mix them. |
| STRIPE_PRICE_PRO | No | — | Stripe price ID for the Pro plan. Only needed for platform billing. | — |
| STRIPE_PRICE_BUSINESS | No | — | Stripe price ID for the Business plan. | — |
| STRIPE_PRICE_AGENCY | No | — | Stripe price ID for the Agency plan. | — |
| GEMINI_API_KEY | No | — | Google Gemini API key. Required for the editor AI writing assistant and AI SEO scoring. | — |
| CLOUDFLARE_ZONE_ID | No (multi) | — | CF zone ID for Cloudflare for SaaS custom domain provisioning. Only needed for multi-mode Pro+ tenants. | — |
| CLOUDFLARE_FALLBACK_ORIGIN | No (multi) | — | Fallback origin hostname for Cloudflare for SaaS routing. | — |
| PREVIEW_SECRET | No | — | Draft preview token. Generate: openssl rand -hex 16 | Keep short-lived if sharing with editors. |
| CRON_SECRET | No | — | Protects /api/cron/* endpoints. Generate: openssl rand -hex 32 | — |
Single-instance vs multi-tenant mode
The choice between modes is a deployment topology decision, not a feature tier. Single-instance is a genuinely good fit for a personal blog, an agency client site, or a small business that wants one clean installation with no multi-tenancy overhead. Multi-tenant mode is for when you're building a SaaS product on top of VeloCMS — your customers get their own isolated PocketBase instances, their own subdomain, and optionally their own Stripe account via BYOK billing. Production at velocms.org runs multi-mode.
| Feature | VELOCMS_MODE=single | VELOCMS_MODE=multi |
|---|---|---|
| PocketBase instances | 1 total | 1 master + 1 per tenant |
| Tenant data isolation | N/A — single blog | Full DB-level isolation per tenant |
| Custom domains (reader-facing) | Set via NEXT_PUBLIC_SITE_URL | Per-tenant via Cloudflare for SaaS (Pro+) |
| Stripe billing | Optional — platform Stripe or BYOK | Per-tenant BYOK Stripe or platform billing |
| Wildcard DNS required | No | Yes — *.yourdomain.com → your server |
| NEXT_PUBLIC_PLATFORM_DOMAIN | Not required | Required — apex domain for subdomain routing |
| Best for | Personal blog, single agency site, hobby project | SaaS platform, multi-blog hosting, resellers |
| Ops complexity | Low — one PB to back up and monitor | Higher — master + N tenant PBs, wildcard TLS |
If you're unsure, start with single. You can migrate to multi later — the schema is the same, the main difference is where the PocketBase files live and how the middleware routes requests.
First admin user bootstrap
PocketBase has its own admin UI — it's the low-level control panel for the database. The first time you visit it, you'll be prompted to create a superuser. That superuser becomes the credentials you log into VeloCMS with at /login. You only get this 'create superuser' prompt once on a fresh DB, so do it before you do anything else.
# While Docker Compose is running, open the PocketBase admin UI:
open http://localhost:8090/_/
# Create your superuser email + password when prompted.
# Then log into VeloCMS:
open http://localhost:3000/loginIf you'd rather bootstrap headlessly — useful in CI or automated provisioning — PocketBase also accepts credentials via environment variables on first start. The `POCKETBASE_ADMIN_EMAIL` and `POCKETBASE_ADMIN_PASSWORD` vars in your `.env` are read by the PB container during schema initialization. After first boot, changing those env vars has no effect on an existing DB; superuser management then lives entirely in the PB admin UI.
Once you're logged into the VeloCMS admin at `/admin`, optionally run the demo content seeder to populate your install with sample posts, a theme, and a few media items so the interface isn't completely empty while you're evaluating it.
# Optional: seed demo content (posts + default tenant + media placeholders)
# Run this from inside the velocms container or from the repo if you have it locally:
node scripts/seed-multi-tenant.mjsCustom domain and TLS setup
For a single-instance deployment, point your domain's A record at your server IP and configure a reverse proxy (nginx or Caddy) to forward port 443 → 3000. Caddy is the easiest path — it handles Let's Encrypt automatically.
# Caddyfile — put in /etc/caddy/Caddyfile or your Caddy config path
yourdomain.com {
reverse_proxy localhost:3000
}
# Caddy auto-provisions TLS via Let's Encrypt. No certbot needed.
# Reload after editing: systemctl reload caddyFor multi-tenant mode you additionally need a wildcard DNS record. The wildcard must be DNS-only (gray cloud in Cloudflare) — not proxied. Cloudflare's proxy doesn't issue wildcard certificates on the free plan, and Railway handles TLS termination for each subdomain if you deploy there.
# Add these two records in the Cloudflare DNS dashboard:
#
# Type Name Content Proxy status
# A yourdomain.com <server-IP> Proxied (orange cloud)
# A *.yourdomain.com <server-IP> DNS only (grey cloud)
#
# The apex domain stays proxied (CDN + DDoS protection).
# The wildcard MUST be grey cloud — Railway or your Caddy handles TLS.Backup strategy
PocketBase stores all data in SQLite files on disk. Backing up is straightforward: copy the `pb_data` directory while PocketBase is either stopped or using the PB backup API (which triggers a hot checkpoint). The recommended approach is a daily cron that runs `pocketbase backup` and streams the result to R2. That way your backups live separately from your primary storage and survive a server failure.
#!/usr/bin/env bash
# Daily PocketBase backup -> Cloudflare R2
# Add to crontab: 0 3 * * * /opt/velocms/backup-daily.sh
set -euo pipefail
DATE=$(date +%Y-%m-%d)
BACKUP_FILE="velocms-backup-${DATE}.zip"
PB_URL="${POCKETBASE_URL:-http://localhost:8090}"
R2_BUCKET="${R2_BUCKET_NAME:-velocms-backups}"
# Trigger PocketBase hot backup via API (requires superuser auth)
curl -s -X POST \
-H "Authorization: ${PB_ADMIN_TOKEN}" \
"${PB_URL}/api/backups" \
-d "{"name":"${BACKUP_FILE}"}"
# Wait for backup to appear in PB's local backup folder, then stream to R2
# Assumes rclone is configured with the Cloudflare R2 remote named "r2"
rclone copy "${PB_DATA_DIR}/backups/${BACKUP_FILE}" "r2:${R2_BUCKET}/daily/"
echo "[backup] ${BACKUP_FILE} uploaded to R2 at $(date)"
# Prune local backups older than 7 days
find "${PB_DATA_DIR}/backups/" -name "*.zip" -mtime +7 -deleteRestore is the reverse: download the zip from R2, place it in `pb_data/backups/`, and call the PB restore API. Run a restore drill at least monthly — a backup you've never tested is a backup you don't actually have.
# Download a backup from R2:
rclone copy "r2:velocms-backups/daily/velocms-backup-2026-04-26.zip" ./restore/
# Restore via PocketBase API (while PB is running):
curl -X PUT \
-H "Authorization: <admin-token>" \
"${PB_URL}/api/backups/velocms-backup-2026-04-26.zip/restore"
# PocketBase will restart automatically after restore.Updating VeloCMS
Updates are a two-step pull-and-restart. VeloCMS applies PocketBase migrations automatically on startup — you don't need to run migration scripts manually unless you're upgrading a major version that ships a breaking schema change (those are documented in the release notes with explicit pre-upgrade steps).
# 1. Pull the latest images (do this from your velocms working directory)
docker compose pull
# 2. Take a backup before upgrading (see backup strategy above)
./backup-daily.sh
# 3. Restart with the new images — zero-downtime if using a load balancer
docker compose up -d --remove-orphans
# 4. Verify the app is healthy
docker compose ps
curl -s http://localhost:3000/api/health | jq .To pin to a specific release rather than tracking `latest`, replace the image tag in your `docker-compose.yml`. For example, `velocms/velocms:1.2.0` and `velocms/pocketbase:1.2.0`. Pinned tags are recommended for production — they let you control the upgrade window and rule out an image pull as the cause if something breaks.
Troubleshooting
Most startup failures are either a missing env var or a network reachability issue between the Next.js container and PocketBase. The Docker logs (`docker compose logs -f velocms`) are the first place to look — VeloCMS logs structured JSON errors to stdout, so `| jq .` helps parse them.
| Error | Likely cause | Fix |
|---|---|---|
| Port 3000 already in use | Another process is on :3000 | Change the host-side port in docker-compose.yml: "3001:3000". Set NEXT_PUBLIC_SITE_URL accordingly. |
| PocketBase not reachable / ECONNREFUSED | POCKETBASE_URL is wrong, or PB container hasn't started yet | In Docker, the URL must be http://pocketbase:8090 (internal DNS). Check docker compose ps to confirm pocketbase is healthy. |
| Stripe webhook: invalid signature | STRIPE_WEBHOOK_SECRET is the CLI local secret, not the Dashboard signing secret | Go to Stripe Dashboard → Webhooks → your endpoint → Signing secret. That value goes in STRIPE_WEBHOOK_SECRET. The CLI whsec_ is temporary and only valid for stripe listen sessions. |
| Media uploads fail / R2 403 | CLOUDFLARE_API_TOKEN lacks R2 write permission, or bucket CORS is not configured | In Cloudflare R2 bucket settings, add CORS rule: allow * origin with PUT + GET methods. Verify the token has Object Read & Write scope. |
| Email not delivered | RESEND_FROM_EMAIL domain not verified in Resend | In Resend dashboard, check domain verification status. DNS propagation can take up to 24h after adding TXT records. |
| ENCRYPTION_KEY error on startup | Key is not exactly 64 hex characters | Regenerate: openssl rand -hex 32. This produces exactly 64 hex chars. |
| First admin login fails / 'user not found' | Superuser wasn't created before accessing /login | Visit http://localhost:8090/_/ first and complete the superuser setup. |
| Subdomain tenants 404 in multi-mode | Wildcard DNS not propagated, or DNS is proxied (orange cloud) instead of gray | Verify the wildcard A record in Cloudflare is DNS-only. Check propagation with: dig +short alice.yourdomain.com |
Reference appendix
Docker images
| Image | Tag | Contents |
|---|---|---|
| velocms/velocms | latest | Next.js 16 app, all API routes, edge middleware, SSR + RSC |
| velocms/velocms | x.y.z | Pinned release — recommended for production. Matches pocketbase image version. |
| velocms/pocketbase | latest | PocketBase 0.36 with VeloCMS schema pre-loaded. Auto-applies migrations on start. |
| velocms/pocketbase | x.y.z | Pinned release matching the app image version. |
Hardware sizing quick-reference
| Provider | Instance type | RAM | Cost/mo (approx) | Suitable for |
|---|---|---|---|---|
| Hetzner | CX22 | 4 GB | $6 | Personal blog or small multi-tenant (up to ~20 sites) |
| Hetzner | CX32 | 8 GB | $13 | Production multi-tenant up to ~100 sites |
| Railway | Starter service | 512 MB | ~$5 | Single-instance dev/staging only |
| Railway | Pro service (2 vCPU / 4 GB) | 4 GB | ~$20 | Production single-instance or small multi-tenant |
| Fly.io | shared-cpu-2x / 4 GB | 4 GB | $14 | Good for single-instance with Fly Volumes for PB persistence |
Key file paths inside the Docker container
| Path | Description |
|---|---|
| /pb/pb_data | PocketBase data directory — databases, uploads, backups. Mount as a volume. |
| /pb/pb_migrations | Auto-applied migrations bundled with the image. |
| /app | Next.js application root. |
| /app/.next | Production build output — do not mount over this. |