Self-Hosting VeloCMS

15 min readUpdated 27 Apr 2026

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.

ProfileTenantsRAMDiskCPUNotes
Minimum1 (single-instance mode)1 GB10 GB SSD1 vCPUPersonal blog or small agency site. SQLite fits comfortably.
ComfortableUp to ~20 tenants2 GB25 GB SSD1–2 vCPUSmall multi-tenant deployment. Allocate ~500 MB per additional PB instance.
ProductionUp to ~100 tenants4 GB50 GB SSD2 vCPUMulti-tenant SaaS. R2 for media offloads disk pressure significantly.
High-scale100+ tenants8+ GB100 GB SSD + R24+ vCPUConsider 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.

terminal
# 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 ps
bash

The 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:

docker-compose.yml
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:
bash

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.

VariableRequiredDefaultPurposeSecurity note
VELOCMS_MODEYessinglesingle = one blog, one PocketBase. multi = SaaS multi-tenant with master DB.
POCKETBASE_URLYeshttp://localhost:8090Internal URL of the PocketBase service. In Docker, use http://pocketbase:8090.Never expose PB directly on a public port in production.
POCKETBASE_ADMIN_EMAILYesPocketBase superuser email. Created on first PB boot.Use a non-guessable address. Not used for reader/blog-owner auth.
POCKETBASE_ADMIN_PASSWORDYesPocketBase superuser password.Minimum 12 chars. Store in a password manager; not recoverable without DB access.
ENCRYPTION_KEYYes32-byte hex key (64 chars) for AES-256-GCM. Encrypts all tenant API secrets (Stripe keys, AI keys). Generate: openssl rand -hex 32CRITICAL. Loss = all tenant secrets unreadable. Rotation requires a migration. Back up separately from the DB.
NEXT_PUBLIC_SITE_URLYeshttp://localhost:3000Public-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_DOMAINmulti onlyvelocms.orgApex domain for subdomain-based tenant routing (e.g. alice.yourdomain.com).
RESEND_API_KEYYesResend 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_EMAILYesFrom address for all outbound email. Must match a verified domain in your Resend account.
CLOUDFLARE_ACCOUNT_IDYes (media)Cloudflare account ID for R2 presigned upload URLs.
CLOUDFLARE_API_TOKENYes (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_NAMEYes (media)velocms-mediaName of the Cloudflare R2 bucket for all uploaded media.
R2_PUBLIC_URLYes (media)Public base URL of the bucket (r2.dev URL or custom domain).
STRIPE_SECRET_KEYNoStripe 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_KEYNoStripe publishable key (pk_live_...). Required only if using platform Stripe.
STRIPE_WEBHOOK_SECRETNoWebhook 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_PRONoStripe price ID for the Pro plan. Only needed for platform billing.
STRIPE_PRICE_BUSINESSNoStripe price ID for the Business plan.
STRIPE_PRICE_AGENCYNoStripe price ID for the Agency plan.
GEMINI_API_KEYNoGoogle Gemini API key. Required for the editor AI writing assistant and AI SEO scoring.
CLOUDFLARE_ZONE_IDNo (multi)CF zone ID for Cloudflare for SaaS custom domain provisioning. Only needed for multi-mode Pro+ tenants.
CLOUDFLARE_FALLBACK_ORIGINNo (multi)Fallback origin hostname for Cloudflare for SaaS routing.
PREVIEW_SECRETNoDraft preview token. Generate: openssl rand -hex 16Keep short-lived if sharing with editors.
CRON_SECRETNoProtects /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.

FeatureVELOCMS_MODE=singleVELOCMS_MODE=multi
PocketBase instances1 total1 master + 1 per tenant
Tenant data isolationN/A — single blogFull DB-level isolation per tenant
Custom domains (reader-facing)Set via NEXT_PUBLIC_SITE_URLPer-tenant via Cloudflare for SaaS (Pro+)
Stripe billingOptional — platform Stripe or BYOKPer-tenant BYOK Stripe or platform billing
Wildcard DNS requiredNoYes — *.yourdomain.com → your server
NEXT_PUBLIC_PLATFORM_DOMAINNot requiredRequired — apex domain for subdomain routing
Best forPersonal blog, single agency site, hobby projectSaaS platform, multi-blog hosting, resellers
Ops complexityLow — one PB to back up and monitorHigher — 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.

terminal
# 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/login
bash

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

terminal
# 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.mjs
bash

Custom 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
# 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 caddy
bash

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

Cloudflare DNS
# 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.
bash

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.

backup-daily.sh
#!/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 -delete
bash

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

terminal
# 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.
bash

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

terminal
# 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 .
bash

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.

ErrorLikely causeFix
Port 3000 already in useAnother process is on :3000Change the host-side port in docker-compose.yml: "3001:3000". Set NEXT_PUBLIC_SITE_URL accordingly.
PocketBase not reachable / ECONNREFUSEDPOCKETBASE_URL is wrong, or PB container hasn't started yetIn Docker, the URL must be http://pocketbase:8090 (internal DNS). Check docker compose ps to confirm pocketbase is healthy.
Stripe webhook: invalid signatureSTRIPE_WEBHOOK_SECRET is the CLI local secret, not the Dashboard signing secretGo 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 403CLOUDFLARE_API_TOKEN lacks R2 write permission, or bucket CORS is not configuredIn Cloudflare R2 bucket settings, add CORS rule: allow * origin with PUT + GET methods. Verify the token has Object Read & Write scope.
Email not deliveredRESEND_FROM_EMAIL domain not verified in ResendIn Resend dashboard, check domain verification status. DNS propagation can take up to 24h after adding TXT records.
ENCRYPTION_KEY error on startupKey is not exactly 64 hex charactersRegenerate: openssl rand -hex 32. This produces exactly 64 hex chars.
First admin login fails / 'user not found'Superuser wasn't created before accessing /loginVisit http://localhost:8090/_/ first and complete the superuser setup.
Subdomain tenants 404 in multi-modeWildcard DNS not propagated, or DNS is proxied (orange cloud) instead of grayVerify the wildcard A record in Cloudflare is DNS-only. Check propagation with: dig +short alice.yourdomain.com

Reference appendix

Docker images

ImageTagContents
velocms/velocmslatestNext.js 16 app, all API routes, edge middleware, SSR + RSC
velocms/velocmsx.y.zPinned release — recommended for production. Matches pocketbase image version.
velocms/pocketbaselatestPocketBase 0.36 with VeloCMS schema pre-loaded. Auto-applies migrations on start.
velocms/pocketbasex.y.zPinned release matching the app image version.

Hardware sizing quick-reference

ProviderInstance typeRAMCost/mo (approx)Suitable for
HetznerCX224 GB$6Personal blog or small multi-tenant (up to ~20 sites)
HetznerCX328 GB$13Production multi-tenant up to ~100 sites
RailwayStarter service512 MB~$5Single-instance dev/staging only
RailwayPro service (2 vCPU / 4 GB)4 GB~$20Production single-instance or small multi-tenant
Fly.ioshared-cpu-2x / 4 GB4 GB$14Good for single-instance with Fly Volumes for PB persistence

Key file paths inside the Docker container

PathDescription
/pb/pb_dataPocketBase data directory — databases, uploads, backups. Mount as a volume.
/pb/pb_migrationsAuto-applied migrations bundled with the image.
/appNext.js application root.
/app/.nextProduction build output — do not mount over this.