Skip to content

Deployment ​

This page covers running Kestello on a server you control: the web server, PHP-FPM, file permissions, cron, queue workers, Redis, HTTPS behind a proxy, several servers sharing one database, backups that leave the server, monitoring, and the release folders that core updates use. Read it when you move a site from your machine or shared hosting to a VPS or a cluster. For shared hosting, the installation guide is enough.

The examples assume the site lives in /var/www/kestello, PHP 8.5 runs as www-data, and you deploy as a separate user called deploy. Change these to match your server.

Checklist ​

ItemWhere
Document root is public/Web server
storage/ writable by PHPFile permissions
app.url starts with https://, app.debug is falseConfiguration
Cron runs schedule:run every minuteCron
A queue worker under systemd or supervisor, queue.worker set to daemonQueue workers
app.trusted_proxies set when behind a proxy or load balancerHTTPS and proxies
Backups copied off the serverBackups off the server
bin/cms health in your monitoringHealth monitoring

Web server ​

Only public/ is meant to be reachable from the web. It holds index.php, .htaccess, favicon.ico and assets/. Everything else (config/, storage/, core/, content/) is served through PHP or not at all: media at /uploads and public files at /files are streamed by Kestello from storage/app/, and plugin and theme assets are served by Kestello from their assets/ folders.

public/index.php reads core/current.php when it exists and loads core/releases/<version>/bootstrap.php; without it, it loads core/bootstrap.php. bin/cms does the same.

nginx ​

nginx
server {
    listen 443 ssl;
    http2 on;
    server_name example.com;
    root /var/www/kestello/public;
    index index.php;
    charset utf-8;
    client_max_body_size 64M;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.5-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_buffers 16 16k;
        fastcgi_buffer_size 32k;
        fastcgi_read_timeout 300;
    }

    location ~ /\. {
        deny all;
    }
}

server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

$realpath_root matters: it resolves symlinks, so a deploy that switches a symlinked folder is picked up without stale paths. fastcgi_read_timeout 300 gives backups and updates started from the admin time to finish.

If your nginx config has a block for static file extensions (css, js, woff2 and so on), end its try_files with /index.php?$query_string, as the development config in docker/nginx/default.conf does. Files under /uploads, /files and plugin assets are not in public/, so nginx must hand them to PHP.

Let nginx send stored files ​

By default PHP streams files from local storage disks. To let nginx send them instead (lower memory use, range requests handled by nginx), set storage.sendfile in config/storage.php and add an internal location that maps to storage/app/:

php
<?php

return [
    'sendfile' => 'x-accel-redirect',
    'sendfile_prefix' => '/protected-storage',
];
nginx
location /protected-storage/ {
    internal;
    alias /var/www/kestello/storage/app/;
}

Kestello still checks access and sets every header; nginx only sends the bytes. For Apache or LiteSpeed with mod_xsendfile, use 'sendfile' => 'x-sendfile'.

Apache ​

public/.htaccess routes every address that is not a real file to index.php, passes the Authorization header through to PHP (some setups drop it, which breaks API tokens), and refuses hidden files. Point the virtual host at public/ and allow the file to work:

apache
<VirtualHost *:443>
    ServerName example.com
    DocumentRoot /var/www/kestello/public

    <Directory /var/www/kestello/public>
        AllowOverride All
        Require all granted
    </Directory>

    <FilesMatch "\.php$">
        SetHandler "proxy:unix:/run/php/php8.5-fpm.sock|fcgi://localhost"
    </FilesMatch>

    SSLEngine on
    SSLCertificateFile    /etc/letsencrypt/live/example.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
</VirtualHost>

It needs mod_rewrite, mod_proxy_fcgi and mod_ssl.

The .htaccess in the site root is for hosts where the document root cannot be changed. It rewrites every request into public/, and refuses everything when mod_rewrite is missing rather than expose config/ or storage/. On your own server, set the document root to public/ instead.

PHP-FPM ​

The requirements list the PHP version, extensions and php.ini limits. On a server you run, also check these:

