Self-hosting Tracely: Docker, Octane, and load testing
One command brings up the full stack — Octane on FrankenPHP, a dedicated queue worker, Postgres, and Redis — and a second command tries to knock it over.
I said in April that I want Tracely's operational story to be boring and dependable. Boring takes work, so this note is about the Docker stack and the load-testing harness that keeps me honest about it.
One command up
composer run docker
That builds and starts the stack in the foreground and the app comes up on http://localhost:8888. (The compose file deliberately publishes on 8888 instead of 8080, because half the machines I've touched already have something squatting on 8080. If 8888 is taken too, APP_PORT and APP_URL override it.)
The stack has four services:
- app — Laravel Octane on FrankenPHP (
php artisan octane:start --server=frankenphp), PHP 8.4, serving HTTP only. - queue — the same image running
php artisan queue:workagainst thedefault,ingest,heatmapsqueues. - postgres — Postgres 16 with a named volume.
- redis — Redis 7 for queues and cache.
The repo is bind-mounted into the containers and .env.docker sets OCTANE_WATCH=true, so this is also my daily development environment, not a separate artifact that drifts from reality. Whatever I ship in the compose file is what I run all day.
Why the queue worker gets its own container
The most consequential decision in the layout is that ingest jobs do not run inside the app container. Tracely's ingest endpoints return quickly because the actual writes are queued — a batch request becomes one RecordIngestBatchJob that bulk-inserts all of its rows. If those jobs shared CPU with FrankenPHP, a traffic spike would starve the HTTP workers precisely when the dashboard is under the most scrutiny. Separate containers mean the persistence work and the request serving degrade independently, and you can restart workers after a job-code change (docker compose restart queue) without dropping HTTP.
Trying to knock it over
A stack you've never stressed is a stack you don't understand, so the repo ships an ingest load simulator written in Go. With the stack running:
composer run docker:ingest
This seeds a small multi-tenant setup (one stress organization with three sites), writes a manifest of every site's tracking ID, and runs the simulator container against the app. Extra flags pass through after --:
composer run docker:ingest -- -duration=2m -users=32 -max-in-flight=16
Each virtual user behaves like a browser tab: it picks a site from the manifest and posts pageview and event batches through the same public endpoints a real tracker uses. And when I want to find the actual ceiling:
composer run docker:ingest:1k
That's roughly 1,000 concurrent simulated users — 1,000 goroutines with -max-in-flight=0 (concurrency matches users) and a 25-second start stagger so the ramp resembles a real surge rather than a synchronized stampede.
What the load tests taught me
The first honest lesson: the bottleneck usually isn't PHP. The app container serves HTTP and the queue container persists batches, but both talk to the same Postgres, so large INSERT streams from the batch jobs compete with dashboard SELECTs. When pages feel slow mid-stress, that contention is almost always why. The knobs, in the order I reach for them: lower the simulator's concurrency, raise OCTANE_WORKERS so more HTTP requests are served in parallel, and tune the ingest rate limits.
There's also a tempting knob I'll warn you off: TRACELY_INGEST_DISPATCH_SYNC=true bypasses Redis queueing and makes ingest write to the database in-request. It sounds simpler, but under load it's usually worse — HTTP now waits on the busy database — so it exists for debugging only.
The honest caveats
This is a local-first compose file, and I won't pretend otherwise. Credentials are development defaults, there's no TLS terminator in front, and production hardening — real secrets, backups, a reverse proxy — is still your job when self-hosting. What the stack does give you is the correct shape: HTTP and queue work separated, health checks wired so services start in order, and a load harness so you can verify capacity on your own hardware instead of trusting my word for it. That last part matters to me — the numbers a self-hoster should trust are the ones they measured. More on how I think about that in general is at /trust.
Try Tracely on your site
One snippet, cookieless, first-party. See your traffic in minutes without handing it to an ad network.
Start freeGet new posts by email
Short product notes twice a week plus the occasional deep dive. No tracking pixels in the emails, and you can unsubscribe anytime.
Your address is used only to send new posts. No open tracking.