Self-hosting Flarum 2.x at home: Cloudflare Tunnel + BunkerWeb WAF + Redis + Realtime
My ISP blocks inbound ports 80 and 443, so a normal reverse-proxy setup was off
the table. This is the full write-up of what I ended up with — including the
handful of things that broke in ways that were genuinely hard to trace back to
their cause. If you only read one section, make it Gotchas at the end.
Everything runs on a laptop in my living room.
Architecture
browser
│ https://forum.example.com
▼
Cloudflare edge ............ TLS, "Always Use HTTPS", edge WAF, asset cache
│ tunnel (OUTBOUND connection, opened from my house)
▼
cloudflared ................ container, no inbound port
│ http://bunkerweb:8080
▼
BunkerWeb .................. nginx + ModSecurity + OWASP CRS 4.27
│ http://flarum-box:80
▼
flarum-box ................. nginx → php-fpm 8.5 → MariaDB 11.8
│ + realtime websocket daemon (127.0.0.1:6001)
│ + queue worker
│ + scheduler (every minute)
▼
redis ...................... queue, cache, sessions, settings
(internal network, no route to the internet)
Not a single inbound port is open on my router. cloudflared dials out,
Cloudflare answers on my behalf. The ISP's port blocking stopped mattering, and
the public URL is clean — no :8443 in it.
Host
- Debian 13 (trixie), kernel 6.12
- 8 vCPU, 5.5 GB RAM
- Docker, everything in two compose stacks
Versions
| Component | Version |
|---|---|
| Flarum | 2.0.0-rc.5 |
| PHP | 8.5.8 (+ opcache, redis, gd, intl, exif, zip, pdo_mysql) |
| MariaDB | 11.8.8 |
| Redis | 8.10 |
| BunkerWeb | 1.6.13 (OWASP CRS 4.27) |
| cloudflared | 2026.7.3 |
The Flarum container is pianotell/flarum-in-a-box — an all-in-one image
(nginx + php-fpm + MariaDB under s6) that is explicitly labelled a
demo/playground. I use it in production anyway, and the sections below are
mostly about the work needed to make that honest.
1. Making the data actually persistent
The image's README says plainly: "Data is ephemeral for the lifetime of the
container." MariaDB runs inside it and the admin account, database and
extensions are baked at build time. docker rm would take the whole forum with
it.
Two bind mounts fix that:
| Host | Container |
|---|---|
| ./data/mysql | /var/lib/mysql |
| ./data/html | /var/www/html |
Why the whole /var/www/html and not just storage + public/assets:
because the Extension Manager installs extensions by running composer inside
the container — it rewrites composer.json, composer.lock and vendor/.
With a partial mount, everything you install from the admin panel silently
disappears on the next container recreation, while the database still believes
those extensions are enabled.
You cannot just point the mounts at empty directories — that would hide the
baked-in data and the forum would come up with no database. The sequence is:
docker run -d --name seed pianotell/flarum-in-a-box:0.2.13
# ... let it boot, install anything you want ...
docker stop -t 30 seed # so MariaDB closes its files cleanly
docker cp -a seed:/var/lib/mysql/. ./data/mysql/
docker cp -a seed:/var/www/html/. ./data/html/
# docker cp does NOT preserve ownership when copying to the host:
chown -R 100:101 ./data/mysql && chmod 750 ./data/mysql # mysql
chown -R 82:82 ./data/html # www-data
That chown line cost me a while. Without it neither MariaDB nor PHP can write,
and the errors point everywhere except at ownership.
The trade-off, stated plainly: since the mount covers /var/www/html, a
docker pull of a newer image updates PHP/nginx/MariaDB but not Flarum.
The app becomes composer-managed, like on a normal server. That is the price of
having panel-installed extensions survive.
Verified by destroying and recreating: docker compose down && up preserved the
database, settings, enabled extensions and vendor/.
2. Redis for queue, cache, sessions and settings
Flarum 2.x ships only sync and database queue drivers — there is no redis
driver in config.php. Redis comes from FoF Redis, and it is
configured in extend.php, not config.php:
// extend.php
use Flarum\Extend;
return [
new FoF\Redis\Extend\Redis([
'host' => 'redis',
'port' => 6379,
'password' => 'REDACTED',
'database' => 1,
]),
];
One extender turns on four things at once: queue, cache, sessions and settings.
fof/redis is not an extension you enable. php flarum extension:enable
fof-redis answers "There are no extensions by the ID of 'fof-redis'". It is
a plain library — extend.php is what activates it.
The worker stops being optional
On the sync driver every job ran inside the user's request — the visitor
waited for each email to be sent. Once you leave sync, jobs go to Redis and
something has to consume them. Without a worker they pile up and never run,
with no error anywhere.
php flarum queue:work --tries=3 --backoff=10 --timeout=360 --max-time=3600
--timeout=360 is an explicit recommendation from the flarum/realtime README:
the default 60s kills SendTriggerJob mid-flight and it then fails forever.
The scheduler the image didn't have
The image runs no cron at all. Without it, extension-scheduled tasks never run —
no error, no log. Flarum wants php flarum schedule:run once per minute; it
decides internally what is due.
I added both as s6 services via bind mount, without rebuilding the image:
./s6/queue → /etc/s6-overlay/s6-rc.d/queue
./s6/scheduler → /etc/s6-overlay/s6-rc.d/scheduler
./s6/realtime → /etc/s6-overlay/s6-rc.d/realtime
# plus an empty marker file per service in
# /etc/s6-overlay/s6-rc.d/user/contents.d/<name>
That last part matters: a service directory alone does nothing. s6-rc only
starts it if a file with its name exists in user/contents.d/.
Each run script waits for the MariaDB socket first — s6 considers MariaDB "up"
as soon as mariadbd-safe starts, not when the socket accepts connections:
#!/bin/sh
exec 2>&1
i=0
while [ ! -S /run/mysqld/mysqld.sock ] && [ "$i" -lt 60 ]; do sleep 1; i=$((i+1)); done
exec s6-setuidgid www-data /usr/local/bin/php /var/www/html/flarum queue:work ...
Six supervised services in the end: mariadb, nginx, php-fpm (from the
image) and realtime, queue, scheduler (mine).
3. Realtime — and the nginx trap
flarum/realtime has three clients and mixing them up is the most common reason
"realtime won't connect":
// config.php
'websocket' => [
'server-host' => '127.0.0.1', // where the daemon listens
'server-port' => 6001,
'js-client-host' => 'forum.example.com', // what the BROWSER uses
'js-client-port' => 443, // Cloudflare terminates TLS
'js-client-secure' => true,
'php-client-host' => '127.0.0.1', // what the BACKEND uses — stays local
'php-client-port' => 6001,
'php-client-secure' => false,
'app-key' => 'REDACTED',
'app-secret' => 'REDACTED',
'max-connections' => 300,
],
Pinning app-key / app-secret explicitly is worth it: by default they are
derived from hashes of the forum URL and the database password, so changing
either would invalidate live websocket sessions.
The .nginx.conf the package ships is incomplete
The daemon exposes three routes (resources/routes/websocket.php):
GET /app/{appKey} ← the BROWSER connects here (pusher-js)
GET /apps/{appId}/channels ← PHP client
POST /apps/{appId}/events ← PHP client
The .nginx.conf distributed with the package only covers location /apps,
which does not match /app/{appKey} — different prefix. With the official
file, the browser's websocket falls through to try_files and becomes a Flarum
- My logs later confirmed the browser really does hit
/app/{key} with
pusher-js 7.6.0.
What works:
location ~ ^/apps?(/|$) {
proxy_pass http://127.0.0.1:6001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_cache_bypass $http_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_read_timeout 3600; # the connection idles; 60s would drop it
proxy_send_timeout 3600;
}
A regex location wins over the / prefix, so ordering doesn't matter.
Cloudflare Tunnel passes WebSocket upgrades through with no configuration at
all.
4. BunkerWeb in front — five settings that break Flarum
This is the part I'd want to have read beforehand. Every one of these fails in a
way that does not point at the WAF.
ALLOWED_METHODS
Default is GET|POST|HEAD. Reading works fine, so everything looks healthy —
but editing a post, marking as read or saving anything in the admin panel uses
PATCH/DELETE/PUT and gets a 405. The UI just doesn't save, with no visible error.
ALLOWED_METHODS: "GET|POST|HEAD|PATCH|PUT|DELETE|OPTIONS"
REVERSE_PROXY_INTERCEPT_ERRORS
Default yes. Flarum is an SPA talking JSON. With this on, a legitimate 404 or
422 from the API is replaced by BunkerWeb's HTML error page, and the front-end
gets HTML where it expected JSON.
REVERSE_PROXY_INTERCEPT_ERRORS: "no"
AUTO_REDIRECT_HTTP_TO_HTTPS
Default yes. Cloudflare terminates TLS and the tunnel delivers plain HTTP to
the origin. So BunkerWeb answers 301 → https://... to cloudflared, which
re-enters through the tunnel, gets another 301… an infinite loop.
AUTO_REDIRECT_HTTP_TO_HTTPS: "no"
The http→https redirect belongs at the Cloudflare edge ("Always Use HTTPS"),
which is the right layer for it.
Same reasoning for HSTS: BunkerWeb only emits Strict-Transport-Security when
the connection scheme is https (headers.lua:95). Over an HTTP tunnel hop it
never will. Enable HSTS at the edge.
Real client IP
Without this, every visitor shows up in Flarum — and in the WAF's per-IP
counters — as the cloudflared container's IP.
USE_REAL_IP: "yes"
REAL_IP_FROM: "172.16.0.0/12 192.168.0.0/16 10.0.0.0/8"
REAL_IP_HEADER: "CF-Connecting-IP"
CF-Connecting-IP is written by Cloudflare's edge and can't be forged from
outside, because requests only reach the origin through its tunnel.
The CRS rule that blocks every edit
Symptom: you log in fine, but editing or deleting a post, discussion or user
returns 403 — exactly as if your session had expired.
Cause: CRS rule 920450 rejects the X-HTTP-Method-Override header, which
is precisely how Flarum's front-end sends PATCH and DELETE. It adds 5 to the
anomaly score, rule 949110 blocks at 5, and out comes a 403. I had 40 of these
across /api/posts/*, /api/discussions/* and /api/users/*.
It gets worse: each of those 403s incremented BunkerWeb's BADBEHAVIOR
counter (10 = IP ban). Editing enough posts bans the forum's own
administrator.
Fix, as a custom config in the modsec-crs directory (loaded before the CRS
rules — the only point where its policy variables can still be changed):
# rewrite tx.restricted_headers_basic without x-http-method-override
SecAction \
"id:900250,\
phase:1,\
nolog,\
pass,\
t:none,\
setvar:'tx.restricted_headers_basic=/content-encoding/ /proxy/ /lock-token/ /content-range/ /if/ /x-http-method/ /x-method-override/ /x-middleware-subrequest/ /expect/'"
This doesn't weaken anything here: the risk of method-override is bypassing
method-based access control, and ALLOWED_METHODS already permits
PATCH/PUT/DELETE directly. The header grants an attacker no method they couldn't
send outright.
WebSocket through the WAF
BunkerWeb supports numbered reverse-proxy rules, so the websocket gets its own
location with ModSecurity off — the traffic is a binary stream, not inspectable
HTTP, and the CRS would only produce false positives:
REVERSE_PROXY_URL_1: "~ ^/apps?(/|$$)" # $$ escapes $ for compose
REVERSE_PROXY_HOST_1: "http://flarum-box:80"
REVERSE_PROXY_WS_1: "yes"
REVERSE_PROXY_MODSECURITY_1: "no"
REVERSE_PROXY_READ_TIMEOUT_1: "3600s"
REVERSE_PROXY_SEND_TIMEOUT_1: "3600s"
BunkerWeb's template handles this correctly — with WS=yes it stops hiding the
Upgrade response header.
5. Rate limiting: the one that nearly locked me out
BunkerWeb ships LIMIT_REQ_RATE = 2r/s, implemented as a Lua counter with no
burst allowance. That default assumes a classic site serving one page per
click. Flarum is an SPA: opening a discussion fires the page, a dozen assets and
several API calls within the same second, multiplexed over HTTP/2.
Result: 429 during completely normal use.
Then BAD_BEHAVIOR_STATUS_CODES defaults to 400 401 403 404 405 429 444, and
10 of those within 60 seconds means a 24-hour ban. Every one of those codes
appears in legitimate use:
| Code | When it happens with nobody doing anything wrong |
|---|---|
| 400 | csrf_token_mismatch when a session expires |
| 403 | normal API answer for a user without permission |
| 404 | the browser asking for /favicon.ico, /apple-touch-icon.png |
| 429 | the rate limit above, which was already a false positive |
I found my own IP banned for 24 hours, from my own forum, from ordinary
browsing. A 429 is punishment enough on its own — counting it again toward a ban
punishes the same event twice.
LIMIT_REQ_RATE: "100r/s"
LIMIT_CONN_MAX_HTTP1: "50" # the realtime websocket holds one open
BAD_BEHAVIOR_STATUS_CODES: "401 405 444"
BAD_BEHAVIOR_THRESHOLD: "20"
BAD_BEHAVIOR_BAN_TIME: "3600" # 1h, not 24h — recoverable if I'm wrong again
Honest caveat: dropping 403 means a scanner collecting 403s from ModSecurity is
no longer auto-banned. It stays blocked on every request — the WAF still
refuses it — it just gets to keep trying. The trade is worth it: a persistent
scanner costs log noise, while the false positive cost me access to my own site.
docker exec bunkerweb bwcli bans # who's banned
docker exec bunkerweb bwcli unban <ip>
6. Backup/restore vs. the WAF
The ramon/backup extension restores by uploading the archive in
chunks. Two independent blocks hit it, and both are inherent to what the route
does:
- Chunks are sent as
application/octet-stream — a raw body with the offset in
an X-Chunk-Offset header. Not multipart, so
MODSECURITY_REQ_BODY_NO_FILES_LIMIT applies: 128 KB, action Reject. Any
larger chunk dies at the door.
- The chunk payload is a database dump. That is SQL. The CRS 942xxx rules
exist to detect SQL where it doesn't belong — they will fire on every single
restore, forever, because they're right: there really is SQL in there. It
isn't a buggy rule; it's a correct rule applied to a body where SQL is the
whole point.
No amount of tuning fixes that, so ModSecurity comes off for those two routes
only:
REVERSE_PROXY_URL_2: "~ ^/api/backup/(imports|backups/[0-9]+/download)"
REVERSE_PROXY_HOST_2: "http://flarum-box:80"
REVERSE_PROXY_MODSECURITY_2: "no"
REVERSE_PROXY_READ_TIMEOUT_2: "600s"
REVERSE_PROXY_SEND_TIMEOUT_2: "600s"
Scope is deliberately minimal — the upload and the download, not all of
/api/backup. Listing, deleting and key generation stay inspected, and
authorisation is Flarum's job anyway (these endpoints are admin-only); the WAF
was never the authentication barrier.
Verified: 413 KB of blatant SQL injection posted to the restore route returns
400 (Flarum answering), while the identical payload on a normal route
returns 403 (WAF blocking).
7. Performance
Measured before touching anything, which is what stops you optimising the wrong
layer:
| Layer | Median |
|---|---|
| PHP rendering the page | 179 ms |
| + BunkerWeb + tunnel + Cloudflare | 202 ms |
The entire edge cost 23 ms. The bottleneck was PHP, so that's where the work
went:
phpredis instead of Predis. With cache, sessions and settings on Redis,
every request was parsing the Redis protocol in pure PHP. phpredis does it in
C. The base image already ships install-php-extensions, so:
FROM pianotell/flarum-in-a-box:0.2.13
RUN install-php-extensions redis
OPcache: JIT in tracing mode (it ships disabled), memory 128→256 MB,
interned strings 8→32 MB, files 10k→20k, revalidation every 60 s instead of 2.
I deliberately did not set validate_timestamps=0. With the Extension
Manager, code changes in production — a freshly installed extension would
simply not exist until a php-fpm restart, with no error message.
php-fpm: pm.max_children 5→16. Doesn't change single-visit latency; it
changes how many people fit at once.
Brotli and upstream keepalive in BunkerWeb.
Result: 179 ms → 152 ms (15%), with triple the concurrency ceiling. Not
dramatic — but honest.
PHP limits, incidentally, were the real bottleneck for uploads: the image ships
upload_max_filesize = 2M. Note that the limit must match across three
layers, or the smallest one wins and the error surfaces in the wrong place:
PHP's post_max_size, nginx's client_max_body_size, and BunkerWeb's
MAX_CLIENT_SIZE.
Why the "public" number lies
Measuring https://forum.example.com from the server itself gives 370 ms.
That is not the application: an asset served from Cloudflare's cache
(cf-cache-status: HIT, never touching my server) took 196 ms over the same
path, against 6 ms internally.
So 190 ms is just this machine's round trip out to the edge and back through
the tunnel — a leg real visitors never travel. Measure from outside, or use the
internal number.
Nice side effect: assets already go out with Cache-Control: public,
max-age=15552000, immutable and Cloudflare is serving them from its cache, so
visitors get CSS and JS from their nearest edge rather than from my living room.
Gotchas, condensed
Things that failed silently or pointed somewhere other than the cause:
docker cp doesn't preserve ownership copying to the host. MariaDB and
PHP then can't write, and nothing says "ownership".
fof/redis isn't an enableable extension — extension:enable fof-redis
says the ID doesn't exist. extend.php is what activates it.
- Leaving
sync requires a queue worker. Jobs queue up and never run, with
no error.
- The image runs no cron. Scheduled tasks silently never fire.
- realtime's shipped
.nginx.conf misses /app/{key}, which is the route
the browser actually uses.
ALLOWED_METHODS defaulting to GET|POST|HEAD makes the admin panel look
fine while saving nothing.
- CRS 920450 blocks
X-HTTP-Method-Override → 403 on every edit, which
reads as "session expired".
- BADBEHAVIOR counting 400/403/404/429 bans legitimate users — including
the admin — for 24 hours.
AUTO_REDIRECT_HTTP_TO_HTTPS behind a tunnel is an infinite redirect
loop.
- A backup restore is SQL by definition — the CRS is correct to flag it,
which is exactly why that route needs an exemption.
opcache.file_cache pointing at a non-existent directory makes PHP
refuse to start, in a loop. In the browser it appears as a Cloudflare 502 —
about as far from the cause as you can get.
php -i on the CLI shows max_execution_time = 0 — the CLI ignores that
limit by definition. To see what applies on the web, ask php-fpm.
- BunkerWeb's log files are symlinks to the container's stdout, so
grep
on them hangs forever. Use docker logs bunkerweb 2>&1 | grep ModSecurity.
What I'd still like to solve
- Full-page caching for anonymous visitors via Cloudflare Cache Rules is the
only path to an order-of-magnitude win, and the riskiest thing on this list:
it must exclude /api/*, anything carrying a session cookie, and the CSRF
token — otherwise one user's page gets served to another.
- No mail server yet, so no email confirmation and no password recovery.
- The image ships
flarum/extension-manager, which isn't part of the stock
skeleton. I kept it deliberately, but it's the reason
opcache.validate_timestamps=0 is off the table.
Happy to share any config file in full — ask and I'll paste it.
All secrets in this post are redacted. If you copy the ModSecurity or
rate-limit settings, please read the reasoning around them rather than pasting
blindly — a couple of them trade real protection for usability, and whether
that trade is right depends on your forum.