Self-Hosting: Standalone (FrankenPHP)
Standalone Self-Hosting: FrankenPHP Tutorial & Build Log
Preface: This is currently VERY buggy - one of the few places I've enlisted Claude to help more than I've put in. As it stands, there are some Repository steps that are required for this to work properly, and currently I do not have the pipeline sorted to generate the necessary files for download. If your technically minded, have at it. Otherwise, in future updates this will be part of the release. X
The idea here is that the Agent can run as a self-hosted app with zero external services. No Docker knowledge, no PHP install, no npm, no Redis, no MariaDB. This page is the full tutorial, plus a log of how the build came together: every decision, every failure and every fix from the 2026-07-05 prototype session, so nobody (me included) has to work any of it out again.
The short, just-the-commands version lives in standalone/README.md.
This is the long version.
1. Architecture
Players' phones/browsers GM's machine
──────────────────────── ─────────────────────────────────────
https://host ──────────────▶ Caddy (FrankenPHP) :8000
├── /app/* ──▶ Reverb :8085 (localhost only)
└── everything else ──▶ PHP (the Laravel app)
│
Foundry VTT ◀── Agent Bridge ──── /api/foundry/callback ────┘
Two decisions make this whole thing work:
One origin, one port. Reverb never gets its own public port. Caddy
reverse-proxies the Pusher-protocol WebSocket path (/app/*) to the Reverb
process running inside. Players connect to the same host and port they loaded
the page from, so there are no mixed-content problems, no second TLS cert and
no extra firewall rule. So if you've ever asked "doesn't the Reverb port throw a
spanner in self-hosting?", this is the answer: from the outside, the port just
doesn't exist.
WebSocket config at runtime, not build time. The frontend used to read
VITE_REVERB_* from import.meta.env, which got baked into the JS bundle when
Vite built it. That meant every self-hoster would have needed Node and a full
asset rebuild just to point the app at their own domain. Not great. Now
resources/views/partials/reverb-config.blade.php injects
window.reverbConfig (key/host/port/scheme from Laravel config) into every
page, and resources/js/bootstrap.js works through each value in this order:
window.reverbConfig → import.meta.env.VITE_REVERB_* → window.location
The last fallback takes host, port and scheme from the page's own address,
which is exactly right whenever Reverb is proxied on the same origin. End
result: one prebuilt asset bundle works on any host. ddev and existing
.env files carry on working as they were, because the same VITE_REVERB_*
names feed the middle of the chain.
Zero-external-services profile
| Concern | Driver | Why it's enough |
|---|---|---|
| Database | SQLite | One GM + a table of players is tiny load |
| Cache | file |
App only uses the Cache facade |
| Sessions | file |
Same |
| Queue | database |
SyncCharacterDataToDb etc. still async |
| WebSocket | Reverb (child proc) | Proxied same-origin |
Redis and MariaDB are still there if you're running something bigger. They're config, not code.
2. Two ways to run it
Mode A: FrankenPHP binary + repo checkout
For anyone happy with git clone. One downloaded binary gives you PHP and the
web server, and the app runs from your checkout.
# 1. Get FrankenPHP (static binary: PHP + Caddy in one file)
curl -L -o standalone/bin/frankenphp \
https://github.com/php/frankenphp/releases/latest/download/frankenphp-linux-x86_64
chmod +x standalone/bin/frankenphp
# 2. Configure .env.standalone: set APP_KEY, REVERB_APP_KEY/SECRET and an
# absolute DB_DATABASE path. It loads automatically because APP_ENV=standalone.
# 3. Build assets once (any machine with node): npm run build
# (prebuilt public/build/ can also be shipped in a release tarball)
# 4. Migrate and run
touch standalone/database.sqlite
APP_ENV=standalone ./standalone/bin/frankenphp php-cli artisan migrate --force
APP_ENV=standalone ./standalone/bin/frankenphp php-cli artisan reverb:start &
APP_ENV=standalone ./standalone/bin/frankenphp php-cli artisan queue:work &
APP_ENV=standalone ./standalone/bin/frankenphp run --config standalone/Caddyfile
App on http://localhost:8000, with WebSockets riding /app/* on the same origin.
Mode B: one sealed binary (the app inside the executable)
For anyone who should never have to see a git checkout. The whole application
(PHP runtime, Caddy, app code, vendor, prebuilt assets and a default .env)
gets compiled into a single ~136MB executable.
Build it (needs Docker + ~10GB free disk; produces cpr-agent):
ddev npm run build # assets must exist in public/build/ before the docker build
GITHUB_TOKEN=$(gh auth token) docker build \
--secret id=github-token,env=GITHUB_TOKEN \
-t cpr-agent-static -f standalone/static-build.Dockerfile .
docker create --name cpr-tmp cpr-agent-static
docker cp cpr-tmp:/go/src/app/dist/frankenphp-linux-x86_64 cpr-agent
docker rm cpr-tmp
Run it from a directory that is not a Laravel checkout (see gotcha G8 below):
mkdir -p ~/.cpr-agent/storage/framework/{cache/data,sessions,views} ~/.cpr-agent/storage/{app,logs}
cd ~/.cpr-agent
export APP_KEY="base64:$(openssl rand -base64 32)"
export REVERB_APP_KEY=$(openssl rand -hex 16)
export REVERB_APP_SECRET=$(openssl rand -hex 16)
export DB_DATABASE=$HOME/.cpr-agent/database.sqlite
export LARAVEL_STORAGE_PATH=$HOME/.cpr-agent/storage
touch $DB_DATABASE
./cpr-agent php-cli artisan migrate --force
./cpr-agent php-cli artisan reverb:start &
./cpr-agent php-cli artisan queue:work &
./cpr-agent run --config Caddyfile.embedded # copy from standalone/Caddyfile.embedded
The binary carries standalone/.env.embed as its default config, but real
environment variables always win. That's how one binary serves every install
without a rebuild.
Sharing with players
- LAN:
http://<gm-ip>:8000. A plain-http page means plainws://, which browsers are fine with (mixed-content rules only bite onhttps://pages). - Tailscale Funnel / Cloudflare Tunnel: point the tunnel at port 8000.
Both proxy WebSockets, so players get a real
https://URL and the GM doesn't forward any ports. - Public domain: change the Caddyfile site address from
:8000to your domain. Caddy sorts out Let's Encrypt TLS for you, and thanks to thewindow.locationfallback the frontend needs no reconfiguring at all.
3. How I got here
Some context. I was weighing up self-hosting options. Docker Compose is the main route for the people who already run Foundry in Docker. I looked at NativePHP and ruled it out: the Agent is a hub with lots of clients (GM, players and the Bridge), and NativePHP desktop apps are deliberately built as single-user localhost apps. FrankenPHP won as the "easy mode" because its built-in Caddy gives you auto-TLS and WebSocket proxying in the same file as the PHP runtime.
The code changes that unblocked everything
| Change | File(s) | Why |
|---|---|---|
| Runtime Echo config injection | resources/views/partials/reverb-config.blade.php, config/broadcasting.php (frontend block), all three layouts |
Kills the Vite build-time coupling; prebuilt assets work anywhere |
| Fallback chain + same-origin port inheritance | resources/js/bootstrap.js |
window.location.port matters when proxying on a non-standard port (localhost:8000) |
PDO::MYSQL_ATTR_SSL_CA → Pdo\Mysql::ATTR_SSL_CA |
config/database.php |
PHP 8.5 (FrankenPHP ships 8.5) deprecated the old constant and printed warnings above every page; the class exists since 8.4 so ddev is unaffected |
Every suite stayed green the whole way through: 167 PHP tests, 64 JS tests,
Pint clean. (One oddity: composer test exits 1 on a clean tree even though
every test passes. It was like that before, and I still don't know why.)
Nine goes at the build (Mode B)
Every failure below is now baked into standalone/static-build.Dockerfile or
the runbook, so it can't bite again.
composer: command not found(exit 127). The official FrankenPHP docs recipe runscomposer installinsidestatic-builder-gnu, but that image ships neither composer nor php. Fix: a multi-stage build with acomposer:2deps stage. Bonus lesson: the failure first reported exit 0, because the build output was piped throughtail, which swallowed the exit code. Log to a file and append the status instead.ld: final link failed: No space left on device.The disk was 100% full (1.1GB free of 229GB). Pruning Docker's builder cache got back almost nothing, so it needed a proper clear-out. The static build wants ~10GB of headroom, and the linker error says nothing at all about disk space.undefined reference to ldap_*/ber_*. The builder's prebuiltlibcurl.awas compiled with OpenLDAP support, but spc doesn't put openldap in the link set. First workaround:PHP_EXTENSION_LIBS=openldap.Library [openldap] not exist!Wrong name, spc has no lib called that. Second go: addldapas a PHP extension so its lib comes along with it.undefined reference to libssh2_* / ZSTD_*. The ldap symbols resolved, but libcurl also wants libssh2 and zstd. Third go:PHP_EXTENSION_LIBS=libssh2,zstd.- GitHub 403 rate limit downloading the new lib sources. All the repeat
builds burned through the anonymous API quota. Fix: pass
gh auth tokenas a BuildKit secret (--secret id=github-token,env=GITHUB_TOKEN) so it never ends up in the image layers. undefined reference to ngtcp2_* / nghttp2_* / nghttp3_*. These libs linked fine on attempt 3! Adding libs was shifting spc's resolution set, not adding to it. Which smells like an upstream dependency-resolution bug around the prebuilt curl, so I stopped playing whack-a-mole. Fix: dropext-curlentirely. Guzzle quietly falls back to PHP's stream handler (TLS via openssl), nothing in the app callscurl_*directly, and composer.json doesn't need it. Build succeeded.Call to undefined function mb_split()on first boot. spc's mbstring builds with--disable-mbregexunless... (triedPHP_EXTENSION_LIBS=onig, which built and linkedlibonig.a, but PHP still configured--disable-mbregex).mbregexis its own spc extension. Adding it toPHP_EXTENSIONS(it pulls in onig itself) sorted it:mb_split()/mb_ereg()present, Laravel boots, migrations run. Done. (pharwent in at the same time soartisan tinkerworks in the sealed binary.)
Runtime gotchas (Mode B)
- G8: artisan resolution.
./cpr-agent php-cli artisan …looks forartisanin the current directory first. Run it inside a Laravel checkout and you boot the checkout instead,.envand all (I watched it try to connect to ddev'smariadbon hostdb). Always run it from a clean directory, so the embedded app's files come from the binary's extraction dir under/tmp/frankenphp_<hash>/. - Storage has to live outside the binary. Each binary version extracts to
a fresh temp dir, so set
LARAVEL_STORAGE_PATHsomewhere permanent and create the skeleton first (framework/{cache/data,sessions,views},app,logs). - Secrets come from the environment. The baked-in
.env.embedleavesAPP_KEY, the Reverb credentials andDB_DATABASEempty on purpose. Whatever launches it supplies them (env vars beat.envin Laravel). - Local port clashes. Reverb uses 8085 in these configs because ddev holds 8080 on my dev box. Any free localhost port works, it's internal only.
What "works" actually means here
Every check ran against the sealed binary on completely fresh state
(~/.cpr-agent-proto), not just the checkout:
- Page render. A headless-Firefox screenshot of the served login page.
- Runtime config. The served HTML has
window.reverbConfigwith only the key in it (host/port/scheme correctly missing, so the client works them out from the origin). - Transport. A Bun WebSocket client did the real Pusher-protocol
handshake against
ws://localhost:8000/app/<key>(through the Caddy proxy) and gotpusher:connection_establishedback. I cross-checked the socket_id in Reverb's debug log. - The full pipe.
Broadcast::on('embed-test')->as('EmbedTest')->sendNow()fired from the embedded Laravel runtime and turned up at the subscribed client:{"msg":"single binary, night city"}. The queued path (database driver +queue:work) got the same treatment in Mode A, including a job that survived worker restarts.
Still to do
- An
agent:serveartisan command that looks after reverb and the queue as child processes. Three commands become one, and a menubar/NativePHP launcher becomes easy later. - CI to build and publish binaries for each release (Linux first; macOS and Windows need their own FrankenPHP builds).
- A Docker Compose stack for the "I already run Foundry in Docker" crowd. It shares every code change with this work.
- A proper first run: generate keys automatically on first boot instead of
asking people to run
openssl.