Repository navigation
Deploy: Ubuntu Plesk
Multi-instance oCMS deployment on Ubuntu with Plesk, one instance per domain/vhost.
Core themes are embedded in the binary. Each site runs on its own port behind Nginx reverse proxy.
/opt/ocms/bin/ocms # Shared binary (includes themes)
/opt/ocms/bin/ocmsctl # CLI management tool
/etc/ocms/sites.conf # Site registry
/etc/systemd/system/ocms@.service # Systemd template
Per site:
/var/www/vhosts/example.com/ocms/
├── data/ocms.db # SQLite database
├── uploads/ # Media files
├── custom/ # Custom themes/modules (optional)
├── backups/ # Automated backups
├── logs/ocms.log # App log (stdout + stderr); logs/error.log is the error-only log
├── .env # Environment config
└── ocms.pid # PID file (ocmsctl mode)
/etc/systemd/system/ocms@example_com.service.d/instance.conf # Per-site systemd override
# Local machine:
mkdir -p bin
curl -L https://github.com/olegiv/ocms-go/releases/latest/download/ocms-linux-amd64.tar.gz | tar -xz ocms
mv ocms bin/ocms-linux-amd64
scp bin/ocms-linux-amd64 user@server:/tmp/ocms
ssh user@server mkdir -p /tmp/ocms-setup
scp scripts/deploy/* user@server:/tmp/ocms-setup/
# On the server:
sudo mkdir -p /opt/ocms/bin /etc/ocms
sudo cp /tmp/ocms /opt/ocms/bin/ocms
sudo chmod 755 /opt/ocms/bin/ocms
sudo cp /tmp/ocms-setup/ocmsctl /opt/ocms/bin/
sudo chmod 755 /opt/ocms/bin/ocmsctl
sudo cp /tmp/ocms-setup/ocms@.service /etc/systemd/system/
for script in setup-site.sh deploy-multi.sh backup-multi.sh healthcheck-multi.sh generate-logrotate.sh helper.sh; do
sudo cp /tmp/ocms-setup/$script /opt/ocms/
sudo chmod 755 /opt/ocms/$script
done
sudo systemctl daemon-reloadFor development builds, replace the release download with make build-linux-amd64.
sudo /opt/ocms/setup-site.sh example.com example_comArguments: domain, system-user, port (auto-assigned from 8081), group (default: psaserv). Option: --path PATH for a custom instance directory (absolute, under /var/www/vhosts/<domain>/, parent directory must exist).
The script creates directories, generates a session secret, an OCMS_MFA_KEY (production requires two-factor authentication for every account; back the key up with the database) and an embed proxy token, writes the .env, creates the systemd drop-in, registers the site in /etc/ocms/sites.conf, and prints the Nginx snippet for Plesk.
The generated .env sets OCMS_ENV=production, and in production the application refuses to start unless OCMS_TRUSTED_PROXIES (127.0.0.1 for the local nginx) and OCMS_API_ALLOWED_CIDRS are set, or the matching OCMS_REQUIRE_TRUSTED_PROXIES / OCMS_REQUIRE_API_ALLOWED_CIDRS is explicitly false. The script does not write them, so add them to the site's .env before starting it.
- Websites & Domains → your domain → SSL/TLS Certificates
- Click Install under "Let's Encrypt"
- Enable Permanent SEO-safe 301 redirect from HTTP to HTTPS
Paste the snippet from setup-site.sh into Websites & Domains → example.com → Apache & nginx Settings → Additional nginx directives.
Fresh binary/server installs need a one-time seeded bootstrap before the
production service is enabled. Keep the generated .env production default
OCMS_DO_SEED=false; override it only for this foreground bootstrap run:
cd /var/www/vhosts/example.com/ocms
sudo -u example_com env OCMS_ENV=development OCMS_DO_SEED=true /opt/ocms/bin/ocmsWhile the process is running, open https://example.com/admin/ and log in with
admin@example.com / changeme1234. Change the admin email and password, then
stop the foreground process with Ctrl+C.
# For production (systemd)
sudo systemctl enable --now ocms@example_com
# Check status
sudo journalctl -u ocms@example_com -focmsctl <command> <site-id> [options]
Commands:
start <site-id> [--foreground] Start instance
stop <site-id> Stop instance (SIGTERM, SIGKILL after 60 s)
restart <site-id> Restart instance
status <site-id> Show status and health
logs <site-id> [--follow|-f] View logs
logs <site-id> --errors [--follow] View the error-only log
list List all sites with status
health <site-id> Check health endpoint
./scripts/deploy/deploy-binary.sh server.example.com site_a site_b site_cOptions: -u USER (SSH user), --skip-build, --dry-run.
The script backs up the running binary, /opt/ocms/bin/ocms, to ocms.backup (a failed backup stops the deploy), uploads the new binary to /opt/ocms/bin/ocms-linux-amd64, installs it as /opt/ocms/bin/ocms while the instances still run (copying it beside that file and renaming it over, so a failed install stops nothing), then restarts the listed instances and waits for each to report healthy (ocmsctl health; HEALTH_RETRIES and HEALTH_INTERVAL set how long). When one does not, the deploy exits 1 and names it. deploy.sh below does the same for one instance. All instances share /opt/ocms/bin/ocms, so instances not listed keep running the old binary until they restart.
./scripts/deploy/deploy.sh server.example.com my_site \
-v /var/www/vhosts/example.com/ocms -o hostingOptions: -v VHOST (instance directory), -o OWNER, -g GROUP, --skip-binary, --skip-build, --sync-custom, --dry-run.
sudo /opt/ocms/deploy-multi.sh /tmp/ocmsBacks up the current binary to /opt/ocms/bin/ocms.backup, installs the new one as /opt/ocms/bin/ocms while the sites still run, stops every site in sites.conf with ocmsctl stop (through systemd or its PID file, whichever runs it), starts every site through systemd, and health-checks each one. A site that fails to stop or start is reported as failed and the others carry on; a site that did not stop is not started, because its old process may still be running.
./scripts/deploy/sync-prod-to-dev.sh server.example.com my_site \
-v /var/www/vhosts/example.com/ocmsOptions: --no-db, --no-uploads, --no-logs, --sync-custom, --dry-run.
The script stops the production instance while it copies the data, then starts it and waits for it to report healthy (ocmsctl health), exiting 1 when it does not. If a step fails or the sync is interrupted, it starts the instance again before exiting; if the stop fails, it copies nothing.
# Backup all sites
sudo /opt/ocms/backup-multi.sh
# Backup one site
sudo /opt/ocms/backup-multi.sh example_comBackups include database, uploads, and custom content (if present). Stored in each site's backups/ directory with 30-day retention.
sudo /opt/ocms/healthcheck-multi.shRestarts sites that are running but unhealthy (at most 3 times per outage), reports crash-looping (activating) and failed units without restarting them, skips inactive ones, and exits with the number of sites that are down. Supports optional Slack and email alerting, once per outage. Run it as root: its state lives in /run/ocms-health/, which only root can write.
# /etc/cron.d/ocms-multi
SHELL=/bin/bash
0 3 * * * root /opt/ocms/backup-multi.sh >> /var/log/ocms-backup.log 2>&1
*/5 * * * * root /opt/ocms/healthcheck-multi.sh 2>&1 | grep -v "^$"sudo /opt/ocms/generate-logrotate.shGenerates /etc/logrotate.d/ocms from sites.conf (re-run after adding or removing sites). Each site's logs/*.log is rotated daily, 30 days kept, compressed with gzip, using copytruncate.
| Path | Owner | Mode |
|---|---|---|
/opt/ocms/bin/ocms |
root:root |
755 |
{vhost}/ocms/ |
{user}:psaserv |
750 |
{vhost}/ocms/.env |
{user}:psaserv |
600 |
{vhost}/ocms/data/ |
{user}:psaserv |
755 |
{vhost}/ocms/uploads/ |
{user}:psaserv |
755 |
Sites are tracked in /etc/ocms/sites.conf:
# SITE_ID INSTANCE_DIR SYSTEM_USER PORT
example_com /var/www/vhosts/example.com/ocms example_com 8081
blog_example_com /var/www/vhosts/blog.example.com/ocms bloguser 8082
Comment out a line to disable a site without removing data.
sudo systemctl disable --now ocms@example_com
sudo rm -rf /etc/systemd/system/ocms@example_com.service.d
sudo systemctl daemon-reload
sudo rm -rf /var/www/vhosts/example.com/ocms
# Edit /etc/ocms/sites.conf to remove the line- Changed default admin password
- Strong
OCMS_SESSION_SECRET(32+ bytes) -
OCMS_MFA_KEYset and backed up with the database -
OCMS_ENV=production - SSL/TLS enabled
- HTTP redirects to HTTPS
-
.envpermissions set to 600 - Firewall configured (only 80/443 open)
- Backups scheduled
- Health monitoring enabled
sudo systemctl status ocms@example_com
sudo journalctl -u ocms@example_com -n 50Common: missing OCMS_SESSION_SECRET, missing OCMS_TRUSTED_PROXIES / OCMS_API_ALLOWED_CIDRS in production, permission denied on data/, port in use, missing systemd drop-in (see next).
If the journal says OCMS_SESSION_SECRET is not set but .env exists and contains it, the per-instance drop-in is gone. Confirm:
sudo systemctl cat ocms@example_com # no User=/EnvironmentFile= lines?
sudo ls /etc/systemd/system/ocms@example_com.service.d/ 2>&1 # "No such file or directory"?Recreate it (copy from a working site; values come from /etc/ocms/sites.conf):
sudo mkdir -p /etc/systemd/system/ocms@example_com.service.d
sudo tee /etc/systemd/system/ocms@example_com.service.d/instance.conf > /dev/null <<'EOF'
[Service]
User=<system_user>
Group=psaserv
WorkingDirectory=/var/www/vhosts/example.com/ocms
EnvironmentFile=/var/www/vhosts/example.com/ocms/.env
ReadWritePaths=/var/www/vhosts/example.com/ocms
SyslogIdentifier=ocms-example_com
StandardOutput=append:/var/www/vhosts/example.com/ocms/logs/ocms.log
StandardError=append:/var/www/vhosts/example.com/ocms/logs/ocms.log
EOF
sudo systemctl daemon-reload
sudo systemctl start ocms@example_comEvery domain 502s after a server restart; ocmsctl list shows all sites stopped. Cause: units were never systemctl enable-d, so they don't auto-start at boot.
sudo /opt/ocms/bin/ocmsctl list # all "stopped"?
sudo systemctl is-enabled 'ocms@*' # any "disabled"?
sudo awk '!/^#/ && NF>0 {print $1}' /etc/ocms/sites.conf \
| xargs -I{} sudo systemctl enable --now ocms@{}healthcheck-multi.sh does not revive this — it restarts only units that are active but unhealthy, reports crash-looping (activating) and failed ones, and skips inactive ones. systemctl enable is the only reboot-safe fix.
curl http://127.0.0.1:8081/health
sudo nginx -tPlesk generates its own location / block. Use regex pattern, with no URI after
the port:
location ~ ^/(.*)$ {
proxy_pass http://127.0.0.1:8081;
}A proxy_pass with a URI, such as proxy_pass http://127.0.0.1:8081/$1$is_args$args;,
makes nginx rebuild the upstream request from the decoded path. A request for
/uploads/large/x/My%20Photo.jpg then reaches the app with a literal space in the
request line, which is malformed, so the app answers 400. Dropping the URI makes
nginx forward the original request URI unchanged.
Tell the two cases apart without reading config:
curl -s -o /dev/null -w '%{http_code}\n' "https://example.com/uploads/large/x/a%20b.jpg"
# 404 = encoding handled correctly; 400 = proxy_pass still has a URIThis stays hidden until content has filenames with spaces or other percent-encoded characters, so a site can run a long time before hitting it.
sqlite3 {vhost}/ocms/data/ocms.db "PRAGMA integrity_check;"
# Restore from backup if neededGetting Started
Core Features
- Content Management
- Media Library
- Taxonomy
- Menu Builder
- Forms
- Multi-Language
- SEO
- Feeds
- Video Embedding
- Agent-Ready
Administration
API & Integrations
Security
Theme Development
Module Development
Built-in Modules
- Module: Sentinel
- Module: Developer
- Module: DB Manager
- Module: Migrator
- Module: Informer
- Module: Demo Mode
- Module: Internal Analytics
- Module: External Analytics
- Module: Privacy
- Module: Embed
- Module: MCP Server
Deployment