CaveCMS
Deploy to production

Build it locally.
Ship it live.

You built a site in a local install and want it live on a server, one that may already host other apps. The shape: a fresh install on the server, then restore your local content over it. The wizard never runs; the restore is what closes it.

Not sure which host fits? Compare self-host options.

Brand-new site?

Start with the installer

If you have no local content to carry over, install fresh and run the browser wizard. That’s the four-step guide on /get-started. Come back here when you’re moving a site you already built.

Overview

The content-preserving deploy.

Built a site on a laptop install and want it on a production server (maybe a shared one)? The flow is: fresh install on the server → restore your local backup over it. The restore brings a fully-configured site (admin user, settings, pages, media), so you never run the install wizard.

The server keeps its own secrets, port, and paths; only content and media travel in the backup.

Already live and just promoting fresh content? Skip the install and push your local content to production in one command instead. This runbook is for standing up the whole app on a new server.

Placeholders

Substitute your own values throughout. Replace the UPPERCASE tokens before running anything.

example.comthe site’s apex domain
APP_USEROS user the app runs as (e.g. www-data)
APP_PORTloopback port the Node app listens on
DOC_ROOTinstall dir, e.g. /var/www/html/example.com/public_html
DB_NAME / DB_USERdatabase + app user
RELEASEversion to install; pin it to your local version

Pin the version

Install the same release your backup was built on (cat VERSION in the local install). The restore loads a dump taken against that schema; a newer release first can mismatch.

Prerequisites

What the server needs.

Ubuntu/Debian or RHEL with Node 20+, MariaDB 10.6+, a web server (Apache or nginx), PM2,wget, and certbot. Point DNS at the server before issuing certificates.

Confirm the toolchain

node -v            # >= 20
command -v npx pm2 wget certbot

Confirm DNS resolves to the server

dig +short example.com        # must return the server IP
dig +short www.example.com    # empty? issue the cert apex-only (TLS step)

Step 1 · Recon

Don’t assume a clean box.

Pick a free port, learn the web-server flavour, and (critically) if another CaveCMS install already lives here, mirror its conventions: same runtime user, same PM2 home, a per-domain vhost filename.

Survey the server

cat /etc/os-release | head -2
ls /etc/nginx/ 2>/dev/null ; ls /etc/apache2/sites-available/ 2>/dev/null

# what's already listening — pick an APP_PORT NOT in this list
ss -tlnp

# existing node/CaveCMS procs — note their runtime user + PM2_HOME
ps aux | grep -E 'node|pm2|next' | grep -v grep

# existing databases — don't collide on DB_NAME
sudo mysql -N -e 'SHOW DATABASES;'

Inspect an existing install to copy its conventions

sudo grep -E '^(PORT|UPLOADS_ROOT)=' \
  /var/www/html/<other-site>/public_html/env.production
getent passwd <its APP_USER>     # confirm it has a real shell

Step 2 · Back up local

Capture content + media.

Run from inside your local install. Without --include-env, the archive carries content and media only (no secrets), exactly what you want to move.

On your local install

./cavecms backup        # content + media, no secrets
ls -la backups/         # cavecms-backup-<ts>-<sha>.tar.gz

Verify the archive is complete

gzip -t backups/cavecms-backup-*.tar.gz && echo OK
tar -tzf backups/cavecms-backup-*.tar.gz
# expect: manifest.json, database.sql.gz, uploads.tar.gz

Step 3 · Database

Create the DB + dir.

CaveCMS needs only db-scoped privileges, no superuser. The install dir is owned by APP_USER; its parent stays root.

CaveCMS connects over TCP to 127.0.0.1. With name-resolution on (the default), that matches a @'localhost' grant. If your server runs skip-name-resolve, grant @'127.0.0.1' instead.

Create the install directory

sudo mkdir -p DOC_ROOT
sudo chown APP_USER:APP_USER DOC_ROOT
Edit the fields before you copy. This SQL runs in mysql, which executes each line on paste, so there’s no chance to edit after.

Run as root: sudo mysql

Edit the highlighted fields below before you copy. The copy button bakes your values into the snippet, so there is no fumbling at the terminal prompt to fix placeholders after paste.

CREATE DATABASE IF NOT EXISTS  CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS ''@'localhost' IDENTIFIED BY '';
GRANT ALL PRIVILEGES ON .* TO ''@'localhost';
FLUSH PRIVILEGES;

Step 4 · Install

Run the installer as root.

