JekCMS on a VPS: A Production Configuration Reference

The PHP-FPM, OPcache, Nginx, MariaDB, cron and backup settings I hand to anyone running jekcms on a VPS — including the OPcache deploy trap that bit me.

JekCMS on a VPS: A Production Configuration Reference

The PHP-FPM, OPcache, Nginx, MariaDB, cron and backup settings I hand to anyone running jekcms on a VPS — including the OPcache deploy trap that bit me.

My own fleet — fourteen content sites, 5,500+ posts between them — runs jekcms on shared hosting behind Cloudflare, and I wrote up what that actually looks like separately. But when someone asks me how to run jekcms on a box they control, this is the sheet I send. Every value below is a starting point I would defend, not a magic number. Measure your own workload, then adjust.

Assumptions: Ubuntu or Debian, Nginx, PHP 8.3 with FPM, MariaDB, 2 vCPU and 4 GB of RAM. jekcms itself is happy on PHP 8.0+, but if you are building a server today there is no reason to start older.

Size the PHP-FPM pool first

The default pool on Debian-family systems is conservative to the point of uselessness. Edit /etc/php/8.3/fpm/pool.d/www.conf:

pm = dynamic
pm.max_children = 20
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 10
pm.max_requests = 500

Then sudo systemctl restart php8.3-fpm.

Those numbers assume 4 GB of RAM. The math is not mysterious: measure the real per-worker memory of your install with ps -o rss= -C php8.3-fpm, leave room for MariaDB and the OS, divide what remains. jekcms requests are light — most of them are answered from the page cache and never enter a heavy code path — so 20 children on 4 GB is comfortable rather than tight.

pm.max_requests = 500 recycles workers periodically. A blunt instrument against slow memory leaks, admittedly. I would rather recycle needlessly than chase a creeping RSS at three in the morning.

OPcache, and the deploy trap that bit me

In php.ini or a dropped-in conf.d file:

opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0

validate_timestamps=0 is the production setting: PHP stops stat()-ing every file on every request and serves compiled bytecode straight from memory. On NVMe the win is modest; on network-attached or spinning storage it is very real.

Now the catch, which I did not learn from a blog post. With timestamp validation off, a deploy does not take effect until you reset OPcache. My deploys go out through GitHub Actions and rsync, and I have watched a site keep executing old bytecode while the files on disk were provably new. No error, no warning — today's date on the files, yesterday's code in memory. Since then my pipeline treats the OPcache reset as a mandatory deploy step, exactly as mandatory as the file sync, followed by a page-cache purge.

Resetting OPcache after every deploy

The simplest reliable way is reloading FPM:

sudo systemctl reload php8.3-fpm

If you would rather not touch the workers, cachetool talks to FPM over its socket:

php cachetool.phar opcache:reset --fcgi=/run/php/php8.3-fpm.sock

Whichever you pick, put it in the deploy script — not in a runbook you will forget to read.

Nginx: worker settings and fastcgi_cache

The basics for a 2-vCPU box:

worker_processes auto;

events {
    worker_connections 1024;
}

http {
    keepalive_timeout 65;
    client_max_body_size 50m;  # media uploads
}

jekcms ships its own page, data and query caches, so a full-page cache in Nginx is optional — but on a VPS it is cheap insurance, because a fastcgi_cache hit never invokes PHP at all:

fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=jekcms:10m
                   max_size=1g inactive=60m;

# inside the location ~ \.php$ block:
fastcgi_cache jekcms;
fastcgi_cache_valid 200 10m;
fastcgi_cache_bypass $cookie_PHPSESSID;
fastcgi_no_cache $cookie_PHPSESSID;

The bypass on the session cookie is the part people skip and regret: cache the anonymous traffic, never the logged-in admin. Adjust the cookie name if your install uses a custom session name. And remember this layer holds HTML too — one more cache to purge on deploy, one more reason my TTLs stay short.

HTTPS with Let's Encrypt

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com

Certbot installs a systemd timer for renewals — confirm with systemctl list-timers | grep certbot. The server block it writes is serviceable; I tighten it:

ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;

add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

Two deliberate omissions. HSTS first: a long-max-age Strict-Transport-Security header is the right end state, but it is a promise browsers hold you to — do not add it until every last resource loads over HTTPS. A week after the migration, not on day one. Second, Content-Security-Policy deserves more than a one-liner; I keep a production-ready CSP template for jekcms in its own post.

php.ini hardening

  • expose_php = Off — drops the X-Powered-By header
  • display_errors = Off plus log_errors = On — errors go to the log, never to visitors
  • session.cookie_httponly = 1 and session.cookie_secure = 1 — session cookies unreadable from JavaScript, HTTPS-only
  • upload_max_filesize = 50M and post_max_size = 52M — keep both in step with Nginx's client_max_body_size, or uploads fail with confusing errors at whichever layer is smallest
  • max_execution_time = 30 — a runaway request should die, not squat on a worker

