One Binary to Rule Them All: Shipping Laravel as a Single Executable

AxVi
17 min read
7 views
One Binary to Rule Them All: Shipping Laravel as a Single Executable
A deep dive into deploying a modern Laravel 12 developer blog using a static FrankenPHP binary, Docker multi-stage builds, Traefik reverse proxy with automatic SSL, and GitHub Actions CI/CD pipeline. A real-world guide to building the ultimate zero-dependency PHP deployment.

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:

SetupEstimated total sizeNotes
nginx + PHP-FPM~700-800 MBphp:8.5-fpm (516 MB) + nginx:alpine (62 MB) + extensions + app code + vendor
FrankenPHP dynamic~400-450 MBdunglas/frankenphp:php8.5-alpine (203 MB) + extensions (pdo_pgsql, redis, gd, intl...) + app code + vendor
FrankenPHP static (ours)~336 MBEverything 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-slim and not Alpine? Our static binary is compiled with static-builder-gnu which uses glibc. Although the binary is statically linked, some system calls (DNS resolution via getaddrinfo, NSS lookups) may still depend on glibc at runtime. Alpine uses musl libc, which can cause subtle issues with DNS and locales. bookworm-slim guarantees 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:

  1. Package your app into app.tar (Go's //go:embed mechanism)
  2. Set CGO flags from the existing php-config
  3. 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., libhashkit needed by libmemcached). The safe approach is to collect all .a files 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-group tells the linker to resolve all symbols regardless of order.

  • Build constraints — the Go build requires CGO_ENABLED=1 for the unix build tag to be active, otherwise FrankenPHP's internal packages won't compile.

Pro Tips for Static Builds

  1. Skip build-static.sh — call xcaddy directly with pre-compiled libraries. The static-builder-gnu image already has everything compiled — don't waste CI minutes recompiling.

  2. Use GitHub Actions cache — the cache-from: type=gha directive in docker/build-push-action caches intermediate layers, so unchanged stages don't rebuild.

  3. No GITHUB_TOKEN needed — when using direct xcaddy builds, Go modules are fetched from the Go proxy (not GitHub API), so there are no rate limits to worry about.

  4. Add public/hot to .dockerignore — when running npm run dev locally, Vite creates a public/hot file pointing to http://localhost:5173. If this file gets embedded into the static binary via COPY . ., 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.

  5. Create a php wrapper and set PHP_BINARY — the static binary only has the frankenphp executable, but Laravel's scheduler, queue worker, and Symfony's PhpExecutableFinder expect a php binary. 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.com to your-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:

MetricValue
Requests/sec35.5
Avg latency1.4s
P50 latency1.3s
P99 latency2.0s
Success rate100%

Blog post page (/en/blog/...) — 500 requests, 25 concurrent:

MetricValue
Requests/sec20.8
Avg latency1.2s
P50 latency1.2s
P99 latency2.0s
Success rate100%

Stress test — finding the limits on a $3.49/month server (2 vCPU, 4 GB RAM):

Concurrent connectionsSuccess rateRPSAvg latencyP99 latency
50100%351.4s2.0s
100100%302.9s4.5s
15090%333.8s5.6s
20080%315.1s7.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:

  1. Zero runtime dependencies — no PHP installation to manage, no extension conflicts, no version mismatches between environments
  2. Immutable deployments — the exact same binary runs in CI, staging, and production. What you test is what you ship
  3. Minimal attack surface — no package manager, no shell utilities, no unnecessary system libraries. Just your application
  4. Self-contained — the binary includes PHP, Caddy, and your entire application. Copy it to any Linux machine and it runs
  5. 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.

Share this article