Run as root so it can chown the tree, register PM2, and provision /var/log/cavecms + /var/lib/cavecms. Override the PM2 user/home to match an existing install (otherwise it defaults to www-data + /var/www/.pm2).

cd DOC_ROOT (replace UPPERCASE placeholders + creds first)

CAVECMS_PM2_USER=APP_USER \
CAVECMS_PM2_HOME=/home/APP_USER/.pm2 \
CAVECMS_DB_HOST=127.0.0.1 CAVECMS_DB_PORT=3306 \
CAVECMS_DB_USER=DB_USER CAVECMS_DB_PASSWORD='THE_DB_PASSWORD' \
CAVECMS_DB_NAME=DB_NAME \
CAVECMS_SITE_URL=https://example.com \
npx --yes create-cavecms@latest <site-name> \
  --surface=pm2 --port=APP_PORT --version=RELEASE --dir=DOC_ROOT -y

<site-name> becomes the PM2 app name (cavecms-<site-name>), what you’ll pm2 restart. The installer downloads + Ed25519-verifies the release, writes a sealed env.production (mode 600), runs migrations, starts PM2, runs pm2 save, and prints the login path. It ends with CaveCMS installed.

“Sudo is required … Re-run as root” → you ran it as a non-sudo user; re-run as root. “Target directory is not empty” → a prior attempt populated DOC_ROOT; move it aside (sudo mv public_html public_html.aside && sudo mkdir public_html) and re-run, then delete the .aside copy (it holds stale secrets).

Confirm the app is up on loopback

curl -s -o /dev/null -w 'home:%{http_code}\n' http://127.0.0.1:APP_PORT/
# a fresh (un-restored) install 307-redirects to /install — expected; the restore flips it to 200

Step 5 · Restore

This replaces the wizard.

Upload the backup, stage it into the install’s backups/ (owned by APP_USER), and restore. It snapshots first and rolls back on failure, imports the database, and extracts media, and never touches env.production.

Upload + stage (from your laptop, then on the server)

scp backups/cavecms-backup-<ts>-<sha>.tar.gz user@server:~/

# on the server — verify it landed intact, then stage it
gzip -t ~/cavecms-backup-*.tar.gz && echo OK
sudo cp ~/cavecms-backup-*.tar.gz DOC_ROOT/backups/
sudo chown APP_USER:APP_USER DOC_ROOT/backups/cavecms-backup-*.tar.gz

Restore as APP_USER

sudo -u APP_USER bash -lc \
  'cd DOC_ROOT && ./cavecms restore --archive backups/cavecms-backup-<ts>-<sha>.tar.gz --yes'