MariaDB: buffer pool and the slow query log

In /etc/mysql/mariadb.conf.d/50-server.cnf:

innodb_buffer_pool_size = 2G
slow_query_log = 1
long_query_time = 1
log_queries_not_using_indexes = 1

The classic advice is to hand the buffer pool most of your RAM. That advice assumes a dedicated database server. On a 4 GB box that also runs PHP-FPM and Nginx, 2G is the honest ceiling — go higher and you are one traffic spike away from the OOM killer choosing a victim for you.

Turn the slow query log on from day one, not when things get slow. A query that degrades gradually is easy to diagnose with two weeks of history and miserable to diagnose in the middle of an incident. log_queries_not_using_indexes is noisy on small tables; I keep it on anyway, because the noise is where missing indexes hide.

File permissions, and what a deploy must never touch

Ownership and baseline permissions:

sudo chown -R www-data:www-data /var/www/jekcms
sudo find /var/www/jekcms -type d -exec chmod 755 {} \;
sudo find /var/www/jekcms -type f -exec chmod 644 {} \;
sudo chmod 600 /var/www/jekcms/.env

uploads/, cache/ and logs/ must be writable by the FPM user; everything else can stay read-only. The .env file holds your database credentials — 600, no exceptions, and it never enters version control.

The second half of this section is a rule I enforce in my own pipeline. My deploys run rsync from GitHub Actions, and the rsync line has two properties that have saved me more than once: no --delete flag, ever, and an exclude list covering everything the server generates:

rsync -az \
  --exclude '.env' \
  --exclude 'uploads/' \
  --exclude 'cache/' \
  --exclude 'logs/' \
  ./ deploy@vps:/var/www/jekcms/

A deploy that can overwrite uploads/ or .env is a loaded gun — one bad checkout away from deleting every image your authors ever uploaded. And then, as above: OPcache reset, cache purge. That whole sequence is the deploy. The rsync is just the first step.

Install a real cron

jekcms publishes scheduled posts without any cron at all: it keeps a next-job-due timestamp and runs the publisher right after a response has been delivered to a visitor — deterministic, and the visitor never waits for it. That fallback is what makes shared hosting viable. On a VPS you have a crontab, so use it; when a real cron is configured, the visitor-triggered scheduler detects it and switches itself off completely.

Edit the web user's crontab — not root's:

sudo -u www-data crontab -e
* * * * * cd /var/www/jekcms && /usr/bin/php cron.php >> storage/logs/cron.log 2>&1

The leading cd matters — some includes assume the working directory is the install root — and the log redirect leaves a breadcrumb trail for the day something stalls. Verify it ticks: tail -f storage/logs/cron.log should show fresh output every minute, and the Scheduled Tasks screen in the admin should show last-run times no more than a couple of minutes old. The cron setup reference covers systemd timers, cPanel, Plesk and the verification queries if your environment differs.

Backups: 3-2-1, and actually tested

Three copies, two storage types, one off-site. The concrete version:

# root crontab — nightly DB dump at 03:00
0 3 * * * mysqldump --single-transaction --quick jekcms | gzip > /var/backups/jekcms/db-$(date +\%F).sql.gz

# 03:30 — uploads to off-site storage
30 3 * * * rsync -az /var/www/jekcms/uploads/ backup@offsite:/backups/jekcms-uploads/

Mind the escaped \% — cron treats a bare % as a newline, and that exact line has quietly produced files named db-.sql.gz for more than one person. --single-transaction keeps the dump consistent without locking tables. Keep 30 daily dumps, thin them to monthly after that, and add a weekly VPS snapshot through your provider's panel so the server configuration itself is recoverable, not just the data.

Then restore one, on a schedule. A backup you have never restored is a hypothesis. I do not call a dump trustworthy until I have watched it come back up on a clean database at least once.

The whole sheet

FPM sized to RAM. OPcache pinned, with the reset wired into the deploy path. A front cache that skips PHP entirely. TLS with patient HSTS. A database that logs its own slow spots before you need them. Permissions that protect what the server generates, a deploy that cannot touch it, a real cron, and backups you have actually restored. None of it is exotic — but the sum is the difference between a VPS you administer and one that administers you.

Written by

Celil Uyanıkoğlu

Computer engineer with 25+ years in IT. He builds jekcms and runs his own network of content sites on it — every guide published here is tried on those live installs first.

See all posts →

Order Today

One-time payment, lifetime access. Setup in 30 minutes.

View Pricing
  • Setup and live in 30 minutes
  • 13 professional themes
  • AVIF/WebP image optimization
  • Automatic SEO — Sitemap, Schema.org
  • ZeroTrack cookieless analytics

Be the first to know

New features, release notes & CMS guides — a couple of emails a month, no spam.