8. Serving the site: Velocity, Apache, nginx and FrankenPHP
An installed Exponential is a directory of PHP files; something has to accept connections, speak HTTP and HTTPS, hand
static files out and pass every other request to a front controller. This chapter shows how. It starts with
Exponential Velocity, the application server that ships for Exponential and is the recommended way at every
stage from development to production: with Velocity's qbix engine no separate web server and no separate HTTPS
server is needed. Velocity listens on the HTTP and HTTPS ports itself, keeps the application loaded in persistent
workers, answers repeat pages from its own response cache and manages its own certificates, including a self-signed
stand-in and Let's Encrypt. The chapter then covers the traditional setups (Apache with PHP-FPM, including on Plesk;
nginx; FrankenPHP), the file permissions every setup needs, and running behind a reverse proxy.
Warning
.htaccess slows down every request. When .htaccess files are enabled (AllowOverride anything but None), let alone
used, Apache looks for them and reads and evaluates them on every request, in every directory of the path, and
page loading times go up drastically (measured). The only real fix is to switch the feature off for
the virtual host: AllowOverride None in the HTTPS virtual host, and the rules in the virtual host's own
configuration as mod_rewrite rules. That is far faster in production speed tests and well worth the time it takes
to optimize a production configuration. Switch over right away: copy the rules of the
example HTTPS virtual host into your virtual host (with the static cache, also
staticcache_offload.conf), set AllowOverride None, run apachectl configtest, reload Apache and
test the site. The same rules for other servers: nginx, LiteSpeed,
Caddy, IIS, Velocity (built in); where virtual host rules go in a control
panel: Plesk, cPanel, DirectAdmin, ISPConfig.
On LiteSpeed or OpenLiteSpeed, the server's own page cache can also answer anonymous visitors and crawlers without PHP: Page caching with LiteSpeed and OpenLiteSpeed.
Contents · Previous: 7. The console install · Next: 9. Databases
Contents of this chapter
- The choice in one table
- How a request is served
- Exponential Velocity, the recommended way
- Install the engine package
- Choose the engine
- Settings: where they live and how to change them
- First start
- Address, ports, user and group
- Workers: pool size, the dynamic pool and the zygote
- Persistent workers and KeepGlobals
- The response cache
- HTTPS served by Velocity itself
- The configuration tree: /etc/vc, Debian style
- Running Velocity as a service
- Logs
- The dashboard and the control panel
- Deploying a change: exp:velocity deploy
- The engine archive: engine.phar
- Command reference
- FrankenPHP
- Apache with PHP-FPM
- nginx with PHP-FPM
- File permissions and ownership
- Behind a reverse proxy
- Checklist
- References
Every command in this chapter is run from the installation root, the directory that holds index.php. The console
is bin/php/console; ./console is the same program where the installation has the usual shortcut. When you run a
command as root, add --allow-root-user; a script that needs it says so.
8.1 The choice in one table
Exponential Velocity (qbix) | FrankenPHP (through Velocity) | Apache + PHP-FPM | nginx + PHP-FPM | |
|---|---|---|---|---|
| Role | recommended for every stage | production-ready alternative | traditional | traditional |
| PHP version | 8.0 or later (the engine package requires it, from release 0.0.4.54; until then 8.1) | the PHP built into the binary (8.5 in the 1.12 releases) | 8.0 or later | 8.0 or later |
| Separate web server needed | no | no (Caddy is built into the binary) | yes | yes |
| Separate HTTPS / certificate tool needed | no: own certificates, self-signed fallback, ACME | no: your certificate or a self-signed one | yes (mod_ssl plus certbot or a hosting panel) | yes |
| Where the code comes from | Composer package se7enxweb/exponential-velocity, installed with Exponential by composer install | binary downloaded and SHA-256 checked by exp:velocity install | the operating system | the operating system |
| Application kept in memory between requests | yes (persistent workers) | no (clean state per request) | no | no |
| Response cache in front of PHP | yes | not yet | no (the kernel's own caches only) | no |
| HTTP/2 | yes | yes | with mod_http2 | with http2 |
| Rewrite rules | built in, the same list as .htaccess_root | generated Caddyfile, the same list | .htaccess_root | written by you (section 8.6) |
| Commands | exp:velocity start, stop, graceful, deploy, ... | the same commands | systemctl, apachectl | systemctl, nginx -t |
| Suits shared hosting | usually not (needs a long-running process) | usually not | yes | rarely |
PHP's built-in server (php -S) is a third Velocity engine. It is the shipped default only because it needs nothing
installed; it is for development, has no TLS and is not for visitors.
Use a traditional server when your hosting does not allow long-running processes, when you already run a tuned Apache or nginx you want to keep, or when several customers on one machine need operating-system isolation from each other (run one Velocity per site, each as its own user, or a traditional server with one PHP-FPM pool per site). A machine with only PHP 8.0 (the stock PHP of Red Hat Enterprise Linux 9 and its rebuilds) runs Velocity as well, from the engine's release 0.0.4.54 (see chapter 2).
The choices are not exclusive. A common shape is Velocity serving the site and Apache or nginx left in place on the same files during a migration; section 8.7.2 explains what that means for file ownership, and section 8.3.14 how one deploy brings both up to date.
8.2 How a request is served
Whatever serves the site, the document root is the installation root and the same routing applies. It is written
down once in .htaccess_root; Velocity's engines and the generated FrankenPHP configuration implement the same list
rule for rule (expVelocity::STATIC_PATHS, FRONT_CONTROLLERS and ENTRY_SCRIPTS in
kernel/classes/expvelocity.php).
request path
|
+---------------+-----------------------------------------------+
| |
a dot file or dot directory (.git, .env, .htaccess) -----------------> 404 (qbix: 403)
(.well-known stays reachable) |
| |
an existing .php/.phtml/.phar below the root ------------------------> 404
| |
/api/... ------------------------------------------------------------> index_rest.php
/content/treemenu..., /<siteaccess>/content/treemenu... -------------> index_treemenu.php
| |
a listed asset path (design and extension stylesheets, images, |
scripts and fonts; var/*/storage/images; image originals; |
stored PDF exports; public caches; share/icons; extension/<name>/icons |
(images with one extension only); package previews; |
favicon.ico, robots.txt, index.js, sw.js, w3c/p3p.xml) --------------> sent as a file
| |
everything else (URL aliases, modules, /settings/site.ini, ...) -----> index.phpTwo consequences matter for security and are covered again in chapter 13:
- No PHP file runs because its path was asked for, except the front controllers at the root.
- A file outside the list is never handed out.
settings/site.ini, the SQLite database or a kernel source requested by path reachindex.php(which answers with a page or a 404), never the file. Uploaded originals other than images are only delivered throughcontent/download, which checks permissions. - Even inside a listed asset directory, a script (
.php,.phtml,.phar) or a dot path is never sent as a file (expVelocity::NEVER_STATIC; the second rule of.htaccess_root), so a stray script in an extension's design directory can neither run nor leak its source.
Check it on any server. Once the site answers, these four requests show that the routing is in place. Replace the address with your own:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8088/.git/config # 404 (403 from the qbix engine)
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8088/kernel/classes/ezcontentobject.php # 404
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8088/robots.txt # 200 when the file exists
curl -s http://127.0.0.1:8088/settings/site.ini | grep -c '^\[DatabaseSettings\]' # 0: a page, not the fileWhat can go wrong: a 200 for the .git or kernel request means the rules are not active (Apache without
.htaccess or AllowOverride, an nginx block that is not this chapter's); stop and fix that before the site is
reachable from outside. A 404 for every asset under design/ means the document root is not the installation root.
WebDAV is not in this list. It is off by default, and the shipped rules send /index_webdav.php to index.php
like any other script. To serve WebDAV, uncomment RewriteRule ^index_webdav\.php - [L] in .htaccess (or write the
same rule into the HTTPS virtual host), or give WebDAV a host of its own that sends every request to
index_webdav.php. Velocity and the FrankenPHP configuration do not serve WebDAV; use Apache or nginx with PHP-FPM
for it. Since 6.0.15 the script is index_webdav.php, not webdav.php: rules written for the old name keep
working through a forwarding shim that will be removed in a later major version. The rules for each server and the
test (curl -X PROPFIND -H 'Depth: 1', expect 207 Multi-Status) are in
The WebDAV entry script is now index_webdav.php.
SOAP is not in this list either. It is off by default (soap.ini EnableSOAP=false). To serve it, uncomment
RewriteRule ^index_soap\.php - [L] in .htaccess (or write the same rule into the HTTPS virtual host). Velocity and
the FrankenPHP configuration do not serve SOAP; use Apache or nginx with PHP-FPM for it. Since 6.0.15 the script is
index_soap.php, not soap.php: rules and clients that use the old name keep working through a forwarding shim
that will be removed in a later major version. After the reload, test it:
curl -sS -i 'https://www.example.com/index_soap.php?wsdl' answers 200 with
Error: this web page does only understand POST methods; a 404 or a page of the site means the rule is missing.
The rules for each server and the full test are in
The SOAP entry script is now index_soap.php.
8.3 Exponential Velocity, the recommended way
Velocity is part of Exponential as of 6.0.15. The root composer.json requires
se7enxweb/exponential-velocity ~0.0.4.57 (through se7enxweb/exponential-php-floor, on PHP 8.0 and later), so the composer install of chapter 3
has already installed it, on the same PHP as the kernel (8.0 and later, the stock PHP 8.0 of Red Hat Enterprise Linux 9
included). There is nothing extra to download before the first exp:velocity start. This is the turning point of the
6.0 line: the Internet / Web Application Server is a cornerstone of the core product, so serving, caching, HTTPS and
deploying come out of the box, by default, from the same source as the application. The background:
Velocity is part of Exponential 6.0.15.
The four commands of daily work:
./console exp:velocity deploy --allow-root-user # after a change: autoloads, settings, caches, PHP-FPM, Velocity
./console exp:velocity start --allow-root-user
./console exp:velocity restart --allow-root-user # workers load changed classes and settings
./console exp:velocity cache clear --apply --allow-root-user # empty the response cacheVelocity is an application server written for Exponential. A parent process loads the application once and forks workers that answer one request after another; between requests each worker is put back into the state it had when it was forked. The parent accepts every connection itself, answers static files, response-cache hits and its own pages without involving a worker, and terminates TLS. That is why a Velocity site needs no Apache, nginx, PHP-FPM or separate TLS terminator.
Internet
|
:80 / :443 (or 8088 / 8080 as shipped)
|
+-------------------v--------------------------------------------+
| Velocity parent process (root when started as root) |
| - binds the ports, reads the certificate, speaks TLS, HTTP/2 |
| - static files, response-cache hits, /Q/ pages |
| - hands PHP requests to an idle worker |
+--------+-------------------+-------------------+---------------+
| | |
worker (site user) worker (site user) ... (forked by the zygote)
Exponential kept loaded, reset after each requestThe exp:velocity command (also reachable as exp:vc, or directly as php bin/php/velocity.php) drives three
engines with the same verbs: qbix (Velocity's own server, the subject of this section), frankenphp (section 8.4)
and php (PHP's built-in development server).
8.3.1 Install the engine package
Exponential's composer.json requires the engine at ~0.0.4.57, so composer install installs it on every
supported PHP, 8.0 included. Until its release 0.0.4.54 the engine needed PHP 8.1 and was only suggested; an
installation made in that time adds it with Composer:
composer require se7enxweb/exponential-velocity:~0.0.4.57It lands in vendor/se7enxweb/exponential-velocity (the former package name se7enxweb/qbix-webserver is still
found). The tilde constraint follows the engine's last version position, so ~0.0.4.57 accepts later 0.0.4.x
releases.
The package's composer.json requires PHP 8.0 ("php": ">=8.0", from release 0.0.4.54; it was >=8.1) and the
sockets extension (TCP options on client connections, and the zygote of section 8.3.6), so Composer refuses the
package without sockets. On PHP 8.0 a few things differ (AVIF output, the dashboard's memory peak column,
$_FILES full_path); chapter 2 lists them. The
engine also wants pcntl and posix to fork workers and change their user, and openssl for HTTPS. At start-up it
refuses to run without process isolation (pcntl, or a php-cgi binary) and without openssl when HTTPS is
configured, printing the install command; for the rest of its standard set it warns once and starts. To see what this
PHP lacks, with the command that installs it on this operating system:
php bin/php/console exp:velocity ext check
php bin/php/console exp:velocity ext install-hint <extension>Both are the engine's own qbixctl ext:check and ext:install-hint, run with this installation's configuration;
ext check without arguments is the default action.
The engine package also exists as an operating-system package (exponential-velocity, deb and rpm, with a systemd
unit) and as a Docker image; see Velocity packages, Docker images and binaries.
For an Exponential installation the Composer package is what exp:velocity drives.
8.3.2 Choose the engine
settings/velocity.ini ships with [ServerSettings] Engine=php, because PHP's built-in server always works. For any
real site set qbix:
php bin/php/console exp:velocity config set --apply ServerSettings Engine qbix| Engine | Role | Shipped ports (HTTP / HTTPS) | Notes |
|---|---|---|---|
qbix | recommended: development to production | 8088 / 8080 ([ServerSettings] Port, HTTPSPort) | persistent workers, response cache, TLS, HTTP/2, /etc/vc tree |
frankenphp | production-ready alternative | 8089 / 8444 ([FrankenPHPSettings]) | one downloaded binary; clean state per request; HTTPS on by default |
php | development (shipped default) | 8087 / none ([PHPServerSettings]) | php -S with bin/php/velocity-router.php; no TLS, no reload |
Engine is only the default: --engine=<name> reaches another engine for one command, --engine=php,qbix several,
--all every engine (for start, stop, restart, graceful, kill and status). Each engine has its own port,
pid file and logs, so they can run side by side for tests. A site is served by one.
8.3.3 Settings: where they live and how to change them
| File | What it is |
|---|---|
settings/velocity.ini | the shipped defaults, every setting commented; never edit it |
settings/override/velocity.ini.append.php | this installation's values; written by exp:velocity config set |
/etc/vc/... (or var/vc/qbix/etc/ without root) | the engine's configuration tree, generated from the two files above on every start (section 8.3.10) |
php bin/php/console exp:velocity config list # every setting; * marks the ones you set
php bin/php/console exp:velocity config list HTTPSSettings # one block
php bin/php/console exp:velocity config get ServerSettings Workers
php bin/php/console exp:velocity config set --apply ServerSettings Workers 16
php bin/php/console exp:velocity config unset --apply ServerSettings Workers # back to the shipped value
php bin/php/console exp:velocity config paths # which file is whichA changed setting reaches the server at the next restart (or graceful). The workers hold the application in
memory, so the same is true for a changed class or INI file of the application itself (section 8.3.14).
8.3.4 First start
php bin/php/console exp:velocity start
php bin/php/console exp:velocity statusstart writes the configuration, starts the server in the background (detached with setsid), waits until the port
answers and then prints the status. status lists every address to open as a full URL: each site reached by a path,
the administration login, Setup > System information and Setup > Caches, the HTTPS address beside each plain one when
HTTPS is on, and the server's own dashboard. Below that it shows the process, version, configuration, HTTPS state,
log paths and any setting that the running engine does not act on.
With the shipped values the site answers on http://127.0.0.1:8088/, reachable only from the machine itself (or
through an SSH tunnel). Check it:
curl -sI http://127.0.0.1:8088/ | head -1Expect HTTP/1.1 200 OK or a redirect. status --json gives the same information for monitoring.
What can go wrong on the first start:
| You see | Why | What to do |
|---|---|---|
velocity: no server script at ... | the engine package is not installed | section 8.3.1, or --engine=php for a quick look |
velocity: port ... is already in use by another process (the php and frankenphp engines), or a qbix start that does not come up, with the bind error in console.log | another engine or program holds the port | exp:velocity status --all; stop the other one or change Port |
the start succeeds, but curl from another machine gets no answer | Host is still 127.0.0.1 | section 8.3.5 |
| a warning that the workers run as root | nothing names a worker user and the document root belongs to root | section 8.3.5, then 8.7 |
| a page that says the site is in maintenance | an installation run left maintenance mode on | php bin/php/maintenance.php status, then off |
The start-up lines, including the worker user (Workers as: ...) and the TLS state, are in
var/vc/qbix/run/console.log (section 8.3.12).
8.3.5 Address, ports, user and group
To serve visitors directly, bind to all addresses on the standard ports:
php bin/php/console exp:velocity config set --apply ServerSettings Host 0.0.0.0 --allow-root-user
php bin/php/console exp:velocity config set --apply ServerSettings Port 80 --allow-root-user
php bin/php/console exp:velocity config set --apply ServerSettings HTTPSPort 443 --allow-root-user
php bin/php/console exp:velocity restart --allow-root-userSetting ([ServerSettings]) | Shipped | Meaning |
|---|---|---|
Host | 127.0.0.1 | bind address. The default keeps the server reachable only locally, through a tunnel or a reverse proxy |
Port | 8088 | plain HTTP |
HTTPSPort | 8080 | HTTPS, used when HTTPS is on (section 8.3.9) |
DocumentRoot | empty | empty means the installation root, which is correct |
User, Group | empty | who the workers run as when the server is started as root |
AllowRootWorkers | disabled | root as worker user is refused unless enabled |
FollowSymlinks | disabled | a file reached through a symbolic link leading out of the document root is refused (403 on qbix) |
HTTP2 | enabled | HTTP/2 for clients that negotiate it during the TLS handshake |
StaticMaxAge | 31536000 | browser cache lifetime of stylesheets, scripts and images, in seconds. A file replaced under the same name is not fetched again until it expires; lower it while you work on a stylesheet |
ResponseHeaders[] | X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy | headers added to every answer the server builds itself (static files, resized images, its own error pages, redirects); a header already present is never replaced. Strict-Transport-Security has its own setting (section 8.3.9) |
ResponseHeadersOnScripts | enabled | add the same headers to what index.php, index_rest.php and index_treemenu.php answer, where the script did not send that header itself |
Ports below 1024 need root. Started as root, the parent process keeps root (it binds the ports, reads the certificate and performs reloads), and every worker gives root up right after it is forked and before any application code runs. Who the workers become is decided in this order, first match wins:
[ServerSettings] UserandGroupinvelocity.ini(passed as--userand--group);VC_RUN_USERandVC_RUN_GROUPfrom the environment, orexport VC_RUN_USER=...lines in/etc/vc/envvars(the engine also readsQBIX_RUN_USERandQBIX_RUN_GROUP);- the owner and group of the document root.
A user or group that does not exist stops the start. With nothing configured and a document root owned by root, the
workers stay root and the start says so: give the installation its own user (section 8.7) or set User and Group.
Set them to the user the files belong to:
php bin/php/console exp:velocity config set --apply ServerSettings User example --allow-root-user
php bin/php/console exp:velocity config set --apply ServerSettings Group example --allow-root-userStarted as an ordinary user, the server runs entirely as that user; use ports above 1024 then, and put a reverse proxy or a firewall redirect in front (section 8.8).
8.3.6 Workers: pool size, the dynamic pool and the zygote
Setting ([ServerSettings]) | Shipped | Meaning |
|---|---|---|
Workers | 4 | the size of the pool; with SpareWorkers set, the most it grows to. Always set explicitly |
SpareWorkers | 0 | 0: fixed pool. Above 0: a dynamic pool that keeps this many idle workers, forks more when all are busy (up to Workers) and retires extra ones |
IdleWorkerTimeout | 60 | seconds an extra worker may stay idle before it is retired |
Instances | 1 | number of servers sharing the same ports with SO_REUSEPORT (Linux and the BSDs); Workers and SpareWorkers are per instance |
Zygote | empty (engine default: on) | fork new workers from a process that never held a client connection |
StatTtl | 0 | seconds a worker may keep file facts (exists, mtime, size) across requests; at most 10 |
Sizing. A worker of a full Exponential costs about 10 to 11 MB of its own memory (measured as PSS on 12 cores and
PHP 8.5); the shared part, about 47 MB, is paid once. Judge memory by PSS or private memory, never by RSS, which counts
every shared page once per worker and overstates a pool many times. When every worker is busy a request waits in the
parent; it is not refused until the connection limit (maxConnections, 1024) is reached.
A good start for a production site is a dynamic pool:
# settings/override/velocity.ini.append.php
[ServerSettings]
Workers=64
SpareWorkers=8
IdleWorkerTimeout=60Instances. One server answers cached pages in its own process and is bounded by one core (measured: about 2,950 cached pages a second over TLS; four instances, about 12,000). Rendered pages are bounded by the workers, not by the number of instances.
The zygote. fork() hands a new worker every descriptor the server holds, including open visitor connections. A
worker forked from the server under load therefore kept connections open until it retired. The zygote is a process
forked before the first connection is accepted; later workers are forked from it and inherit no connections. It needs
the sockets extension; if it fails, workers are forked from the server as before and the log says so.
Workers are also replaced on their own: after maxRequests requests (1000), when the heap passes
workerMemoryCeiling, or when a request runs past requestTimeout (30 seconds; the client gets 504). These are
engine settings under Q.webserver, described in the worker pool specification.
8.3.7 Persistent workers and KeepGlobals
[ServerSettings] ForkPerRequest decides how long a worker lives:
| Value | Behaviour |
|---|---|
disabled (shipped) | persistent workers: each serves many requests; the request's state is cleared after the response has gone out |
enabled | one request per worker, then a fresh fork: the isolation of PHP-FPM, at the cost of starting every request cold |
Persistent workers keep database connections, stat caches and everything the kernel keeps per process. Measured on a
signed-in administration page: 256 to 284 ms with a fresh worker per request, 73 to 80 ms with persistent workers.
They need an engine that sets REQUEST_TIME_FLOAT per request and carries headers and shutdown functions in this mode
(headers and shutdown functions from qbix-webserver 0.0.4.25, the per-request REQUEST_TIME_FLOAT from Exponential Velocity 0.0.4.42; Exponential requires 0.0.4.54).
Between requests the worker restores static class properties, superglobals, custom globals, output buffers, handlers
and response headers to the state it was forked with. A few registries in the kernel are filled with include_once
and could never be refilled once cleared, so they must be kept: the datatype, workflow event, notification event
and payment gateway registries. The worker scope of the kernel service container (expKernelServicesWorker) is kept
too: it holds the settings files of each siteaccess, built once per worker instead of on every request
(the kernel service container). Those names are built in
(expVelocity::DEFAULT_KEEP_GLOBALS) and need no setting.
[ApplicationSettings]
# Adds to the built-in list. Only for a global filled once by include_once.
KeepGlobals[]
KeepGlobals[]=MyExtensionRegistry
# disabled starts from an empty list (for a kernel without those registries)
KeepGlobalsDefaults=enabled--keep-global=Name[,Name] adds names for one start, restart or command; exp:velocity command prints the
complete command line, exp:velocity layout the resulting list. Keep nothing else: a request-scoped global kept
between requests leaks one visitor's request into the next.
[ServerSettings] PreloadWarmup (render a page in the parent before forking) ships disabled and should stay so;
the settings file explains the features it broke.
8.3.8 The response cache
The response cache keeps rendered anonymous pages and answers repeat requests inside the server process, before a
worker is chosen: no fork, no PHP, no database. Exponential configures it in [CacheSettings]:
| Setting | Shipped | Meaning |
|---|---|---|
Enabled | enabled | the cache is on (it needs qbix-webserver 0.0.4.26 or later, which recognises the session cookie) |
DefaultTtl | 30 | seconds an anonymous page is kept when it brings no lifetime of its own |
Dir | var/cache/qbix-reverse | disk tier for entries too large for shared memory |
APCu, APCuMaxSize | enabled, 0 | small entries in APCu, shared by the workers (apc.enable_cli is set for the server process) |
SkipCookies | empty | cookies that mean "personal, do not cache"; empty derives the session cookie and is_logged_in |
StaleWhileRevalidate | 60 | seconds an expired page is still served while one request renders its replacement |
NotFoundSeconds | 0 | seconds a 404 is remembered; 0 sends every 404 to PHP, so 404.log and security.log see each one |
HonorRequestNoCache | enabled | a request with Cache-Control: no-cache or Pragma: no-cache reaches PHP and its answer is stored again |
SweepEvery, SweepMaxAge | 300, 86400 | clearing expired entries off the disk |
MinifyHtml | enabled | stored pages without template indentation |
Signed-in visitors are never served from the cache, and a page carrying a form token is sent private. After a
template or stylesheet change:
php bin/php/console exp:velocity cache clear --apply # every cached page is rendered again on its next request
php bin/php/console exp:velocity cache stats # where it is, how much it holds, when it was clearedCheck a hit with two requests: the second carries X-Cache: HIT and an Age header.
The kernel's own caches (content view cache, template blocks, the role-aware HTTP cache in settings/httpcache.ini,
the SQL query cache in settings/querycache.ini) work under every server; they are cleared with
bin/php/ezcache.php or exp:cache. See the response cache page.
8.3.9 HTTPS served by Velocity itself
Velocity terminates TLS in its own process, with HTTP/2. Nothing else is needed: no mod_ssl, no stunnel, no reverse
proxy, and for a public certificate not even certbot.
The engine sets $_SERVER['HTTPS'] and REQUEST_SCHEME for the application. A request on the TLS listener is
HTTPS whoever sent it. Since Velocity 0.0.4.44 a request on the plain listener is HTTPS too when it comes from a
trusted proxy (Q.webserver.proxy.trusted, loopback only by default) that sends X-Forwarded-Proto: https,
CloudFront-Forwarded-Proto: https or CF-Visitor: {"scheme":"https"}; from any other client those headers are
ignored. See section 8.8.
With a certificate you have
Any PEM certificate from a CA or a hosting panel (leaf with chain, and the private key; one file may hold both):
php bin/php/console exp:velocity config set --apply HTTPSSettings Certificate /etc/ssl/example.com/fullchain.pem --allow-root-user
php bin/php/console exp:velocity config set --apply HTTPSSettings Key /etc/ssl/example.com/privkey.pem --allow-root-user
php bin/php/console exp:velocity config set --apply HTTPSSettings Enabled true --allow-root-user
php bin/php/console exp:velocity restart --allow-root-user
php bin/php/console exp:velocity ssl show --allow-root-user
curl -sI https://example.com/ | head -1 # expect HTTP/2 200 or a redirectWhat velocity.ini does with these settings (expVelocity::httpsEnabled() and writeServerConfig()):
- HTTPS is switched on only when
Enabledistrue(orenabled) and both files exist. A path that does not exist leaves the server on plain HTTP rather than starting a TLS port that cannot work;statussays HTTPS is off and what switches it on. Relative paths are read from the installation root. - The site file then carries
Q.web.httpswithmode: manualand the two paths, and the server is started with--https-port=<HTTPSPort>. - Before use the pair is verified: the certificate parses, has not expired and the key belongs to it. A pair that fails
is never shown to a visitor; a self-signed certificate stands in until the real one is usable again (the
engine's
fallback, defaultself-signed;nonewould switch HTTPS off instead). - The server reads a private copy of the pair (
active-<port>-<fingerprint>-<pid>.pem), so a file being rewritten elsewhere cannot break a handshake. EverywatchIntervalseconds (60) the source files are checked, and a changed, valid pair is swapped in live, for the next connection, with no restart and no dropped connection. A renewal by your own tool is therefore picked up within a minute. - HTTPS comes up before plain HTTP, so there is never a moment when the server answers HTTP but not HTTPS.
The server process reads the key as root when it was started as root, so the key can stay 0600 root.
On the shipped ports the HTTPS address is https://<host>:8080/ and plain HTTP stays on 8088; both listen at once,
and velocity.ini has no setting that redirects one to the other. Make sure the addresses the site generates are the
HTTPS ones (site.ini [SiteSettings] SiteURL and the siteaccesses' URLs) before you raise HSTSMaxAge.
Strict-Transport-Security
[HTTPSSettings]
HSTSMaxAge=300 # seconds; 0 sends no header
HSTSIncludeSubDomains=disabled
HSTSPreload=disabledThe header goes out on every HTTPS answer (static files, the server's own pages and the scripts' answers) and never
over plain HTTP. A browser that has seen it refuses plain HTTP to that host for the whole period, even after you lower
the value. Raise it to a year (31536000) only when every name of the site is served over HTTPS for good; preload
needs HSTSIncludeSubDomains and at least a year.
Let's Encrypt and other ACME CAs, archives, PKCS#12
The engine's certificate subsystem can do more than velocity.ini exposes: obtain and renew certificates from
Let's Encrypt or any ACME CA (ZeroSSL, Google), read a certificate from an archive (.zip, .tar.gz, .7z and
others) or a .p12/.pfx bundle, take what certbot already manages, download one from a URL, or make a self-signed
certificate without any file (the modes letsencrypt, archive, pkcs12, certbot, remote and self-signed
beside manual). These sources are configured under Q.web.https in the engine's
configuration. In the /etc/vc tree (section 8.3.10) everything in conf-available/ belongs to the administrator and
is merged before the generated site file, so a snippet there is the place for it. Leave [HTTPSSettings] Enabled
at false in this case, so the generated files do not set Q.web.https themselves.
Requirements for the HTTP-01 challenge: DNS of every name points to this machine, and port 80 of this server is
reachable from the internet ([ServerSettings] Port 80), because the CA fetches
/.well-known/acme-challenge/... there.
cat > /etc/vc/conf-available/https-acme.conf <<'EOF'
{ "Q": { "web": { "https": {
"port": 443,
"mode": "letsencrypt",
"acme": {
"email": "[email protected]",
"domains": ["example.com", "www.example.com"],
"directory": "letsencrypt-staging"
}
} } } }
EOF
php bin/php/console exp:velocity conf enable --apply https-acme --allow-root-user
php bin/php/console exp:velocity restart --allow-root-user
php bin/php/console exp:velocity ssl show --allow-root-userAnnotations:
- The files of the tree are JSON objects under Apache's file names.
/etc/vcis the tree whenexp:velocityruns as root;exp:velocity layoutnames the directory in use otherwise. directory: letsencrypt-stagingfirst: its certificates are not trusted by browsers, but its rate limits are generous. Whenssl showlists a staging certificate, remove that line (the default isletsencrypt) and restart.- Until the first certificate arrives, the self-signed one is served; the CA certificate replaces it live.
- Renewal runs in the background when a third of the lifetime is left (
renewAt0.33), with growing pauses after a failure (5 minutes doubling up to a day), so rate limits are never spent.exp:velocity ssl renew [host...]asks for it now. keyType(ec256by default),challenge: dns-01with adnsHookprogram (needed for wildcards),webroot(when another server owns port 80),eab(external account binding) and the other keys are listed in Velocity HTTPS and certificates and in the engine'sdocs/https.md.startreports that the configuration differs fromvelocity.inibecause of the enabled snippet; that is expected.- Back up the
ssl/directory of the configuration directory the server runs with (the engine'sQ.webserver.confDir, unlessselfSigned.dirnames another). Everything in it can be made again except the ACME account keys (acme/account-<ca>.pem).
For a certificate in an archive or bundle use "mode": "archive" with "archive": "<file>" or "mode": "pkcs12"
with "bundle": "<file>" and a password, passwordFile or passwordEnv. For development without any certificate:
{ "Q": { "web": { "https": { "port": 8443, "mode": "self-signed" } } } }.
Inspecting certificates
php bin/php/console exp:velocity ssl show --allow-root-user # served certificate, every known one by expiry
php bin/php/console exp:velocity ssl show --json --allow-root-user # for monitoring: watch daysLeft
php bin/php/console exp:velocity ssl renew --apply example.com --allow-root-userA 90-day CA certificate is renewed when about 30 days are left, so one with fewer than 14 days left means renewal has
been failing for more than two weeks; the last error, which ssl show prints with the ACME state, says why. Keep
NTP running: certificates are checked against the system clock. The control panel's SSL tab shows the same and
can renew and reload from a browser (section 8.3.13).
| Symptom | Cause | Fix |
|---|---|---|
status says HTTPS is off | Enabled is not true, or a named file does not exist | check the paths with ls -l; config get HTTPSSettings Certificate |
| browser warns about the certificate | the self-signed stand-in is served: the real pair is expired, mismatched or not yet issued | ssl show; the start-up log names the reason |
ACME Invalid response from http://.../.well-known/acme-challenge/... | port 80 does not reach this server | open port 80, or set acme.webroot for the server that owns it |
none of the certificates belongs to the private key | key and certificate from different requests | use the key the certificate was issued for |
8.3.10 The configuration tree: /etc/vc, Debian style
Velocity's configuration on disk mirrors Debian's /etc/apache2, so an administrator who knows Apache finds
everything in the expected place:
/etc/vc/ Velocity's overlay on the engine's base tree /etc/qbix
├── vc.conf settings shared by every site (apache2.conf)
├── ports.conf listen ports (ports.conf)
├── envvars environment of the server process (envvars)
├── conf-available/*.conf shared snippets -> conf-enabled/ (symlinks)
├── mods-available/*.conf engine modules: http2, cache, ... -> mods-enabled/ (symlinks)
├── sites-available/*.conf one file per installation -> sites-enabled/ (symlinks)
├── designs/ the server's own page designs
└── ssl/ certificates (self-signed, imported, ACME), in the directory the server runs with
/var/lib/vc/sites/<site>.json what is known about each installationWhich directory. [LayoutSettings] ConfDir=auto uses /etc/vc if it exists or can be created (that is, when
exp:velocity runs as root), else an existing /etc/qbix, else var/vc/qbix/etc inside the installation.
disabled uses a single generated file only (var/tmp/velocity-server.json, which is written in every case, so an
older engine can still start from it); a path names a directory. [LayoutSettings] StateDir=auto puts the site
metadata in /var/lib/vc when the configuration is in /etc/vc, else in var/vc/qbix/lib. The site's name in sites-available is
[LayoutSettings] SiteName, by default the host of site.ini [SiteSettings] SiteURL.
The overlay. /etc/qbix is the engine's own base tree (as the operating-system package installs it); /etc/vc is
stacked on top of it by the engine's vc distribution and loaded after it, so it wins.
Load order, later wins (a deep merge, like Apache's includes):
- engine defaults, then the application's
config/server.json vc.confports.confmods-enabled/*in name orderconf-enabled/*in name order- the site file,
sites-enabled/<site>.conf - command-line flags (
--port,--workers, ...)
Who writes what. velocity.ini stays the source. On every start and restart (and on layout migrate)
exp:velocity writes ports.conf, the module files it manages and sites-available/<site>.conf (mode 0600: it holds
certificate paths and the dashboard token). Generated files carry "_generated": "exp:velocity"; remove that marker
from a file and it is never overwritten again, like a dpkg conffile. vc.conf, envvars and anything else you add are
created once and only ever read. Before the tree is used, the generated pieces are merged and compared with the single
configuration they replace; if that check fails, the server is started from the single file and the log says why.
Differences you made on purpose (a disabled module, an enabled snippet) are honoured and named in the start message.
Enable and disable, like a2ensite, a2enconf and a2enmod:
php bin/php/console exp:velocity layout --allow-root-user # every path, what is enabled, the files in use
php bin/php/console exp:velocity layout migrate --apply --allow-root-user # write the tree now
php bin/php/console exp:velocity site enable --apply <name> --allow-root-user # sites-enabled/<name>.conf -> ../sites-available/
php bin/php/console exp:velocity site disable --apply <name> --allow-root-user # removes only the symlink
php bin/php/console exp:velocity conf enable --apply|disable <name> --allow-root-user
php bin/php/console exp:velocity mod enable --apply|disable <name> --allow-root-user
php bin/php/console exp:velocity ctl configtest --allow-root-user # the engine's own check of the treeThe tree belongs to the qbix engine. On frankenphp the configuration is a generated Caddyfile and on php a router
script; there layout lists those files and site|conf|mod refuse.
8.3.11 Running Velocity as a service
exp:velocity has no command that installs a boot service. Two routes:
The engine's own units. The engine package ships examples:
vendor/se7enxweb/exponential-velocity/service/qbixserver.service(systemd) andcom.qbix.server.plist(launchd), with aREADME.md, for a plain Qbix server started with--root,--portand--pid;vendor/se7enxweb/exponential-velocity/packaging/systemd/exponential-velocity.service, the unit of the operating-system package (User=qbix,AmbientCapabilities=CAP_NET_BIND_SERVICE,EnvironmentFile=-/etc/default/exponential-velocity, hardening withNoNewPrivileges,ProtectSystem,ProtectHome,PrivateTmp).
Both start the engine directly and therefore bypass what exp:velocity adds: the generated /etc/vc site file,
KeepGlobals, [PHPSettings] IniOptions[] (OPcache, APCu), the worker user, the engine archive and the instances.
A unit that runs exp:velocity (derived). The following unit is not shipped; it is derived from what
expVelocity::start() does (it starts the server detached and returns once the port answers, leaving the pid in
var/vc/qbix/run/server.pid) and from the commands above. It keeps every setting in velocity.ini:
# /etc/systemd/system/exponential-velocity-example.service
[Unit]
Description=Exponential Velocity for example.com
After=network-online.target mysqld.service
Wants=network-online.target
[Service]
Type=forking
WorkingDirectory=/var/www/example.com
ExecStart=/usr/bin/php bin/php/console exp:velocity start --allow-root-user
ExecStop=/usr/bin/php bin/php/console exp:velocity stop --allow-root-user
ExecReload=/usr/bin/php bin/php/console exp:velocity graceful --allow-root-user
PIDFile=/var/www/example.com/var/vc/qbix/run/server.pid
TimeoutStartSec=90
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetsystemctl daemon-reload
systemctl enable --now exponential-velocity-example
systemctl status exponential-velocity-exampleNotes: it runs as root so the server can bind ports 80 and 443 and read the key; the workers still drop to
[ServerSettings] User and Group. Adjust After= to your database service. With Instances above 1, PIDFile
names instance 0 only. Do not combine it with the operating-system package's unit for the same ports.
8.3.12 Logs
Everything Velocity writes is below var/vc/, one directory per engine:
var/vc/
├── qbix/
│ ├── etc/ the configuration tree when /etc/vc is not used
│ ├── lib/ site metadata when /var/lib/vc is not used
│ ├── log/ <siteaccess>-access.log, <siteaccess>-error.log, velocity-layout.log ([LogSettings] Dir)
│ └── run/ server.pid, console.log (PidFile, LogFile)
├── frankenphp/ bin/ caddy/ tls/ log/ run/
└── php/ log/server.log, run/server.pidSetting ([LogSettings]) | Shipped | Meaning |
|---|---|---|
Enabled | enabled | without it the server records nothing at all |
Dir | var/vc/qbix/log | the qbix engine's log directory |
AccessName, ErrorName | empty | empty names the files after the default siteaccess (site-access.log) |
Format | combined | Apache's combined format; qbix adds the response time; common; or a format string |
FileMode, DirMode | 0640, 0750 | an access log names visitors; it is not for every account to read |
Rotation puts the date before the extension (site-access.2026-09-24.log). console.log holds what the process
prints, including the start-up lines (Workers as: ..., tls: certificate ready ...). The kernel's own logs stay in
var/<site>/log/ as under any server.
8.3.13 The dashboard and the control panel
The server answers a few pages of its own, below /Q/, independent of the application:
| View | What | Who may open it (as exp:velocity configures it) |
|---|---|---|
/Q/dashboard | live requests, workers, memory, status codes | this machine; elsewhere with [DashboardSettings] Token |
/Q/stats, /Q/metrics, full /Q/health | figures, Prometheus metrics, health report | this machine; elsewhere with the Token or the panel login |
/Q/phpinfo | phpinfo() including the process environment | this machine; from elsewhere only with the Token or a panel session, never through Remote=enabled |
/Q/panel | control panel: domains, certificates, cache, logs, workers, settings | this machine, then the panel password |
/Q/health (status only), /Q/docs | "ok", documentation | everyone |
A token is any long random string, for example from openssl rand -hex 24; set it with
exp:velocity config set DashboardSettings Token <value> and pass it as ?token=... or as
Authorization: Bearer .... [DashboardSettings] Remote=enabled opens the figures remotely without a token; use it only on a port a firewall
already restricts. To reach the panel of a remote server, use an SSH tunnel to the server's own port.
The first visit of /Q/panel sets the panel password (at least 16 characters with the engine's strength rules; bcrypt;
failed sign-ins lock the address out with growing pauses). On a machine reachable from the internet set it at once.
Its storage must not be writable by group or others, or the panel locks itself; the engine's qbixctl panel:check
shows why. Details: Velocity control panel.
8.3.14 Deploying a change: exp:velocity deploy
Workers keep classes, INI values and compiled templates in memory, so a changed PHP class, operator or INI file reaches them only after a restart, and caches that hold rendered output must be cleared after the new code runs, or the old output is cached again. One command does it in the right order:
php bin/php/console exp:velocity deploy --dry-run --allow-root-user # what it would do, nothing else
php bin/php/console exp:velocity deploy --allow-root-user # the usual case
php bin/php/console exp:velocity deploy --kernel --allow-root-user # a kernel class was added or renamed| # | Step | By hand |
|---|---|---|
| 1 | extension autoloads | php bin/php/ezpgenerateautoloads.php -e |
| 2 | kernel autoloads, only with --kernel (restricted to kernel/ and lib/) | php bin/php/ezpgenerateautoloads.php -k |
| 3 | INI caches | php bin/php/ezcache.php --clear-tag=ini |
| 4 | template, template-override, translation and design_base caches | --clear-id=<id>, one per call |
| 5 | engine archive, when Velocity runs from one (rebuilt only when something in it changed) | php bin/php/console exp:phar build |
| 6 | reload PHP-FPM | systemctl reload <[DeploySettings] PhpFpmService> |
| 7 | restart Velocity, when it runs | exp:velocity restart |
| 8 | content, exphttpcache, (--packer: ezjscore-packer,) template-block caches | --clear-id=<id> |
| 9 | Velocity's response cache | exp:velocity cache clear |
Each step prints PASS, FAIL or SKIP with its time; the first failure stops the run (exit status 1) and the steps
not run are listed as NOT RUN. --dry-run prints each step with what it would do (DRY) and changes nothing.
Why this order: the caches that hold rendered output (content view cache, HTTP cache, template blocks, the response cache) are cleared only after PHP-FPM has been reloaded and Velocity restarted. Cleared earlier, every page requested in between is rendered by the old code and cached again, and the change looks as if it had not worked.
Some steps decide for themselves that they have nothing to do, and say so:
- Engine archive (step 5) runs only when Velocity runs from an archive (
EnginePhar, section 8.3.15) and is running. Then every file going into the archive is parsed first, so a kernel file that does not parse stops the deploy while PHP-FPM and Velocity still run the old code. Without an archive there is no such check: runphp -lon what you changed. - Restart Velocity (step 7) restarts a running server only. A stopped one stays stopped: a deploy brings the running services up to date, it does not decide which ones should run.
- Reload PHP-FPM (step 6) is skipped with a note when no pool was found, when it is not run as root, or when the
machine has no
systemctl.
Options: --kernel, --no-autoload, --no-fpm, --no-velocity, --rebuild-phar, --packer (clear the packed
scripts and styles together with the template blocks; needed only when a packer server function or its settings
changed), --engine=<name>, --dry-run, --json.
[DeploySettings] PhpFpmService names the PHP-FPM to reload, so an Apache or nginx beside Velocity runs the new code
too: auto (the default) finds the pool configuration named after the installation's hosting domain or naming its
directory, in /opt/plesk/php/*/etc/php-fpm.d, /etc/php-fpm.d, /etc/php/*/fpm/pool.d and
/etc/opt/remi/php*/php-fpm.d; a unit name (or several, comma-separated) names it; disabled when Velocity serves the
site alone. The step is a graceful systemctl reload and needs root; run as anyone else it is skipped with a note.
For a template or stylesheet change no restart is needed: clear the template caches as usual and run
exp:velocity cache clear. exp:velocity graceful re-executes the server while keeping the listening socket, so a
restart drops no request.
8.3.15 The engine archive: engine.phar
The kernel can run from one archive, dist/engine.phar, instead of the files in kernel/, lib/ and autoload/. It
is worth having for distribution and for knowing that the engine runs exactly what was built; measured, it is not
faster once OPcache is warm.
php bin/php/console exp:phar build # build (only when something changed; --force always)
php bin/php/console exp:phar check # is the archive current?
php bin/php/console exp:phar info
php bin/php/console exp:velocity config set --apply ServerSettings EnginePhar enabled
php bin/php/console exp:velocity restartWriting an archive needs PHP's phar.readonly off. The console starts every command in a fresh PHP, so a
php -d phar.readonly=0 in front of bin/php/console never reaches the command; exp:phar build therefore checks
first whether the archive is current and, when it has to write, runs itself again with -d phar.readonly=0. Nothing
needs to be set by hand.
[ServerSettings] EnginePhar takes disabled (the files on disk, the default), enabled (dist/engine.phar) or a
path. A path that does not exist stops the start instead of silently falling back. With an archive in use, start,
restart and graceful rebuild it first when a file in kernel/, lib/ or autoload/ was added, removed or
changed (engine.phar is current, not rebuilt otherwise); --rebuild-phar forces it. See
Engine archive (phar).
8.3.16 Command reference
| Command | Does |
|---|---|
exp:velocity status (default verb) | overview of every engine, then details; --json, --all, --engine= |
exp:velocity start / stop / restart | as named; start waits until the port answers |
exp:velocity graceful | re-executes the server, keeping the listening socket; no dropped requests |
exp:velocity kill | stop without asking, for a wedged worker |
exp:velocity deploy | everything a PHP change needs, in order (section 8.3.14) |
exp:velocity command | print the command line it would run, and exit |
exp:velocity config list|get|set|unset|paths | read and write velocity.ini values |
exp:velocity cache clear|stats | the response cache |
exp:velocity layout [migrate] | the configuration tree and every file the server uses |
exp:velocity site|conf|mod enable|disable <name> | the a2ensite family |
exp:velocity ssl show|renew [host...] | certificates (qbix only; the other engines take their certificate from [HTTPSSettings]). show is the default action. Not listed by --help, but accepted |
exp:velocity ctl <args> | the engine's qbixctl with this installation's tree, site and pid file (ctl status, ctl configtest, ...) |
exp:velocity ext check|list|plan|install-hint|build | PHP extensions the engine expects |
exp:velocity install | FrankenPHP: download and verify the binary (--force, --from=<file>, --check, --trust-github-digest); qbix: nothing to do |
Options are accepted in GNU and BSD spellings (--keep-global V, -keep-global=V, -json); everything after -- is
passed to the engine untouched. exp:velocity --help prints the full list. An option that belongs to another verb is
refused with a message naming the verbs it goes with, for example velocity: --dry-run goes with deploy or
velocity: --rebuild-phar goes with start, restart, graceful and deploy; an unknown verb lists the valid ones.
8.4 FrankenPHP
FrankenPHP is the Caddy web server with PHP built in, as one binary. Velocity downloads it, verifies it and drives it
with the same commands. It runs in classic mode: a pool of PHP threads, each request starting from a clean state as
under PHP-FPM, so ForkPerRequest, KeepGlobals and the response cache do not apply.
php bin/php/console exp:velocity config set --apply ServerSettings Engine frankenphp --allow-root-user
php bin/php/console exp:velocity install --allow-root-user # optional: start installs it when missing
php bin/php/console exp:velocity start --allow-root-user- The binary.
installfetches the release asset for this machine for[FrankenPHPSettings] Version(shipped1.12.7) and refuses it unless its SHA-256 matches theSha256[<asset>]line pinned beside it. It is stored invar/vc/frankenphp/bin/.Variantchooses the Linux build (gnu, the default, loads.soextensions;musl;mimalloc).BinaryPathnames a build of your own.install --from=<file>installs a copied file on a machine without access to GitHub, checked the same way. - PHP settings. The binary reads no
php.iniof the machine, so[FrankenPHPSettings] IniOptions[]shipsdisplay_errors=Off,log_errors=On,memory_limit=512M,opcache.enable=1and the upload limits;PHPIniFilenames aphp.iniof your own instead. - Threads.
[FrankenPHPSettings] Workersis the number of PHP threads; empty means FrankenPHP's default, twice the CPU cores; more threads than that measured slower under heavy load.SpareWorkersadds threads under load. - HTTPS. On by default (
HTTPS=enabled) onHTTPSPort(8444) beside HTTP onPort(8089). It uses[HTTPSSettings] CertificateandKeywhen both are set; with both empty it makes a self-signed certificate for the machine invar/vc/frankenphp/tls/(valid a year) and renews it a month before it runs out. A public site names its certificate; after replacing the files, runexp:velocity graceful.start --no-httpsandstart --httpsdecide for one start (withstart,restartandgracefulonly);HTTPS=disabledswitches it off.exp:velocity sslis theqbixengine's and refuses here. - Compression.
[FrankenPHPSettings] Compression(zstd br gzip) is what Caddy offers clients; Caddy compresses nothing unless told to.disabledswitches it off. - Configuration.
var/vc/frankenphp/run/Caddyfileis generated on everystart,gracefulandrestartand validated withfrankenphp validatebefore it replaces the old one. Its routing is.htaccess_root's; it does not usephp_server, which would serve every existing file. Directives of your own go in a file named bySiteInclude.exp:velocity ctl caddyfileprints the configuration. - Control.
stopandgracefuluse Caddy's admin API on the owner-only socketvar/vc/frankenphp/run/admin.sock;gracefulreloads the configuration without dropping a connection. - Logs.
var/vc/frankenphp/log/access.loganderror.log, in Caddy's JSON format.
Details: FrankenPHP and Velocity engines.
8.5 Apache with PHP-FPM
Apache with mod_rewrite, mod_proxy_fcgi and a PHP-FPM pool is the traditional setup and needs no long-running
process of its own.
8.5.1 Step by step
Enable the rewrite rules. They ship as
.htaccess_root; copy, do not move, so an update can bring a new version:./console exp:setup:htaccess --apply # or by hand: cp .htaccess_root .htaccessUse
.htaccess_root_staticinstead only when you serve a generated static cache (it is.htaccess_rootwith the static cache offload rules added):./console exp:setup:htaccess --static --apply. The command copies with the template's mode (and, run as root with--allow-root-user, its owner), shows a diff and refuses when a different.htaccessis already there, and backs the old one up tovar/backup/setup/<time>/before--force --applyreplaces it../console exp:setup:statussays which template the current.htaccessis a copy of. See Setting up .htaccess and config.php from the console.Load the modules.
mod_rewrite,mod_proxyandmod_proxy_fcgi(Debian:a2enmod rewrite proxy_fcgi); for HTTPSmod_ssl, for HTTP/2mod_http2.Create the virtual host. Adjust the paths and the PHP-FPM socket to your system:
<VirtualHost *:80> ServerName example.com ServerAlias www.example.com # The installation root, where index.php is DocumentRoot /var/www/example.com DirectoryIndex index.php <Directory /var/www/example.com> Options FollowSymLinks # Lets Apache read the .htaccess created in step 1 AllowOverride All Require all granted </Directory> # Every PHP script goes to the PHP-FPM pool of this site <FilesMatch \.php$> SetHandler "proxy:unix:/run/php-fpm/www.sock|fcgi://localhost" </FilesMatch> </VirtualHost>If you prefer not to use
.htaccess(AllowOverride Nonesaves a file lookup per directory), copy the rules of.htaccess_rootinto the<Directory>block; inside<Directory>they keep their form. In server context (outside<Directory>) every pattern needs a leading slash, as indoc/eng-us/examples/ezpublish.conf, a complete virtual host (HTTP redirect, HTTPS, HTTP/2, PHP-FPM, headers) with the rules of this release's.htaccess_rootin server context (Apache virtual host). Compare your copy with.htaccess_rootafter every upgrade and merge new rules.HTTPS. Add a
*:443virtual host with the same content plus:SSLEngine on SSLCertificateFile /etc/ssl/example.com/fullchain.pem SSLCertificateKeyFile /etc/ssl/example.com/privkey.pem Protocols h2 http/1.1and obtain the certificate with certbot or your hosting panel. Redirect port 80 to HTTPS in the
*:80host (Redirect permanent / https://example.com/) once HTTPS works.Check and reload:
apachectl configtest # Syntax OK systemctl reload apache2 # httpd on Red Hat style systems curl -sI https://example.com/ | head -1A 404 for every page means
.htaccessis not read (AllowOverride) ormod_rewriteis off; a 500 means a look at the Apache and PHP-FPM error logs.Security headers are sent by the kernel on its pages (
site.ini [HTTPHeaderSettings] SecurityHeaders[]). For static files, add the same withmod_headersif you want them there as well; see chapter 13.
8.5.2 PHP-FPM
The pool runs PHP as the site's user. Settings of interest for Exponential (in the pool file or php.ini):
memory_limit (at least 256M; more for large imports), max_execution_time, upload_max_filesize and
post_max_size (your largest upload), OPcache on. After a PHP class, operator or INI change, reload the FPM master
that serves the site:
systemctl reload php-fpm # the distribution's single PHP
php bin/php/console exp:velocity deploy --no-velocity --allow-root-user # finds the right one and clears caches in orderOn machines with several PHP versions the master serving the site is often not the one called php-fpm. Find it by
its pool file: [DeploySettings] PhpFpmService=auto does this (deploy --dry-run shows what it found and why).
8.5.3 Plesk
Plesk generates the virtual host itself; do not edit its files. In Plesk:
- point the domain's document root at the installation root (the directory with
index.php); - choose "FPM application served by Apache" in the PHP settings, and the PHP version;
- keep
.htaccess(copied from.htaccess_root, for example with./console exp:setup:htaccess --apply); Plesk's Apache reads it; - install the certificate in Plesk (its Let's Encrypt extension or your own) and switch on the redirect to HTTPS.
Plesk names each pool after the domain in /opt/plesk/php/<version>/etc/php-fpm.d/<domain>.conf, served by the systemd
unit plesk-php<XY>-fpm (for example plesk-php85-fpm). systemctl restart php-fpm restarts a different master that
no Plesk site uses. Reload the right one (systemctl reload plesk-php85-fpm), or let exp:velocity deploy find it:
auto recognises Plesk's naming by the hosting domain in the installation's path (/var/www/vhosts/<domain>/...).
8.6 nginx with PHP-FPM
This configuration is derived, not shipped. Exponential ships no nginx configuration in its repository. The server
block below is a translation of .htaccess_root (the version in this release) into nginx directives, rule for rule,
following nginx's own documentation for location, try_files, return and fastcgi_pass. Test it on your site
before you rely on it, and compare it with .htaccess_root after every upgrade.
server {
listen 80;
server_name example.com www.example.com;
# The installation root, where index.php is
root /var/www/example.com;
index index.php;
# Uploads: nginx's default limit is 1 MB
client_max_body_size 64m;
# 1. No dot file or dot directory, anywhere; .well-known stays reachable
location ~ (^|/)\.(?!well-known(/|$)) {
return 404;
}
# 2. No script below the root runs because its path was asked for
# (.htaccess_root answers 404 only when the file exists; this answers 404 always)
location ~ ^/.+/[^/]+\.(php[0-9]?|phtml|phar)$ {
return 404;
}
# 3. Front controllers
location ^~ /api/ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index_rest.php;
fastcgi_param SCRIPT_NAME /index_rest.php;
fastcgi_pass unix:/run/php-fpm/www.sock;
}
location = /index_rest.php {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index_rest.php;
fastcgi_param SCRIPT_NAME /index_rest.php;
fastcgi_pass unix:/run/php-fpm/www.sock;
}
location ~ ^/([^/]+/)?content/treemenu {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index_treemenu.php;
fastcgi_param SCRIPT_NAME /index_treemenu.php;
fastcgi_pass unix:/run/php-fpm/www.sock;
}
# 4. Files served as they are (the "- [L]" rules of .htaccess_root);
# a listed path that does not exist is a 404, as under Apache
location ~ ^/var/([^/]+/)?storage/images(-versioned)?/ { try_files $uri =404; }
location ~ ^/var/([^/]+/)?storage/original/image/.+\.(png|jpe?g|gif|webp|svg)$ { try_files $uri =404; }
location ~ ^/var/([^/]+/)?storage/pdf/[A-Za-z0-9][A-Za-z0-9._-]*\.pdf$ { try_files $uri =404; }
location ~ ^/var/([^/]+/)?cache/(texttoimage|public)/ { try_files $uri =404; }
# With site.ini [FileSettings] CacheVarDir=var_cache (multi-site hosting, see
# doc/eng-us/features/6.0/multi-site-log-and-cache-paths.md):
#location ~ ^/var_cache/([^/]+/)?cache/(texttoimage|public)/ { try_files $uri =404; }
location ~ ^/design/[^/]+/(stylesheets|images|javascript|fonts)/ { try_files $uri =404; }
location ~ ^/share/icons/ { try_files $uri =404; }
location ~ ^/extension/[^/]+/icons/[A-Za-z0-9_/-]+\.(svg|png|gif)$ { try_files $uri =404; }
location ~ ^/extension/[^/]+/design/[^/]+/(stylesheets|flash|images|lib|javascripts?|fonts|vendor|media)/ { try_files $uri =404; }
location ~ ^/packages/styles/.+/(stylesheets|images|javascript)/[^/]+/ { try_files $uri =404; }
location ~ ^/packages/styles/.+/thumbnail/ { try_files $uri =404; }
location ~ ^/var/storage/packages/.+\.(png|jpe?g|gif|webp)$ { try_files $uri =404; }
location = /favicon.ico { try_files $uri =404; }
location = /robots.txt { try_files $uri =404; }
location = /index.js { try_files $uri =404; }
location = /sw.js { try_files $uri =404; }
location = /w3c/p3p.xml { try_files $uri =404; }
# 5. Everything else goes to index.php, with the original REQUEST_URI
location / {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_pass unix:/run/php-fpm/www.sock;
}
}How it maps and why it is ordered this way:
- nginx chooses a location by its own precedence, not by file order: exact matches (
=) first, then the longest^~prefix, then regular expressions in the order they appear, then the longest plain prefix. The dot-file and script rules are therefore the first regular expressions, as they are the first rules of.htaccess_root;/is the plain prefix that catches the rest, like the finalRewriteRule .* index.php. .htaccess_root's/api/and tree menu rules rewrite to a front controller. Here the front controller is named inSCRIPT_FILENAMEand the request is not rewritten, soREQUEST_URI(set byfastcgi_paramsfrom$request_uri) still carries the address the visitor asked for, exactly as with Apache.- A root-level
.phpother than the front controllers (for example/config.php) reacheslocation /and is answered byindex.php, never executed: as with.htaccess_root. - The static locations have no
fastcgi_pass, so no PHP runs there; the script rule before them makes sure a.phpbelow an asset directory is not sent as text either. fastcgi_paramsof the nginx packages passesHTTPSwhen the connection is TLS, which is what Exponential checks first.- Not translated: the commented-out favicon and debug lines of
.htaccess_root, and/design/standard/images/favicon.ico, which the design rule already covers.
For HTTPS add listen 443 ssl; (and http2 on; on nginx 1.25.1 or later), ssl_certificate and
ssl_certificate_key, and a separate server on port 80 that returns 301 https://$host$request_uri. Check with
nginx -t and reload with systemctl reload nginx. Security headers for static files: add_header ... always;
(chapter 13).
8.7 File permissions and ownership
8.7.1 What must be writable
The user that runs PHP for the site (the PHP-FPM pool user, the Velocity worker user or the FrankenPHP process) must be
able to write these directories, the list the setup wizard checks (settings/setup.ini [directory_permissions]):
| Directory | Why |
|---|---|
var/ and below: var/cache, var/log, var/storage, var/autoload, var/<site>/... | caches, compiled templates, logs, uploaded files and image variations, autoload arrays |
settings/, settings/override, settings/siteaccess, settings/siteaccess/<admin> | the setup wizard and the administration write settings |
design/, extension/ | the setup wizard and package installation write there |
After installation you can take write access to design/ and extension/ away again if nothing installs packages
from the administration; settings/ stays writable when administrators change settings in the browser (and the
setup's settings_permission check expects it).
Everything else (kernel, lib, vendor, index.php) only needs to be readable. The system check prints commands for
your machine of this form:
sudo chown -R example:example design extension settings var
sudo chmod -R ug+rwx design extension settings var8.7.2 One user, or one group
Problems with permissions almost always come from two users writing the same files: the web server's PHP, the
Velocity workers, cronjobs and command-line scripts. A cache file written by root from the command line cannot be
replaced by the site user afterwards, and the site breaks in odd places.
owner: example group: example
PHP-FPM pool ----------------+
Velocity workers ([ServerSettings] User/Group)
cronjobs, console scripts ----+---- all write var/ as the same userRecommended, in order:
Run everything as the site's user. PHP-FPM pool
user = example; Velocity[ServerSettings] User=example,Group=example(or leave them empty and let the document root's owner decide); cronjobs from that user's crontab (crontab -u example -e) or withsudo -u example php runcronjobs.php. Velocity started as root is fine: only its parent process stays root.If the users must differ (for example Apache's
apacheand a deploy user), give them a common group, make the writable directories group-writable and setgid so new files inherit the group, and use permissions that keep the group's write access:sudo chgrp -R example design extension settings var sudo find var settings -type d -exec chmod 2775 {} +# settings/override/site.ini.append.php [FileSettings] StorageDirPermissions=0770 StorageFilePermissions=0660 TemporaryPermissions=0770 LogFilePermissions=0660The shipped values are
0777and0666(world-writable);site.iniitself recommends0770and0660with the right user and group, which is the hardened choice (chapter 13).
When Apache (PHP-FPM) and Velocity serve the same installation side by side, they share var/, the caches and the
sessions: give them the same user, or the setup of point 2. Test with both running and look at the owner of new files
in var/ after a few requests.
8.7.3 Velocity's own files
| Path | Written by | Mode |
|---|---|---|
/etc/vc/sites-available/<site>.conf | exp:velocity (root) | 0600 |
ssl/ of the configuration directory: private keys | the server | 0600, written atomically, never logged |
var/vc/qbix/log/ | the server | [LogSettings] FileMode 0640, DirMode 0750 |
var/cache/qbix-reverse/ | the server for the workers | [CacheSettings] FileMode 0640, DirMode 0750 |
The cache and precompression directories are handed to the worker user at start. The worker user must be able to read
the engine's source tree in vendor/.
8.8 Behind a reverse proxy
Velocity does not need a proxy in front: it serves HTTPS itself. A proxy is still the right shape when a load balancer
or CDN already terminates TLS, when one public address carries several services, or when the server must run as an
ordinary user on a high port. Velocity's shipped Host=127.0.0.1 is made for that: the server is reachable only
from the machine, and only through the proxy.
What Exponential reads from a proxy (lib/ezutils/classes/ezsys.php):
- Which proxies are believed. The forwarded headers below count only when the peer of the connection
(
REMOTE_ADDR) is listed insite.ini [HTTPHeaderSettings] TrustedProxies[]: addresses and ranges, IPv4 and IPv6, by default127.0.0.1and::1, a proxy on the same machine. A proxy on another address must be added, for exampleTrustedProxies[]=192.0.2.10orTrustedProxies[]=10.0.0.0/8. See Forwarded headers are trusted only from configured proxies. - HTTPS.
eZSys::isSSLNow()checks$_SERVER['HTTPS']first, then the port againstsite.ini [SiteSettings] SSLPort(443), then, from a trusted proxy,X-Forwarded-Proto: https, thenX-Forwarded-Port, thenX-Forwarded-ServeragainstSSLProxyServerName. A proxy that terminates TLS must setX-Forwarded-Proto; then generated URLs and theSecureflag of the session cookie ([Session] CookieSecure=auto) are right. - The host.
X-Forwarded-Hostfrom a trusted proxy replacesHost; otherwise let the proxy pass the originalHost(proxy_set_header Host $host,ProxyPreserveHost On). - The visitor's address.
[HTTPHeaderSettings] ClientIpByCustomHTTPHeader=X-Forwarded-FormakeseZSys::clientIP()read that header from a trusted proxy, from the right: trusted proxies are skipped and the first other address is the visitor. Entries a client put in front are ignored, so a proxy may append ($proxy_add_x_forwarded_for) or overwrite. - Velocity answers these questions before the kernel sees the request; see "Velocity behind a proxy" below.
Velocity behind a proxy. The engine has its own list of trusted proxies, Q.webserver.proxy.trusted in the
engine configuration, by default 127.0.0.1 and ::1. It is separate from TrustedProxies[] because the engine
decides before any PHP of the application runs:
- The visitor's address. From a trusted proxy the engine takes the visitor's address from
X-Forwarded-For(or the header named inQ.webserver.proxy.headers.ip), read from the right: the first address that is not a trusted proxy is the visitor. It hands that address to the kernel asREMOTE_ADDR. The kernel then sees the visitor as the peer:ClientIpByCustomHTTPHeaderis not needed, andTrustedProxies[]does not affect the visitor's address. - HTTPS. The engine sets
$_SERVER['HTTPS']andREQUEST_SCHEME, which the kernel believes without any proxy setting. A TLS connection to Velocity itself is HTTPS whoever sent it. A plain connection counts as HTTPS when a trusted proxy sendsX-Forwarded-Proto: https(or the header named inQ.webserver.proxy.headers.proto),CloudFront-Forwarded-Proto: httpsor Cloudflare'sCF-Visitor: {"scheme":"https"}; of a list such ashttps, httpthe first entry counts. - Engine versions. Reading the forwarded protocol only from trusted proxies arrived in Exponential Velocity
0.0.4.44 (engine commit
380a64d, 5 October 2026), the release Exponential'scomposer.jsonsuggests. Up to and including 0.0.4.43 a worker took those headers from any client, so a visitor on a plain listener could make a request look like HTTPS to the application. Check the installed version withcomposer show se7enxweb/exponential-velocityand update withcomposer require se7enxweb/exponential-velocity:~0.0.4.44. On an older engine, keep Velocity's plain port unreachable from outside (the shippedHost=127.0.0.1, or a firewall) whenever a proxy is in front. From 0.0.4.44 on, a proxy that terminates TLS must be listed inQ.webserver.proxy.trusted, or the site sees plain HTTP behind it. - The host.
X-Forwarded-Hostreaches the kernel from the visitor's address and is not used. Let the proxy pass the originalHostheader.
To trust a proxy on another address, put the list in a snippet of the configuration tree (section 8.3.10) and enable it. Name the loopback addresses too, so a local proxy keeps working:
cat > /etc/vc/conf-available/reverse-proxy.conf <<'EOF'
{ "Q": { "webserver": { "proxy": { "trusted": ["127.0.0.1", "::1", "192.0.2.10", "10.0.0.0/8"] } } } }
EOF
php bin/php/console exp:velocity conf enable --apply reverse-proxy --allow-root-user
php bin/php/console exp:velocity restart --allow-root-userreverse-proxy is only a name you choose; any file in conf-available/ works the same way. Entries are addresses or
CIDR ranges.
nginx in front of Velocity (derived example):
location / {
proxy_pass http://127.0.0.1:8088;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
# appending is safe: Exponential reads the list from the right
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
}Apache in front of Velocity (derived example):
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:8088/
ProxyPassReverse / http://127.0.0.1:8088/
RequestHeader set X-Forwarded-Proto "https"(mod_proxy, mod_proxy_http and mod_headers; Apache's mod_proxy adds X-Forwarded-For itself, appending to a
value the client sent, which is safe because the list is read from the right.)
Notes:
- Let's Encrypt behind a proxy: the proxy owns port 80, so either let it obtain the certificate, or forward
/.well-known/acme-challenge/to Velocity, or set the engine'sacme.webrootto a directory the proxy serves. - Caching proxies must not store signed-in pages. Exponential sends
privateon pages that carry a form token; a proxy that purges by tags reads the header named inhttpcache.ini [HttpCacheSettings] TagHeader(disabledby default). - The Velocity response cache still works behind a proxy; clear it with
exp:velocity cache clearas usual. - Apache or nginx in front of Apache with PHP-FPM (Plesk's nginx in front of Apache is this shape): when the inner
server rewrites
REMOTE_ADDRto the visitor withmod_remoteipor nginx'srealipmodule and is reached over HTTPS, the kernel sees the visitor andHTTPS=ondirectly, and needs neitherTrustedProxies[]changes norClientIpByCustomHTTPHeader.
Check it. A forwarded header sent by a visitor must change nothing. Against the site directly (no proxy):
curl -s -o /dev/null -w '%{redirect_url}\n' -H 'X-Forwarded-Proto: https' http://example.com/user/logout
# expected: http://example.com/ -- an https:// address here means the header was believedThrough the proxy, a page's absolute links and redirects must carry https://, and the debug bar's "Is my address
listed?" (iptest) shows the address the kernel uses for the visitor.
What can go wrong behind a proxy:
| Symptom | Cause | Fix |
|---|---|---|
redirects and absolute links go to http:// | the proxy is not trusted (not loopback, not in TrustedProxies[] or, for Velocity, in Q.webserver.proxy.trusted), or it sends no X-Forwarded-Proto | list it; set the header in the proxy |
| every visitor has the proxy's address (sign-in lockout, logs) | the proxy is not trusted, or ClientIpByCustomHTTPHeader is not set under Apache or nginx with PHP-FPM | list the proxy; set ClientIpByCustomHTTPHeader=X-Forwarded-For |
| a redirect loop after switching the site to HTTPS | the proxy speaks HTTPS to the visitor, plain HTTP to the server, and the server does not know | as the first row |
| signed-in pages served to other visitors | a caching proxy stores pages with a session cookie | do not cache responses that set or depend on the session cookie |
8.9 Checklist
- To do:
Engine=qbixset,exp:velocity statusshows the server running, the site answers. - To do:
Host,Port,HTTPSPortas intended; ports 80 and 443 open in the firewall when Velocity faces visitors. - To do: Workers run as the site's user (
Workers as: ...invar/vc/qbix/run/console.log), never root. - To do: HTTPS: a CA certificate is served (
exp:velocity ssl show), not the self-signed stand-in; renewal planned. - To do:
HSTSMaxAgeraised only after every name is on HTTPS for good. - To do: A boot service starts Velocity after a reboot.
- To do:
[DeploySettings] PhpFpmService=disabledwhen Velocity serves the site alone;exp:velocity deploy --dry-runshows the steps you expect. - To do: Apache or nginx:
.htaccesscopied from.htaccess_root(or the derived nginx block), front controllers and asset paths tested, a.phpbelow the root answers 404;./console exp:setup:statusnames the template. - To do:
var/andsettings/writable by the one user (or group) that runs PHP; no root-owned files invar/. - To do: Behind a proxy: the proxy is trusted (
TrustedProxies[], or for VelocityQ.webserver.proxy.trusted), it setsX-Forwarded-Protoand appends to or setsX-Forwarded-For;ClientIpByCustomHTTPHeaderset only under Apache or nginx with PHP-FPM; the curl check of section 8.8 passes.
A public demo. A demo whose administrator sign-in is published needs the environment variable EXP_DEMO_MODE=1 in the PHP of every server that serves it: SetEnv EXP_DEMO_MODE 1 (Apache, LiteSpeed), env[EXP_DEMO_MODE] = 1 (PHP-FPM pool), fastcgi_param EXP_DEMO_MODE 1 (nginx) or the environment of the unit that starts Velocity. Ready-made files: Demo mode examples; background: Demo mode.
8.10 References
In this repository:
- Operating system support: the operating systems, web servers and PHP SAPIs, sorted into tiers (tested here, known to work, expected to work, not possible today)
- Velocity engines: engines,
deploy,Instances, FrankenPHP, logs, the files each engine serves, the/Q/views - Velocity: running Exponential in a persistent-worker web server
- Velocity on-disk layout (Debian Apache style)
- Velocity HTTPS and certificates
- Velocity response cache, control panel, web server, packages and binaries
- Specifications: worker pool, engine settings, HTTP/2 and security, security defaults
- Velocity engine upgrade notes, FrankenPHP, Engine archive (phar)
- Deploying guide
- Forwarded headers are trusted only from configured proxies (
TrustedProxies[], examples for Apache, nginx, Velocity, load balancers and CDNs) - Requirements (PHP versions per server) and security hardening
- Code:
kernel/classes/expvelocity.php,expvelocitydeploy.php,expvelocityconfiglayout.php,expvelocityfrankenphp.php,kernel/private/classes/commands/velocity.php,lib/ezutils/classes/ezsys.php,lib/ezutils/classes/eztrustedproxy.php; in the engine packagesrc/Q/WebServer/Proxy.php(trusted proxies, forwarded protocol) andsrc/Q/WebServer/RunAs.php(worker user) - The settings:
settings/velocity.ini; the rewrite rules:.htaccess_root,.htaccess_root_static; the PHP router of the development engine:bin/php/velocity-router.php - The previous single-page guide: doc/eng-us/INSTALL.md, section 8
External:
- Exponential Velocity engine: https://github.com/se7enxweb/exponential-velocity (its
docs/https.md,docs/layout.md,docs/workers.md,docs/requirements.md,docs/dashboard.md,docs/panel.md,service/andpackaging/systemd/); the forwarded-protocol change is commit 380a64d, released in v0.0.4.44 - Apache HTTP Server: mod_rewrite, mod_proxy_fcgi, mod_ssl, mod_proxy, mod_headers, mod_http2, mod_remoteip, AllowOverride
- nginx: location, try_files, fastcgi module, proxy module, ssl module, realip module, client_max_body_size
- PHP: FastCGI Process Manager (FPM), FPM configuration, built-in web server, OPcache
- FrankenPHP: https://frankenphp.dev/ and its documentation; Caddy: https://caddyserver.com/docs/
- Let's Encrypt: How it works, challenge types, staging environment, rate limits; ACME: RFC 8555
- systemd: systemd.service
- Debian's Apache layout: README.Debian of apache2
- HSTS: RFC 6797, hstspreload.org
Contents · Previous: 7. The console install · Next: 9. Databases
Become an Exponential Partner
Partners share their information, provide funding, contribute back and develop the products with us, and that is how Exponential grows. A partner website and dashboard are planned for mid-2027: please become a partner today to speed up our growth rate and land new features faster.