SettingWhy
PHP-FPM (or LiteSpeed), not mod_phpKestello sends the response first and then does deferred work (mail for password resets, the web runner) after fastcgi_finish_request(). Without it, the web runner does not run at all.
proc_open not in disable_functionsThe update probe runs a fresh php bin/cms update:probe process. Without proc_open, updates fall back to the HTTP probe.
sodium loadedRequired by Kestello's core.json, and needed to verify package signatures.
opcache.enable=1Pages build much faster.
opcache.validate_timestamps=1Updates replace PHP files in place and switch core/current.php. An update reset clears OPcache only in the process that runs it, so with validate_timestamps=0 PHP-FPM keeps serving old code after an update from the command line. If you turn timestamps off, reload PHP-FPM after every update and deploy.
clear_env = no in the pool, or env[KESTELLO_…] linesPHP-FPM hides the environment from PHP by default. Kestello reads KESTELLO_* variables (for example KESTELLO_CACHE_DRIVER) with getenv().
memory_limit = 256M, upload_max_filesize and post_max_size matching nginx's client_max_body_sizeImage resizing needs memory; Site Health warns when post_max_size is smaller than upload_max_filesize.

A pool for one site:

ini
; /etc/php/8.5/fpm/pool.d/kestello.conf
[kestello]
user = www-data
group = www-data
listen = /run/php/php8.5-fpm.sock
listen.owner = www-data
listen.group = www-data

pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500

clear_env = no
php_admin_value[memory_limit] = 256M
php_admin_value[upload_max_filesize] = 64M
php_admin_value[post_max_size] = 64M
php_admin_value[max_execution_time] = 300

Size pm.max_children to your memory: roughly the memory you can give PHP divided by the memory one request uses.

File permissions ​

FolderPHP needsWhy
storage/read and write, recursivelyCaches, logs, sessions, local storage disks (media, backups), maintenance mode, locks, compiled platform settings, staged packages. Site Health checks storage/, storage/framework, the logs folder and storage/app.
config/read; write for the installer and key:generateSee below.
content/plugins/, content/themes/read; write to install and update from the adminIf PHP cannot write here, install and update plugins and themes from the command line as the owner.
core/read; write to update Kestello from the adminThe update writes core/releases/<version>/ and core/current.php.
everything elseread

A setup where the deploy user owns the code and PHP can write only where it must:

bash
cd /var/www/kestello
sudo chown -R deploy:www-data .
sudo find . -type d -exec chmod 750 {} +
sudo find . -type f -exec chmod 640 {} +
sudo chmod 750 bin/cms
sudo chmod -R ug+rwX storage
sudo find storage -type d -exec chmod g+s {} +

Add sudo chmod -R g+w content/plugins content/themes core if you want installs and core updates from the admin. Run bin/cms as www-data (sudo -u www-data php bin/cms …), so files it creates in storage/ stay writable for PHP-FPM.

WARNING

Saving the General group under Settings in the admin writes the site name, description, address and timezone back into config/app.php when PHP can write to it, so they are known before the database is read. A file that contains code (getenv(), $_ENV, $_SERVER, define(), functions, require or include) is never written back: the values saved in the admin still apply from the database. Comments in a plain file are lost when it is written back.

config/app.php holds app.secret_key, which decrypts secrets stored in the database. Keep it readable only by the owner and PHP (640).

Cron ​

Run the scheduler every minute, as the PHP user:

bash
sudo crontab -u www-data -e
text
* * * * * php /var/www/kestello/bin/cms schedule:run --quiet

schedule:run runs the tasks that are due and exits with 1 if any of them failed. Unless queue.worker is daemon, one of those tasks (queue.work) works the queue for up to 50 seconds each minute, so a cron job alone runs background jobs too.

Each task's time slot is claimed in the database, so a slot runs once even when cron fires on several servers or together with the web runner. Running the same cron line on every server is safe.

Without cron, the web runner runs due tasks after a page response has been sent, at most once a minute. cron.web_runner controls it: auto (the default) runs only while no real cron or pinger has run in the last 5 minutes, true always, false never. cron.web_runner_seconds (default 20, between 5 and 55) limits each run. Once a real cron job runs, auto stops by itself. Where you cannot run cron at all, bin/cms cron:token turns on POST /system/cron for an external cron service; it prints the token to send in Authorization: Bearer <token>.

