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
| Project | FrankenPHP (PHP 8.4 + embedded Caddy) |
| License | MIT (App), Apache 2.0 (Caddy) |
| Recommended Plan | RamNode Cloud VPS 2 vCPU / 2–4 GB |
| OS | Ubuntu 24.04 LTS |
| Best For | Laravel Octane, Symfony, API Platform |
Base Server + Firewall (incl. UDP/443 for HTTP/3)
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 enableForgetting 443/udp is the #1 reason HTTP/3 silently fails — browsers fall back to H2 and you never notice.
Install Docker Engine
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-pluginDockerfile (multi-stage, non-root, worker mode)
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.
Caddyfile — TLS, security headers, asset cache
{
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.
PHP — opcache + JIT for worker mode
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 = tracingvalidate_timestamps = 0 means opcache never re-checks files — correct for immutable Docker builds. Restart the container or call /frankenphp/workers/restart to reload.
Compose Stack (app + Postgres + Redis)
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: {} }echo "DB_PASSWORD=$(openssl rand -hex 24)" > /opt/app/.env
chmod 600 /opt/app/.envFirst Start + Verify HTTP/3
cd /opt/app
docker compose build
docker compose up -d
docker compose logs -f app # watch for: certificate obtained successfullycurl -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.
Backups + Zero-Downtime Deploys
#!/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 -deletegit -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:reloadFor Symfony, replace the last line with: curl -X POST http://localhost:2019/frankenphp/workers/restart.
