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.com | the site’s apex domain |
| APP_USER | OS user the app runs as (e.g. www-data) |
| APP_PORT | loopback port the Node app listens on |
| DOC_ROOT | install dir, e.g. /var/www/html/example.com/public_html |
| DB_NAME / DB_USER | database + app user |
| RELEASE | version 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 certbotConfirm 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 shellStep 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.gzVerify 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.gzStep 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_ROOTmysql, 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 200Step 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.gzRestore 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-zeroRestart + 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:404Step 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 cleanEnable 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.pemStale-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 -nEnable 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 nginxStep 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 # 401Finding 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/caccessGotchas
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 emptyPM2 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.
Keep going
Related
Compare hosting
Self-host on a VPS, cPanel shared hosting, or your laptop, compared across cost, control, requirements, and AI-edit support.
cPanel troubleshooting
Fix the common cPanel / LiteSpeed snags — unstyled site, the setup-wizard 404, database connection limits, 503s, updates, restores, and memory limits — with the exact cause and operator fix for each.
AI agents
Point Cursor, Claude Code, Windsurf, or Codex at your site: what AGENTS.md is, how to mint a token, and how to make your first change by prompt.