Worker Mode
    PHP App Server

    Deploy FrankenPHP on a VPS

    A modern PHP app server built on Caddy. Replace Nginx + PHP-FPM with one process: TLS, HTTP/3, worker mode, and automatic Let's Encrypt — no separate ACME client.

    At a Glance

    ProjectFrankenPHP (PHP 8.4 + embedded Caddy)
    LicenseMIT (App), Apache 2.0 (Caddy)
    Recommended PlanRamNode Cloud VPS 2 vCPU / 2–4 GB
    OSUbuntu 24.04 LTS
    Best ForLaravel Octane, Symfony, API Platform
    1

    Base Server + Firewall (incl. UDP/443 for HTTP/3)

    Update + ufw
    apt update && apt upgrade -y
    apt install -y ca-certificates curl gnupg ufw fail2ban unattended-upgrades git
    dpkg-reconfigure --priority=low unattended-upgrades
    
    ufw default deny incoming
    ufw default allow outgoing
    ufw allow OpenSSH
    ufw allow 80/tcp
    ufw allow 443/tcp
    ufw allow 443/udp     # HTTP/3 over QUIC
    ufw --force enable

    Forgetting 443/udp is the #1 reason HTTP/3 silently fails — browsers fall back to H2 and you never notice.

    2

    Install Docker Engine

    Official Docker repo
    install -m 0755 -d /etc/apt/keyrings
    curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
    chmod a+r /etc/apt/keyrings/docker.gpg
    
    echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
      > /etc/apt/sources.list.d/docker.list
    
    apt update
    apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
    3

    Dockerfile (multi-stage, non-root, worker mode)

    /opt/app/Dockerfile
    FROM dunglas/frankenphp:php8.4 AS builder
    RUN install-php-extensions pdo_mysql pdo_pgsql redis intl zip gd bcmath opcache
    COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
    WORKDIR /app
    COPY src/composer.json src/composer.lock ./
    RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
    COPY src/ .
    RUN composer dump-autoload --optimize --classmap-authoritative
    
    FROM dunglas/frankenphp:php8.4
    RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
    RUN install-php-extensions pdo_mysql pdo_pgsql redis intl zip gd bcmath opcache
    
    COPY conf/php.ini    /usr/local/etc/php/conf.d/zz-app.ini
    COPY conf/Caddyfile  /etc/caddy/Caddyfile
    WORKDIR /app
    COPY --from=builder /app /app
    
    ARG USER=appuser
    RUN useradd -r -u 1001 ${USER} && chown -R ${USER}:${USER} /app /config /data
    USER ${USER}
    
    ENV SERVER_NAME=":80"
    ENV FRANKENPHP_CONFIG="worker /app/public/index.php"

    Pin php8.4 — minor PHP bumps should be deliberate. Worker mode keeps your framework loaded between requests for ~10× throughput on Laravel/Symfony.

    4

    Caddyfile — TLS, security headers, asset cache

    /opt/app/conf/Caddyfile
    {
        email admin@example.com
        auto_https on
        servers {
            protocols h1 h2 h3
            trusted_proxies static private_ranges
        }
        frankenphp {
            num_threads 8
            max_threads auto
            max_requests 500
            worker {
                file /app/public/index.php
                num 4
                env APP_ENV production
            }
        }
    }
    
    app.example.com {
        root * /app/public
        encode zstd br gzip
    
        @static path *.css *.js *.woff2 *.ttf *.svg *.png *.jpg *.webp *.avif *.ico
        header @static Cache-Control "public, max-age=31536000, immutable"
    
        header {
            Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
            X-Content-Type-Options "nosniff"
            X-Frame-Options "SAMEORIGIN"
            Referrer-Policy "strict-origin-when-cross-origin"
            -Server
        }
    
        @hidden path_regexp hidden ^/\.(?!well-known/)
        respond @hidden 404
        php_server
    }

    max_requests 500 recycles each worker after 500 requests — safety net against memory leaks. Other workers stay live, so it's invisible to users.

    5

    PHP — opcache + JIT for worker mode

    /opt/app/conf/php.ini
    memory_limit = 256M
    max_execution_time = 60
    post_max_size = 20M
    upload_max_filesize = 20M
    
    display_errors = Off
    log_errors = On
    error_log = /dev/stderr
    date.timezone = UTC
    
    realpath_cache_size = 4M
    realpath_cache_ttl = 600
    
    opcache.enable = 1
    opcache.memory_consumption = 256
    opcache.interned_strings_buffer = 32
    opcache.max_accelerated_files = 30000
    opcache.validate_timestamps = 0
    opcache.save_comments = 1
    
    opcache.jit_buffer_size = 128M
    opcache.jit = tracing

    validate_timestamps = 0 means opcache never re-checks files — correct for immutable Docker builds. Restart the container or call /frankenphp/workers/restart to reload.

    6

    Compose Stack (app + Postgres + Redis)

    /opt/app/compose.yaml
    services:
      app:
        build: { context: ., dockerfile: Dockerfile }
        image: yourorg/app:latest
        restart: unless-stopped
        environment:
          SERVER_NAME: app.example.com
          APP_ENV: production
          DB_HOST: db
          DB_PASSWORD: ${DB_PASSWORD}
          REDIS_HOST: redis
        ports:
          - "80:80"
          - "443:443"
          - "443:443/udp"        # HTTP/3
        volumes:
          - caddy_data:/data
          - caddy_config:/config
          - app_storage:/app/storage
        depends_on: [db, redis]
    
      db:
        image: postgres:16-alpine
        restart: unless-stopped
        environment:
          POSTGRES_DB: app
          POSTGRES_USER: app
          POSTGRES_PASSWORD: ${DB_PASSWORD}
        volumes: [db_data:/var/lib/postgresql/data]
    
      redis:
        image: redis:7-alpine
        restart: unless-stopped
        command: ["redis-server","--maxmemory","256mb","--maxmemory-policy","allkeys-lru"]
    
    volumes: { caddy_data: {}, caddy_config: {}, app_storage: {}, db_data: {} }
    Generate DB password
    echo "DB_PASSWORD=$(openssl rand -hex 24)" > /opt/app/.env
    chmod 600 /opt/app/.env
    7

    First Start + Verify HTTP/3

    Build + start
    cd /opt/app
    docker compose build
    docker compose up -d
    docker compose logs -f app    # watch for: certificate obtained successfully
    From your workstation
    curl -I https://app.example.com         # HTTP/2
    curl -I --http3 https://app.example.com  # HTTP/3 (needs curl built with --with-quic)

    If ACME fails: DNS not propagated, or the VPS provider's panel firewall is blocking 80/443 separately from ufw.

    8

    Backups + Zero-Downtime Deploys

    /opt/app/backup.sh
    #!/usr/bin/env bash
    set -euo pipefail
    STAMP=$(date +%Y%m%d-%H%M%S); BACKUP_DIR=/var/backups/app
    mkdir -p "$BACKUP_DIR"
    cd /opt/app && set -a && source .env && set +a
    
    docker compose exec -T db pg_dump -U app -d app | gzip > "$BACKUP_DIR/db-${STAMP}.sql.gz"
    docker run --rm -v app_caddy_data:/data -v "$BACKUP_DIR":/b alpine \
        tar -czf "/b/caddy-${STAMP}.tar.gz" -C /data .
    docker run --rm -v app_app_storage:/data -v "$BACKUP_DIR":/b alpine \
        tar -czf "/b/storage-${STAMP}.tar.gz" -C /data .
    find "$BACKUP_DIR" -type f -mtime +14 -delete
    Deploy loop (Laravel example)
    git -C src pull
    docker compose build app
    docker compose up -d app                 # Caddy graceful reload — zero downtime
    docker compose exec app php artisan migrate --force
    docker compose exec app php artisan octane:reload

    For Symfony, replace the last line with: curl -X POST http://localhost:2019/frankenphp/workers/restart.