Verify it landed (don't trust stdout alone)

sudo mysql -N -e "SELECT
  (SELECT COUNT(*) FROM pages) AS pages,
  (SELECT COUNT(*) FROM content_blocks) AS blocks,
  (SELECT COUNT(*) FROM users) AS users;" DB_NAME
sudo find DOC_ROOT/uploads -type f | wc -l   # non-zero
Because no secrets transfer, any integrations/SMTP you configured locally (encrypted with the local key) won’t decrypt under the server’s new key, so re-enter them in the admin after go-live. Then restart and confirm the wizard is closed:

Restart + confirm

sudo -u APP_USER bash -lc \
  'PM2_HOME=/home/APP_USER/.pm2 pm2 restart cavecms-<site-name> --update-env'
curl -s -o /dev/null -w 'home:%{http_code} wizard:' http://127.0.0.1:APP_PORT/ ; \
curl -s -o /dev/null -w '%{http_code}\n'            http://127.0.0.1:APP_PORT/install
# expect  home:200 wizard:404

Step 6 · Web server

Proxy the domain.

CaveCMS ships hardened templates under scripts/apache/ and scripts/nginx/. The bundled installers write a fixed cavecms.conf, which collides if the box already has a CaveCMS install. On a shared box, render the template yourself to a per-domain filename.

Apache: render to a per-domain vhost

awk -v apex=example.com -v staging=staging.example.com \
    -v login=unused -v doc=DOC_ROOT -v port=APP_PORT '
  { gsub(/__APEX__/,apex); gsub(/__STAGING__/,staging); gsub(/__LOGIN_PATH__/,login);
    gsub(/__DOC_ROOT__/,doc); gsub(/__PORT__/,port); print }' \
  DOC_ROOT/scripts/apache/cavecms.conf.template \
  | sudo tee /etc/apache2/sites-available/example.com.conf >/dev/null

# sanity: no placeholders left
grep -n '__APEX__\|__DOC_ROOT__\|__PORT__\|__STAGING__' \
  /etc/apache2/sites-available/example.com.conf || echo clean

Enable the required Apache modules (idempotent): proxy proxy_http headers rewrite ssl expires deflate http2 via sudo a2enmod <mod>. In the Apache template, __LOGIN_PATH__ appears only in a header comment, so its value doesn’t matter.

nginx boxes: the installer prints a ready-to-paste server block at the end of step 4; use that, proxying / to http://127.0.0.1:APP_PORT and serving /_next/static + /uploads from disk. (The nginx template does use __LOGIN_PATH__ in its body, so substitute your real login path there.)

Step 7 · TLS

Issue the certificate.

Issue apex-only unless www actually resolves. The Apache authenticator validates on :80 without the proxy vhost enabled yet.

certbot

sudo certbot certonly --apache -d example.com --non-interactive --agree-tos --keep-until-expiring
ls -la /etc/letsencrypt/live/example.com/      # fullchain.pem + privkey.pem

Stale-lineage fix (if certbot made example.com-0001)

sudo certbot delete --cert-name example.com -n
sudo certbot certonly --apache -d example.com --cert-name example.com -n --agree-tos
sudo certbot delete --cert-name example.com-0001 -n

Enable the site: syntax-check, then reload

# Apache
sudo a2ensite example.com && sudo apache2ctl configtest && sudo systemctl reload apache2
# nginx
sudo nginx -t && sudo systemctl reload nginx

Step 8 · Verify

Assert it, don’t eyeball it.

From your laptop, against the real public URL. Then a real browser pass: the login page is anti-bot guarded and will 403/404 a blind curl even when it works, so confirm the form renders in a browser.

Public end-to-end checks

# live + TLS + redirect
curl -s -o /dev/null -w 'home:%{http_code} ssl:%{ssl_verify_result}\n' https://example.com/
curl -s -o /dev/null -w 'http->:%{http_code} ->%{redirect_url}\n'       http://example.com/

# secrets must NOT be web-readable (expect 403/404 each)
for p in env.production .env .git/config package.json pnpm-lock.yaml; do
  curl -s -o /dev/null -w "$p:%{http_code} " https://example.com/$p; done; echo

# auth enforced (no cookies)
curl -s -o /dev/null -w 'admin:%{http_code} ->%{url_effective}\n' -L https://example.com/admin
curl -s -o /dev/null -w 'admin-api:%{http_code}\n' https://example.com/api/admin/settings  # 401

Finding the real login path

The effective login path lives in the database, not just the env file, and after a content restore the restored site’s custom path wins, so the installer-printed path may 404. Check it:

Query the DB for the live login path

sudo mysql -e "SELECT * FROM settings WHERE settings.key='security_login_path';" DB_NAME
# e.g. {"path":"caccess"}  ->  log in at  https://example.com/caccess

Gotchas

The things that bite.

Hard-won notes from real deploys. A quick security check rounds it out.

Security check

sudo ls -la DOC_ROOT/env.production    # -rw------- APP_USER (mode 600)
sudo find DOC_ROOT -maxdepth 2 -user root   # expect empty

PM2 runtime user

Defaults to www-data + /var/www/.pm2. To match an existing install, set CAVECMS_PM2_USER + CAVECMS_PM2_HOME. Starting PM2 as bare root registers the app under the wrong daemon.

Survives reboot

pm2 save + a boot unit (pm2-APP_USER.service) keep the app up across reboots; the installer runs pm2 save. Confirm with systemctl is-active pm2-APP_USER.service.

Login path is in the DB

settings.security_login_path overrides env LOGIN_PATH. After a restore, the restored site’s path wins, so always check the DB before concluding login is broken.

Pin the version

Install the same RELEASE the backup was built on, so the restored dump’s schema matches the code.

Don’t run the wizard on a restored site

The restore already brought a complete install; the wizard would create a second admin. A correct restore returns 404 at /install.

Shared box

Never let the bundled install-apache.sh / install-nginx.sh overwrite a sibling’s cavecms.conf. Use a per-domain vhost filename.

Snapshots for rollback

If the APP_USER PM2 daemon was already running before this install, run pm2 update once (in a maintenance window, since it restarts that user’s PM2 apps) so in-app update snapshots/rollback work. Updates still apply without it.

Next

Live, with your content intact.

Re-enter any integrations in the admin, and you’re done. Building or editing from here? An AI agent can drive the site over the HTTP API.