One Binary to Rule Them All: Shipping Laravel as a Single Executable
Why Build a Developer Blog in 2026?
In the age of social media and content platforms, building your own developer blog might seem like overkill. But there's something uniquely valuable about having a corner of the internet that's truly yours — a place where you control the stack, the design, and the content. No algorithms deciding who sees your posts, no platform risk, and best of all — your blog itself becomes a portfolio piece.
This is the story of how I built and deployed axvi.dev — a bilingual developer blog powered by a cutting-edge PHP stack. This isn't just another "how to deploy Laravel" tutorial. This is a guide to achieving what was previously impossible in the PHP world — packaging your entire application into a single, self-contained binary with zero runtime dependencies.
The Tech Stack
Before diving into deployment, let's look at what we're deploying:
- Laravel 12 — the latest version of the PHP framework
- Livewire 4 / Volt — reactive UI components without writing JavaScript
- Filament 5 — a beautiful admin panel for content management
- FrankenPHP via Laravel Octane — a modern PHP application server built on Caddy
- PostgreSQL — the database
- Redis — caching, sessions, and queues
- DaisyUI / Tailwind CSS — frontend styling with dracula and light themes
- Spatie Translatable — multilingual content (English + Ukrainian)
- Spatie Media Library — image management
- honeystone/laravel-seo — SEO metadata
- laravel-actions — business logic organized as action classes
Why FrankenPHP?
FrankenPHP is a modern PHP application server written in Go, built on top of Caddy. Unlike traditional PHP-FPM setups, FrankenPHP:
- Keeps your Laravel application in memory (via Octane), eliminating the bootstrap cost on every request
- Serves as both a web server and PHP runtime — no nginx/Apache + PHP-FPM combo needed
- Supports HTTP/3, early hints, and automatic HTTPS out of the box
- Can be compiled into a single static binary containing PHP, all extensions, and Caddy
That last point is what makes FrankenPHP truly revolutionary. Imagine shipping your entire PHP application — framework, dependencies, web server, and PHP itself — as a single executable file. No apt install php, no extension management, no version conflicts. Just one binary that runs everywhere.
Multilingual Architecture
The blog supports English and Ukrainian using Spatie Translatable. Every piece of content — posts, categories, tags — stores translations as JSON in the database. Routes are prefixed with locale: /{locale}/blog, /{locale}/blog/{slug}.
The Filament admin panel makes managing translations seamless — each translatable field gets a language switcher, so you can write content in both languages from the same form.
Server Setup
Choosing Infrastructure
I went with Hetzner Cloud for hosting. For a developer blog, even their smallest shared instance is more than enough:
- CX23: 2 vCPU, 4 GB RAM, 40 GB SSD — around $3.49/month
- Location: Nuremberg, Germany — great connectivity across Europe
- OS: Ubuntu 24.04 LTS
Initial Server Configuration
After creating the server, the first steps are always the same:
1. Create a non-root user:
adduser deploy
usermod -aG sudo deploy
cp -r /root/.ssh /home/deploy/.ssh
chown -R deploy:deploy /home/deploy/.ssh
2. Configure the firewall:
sudo ufw allow 22
sudo ufw allow 80
sudo ufw allow 443
sudo ufw enable
3. Install Docker:
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu noble stable" | \
sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker deploy
Simple, battle-tested, boring. Exactly what you want for infrastructure.
The Docker Architecture
Here's where things get interesting. The Dockerfile uses a multi-stage build with four stages, each responsible for a specific part of the build pipeline:
Stage 1: PHP Dependencies
FROM php:8.5-cli-alpine AS composer-builder
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --ignore-platform-reqs --no-dev -a --no-scripts
COPY . .
RUN composer dump-autoload --optimize
A dedicated stage for Composer dependencies ensures a clean, production-only vendor directory with an optimized autoloader. This stage runs first because the frontend build depends on it.
Stage 2: Frontend Builder
FROM node:22-alpine AS frontend-builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
COPY --from=composer-builder /app/vendor vendor
RUN npm run build
This stage compiles Tailwind CSS and JavaScript assets using Vite. Notice the COPY --from=composer-builder — Tailwind CSS v4 with its Vite plugin resolves CSS imports from the vendor/ directory (such as syntax highlighting themes from Composer packages), so the vendor directory must be available during the frontend build.
Stage 3: The Static Binary Builder
This is where the magic happens:
FROM --platform=linux/amd64 dunglas/frankenphp:static-builder-gnu AS static-builder
WORKDIR /go/src/app/dist/app
COPY --from=composer-builder /app .
COPY --from=frontend-builder /app/public/build public/build
WORKDIR /go/src/app/
RUN BUILDROOT=/go/src/app/dist/static-php-cli/buildroot \
&& cd dist/app && tar cf /go/src/app/app.tar . && cd /go/src/app \
&& sha256sum app.tar | cut -d' ' -f1 > app_checksum.txt \
&& export CGO_ENABLED=1 \
&& export CGO_CFLAGS="$($BUILDROOT/bin/php-config --includes) -I$BUILDROOT/include" \
&& ALL_LIBS=$(find $BUILDROOT/lib -name '*.a' -exec basename {} .a \; \
| sed 's/^lib/-l/' | tr '\n' ' ') \
&& export CGO_LDFLAGS="-L$BUILDROOT/lib \
-Wl,--start-group -lphp $ALL_LIBS -Wl,--end-group \
-lpthread -lm -ldl -lrt -lresolv -lutil -lstdc++" \
&& export PATH="$BUILDROOT/../pkgroot/x86_64-linux/go-xcaddy/bin:$PATH" \
&& XCADDY_GO_BUILD_FLAGS="-ldflags '-w -s'" \
xcaddy build --output dist/frankenphp-linux-x86_64 \
--with github.com/dunglas/frankenphp=/go/src/app \
--with github.com/dunglas/frankenphp/caddy=/go/src/app/caddy \
--with github.com/dunglas/caddy-cbrotli
Notice we only include caddy-cbrotli (Brotli compression) as an extra Caddy module. The default FrankenPHP examples also include Mercure (real-time push) and Vulcain (HTTP/2 preload), but unless your app actually uses them, they just add binary size and cause unexpected browser permission popups. Keep the binary lean — add modules only when you need them.
Here's the key insight: the static-builder-gnu image already ships with PHP and all 60+ extensions pre-compiled as libphp.a in the buildroot. The default approach using build-static.sh ignores this and recompiles everything from scratch (~20 minutes). Instead, we call xcaddy directly, linking against the pre-compiled libraries — reducing the build to ~1-2 minutes.
The --start-group/--end-group linker flags resolve circular dependencies between static libraries, and we dynamically collect all .a files from the buildroot to ensure nothing is missing.
The result? A single frankenphp-linux-x86_64 executable (~240 MB) that contains:
- The Caddy web server
- PHP 8.5 interpreter with all 60+ extensions
- Your entire Laravel application code and vendor dependencies
- Frontend assets
The final production Docker image weighs in at ~336 MB. To put that in perspective:
| Setup | Estimated total size | Notes |
|---|---|---|
| nginx + PHP-FPM | ~700-800 MB | php:8.5-fpm (516 MB) + nginx:alpine (62 MB) + extensions + app code + vendor |
| FrankenPHP dynamic | ~400-450 MB | dunglas/frankenphp:php8.5-alpine (203 MB) + extensions (pdo_pgsql, redis, gd, intl...) + app code + vendor |
| FrankenPHP static (ours) | ~336 MB | Everything included: PHP, Caddy, 60+ extensions, app code, frontend assets — nothing to install at runtime |
The static binary approach is not only the smallest — it's also the most predictable. The classic nginx + PHP-FPM setup starts at 578 MB of base images alone and grows with every docker-php-ext-install. The dynamic FrankenPHP image saves you nginx but still requires extension installation and dependency management. Our static image has everything baked in — there's nothing left to install, configure, or download at runtime.
Stage 4: Production Image
FROM debian:bookworm-slim AS static-prod
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -r app && useradd -r -g app -d /app app
WORKDIR /app
COPY --from=static-builder /go/src/app/dist/frankenphp-linux-x86_64 \
/usr/local/bin/frankenphp
RUN mkdir -p storage/logs storage/framework/cache/data \
storage/framework/sessions storage/framework/views \
storage/app/public bootstrap/cache \
/data/caddy /config/caddy \
&& chown -R app:app storage bootstrap/cache /data /config
USER app
EXPOSE 80
CMD ["frankenphp", "php-cli", "artisan", "octane:frankenphp", \
"--host=0.0.0.0", "--port=80"]
The final image is beautifully minimal: a slim base OS + one binary + writable directories for Laravel's cache and sessions. Note the /data/caddy and /config/caddy directories — Caddy needs these for internal storage and config autosave, and since we run as a non-root app user, they must be created and owned upfront. A dedicated non-root app user runs the process. No PHP installation, no Composer, no Node.js. The attack surface is minimal, and there's nothing to break.
Why
debian:bookworm-slimand not Alpine? Our static binary is compiled withstatic-builder-gnuwhich uses glibc. Although the binary is statically linked, some system calls (DNS resolution viagetaddrinfo, NSS lookups) may still depend on glibc at runtime. Alpine uses musl libc, which can cause subtle issues with DNS and locales.bookworm-slimguarantees compatibility at the cost of ~80 MB — a fair trade-off for production reliability.
Building the Static Binary: A Deep Dive
Building a static PHP binary is a journey, and PHP 8.5 makes it even more exciting since you're working with the latest features. Here's what the process looks like and the key insights I gained.
The build-static.sh Trap
The official FrankenPHP documentation recommends using build-static.sh with PHP_EXTENSIONS and PHP_EXTENSION_LIBS variables. What it doesn't tell you is that the static-builder-gnu image already ships with all 60+ extensions pre-compiled as static libraries in the buildroot.
You can verify this yourself:
docker run --rm dunglas/frankenphp:static-builder-gnu \
cat /go/src/app/dist/static-php-cli/buildroot/build-extensions.json
The problem? build-static.sh calls spc build which always recompiles PHP from scratch regardless of existing buildroot artifacts. There's no incremental build support in static-php-cli. This means ~20 minutes wasted on every build recompiling something that's already there.
The Solution: Direct xcaddy Build
Instead of build-static.sh, we call xcaddy directly — the Go build tool that creates the Caddy binary with FrankenPHP module. The pre-compiled libphp.a and all extension libraries are already in the buildroot, so we just need to:
- Package your app into
app.tar(Go's//go:embedmechanism) - Set CGO flags from the existing
php-config - Link all static libraries with
--start-group/--end-group
Result: ~1-2 minutes instead of ~20 minutes. A 10x improvement.
Gotchas with Static Linking
When linking against pre-compiled static libraries, watch out for:
Missing libraries in
php-config --libs— the generated flags may not include all libraries present in the buildroot (e.g.,libhashkitneeded bylibmemcached). The safe approach is to collect all.afiles from buildroot dynamically.Circular dependencies — static libraries reference each other in complex ways. The linker processes libraries left-to-right and may miss symbols. Wrapping everything in
-Wl,--start-group ... -Wl,--end-grouptells the linker to resolve all symbols regardless of order.Build constraints — the Go build requires
CGO_ENABLED=1for theunixbuild tag to be active, otherwise FrankenPHP's internal packages won't compile.
Pro Tips for Static Builds
Skip
build-static.sh— callxcaddydirectly with pre-compiled libraries. Thestatic-builder-gnuimage already has everything compiled — don't waste CI minutes recompiling.Use GitHub Actions cache — the
cache-from: type=ghadirective in docker/build-push-action caches intermediate layers, so unchanged stages don't rebuild.No
GITHUB_TOKENneeded — when using directxcaddybuilds, Go modules are fetched from the Go proxy (not GitHub API), so there are no rate limits to worry about.Add
public/hotto.dockerignore— when runningnpm run devlocally, Vite creates apublic/hotfile pointing tohttp://localhost:5173. If this file gets embedded into the static binary viaCOPY . ., Laravel's Vite helper will think the dev server is running in production, loading assets from localhost instead of the compiled build. This causes broken styles/scripts and a Chrome "Access other apps and services" permission popup. Always exclude it from the Docker build context.Create a
phpwrapper and setPHP_BINARY— the static binary only has thefrankenphpexecutable, but Laravel's scheduler, queue worker, and Symfony'sPhpExecutableFinderexpect aphpbinary. Without it, scheduled commands fail with'php': No such file or directory. Add this to your Dockerfile:
RUN printf '#!/bin/sh\nexec frankenphp php-cli "$@"\n' > /usr/local/bin/php \
&& chmod +x /usr/local/bin/php
ENV PHP_BINARY=/usr/local/bin/php
The PHP_BINARY env var is critical — in the embedded filesystem, PHP reports PHP_BINARY as the artisan script path instead of the actual PHP executable. Without it, the scheduler fails to run commands correctly. With this setup, you can also use familiar commands like docker exec app php artisan migrate instead of the verbose frankenphp php-cli artisan migrate.
Production Docker Compose
The production setup uses six services orchestrated by Docker Compose:
services:
traefik:
image: traefik:v3
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
ports:
- "80:80"
- "443:443"
app:
image: ghcr.io/your-org/your-app:latest
healthcheck:
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/80'"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`${APP_DOMAIN}`)"
- "traefik.http.routers.app.tls.certresolver=letsencrypt"
- "traefik.http.routers.app-www.rule=Host(`www.${APP_DOMAIN}`)"
- "traefik.http.routers.app-www.middlewares=www-redirect"
- "traefik.http.middlewares.www-redirect.redirectregex.permanent=true"
worker:
image: ghcr.io/your-org/your-app:latest
command: ["frankenphp", "php-cli", "artisan", "queue:work"]
healthcheck:
test: ["CMD-SHELL", "php artisan queue:monitor redis:default,email --max=100"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
scheduler:
image: ghcr.io/your-org/your-app:latest
command: ["frankenphp", "php-cli", "artisan", "schedule:work"]
healthcheck:
test: ["CMD-SHELL", "php artisan schedule:list --no-interaction > /dev/null"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
db:
image: postgres:17
redis:
image: redis:alpine
Traefik as Reverse Proxy
Traefik is the unsung hero of this setup. It automatically:
- Obtains and renews Let's Encrypt SSL certificates via HTTP challenge
- Redirects HTTP to HTTPS
- Redirects
www.your-domain.comtoyour-domain.com - Routes traffic to the correct container
- Provides health checking
All of this with zero configuration files — everything is declared through Docker labels. No more manually managing SSL certificates or writing nginx configs.
Health Checks Without curl
Since our minimal production image doesn't include curl or wget, the app healthcheck uses Bash's built-in /dev/tcp to verify the port is open. This avoids installing extra packages just for health checking — keeping the image lean and the attack surface small.
The Static Binary in Action
With the static binary, running Artisan commands looks slightly different:
# Instead of: php artisan migrate
frankenphp php-cli artisan migrate
# Inside Docker:
docker compose exec app frankenphp php-cli artisan tinker
The frankenphp php-cli prefix replaces php — the binary contains the PHP interpreter, so no separate PHP installation is needed. One binary to rule them all.
CI/CD Pipeline
The GitHub Actions workflow handles everything automatically on push to master:
jobs:
build:
steps:
- name: Build and push
uses: docker/build-push-action@v6
with:
target: static-prod
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build
steps:
- name: Deploy to server
uses: appleboy/ssh-action@v1
with:
script: |
cd /opt/axvi
COMPOSE="docker compose -f docker-compose.prod.yml"
ARTISAN="$COMPOSE exec -T app frankenphp php-cli artisan"
$COMPOSE pull
$COMPOSE up -d
$ARTISAN storage:link --force
$ARTISAN migrate --force
$ARTISAN db:seed --force
$ARTISAN optimize:clear
$ARTISAN optimize
docker image prune -f
Push to master → build static binary → push Docker image to GHCR → SSH into server → pull and restart. Fully automated, zero-downtime deployments. With the direct xcaddy approach, the entire build pipeline completes in under 3 minutes.
Note the storage:link --force step — since the static binary embeds the application files, the public/storage symlink doesn't survive container rebuilds. This command recreates the symlink pointing to the persistent Docker volume, ensuring uploaded media (like post cover images managed by Spatie Media Library) remain accessible.
Performance Optimizations
OPcache + JIT
The production php.ini enables OPcache with JIT compilation:
opcache.enable = 1
opcache.memory_consumption = 256
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.jit = 1255
opcache.jit_buffer_size = 128M
Combined with Octane keeping the application in memory, this delivers response times that rival Go and Node.js applications.
Laravel Octane
FrankenPHP with Octane keeps the Laravel application bootstrapped in memory. The first request loads the framework, and subsequent requests skip the entire bootstrap process. This alone can reduce response times by 50-70% compared to traditional PHP-FPM setups.
The static binary makes this even better — since PHP itself is compiled with all extensions statically linked, there's no dynamic library loading overhead. Everything is in one memory-mapped binary.
Real-World Benchmarks
To see how the setup performs in practice, I ran load tests from my local machine (Ukraine) against the production server (Hetzner, Nuremberg, Germany) using hey. These numbers include full network round-trip (~3000 km), DNS resolution, and TLS handshake — this is what real users experience, not synthetic localhost benchmarks.
Home page (/en) — 1000 requests, 50 concurrent:
| Metric | Value |
|---|---|
| Requests/sec | 35.5 |
| Avg latency | 1.4s |
| P50 latency | 1.3s |
| P99 latency | 2.0s |
| Success rate | 100% |
Blog post page (/en/blog/...) — 500 requests, 25 concurrent:
| Metric | Value |
|---|---|
| Requests/sec | 20.8 |
| Avg latency | 1.2s |
| P50 latency | 1.2s |
| P99 latency | 2.0s |
| Success rate | 100% |
Stress test — finding the limits on a $3.49/month server (2 vCPU, 4 GB RAM):
| Concurrent connections | Success rate | RPS | Avg latency | P99 latency |
|---|---|---|---|---|
| 50 | 100% | 35 | 1.4s | 2.0s |
| 100 | 100% | 30 | 2.9s | 4.5s |
| 150 | 90% | 33 | 3.8s | 5.6s |
| 200 | 80% | 31 | 5.1s | 7.7s |
The server comfortably handles 100 concurrent connections with zero errors and sustains ~30-35 requests/second. Drops only start at 150+ concurrent connections. The latency numbers include ~500ms of network overhead (DNS, TLS, data transfer across Europe) — actual server response time is around 500-600ms.
For a developer blog on the cheapest Hetzner instance, this is serious headroom. You'd need thousands of simultaneous readers before this setup even breaks a sweat.
Why the Static Binary is Worth It
The static FrankenPHP binary represents a paradigm shift in PHP deployment:
- Zero runtime dependencies — no PHP installation to manage, no extension conflicts, no version mismatches between environments
- Immutable deployments — the exact same binary runs in CI, staging, and production. What you test is what you ship
- Minimal attack surface — no package manager, no shell utilities, no unnecessary system libraries. Just your application
- Self-contained — the binary includes PHP, Caddy, and your entire application. Copy it to any Linux machine and it runs
- HTTP/3 + QUIC out of the box — with ngtcp2 and nghttp3 compiled in, your application speaks the latest protocols natively
With the direct xcaddy approach, the build is just as fast as a standard Docker image — under 3 minutes total. Your production environment gets a bulletproof, minimal deployment artifact that's impossible to achieve with traditional PHP setups.
Conclusion
Deploying a Laravel blog in 2026 with a static FrankenPHP binary is the future of PHP deployment. The combination of FrankenPHP's static builds, Traefik's automatic SSL, and GitHub Actions CI/CD creates a deployment pipeline that's both powerful and elegant.
The blog is live at axvi.dev. The entire stack runs on a $3.49/month Hetzner server, handles traffic effortlessly, and deploys automatically on every git push. One binary, one server, zero compromises.
Have questions about this setup? Find me on GitHub, LinkedIn, or X.