See Configuration for where these keys live.

Queue workers ​

A worker process picks up jobs within seconds instead of once a minute. Run it under a supervisor that restarts it, because it exits on purpose: when --max-jobs or --max-time is reached, when memory passes --memory, on SIGTERM, SIGINT or SIGQUIT (after the current job), and after queue:restart. It exits with 0 in each case.

When a worker runs, tell the scheduler to stop working the queue itself by setting queue.worker to daemon in config/queue.php:

php
<?php

return [
    'worker' => 'daemon',
];

With the default cron, the scheduler's queue.work task keeps running each minute next to your worker. That is harmless but wasteful.

systemd ​

ini
# /etc/systemd/system/kestello-worker@.service
[Unit]
Description=Kestello queue worker %i
After=network.target mysql.service

[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/kestello
ExecStart=/usr/bin/php bin/cms queue:work --max-time=3600 --memory=192
Restart=always
RestartSec=3
KillSignal=SIGTERM
TimeoutStopSec=120

[Install]
WantedBy=multi-user.target
bash
sudo systemctl daemon-reload
sudo systemctl enable --now kestello-worker@1 kestello-worker@2

Signal handling needs the pcntl extension in the command line PHP. Without it, SIGTERM stops the worker immediately instead of after the current job; give TimeoutStopSec room either way.

supervisor ​

ini
; /etc/supervisor/conf.d/kestello-worker.conf
[program:kestello-worker]
command=/usr/bin/php /var/www/kestello/bin/cms queue:work --max-time=3600 --memory=192
directory=/var/www/kestello
user=www-data
numprocs=2
process_name=%(program_name)s_%(process_num)02d
autostart=true
autorestart=true
stopsignal=TERM
stopwaitsecs=120
stdout_logfile=/var/www/kestello/storage/logs/worker.log
redirect_stderr=true

Options ​

OptionDefaultMeaning
--queue=<list>high,default,mail,media,lowQueues to work, in priority order. Run a separate worker with --queue=media if image work should not delay mail.
--max-time=<s>0 (no limit)Exit after this many seconds.
--max-jobs=<n>0 (no limit)Exit after this many jobs.
--memory=<mb>128Exit when memory use passes this.
--sleep=<s>3Wait between polls of an empty queue.
--stop-when-emptyoffExit when there is nothing left.
--quietoffNo output.

Workers pause on their own while a restore rebuilds the database. bin/cms queue:monitor shows queue sizes and the last worker heartbeat; System → Background jobs shows the same in the admin.

After a deploy ​

Workers are long-running PHP processes and keep the code they started with. After you change code on the server:

bash
sudo -u www-data php bin/cms migrate
sudo -u www-data php bin/cms cache:clear
sudo -u www-data php bin/cms queue:restart

queue:restart stores a restart mark in the cache; each worker sees it after its current job, exits, and its supervisor starts it again on the new code. Because the mark lives in the cache, workers on other servers only see it when the servers share a cache store (Redis or the database), not with the default file cache.

Redis ​

Redis (or Valkey, KeyDB, Dragonfly, a managed service) can hold the cache, sessions and the queue. Switch from the admin under System → Infrastructure, which tests the connection before saving, or from the command line:

bash
sudo -u www-data php bin/cms platform:set cache redis host=127.0.0.1 password=@env:REDIS_PASSWORD
sudo -u www-data php bin/cms platform:set session redis host=127.0.0.1 password=@env:REDIS_PASSWORD
sudo -u www-data php bin/cms platform:show

Or pin it in config/platform.php, which locks the setting in the admin:

php
<?php

return [
    'cache' => ['driver' => 'redis', 'url' => getenv('REDIS_URL') ?: null],
    'session' => ['driver' => 'redis', 'url' => getenv('REDIS_URL') ?: null],
];
OptionDefaultMeaning
urlnoneredis://user:pass@host:6379/0 or rediss://…. Overrides the fields below.
host127.0.0.1
port6379
username, passwordnone
database00 to 15.
tlsfalse
timeout2.0Seconds.
prefixemptyKey prefix. Empty means kst:<site id>:<c|s|q>:, where the site id comes from the site's folder path.

For the queue, use a Redis that persists to disk (appendonly yes), so queued jobs survive a Redis restart. If Redis goes away, new jobs go to the database queue instead of being lost.

The configuration reference lists the environment variables (KESTELLO_<SUBSYSTEM>_<OPTION>) and the precedence of the layers.

HTTPS and proxies ​

Set app.url to the https:// address. Site Health reports a live site without HTTPS as critical.

Behind a load balancer, CDN or reverse proxy that terminates TLS, two things need configuring:

  1. The client address. List the proxies in app.trusted_proxies (IPs or CIDR ranges, IPv4 and IPv6). Kestello then reads X-Forwarded-For from the right and takes the first address that is not a trusted proxy. With the list empty (the default), the client address is always the connecting address, so a client cannot fake its address to dodge per-IP rate limits.

    php
    'trusted_proxies' => ['10.0.0.0/8', '192.168.1.10'],
  2. Secure cookies. Kestello marks cookies Secure when PHP sees HTTPS set, port 443, or X-Forwarded-Proto: https from an address in app.trusted_proxies. If your proxy sends none of these, set fastcgi_param HTTPS on; in the nginx that talks to PHP-FPM, or set app.secure_cookies to true in config/app.php.

Several servers, one database ​

Kestello can run on several web servers behind a load balancer, sharing one database. What has to be shared:

WhatHow
DatabaseOne MySQL, MariaDB or PostgreSQL server for all.
SessionsThe default database driver, or Redis. Not file.
CacheRedis or database. The default file cache is per server, so pages purged on one server stay cached on the others, and queue:restart does not reach their workers.
Media and backupsAn S3-compatible disk in config/storage.php, or a shared filesystem mounted at storage/app. Move existing media with bin/cms media:move --to=<disk>.
CodeThe same core/, content/plugins/ and content/themes/ on every server, deployed by your own process.

Three things are per server, by design:

  • Compiled infrastructure settings. Each server keeps storage/framework/platform.php, built from the database. A change saved under System → Infrastructure rebuilds it only on the server that handled the request. Run php bin/cms platform:compile on the other servers after such a change and after each deploy.
  • Maintenance mode is a file, storage/framework/maintenance.json. Restores and updates turn it on only on the server that runs them.
  • Updates from the admin change files only on the server that handled the request. On several servers, install and update plugins, themes and Kestello through your deploy process, then run migrate once.

When several servers share one Redis, set the same prefix on all of them, or install the site at the same path everywhere. The default prefix is derived from the site's folder path, so servers with different paths would not share keys.

Backups off the server ​

Kestello's scheduled backups (nightly by default, under System → Backups) are written to the backups storage disk, which is storage/app/backups on the same server. Put that disk somewhere else so a lost server does not take its backups with it:

php
<?php
// config/storage.php
return [
    'disks' => [
        'backups' => [
            'driver' => 's3',
            'bucket' => 'example-backups',
            'region' => 'eu-central-1',
            'key' => getenv('BACKUP_S3_KEY'),
            'secret' => getenv('BACKUP_S3_SECRET'),
        ],
    ],
];
bash
sudo -u www-data php bin/cms storage:test backups

Or copy storage/app/backups/ off the server with your own tool (rsync, restic, your host's snapshots).

A backup holds the database and, when Include files (uploads, plugins, themes) is on, content/plugins, content/themes, uploads, storage/fonts, storage/forms and storage/plugins. Scheduled backups leave the files out until you turn that switch on under System → Backups. Files on S3 and other remote disks are not copied. A backup does not hold config/. Keep a copy of config/app.php and config/database.php somewhere safe as well: without the same app.secret_key, secrets stored in the database (mail passwords, API keys) cannot be decrypted after a restore on a new server.

See Backups for restoring.

Health monitoring ​

bash
sudo -u www-data php bin/cms health
sudo -u www-data php bin/cms health --json
sudo -u www-data php bin/cms health --only=core.database,core.queue,core.scheduler
Exit codeMeaning
0Nothing critical. Recommendations may be listed.
2At least one check is critical.

Check ids include core.database, core.drivers, core.queue, core.scheduler, core.storage, core.writable, core.disk-space, core.backups, core.https, core.debug, core.app-key, core.php-version, core.php-extensions, core.php-limits, core.extensions and core.page-cache; plugins add their own. --json prints the full report with each check's id, status, message and details.

A cron line for a monitor that only needs the exit code:

text
*/5 * * * * php /var/www/kestello/bin/cms health > /dev/null 2>&1 || curl -fsS https://monitor.example.com/alert

For an HTTP uptime check, GET /api/v1/status answers 200 with {"data":{"status":"ok","time":"…"}} once PHP has booted the site. It does not run the Site Health checks. Kestello also runs Site Health hourly itself and notifies administrators about new critical problems.

Core releases ​

A production site runs Kestello from release folders:

text
core/
├── current.php           return '0.2.0';
└── releases/
    ├── 0.1.0/
    └── 0.2.0/

core/current.php names the running release. public/index.php and bin/cms read it on every request. A core update unpacks the new release next to the others and renames a new current.php over the old one, so the switch is atomic and a failed update switches back the same way. After a successful update, the running release, the previous one and one more are kept; older folders are deleted.

bash
sudo -u www-data php bin/cms core:update --status
text
  Kestello 0.2.0 (release folders)
    * 0.2.0
      0.1.0

A development checkout has no current.php and runs core/ directly. Its first core update creates the pointer.

Updates come from release zips built with core:release. Apply them from System → Updates or with core:update; Packaging and signing describes the checks, the backup and the rollback.

TIP

When you run PHP-FPM with opcache.validate_timestamps=0, reload PHP-FPM after core:update on the command line. Otherwise PHP-FPM keeps the old current.php in OPcache and serves the old release.

Server-side rendering ​

The optional SSR process (node core/ssr/server.mjs) is a long-running Node.js 24 or newer process. Run it under systemd like a worker, bound to localhost, and point app.ssr.url (or KESTELLO_SSR_URL) at it:

ini
# /etc/systemd/system/kestello-ssr.service
[Unit]
Description=Kestello SSR
After=network.target

[Service]
User=www-data
WorkingDirectory=/var/www/kestello
Environment=SSR_HOST=127.0.0.1 SSR_PORT=13714 KESTELLO_ROOT=/var/www/kestello
ExecStart=/usr/bin/node core/ssr/server.mjs
Restart=always

[Install]
WantedBy=multi-user.target

It needs npm install in the site root for Vue's server renderer. Restart it after deploys that change components. If it is down, pages render in the browser; see Frontend.

If something goes wrong ​

Site Health: "Kestello cannot write to: storage/framework, … Check folder permissions." PHP-FPM's user cannot write there. Fix ownership, and run bin/cms as the same user so it does not create files PHP-FPM cannot change.

"The queue driver is "sync": jobs run when pushed, there is nothing to work on." from queue:work. The queue subsystem uses the sync driver. Switch it to database or redis.

Jobs pile up although a worker runs. Check bin/cms queue:monitor for the last heartbeat. Workers pause during restores; one that never comes back usually crashed on start, so read the supervisor's log.

Background jobs run once a minute although a worker runs. queue.worker is still cron. Set it to daemon.

"Could not read platform settings from the database." from platform:compile. The database is not reachable from this server.

Rate limits hit everyone at once, or every request comes from the proxy's address. app.trusted_proxies is empty behind a proxy.

Sign-in does not stick behind a proxy. Cookies are not marked Secure correctly or are dropped. Set fastcgi_param HTTPS on; or app.secure_cookies.

API tokens are ignored on Apache. The Authorization header does not reach PHP. Check that public/.htaccess is used (AllowOverride All) or copy its HTTP_AUTHORIZATION rule into the virtual host.

"Another update is running. Try again when it has finished." An update or the rollback of one holds storage/framework/update.lock.

"The core folder is not writable by PHP, so the release cannot be put in place." Run core:update as the owner of core/, or give PHP write access to it.

Visitors see the maintenance page long after an update. A rollback that could not finish keeps visitors out for up to 7 days, so someone looks first. Signed-in administrators still get in. Fix the cause, then delete storage/framework/maintenance.json.

Next ​

Kestello documentation.