Getting Started
VeloCMS runs anywhere Docker runs — a $6/mo VPS, a Railway service, or your laptop. This guide walks you through the full self-hosting path: Docker Compose setup, environment variables, Cloudflare R2 for media, wildcard DNS, and creating your first admin account. By the end you'll have a running instance you can actually log into.
Prerequisites
- Docker 24+ and Docker Compose v2
- A domain name (or subdomain) you control
- A Cloudflare account (free tier works) for R2 media storage
- Optional but recommended: a Railway account for managed hosting
Docker Compose quickstart
VeloCMS ships pre-built Docker images — you don't need the source code to run it. Three commands and you have a live instance: download the Compose template, fill in your secrets, and pull the images.
# 1. Create a working directory
mkdir velocms && cd velocms
# 2. Download the docker-compose template and env example
curl -O https://velocms.org/install/docker-compose.yml
curl -O https://velocms.org/install/.env.example
cp .env.example .env
# Edit .env with your secrets (POCKETBASE_ADMIN_PASSWORD, ENCRYPTION_KEY, etc.)
# 3. Pull the images and start
docker compose up -dRequired environment variables
VeloCMS reads all configuration from environment variables — no config files, no magic. The .env.example in the root covers every required and optional variable. Here are the ones you must set before first launch:
# Core
VELOCMS_MODE=single # or "multi" for multi-tenant SaaS mode
POCKETBASE_URL=http://localhost:8090
POCKETBASE_ADMIN_EMAIL=admin@example.com
POCKETBASE_ADMIN_PASSWORD=changeme123
# Public site URL (used for sitemap, OG images, canonical links)
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
NEXT_PUBLIC_PLATFORM_DOMAIN=yourdomain.com
# Encryption key — 64 hex characters (32 bytes). Generate with:
# openssl rand -hex 32
ENCRYPTION_KEY=<64-char-hex>
# Cloudflare R2 media storage
CLOUDFLARE_ACCOUNT_ID=<your-account-id>
CLOUDFLARE_API_TOKEN=<your-api-token>
R2_BUCKET_NAME=velocms-media
R2_PUBLIC_URL=https://pub-<hash>.r2.dev
# Email (Resend)
RESEND_API_KEY=re_...
RESEND_FROM_EMAIL=noreply@yourdomain.com
# AI writing assistant (optional — editor AI features require this)
GEMINI_API_KEY=AI...
# Billing — only required for membership/paywall features
STRIPE_SECRET_KEY=sk_live_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...Single-instance vs multi-tenant mode
VeloCMS ships in two modes controlled by a single env var. VELOCMS_MODE=single is what you want for a personal blog or a small agency running one site — everything lives in one PocketBase instance, dead simple. VELOCMS_MODE=multi is the SaaS mode: a master PocketBase handles auth and billing, and each tenant gets their own isolated PocketBase with their own content. Multi-mode is what velocms.org itself runs in production.
| Feature | single | multi |
|---|---|---|
| PocketBase instances | 1 | 1 master + N tenant |
| Tenant isolation | none (single blog) | full DB isolation |
| Custom domains | no | yes (Cloudflare for SaaS) |
| Stripe billing | optional | per-tenant BYOK or platform Stripe |
| Best for | personal blog, agency site | SaaS platform, blog hosting |
Setting up Cloudflare R2
R2 is where all uploaded media lives — images, videos, file attachments. It's S3-compatible and has zero egress fees, which matters when you're hosting a photo-heavy blog. You'll create a bucket, generate an API token with object write permissions, and grab the public URL.
# 1. Log into https://dash.cloudflare.com → R2 Object Storage
# 2. Create a bucket: "velocms-media" (or any name you prefer)
# 3. Under bucket settings, enable Public Access → note the r2.dev URL
# 4. Create an API Token with:
# - Object Read & Write permissions on your bucket
# - Copy the token value — you only see it once
# 5. Set in .env.local:
CLOUDFLARE_ACCOUNT_ID=<from Cloudflare dashboard top-right>
CLOUDFLARE_API_TOKEN=<the token you just copied>
R2_BUCKET_NAME=velocms-media
R2_PUBLIC_URL=https://pub-<hash>.r2.devWildcard DNS setup
If you're running in multi-tenant mode, each tenant gets their own subdomain (e.g. alice.yourdomain.com). You'll need a wildcard DNS record pointing to your server, and Cloudflare configured to not proxy it (grey cloud). The apex domain stays orange cloud (Cloudflare-proxied).
# Add these two records in Cloudflare DNS:
Type Name Content Proxy status
A yourdomain.com <your-server-IP> Proxied (orange cloud)
A *.yourdomain.com <your-server-IP> DNS only (grey cloud)
# The wildcard MUST be grey cloud — Cloudflare's proxy doesn't support
# wildcard certificates on free plans, and Railway handles TLS for subdomains.Creating your first admin account
Once Docker Compose is running, PocketBase exposes its admin UI at port 8090. The very first time you visit it, you'll be prompted to create a superuser — this is your admin account. After that, you can log into VeloCMS at /login with those credentials.
# Navigate to PocketBase admin (while Docker is running):
open http://localhost:8090/_/
# Create your superuser email + password when prompted.
# Then visit the VeloCMS admin panel:
open http://localhost:3000/loginDeploying to Railway
Railway is the recommended production host — it handles Docker builds, zero-downtime deploys, automatic TLS, and custom domains out of the box. Once the public repo is available (Q3 2026 at github.com/VeloCMS), Railway auto-deploys from git push. Until then, use the Railway Docker registry directly or contact hello@velocms.org for managed hosting.
# Install Railway CLI
npm install -g @railway/cli
# Login and link to your project
railway login
railway link
# Set production env vars (do this before the first deploy)
railway variables --set "VELOCMS_MODE=multi" --skip-deploys
railway variables --set "POCKETBASE_URL=https://your-pb.up.railway.app" --skip-deploys
railway variables --set "NEXT_PUBLIC_SITE_URL=https://yourdomain.com" --skip-deploys
# ... (set all other vars from .env.example / public/install/.env.example)
# Deploy — Railway auto-deploys from the VeloCMS Docker image
railway upThat's it. Once the first deploy finishes, visit your Railway service URL to confirm everything is live. Check the build logs if anything looks wrong — Railway's log viewer is genuinely useful for tracing startup errors.
Reference
Environment variables
| Variable | Required | Default | Description |
|---|---|---|---|
| VELOCMS_MODE | Yes | single | single = one blog, multi = SaaS multi-tenant |
| POCKETBASE_URL | Yes | http://localhost:8090 | PocketBase API base URL (internal in Docker) |
| POCKETBASE_ADMIN_EMAIL | Yes | — | PocketBase superuser email |
| POCKETBASE_ADMIN_PASSWORD | Yes | — | PocketBase superuser password |
| NEXT_PUBLIC_SITE_URL | Yes | http://localhost:3000 | Public URL — used in sitemap, OG images, canonical links |
| NEXT_PUBLIC_PLATFORM_DOMAIN | Yes (multi) | velocms.org | Apex domain for subdomain tenant routing |
| ENCRYPTION_KEY | Yes | — | 64-char hex (openssl rand -hex 32). Encrypts tenant API keys. |
| CLOUDFLARE_ACCOUNT_ID | Yes | — | Cloudflare account ID for R2 presigned uploads |
| CLOUDFLARE_API_TOKEN | Yes | — | R2-scoped API token (Object Read & Write) |
| R2_BUCKET_NAME | Yes | velocms-media | Cloudflare R2 bucket name |
| R2_PUBLIC_URL | Yes | — | Public r2.dev or custom domain URL for media files |
| RESEND_API_KEY | Yes | — | Resend send-only API key (re_...) |
| RESEND_FROM_EMAIL | Yes | — | From address for transactional email |
| GEMINI_API_KEY | No | — | Google Gemini API key. Required for editor AI features. |
| STRIPE_SECRET_KEY | No | — | Stripe restricted key (sk_live_...). Required for membership billing. |
| NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | No | — | Stripe publishable key (pk_live_...) |
| STRIPE_WEBHOOK_SECRET | No | — | Stripe webhook signing secret (whsec_...) |
| STRIPE_PRICE_PRO | No | — | Stripe price ID for Pro plan |
| STRIPE_PRICE_BUSINESS | No | — | Stripe price ID for Business plan |
| STRIPE_PRICE_AGENCY | No | — | Stripe price ID for Agency plan |
| CLOUDFLARE_ZONE_ID | No (multi) | — | CF zone for custom domain routing (Pro+ tenants) |
| CLOUDFLARE_FALLBACK_ORIGIN | No (multi) | — | Fallback origin hostname for CF for SaaS |
| NEXT_PUBLIC_SENTRY_DSN | No | — | Sentry DSN for error monitoring |
| PREVIEW_SECRET | No | — | Draft mode preview token (openssl rand -hex 16) |
| CRON_SECRET | No | — | Protects /api/cron/* endpoints (openssl rand -hex 32) |
| BETA_WAITLIST_ENABLED | No | — | Set true to gate signups behind waitlist |
| BETA_INVITE_CODES | No | — | CSV of invite codes that bypass waitlist |
| UNSPLASH_ACCESS_KEY | No | — | Unsplash API key for onboarding featured image |
Install path summary
| Step | Command | Notes |
|---|---|---|
| 1. Get template | curl -O https://velocms.org/install/docker-compose.yml | Sanitized Docker Compose for self-hosters |
| 2. Get env template | curl -O https://velocms.org/install/.env.example && cp .env.example .env | Fill in required vars before starting |
| 3. Start services | docker compose up -d | Starts VeloCMS on :3000 + PocketBase on :8090 |
| 4. Create admin | open http://localhost:8090/_/ | Create superuser on first visit |
| 5. Log in | open http://localhost:3000/login | Sign in with superuser credentials |
| 6. Set domain | Edit NEXT_PUBLIC_SITE_URL in .env + restart | Required for sitemap and email links |
Single-instance vs multi-tenant
| Feature | single | multi |
|---|---|---|
| PocketBase instances | 1 | 1 master + N tenant |
| Tenant isolation | none (single blog) | full DB isolation per tenant |
| Custom domains | no | yes (Cloudflare for SaaS, Pro+ plan) |
| Stripe billing | optional (BYOK) | per-tenant BYOK or platform Stripe |
| Wildcard DNS needed | no | yes (*.yourdomain.com) |
| Best for | personal blog, agency site | SaaS platform, multi-blog hosting |
| Additional env vars | — | MASTER_POCKETBASE_URL, NEXT_PUBLIC_PLATFORM_DOMAIN, CLOUDFLARE_ZONE_ID |
Docker image reference
| Image | Tag | Contents |
|---|---|---|
| velocms/velocms | latest | Next.js 16 app + all API routes + edge middleware |
| velocms/velocms | x.y.z | Pinned release — recommended for production |
| velocms/pocketbase | latest | PocketBase 0.36 with VeloCMS schema pre-loaded |
| velocms/pocketbase | x.y.z | Pinned release matching the app image version |