Classical server (Apache httpd + PHP-FPM)
About 852 wordsAbout 3 min
A Linux server running Spora without Docker. Apache httpd terminates TLS and reverse-proxies to PHP-FPM via mod_proxy_fcgi; supervisord manages the agent worker.
Use this when Docker isn’t an option (locked-down VPS, air-gapped environment, shared host with shell access). For most operators, the Docker — multi-container path is simpler.
Prereqs
- Linux (Debian 12 / Ubuntu 24.04 or RHEL family)
- Apache httpd 2.4.10+ with
mod_proxy,mod_proxy_fcgi,mod_ssl,mod_headers,mod_rewrite - PHP 8.4 with FPM and the extensions:
pdo_mysql,mbstring,xml,curl,zip,intl - Composer 2.x
- systemd
- supervisord (for the worker)
sudo apt install apache2 php8.4-fpm php8.4-{cli,mbstring,xml,curl,zip,intl,mysql,sqlite3} composer supervisor
sudo a2enmod proxy proxy_fcgi ssl headers rewritePHP 8.4 is not in Debian 12’s default repos — install it from Sury’s PHP PPA (or use Debian 13 / Ubuntu 24.04 where PHP 8.4 is native).
For full Apache, PHP-FPM, and systemd setup details, see the upstream docs:
Layout
flowchart TB
Internet([Internet])
Apache["Apache httpd<br/>:443 TLS, mod_proxy_fcgi → PHP-FPM"]
PHPFPM["PHP-FPM<br/>(Spora pool)"]
App["spora-app<br/>/var/www/spora-app/public"]
Worker["spora-worker<br/>(supervisord)"]
Mercure["Mercure hub (optional)"]
Internet -->|TLS| Apache
Apache -->|/index.php via SetHandler| PHPFPM
PHPFPM --> App
Apache -.->|/.well-known/mercure<br/>ProxyPass| Mercure
Worker -->|drains queue| App
classDef edge fill:var(--spora-paper),stroke:var(--spora-warm),color:var(--spora-ink)
classDef svc fill:var(--spora-paper-deep),stroke:var(--spora-warm-deep),color:var(--spora-ink)
class Internet edge
class Apache,PHPFPM,Mercure,Worker svc1. Apache vhost
The vhost below handles everything: TLS, PHP dispatch, the SPA fallback, and the deny-by-existence rule. The project also ships a spora/.htaccess (the shared-host fallback for the cPanel-style deploy) but you do not need it on the classical-server path — the vhost’s DocumentRoot is public/, and the rewrite rules live in the vhost itself.
<VirtualHost *:80>
ServerName yourdomain.com
ServerAlias www.yourdomain.com
Redirect permanent / https://yourdomain.com/
</VirtualHost>
<VirtualHost *:443 ssl http2>
ServerName yourdomain.com
ServerAlias www.yourdomain.com
DocumentRoot /var/www/spora-app/public
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/yourdomain.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/yourdomain.com/privkey.pem
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
SSLCipherSuite HIGH:!aNULL:!MD5
<Directory /var/www/spora-app/public>
Options -Indexes -MultiViews
AllowOverride None
Require all granted
</Directory>
# PHP-FPM via mod_proxy_fcgi (Apache 2.4.10+).
<FilesMatch "\.php$">
SetHandler "proxy:unix:/run/php/php8.4-fpm-spora.sock|fcgi://localhost"
</FilesMatch>
# SPA fallback — anything that isn't a real file/directory routes
# to /index.php, which then serves the SPA shell or the /api/*
# endpoint. (Equivalent to the spora/.htaccess shared-host fallback
# pattern, but inlined here because DocumentRoot is public/.)
FallbackResource /index.php
ErrorLog ${APACHE_LOG_DIR}/spora-error.log
CustomLog ${APACHE_LOG_DIR}/spora-access.log combined
</VirtualHost>2. PHP-FPM pool
The pool runs as a dedicated spora system user (separate from the web server’s user, so the FPM workers can’t be tricked into writing to anything the web server owns). Create it once:
sudo useradd --system --home /var/www/spora-app --shell /usr/sbin/nologin sporaThen /etc/php/8.4/fpm/pool.d/spora.conf:
[spora]
user = spora
group = spora
listen = /run/php/php8.4-fpm-spora.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 = 8
pm.max_requests = 500
php_admin_value[memory_limit] = 256M
php_admin_value[upload_max_filesize] = 50M
php_admin_value[post_max_size] = 50M
php_admin_value[max_execution_time] = 1203. supervisord for the worker
The worker drains the queued tasks in async mode (SPORA_SYNC_MODE=false, the value shipped in spora/.env.example; per env-vars §Worker / Sync Mode). Create the log directory first (supervisord will fail to start the program if the log path doesn’t exist), then write the program config:
sudo mkdir -p /var/log/spora
sudo chown spora:spora /var/log/spora/etc/supervisor/conf.d/spora.conf:
[program:spora-worker]
command=/usr/bin/php /var/www/spora-app/bin/spora worker:run --daemon
user=spora
autostart=true
autorestart=true
startretries=3
stderr_logfile=/var/log/spora/worker.err.log
stdout_logfile=/var/log/spora/worker.out.logApply the new program config without restarting the supervisord daemon (works on every distro, doesn’t drop the existing program set):
sudo supervisorctl reread
sudo supervisorctl update
sudo systemctl reload php8.4-fpm4. TLS
sudo apt install certbot python3-certbot-apache
sudo certbot --apache -d yourdomain.com -d www.yourdomain.comThe Let’s Encrypt cert is auto-renewed by a certbot systemd timer. See the Certbot docs.
5. Mercure (optional)
The live chat UI streams updates via Mercure (Server-Sent Events). For a public deploy, run a Mercure hub and reverse-proxy it through Apache at /.well-known/mercure. See the Mercure install docs — Mercure is a Go binary; there is no apt install mercure in the standard repos.
For a local-only or single-user deploy, you can skip Mercure — the chat UI will fall back to polling, with extra delay on message delivery.
What’s next
- For Docker-based deploys: Docker — multi-container
- For a shared cPanel/FTP host: Shared host(Open in new window)
- For local laptop: Local — PHP / Ollama / LM Studio(Open in new window)