Skip to content

Instantly share code, notes, and snippets.

@mrchrisadams
Last active January 4, 2026 06:30
Show Gist options
  • Select an option

  • Save mrchrisadams/aa812e59736e15f68f89558902f72f2b to your computer and use it in GitHub Desktop.

Select an option

Save mrchrisadams/aa812e59736e15f68f89558902f72f2b to your computer and use it in GitHub Desktop.
WordPress + FrankenPHP + SQLite with systemd socket activation (start on demand, stop when idle)

WordPress with SQLite on FrankenPHP

A lightweight WordPress setup using SQLite instead of MySQL/MariaDB, powered by FrankenPHP. No database server required.

Quick Start

For a fresh Ubuntu 24.04 server:

curl -fsSL https://raw.githubusercontent.com/YOUR_REPO/setup.sh | sudo bash -s -- yourdomain.com 8000

Or clone and run:

git clone https://github.com/YOUR_REPO/wordpress-frankenphp-sqlite
cd wordpress-frankenphp-sqlite
sudo ./setup.sh yourdomain.com 8000

What You Get

  • WordPress running on FrankenPHP (high-performance PHP application server)
  • SQLite database (no MySQL/MariaDB needed)
  • Secure directory structure with database outside web root
  • Systemd service for automatic startup
  • Minimal resource usage

Directory Structure

/var/www/yourdomain.com/
├── wp-config.php          # WordPress config (outside document root)
├── database/              # SQLite database (not web-accessible)
│   └── wordpress.db
└── public/                # Document root
    ├── wp-admin/
    ├── wp-content/
    ├── wp-includes/
    └── index.php

Manual Installation

Follow these steps if you prefer to install manually or need to customize the setup.

Prerequisites

  • Ubuntu 24.04 (or similar Debian-based system)
  • Root/sudo access
  • A domain name (optional, can use IP address)

Step 1: Install FrankenPHP

Add the repository and install FrankenPHP:

# Add the static-php repository (provides FrankenPHP)
curl -fsSL https://deb.henderkes.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/static-php.gpg
echo "deb [signed-by=/usr/share/keyrings/static-php.gpg] https://deb.henderkes.com/ stable main" | \
    sudo tee /etc/apt/sources.list.d/static-php.list

# Install FrankenPHP
sudo apt-get update
sudo apt-get install -y frankenphp

Step 2: Install Required PHP Extensions

FrankenPHP uses a modular static PHP build. Install PDO and SQLite support:

sudo apt-get install -y php-zts-pdo php-zts-pdo-sqlite php-zts-sqlite3

Verify the extensions are loaded:

php -m | grep -i pdo
# Should output:
# PDO
# pdo_sqlite

Step 3: Install WP-CLI

WP-CLI is the command-line interface for WordPress:

curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar
sudo mv wp-cli.phar /usr/local/bin/wp

# Verify installation
wp --info

Step 4: Create Directory Structure

Set up the site directory following the Filesystem Hierarchy Standard:

export SITE_NAME="yourdomain.com"

sudo mkdir -p /var/www/${SITE_NAME}/public
sudo mkdir -p /var/www/${SITE_NAME}/database
sudo chown -R frankenphp:frankenphp /var/www/${SITE_NAME}/

Step 5: Download WordPress

Use WP-CLI to download WordPress:

sudo -u frankenphp wp core download --path=/var/www/${SITE_NAME}/public/

Step 6: Install SQLite Database Integration Plugin

Download and install the official SQLite plugin:

# Note: We can't use `wp plugin install` here because WordPress tries to
# connect to MySQL before the SQLite plugin is active
curl -sLo /tmp/sqlite-database-integration.zip \
    https://downloads.wordpress.org/plugin/sqlite-database-integration.zip
sudo unzip -q -o /tmp/sqlite-database-integration.zip \
    -d /var/www/${SITE_NAME}/public/wp-content/plugins/
sudo chown -R frankenphp:frankenphp \
    /var/www/${SITE_NAME}/public/wp-content/plugins/sqlite-database-integration
rm -f /tmp/sqlite-database-integration.zip

Copy the database drop-in file:

sudo cp /var/www/${SITE_NAME}/public/wp-content/plugins/sqlite-database-integration/db.copy \
       /var/www/${SITE_NAME}/public/wp-content/db.php
sudo chown frankenphp:frankenphp /var/www/${SITE_NAME}/public/wp-content/db.php

Step 7: Create wp-config.php

Generate the configuration file using WP-CLI:

export SITE_URL="https://yourdomain.com:8000"  # Adjust for your setup

# Create initial wp-config.php
sudo -u frankenphp wp config create \
    --path=/var/www/${SITE_NAME}/public/ \
    --dbname=wordpress \
    --dbuser='' \
    --dbpass='' \
    --dbhost='' \
    --skip-check

# Add SQLite configuration
sudo -u frankenphp wp config set DB_ENGINE sqlite --path=/var/www/${SITE_NAME}/public/
sudo -u frankenphp wp config set DB_DIR "/var/www/${SITE_NAME}/database/" --path=/var/www/${SITE_NAME}/public/
sudo -u frankenphp wp config set DB_FILE wordpress.db --path=/var/www/${SITE_NAME}/public/

# Set site URLs
sudo -u frankenphp wp config set WP_HOME "${SITE_URL}" --path=/var/www/${SITE_NAME}/public/
sudo -u frankenphp wp config set WP_SITEURL "${SITE_URL}" --path=/var/www/${SITE_NAME}/public/

Move wp-config.php Outside Document Root

For security, move the config file one level up:

sudo mv /var/www/${SITE_NAME}/public/wp-config.php /var/www/${SITE_NAME}/wp-config.php

Update ABSPATH since wp-config.php is now one level above WordPress:

sudo sed -i "s|define( 'ABSPATH', __DIR__ . '/' );|define( 'ABSPATH', __DIR__ . '/public/' );|" \
    /var/www/${SITE_NAME}/wp-config.php

Add reverse proxy HTTPS handling:

sudo tee -a /var/www/${SITE_NAME}/wp-config.php << 'EOF'

// Handle reverse proxy HTTPS
if ( isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https' ) {
    $_SERVER['HTTPS'] = 'on';
}
EOF

Key points about wp-config.php placement:

  • WordPress automatically looks one directory up if it doesn't find wp-config.php in its own directory
  • ABSPATH is set to __DIR__ . '/public/' because the config file is now one level above WordPress
  • This keeps wp-config.php (which contains secrets) outside the web-accessible directory

Step 8: Set Permissions

# Set ownership to FrankenPHP user
sudo chown -R frankenphp:frankenphp /var/www/${SITE_NAME}/

# Secure the database directory (only frankenphp and root can access)
sudo chmod 750 /var/www/${SITE_NAME}/database

# Ensure wp-content is writable for uploads, plugins, themes
sudo chmod -R 755 /var/www/${SITE_NAME}/public/wp-content

Step 9: Configure FrankenPHP

Edit /etc/frankenphp/Caddyfile:

export PORT=8000

sudo tee /etc/frankenphp/Caddyfile << EOF
{
    frankenphp
}

:${PORT} {
    root /var/www/${SITE_NAME}/public/
    encode zstd br gzip
    php_server
}
EOF

Step 10: Configure Systemd Override

FrankenPHP's systemd service uses ProtectSystem=full which makes /var read-only by default. We need to allow writes to specific directories:

sudo mkdir -p /etc/systemd/system/frankenphp.service.d/
sudo tee /etc/systemd/system/frankenphp.service.d/override.conf << EOF
[Service]
ReadWritePaths=/var/www/${SITE_NAME}/public/wp-content
ReadWritePaths=/var/www/${SITE_NAME}/database
EOF

sudo systemctl daemon-reload

Step 11: Start FrankenPHP

sudo systemctl enable frankenphp
sudo systemctl restart frankenphp

Verify it's running:

sudo systemctl status frankenphp
curl -I http://localhost:8000/

Step 12: Complete WordPress Installation

Option A: Via Browser

Visit your site in a browser:

http://yourdomain.com:8000/

You'll see the WordPress installation wizard. Fill in your site details.

Option B: Via WP-CLI (Headless)

Complete the installation entirely from the command line:

sudo -u frankenphp wp core install \
    --path=/var/www/${SITE_NAME}/public/ \
    --url="${SITE_URL}" \
    --title="My Site" \
    --admin_user=admin \
    --admin_password="YourSecurePassword123!" \
    [email protected]

WP-CLI Reference

Common WP-CLI commands for managing your site:

# Always run as frankenphp user with the correct path
export WP="sudo -u frankenphp wp --path=/var/www/yourdomain.com/public/"

# Update WordPress core
$WP core update

# Update all plugins
$WP plugin update --all

# Update all themes
$WP theme update --all

# Install and activate a plugin
$WP plugin install jetpack --activate

# Install and activate a theme
$WP theme install flavor --activate

# Create a new user
$WP user create editor [email protected] --role=editor

# Search and replace (useful when migrating)
$WP search-replace 'old-domain.com' 'new-domain.com'

# Export database (creates SQL-like output even for SQLite)
$WP db export backup.sql

# Clear caches
$WP cache flush

Troubleshooting

"PHP PDO Extension is not loaded"

Install the missing extensions:

sudo apt-get install -y php-zts-pdo php-zts-pdo-sqlite php-zts-sqlite3
sudo systemctl restart frankenphp

"Unable to create a file in the directory"

The systemd service is blocking writes. Check the override:

cat /etc/systemd/system/frankenphp.service.d/override.conf

Ensure ReadWritePaths includes both wp-content and database directories.

Database Locked Errors

Ensure the frankenphp user owns the database:

sudo chown frankenphp:frankenphp /var/www/yourdomain.com/database/wordpress.db

WP-CLI Errors

Always run WP-CLI as the frankenphp user:

sudo -u frankenphp wp --path=/var/www/yourdomain.com/public/ <command>

Check Logs

sudo journalctl -u frankenphp -f

Security Notes

  1. Database location: The SQLite database is stored outside the document root in /var/www/site/database/, making it inaccessible via web requests.

  2. wp-config.php location: Also outside the document root, protecting your authentication keys and database credentials.

  3. Directory permissions: The database directory has mode 750, readable only by the frankenphp user and root.

  4. HTTPS: If you're behind a reverse proxy that terminates SSL, the config includes handling for X-Forwarded-Proto headers.

Backups

To backup your entire site:

tar czf backup-$(date +%Y%m%d).tar.gz /var/www/yourdomain.com/

The SQLite database is just a single file, making backups simple:

cp /var/www/yourdomain.com/database/wordpress.db wordpress-backup-$(date +%Y%m%d).db

Or use WP-CLI:

sudo -u frankenphp wp db export backup.sql --path=/var/www/yourdomain.com/public/

Resource Usage

This setup is extremely lightweight:

  • No MySQL/MariaDB service running
  • Single process (FrankenPHP) handles both web serving and PHP
  • SQLite database is just a file, no daemon required
  • Typical memory usage: ~50-100MB for FrankenPHP

Socket Activation (Optional)

For even lower resource usage, you can configure systemd to:

  1. Start FrankenPHP only when traffic arrives
  2. Stop it automatically when idle

This is ideal for low-traffic sites or development environments.

See SOCKET_ACTIVATION.md for detailed setup, or use the quick install below.

Quick Install

# Stop the always-on service
sudo systemctl stop frankenphp
sudo systemctl disable frankenphp

# Install socket activation units
sudo cp frankenphp.socket /etc/systemd/system/
sudo cp frankenphp-socket.service /etc/systemd/system/frankenphp.service
sudo cp frankenphp-idle.service /etc/systemd/system/
sudo cp frankenphp-idle.timer /etc/systemd/system/

# Update Caddyfile for socket activation
sudo cp Caddyfile.socket-activated /etc/frankenphp/Caddyfile
# Edit /etc/frankenphp/Caddyfile to set your site root

# Enable socket and idle timer
sudo systemctl daemon-reload
sudo systemctl enable --now frankenphp.socket
sudo systemctl enable --now frankenphp-idle.timer

How It Works

Request → Socket (always listening) → Starts Service → Handles Request
                                              ↓
                              Timer checks every N minutes
                                              ↓
                              No connections? Stop service.
                                              ↓
                              Next request? Socket restarts it.

Verify

# Socket listening, service stopped
ss -tlnp | grep 8000                          # Port open
sudo systemctl is-active frankenphp.service   # inactive

# Make a request
curl http://localhost:8000/

# Service now running
sudo systemctl is-active frankenphp.service   # active

# Wait for idle timer (default: 1 min for testing, 5 min for production)
# Service will stop automatically

Tuning Idle Timeout

Edit /etc/systemd/system/frankenphp-idle.timer:

[Timer]
OnBootSec=5min
OnUnitActiveSec=5min  # Check every 5 minutes

Then reload: sudo systemctl daemon-reload && sudo systemctl restart frankenphp-idle.timer

License

MIT

# Caddyfile for systemd socket activation
#
# The socket unit passes the listening socket as file descriptor 3.
# FrankenPHP/Caddy binds to this instead of opening its own socket.
#
# Edit the root path below to match your site.
{
frankenphp
}
http://localhost {
# Use systemd-passed file descriptor
bind fd/3
# Set your WordPress public directory
root /var/www/yourdomain.com/public/
encode zstd br gzip
php_server
}
[Unit]
Description=Stop FrankenPHP if idle
After=frankenphp.service
[Service]
Type=oneshot
ExecStart=/bin/bash -c 'if ! systemctl is-active --quiet frankenphp.service; then exit 0; fi; CONNS=$(ss -tn state established "( sport = :8000 )" 2>/dev/null | tail -n +2 | wc -l); if [ "$CONNS" -eq 0 ]; then echo "No active connections, stopping frankenphp.service"; systemctl stop frankenphp.service; else echo "$CONNS active connection(s), keeping alive"; fi'
[Unit]
Description=Check FrankenPHP idle status
[Timer]
# First check 1 min after boot
OnBootSec=1min
# Then every 1 min (for testing; use 5min in production)
OnUnitActiveSec=1min
[Install]
WantedBy=timers.target
[Unit]
Description=FrankenPHP (Socket Activated)
Documentation=https://frankenphp.dev/docs/
After=network.target
Requires=frankenphp.socket
[Service]
Type=notify
User=frankenphp
Group=frankenphp
ExecStart=/usr/bin/frankenphp run --environ --config /etc/frankenphp/Caddyfile
ExecReload=/usr/bin/frankenphp reload --config /etc/frankenphp/Caddyfile --force
TimeoutStopSec=5s
LimitNOFILE=1048576
LimitNPROC=512
PrivateTmp=true
ProtectSystem=full
AmbientCapabilities=CAP_NET_BIND_SERVICE
# Don't restart - let socket activation handle it
Restart=no
[Install]
WantedBy=multi-user.target
[Unit]
Description=FrankenPHP Socket
Documentation=https://frankenphp.dev/docs/
[Socket]
ListenStream=8000
NoDelay=true
# Accept connections in the service, not in the socket
Accept=no
[Install]
WantedBy=sockets.target
#!/bin/bash
#
# WordPress with SQLite on FrankenPHP - Setup Script (FIXED VERSION)
#
# Usage: sudo ./setup.sh <domain> [port]
#
# Example: sudo ./setup.sh mysite.com 8000
#
# FIXES from original setup.sh:
# 1. GPG key URL https://deb.henderkes.com/gpg.key returns 404
# 2. APT repository deb.henderkes.com/stable/main returns 404
# 3. Packages frankenphp, php-zts-pdo, php-zts-pdo-sqlite, php-zts-sqlite3 not available
# -> Solution: Download FrankenPHP binary directly from GitHub releases
# -> FrankenPHP includes PHP with all required extensions built-in
#
# Options (via environment variables):
# SKIP_VERIFY=1 - Skip verification steps
# VERBOSE=1 - Show verbose output
# WP_TITLE - Site title (default: "My Site")
# WP_ADMIN_USER - Admin username (default: "admin")
# WP_ADMIN_EMAIL - Admin email (default: "[email protected]")
# WP_ADMIN_PASS - Admin password (auto-generated if not set)
# AUTO_INSTALL=1 - Automatically run wp core install
# DEV_USER - Username to grant write access (enables shared group permissions)
# PERMISSIONS - Permission model: "shared" (default if DEV_USER set) or "secure"
#
# Permission Models:
# shared - DEV_USER and frankenphp can both write to all files (good for dev)
# secure - DEV_USER owns files, frankenphp can only write to uploads/database (good for prod)
#
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Logging functions
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
verbose() { [[ "${VERBOSE:-0}" == "1" ]] && echo -e "${BLUE}[DEBUG]${NC} $1" || true; }
# Verification function - runs a check and reports pass/fail
verify() {
local description="$1"
local command="$2"
if [[ "${SKIP_VERIFY:-0}" == "1" ]]; then
return 0
fi
verbose "Verifying: $description"
verbose "Command: $command"
if eval "$command" > /dev/null 2>&1; then
echo -e " ${GREEN}✓${NC} $description"
return 0
else
echo -e " ${RED}✗${NC} $description"
return 1
fi
}
# Check root
[[ $EUID -ne 0 ]] && error "This script must be run as root (use sudo)"
# Parse arguments
SITE_NAME="${1:-}"
PORT="${2:-8000}"
[[ -z "$SITE_NAME" ]] && error "Usage: $0 <domain> [port]\nExample: $0 mysite.com 8000"
# Validate port
[[ ! "$PORT" =~ ^[0-9]+$ ]] && error "Port must be a number"
[[ "$PORT" -lt 1 || "$PORT" -gt 65535 ]] && error "Port must be between 1 and 65535"
# Detect site URL
if [[ "$PORT" == "80" ]]; then
SITE_URL="http://${SITE_NAME}"
elif [[ "$PORT" == "443" ]]; then
SITE_URL="https://${SITE_NAME}"
else
SITE_URL="http://${SITE_NAME}:${PORT}"
fi
echo ""
echo -e "${GREEN}WordPress + SQLite + FrankenPHP Setup (FIXED)${NC}"
echo "================================================="
echo " Site: ${SITE_NAME}"
echo " Port: ${PORT}"
echo " URL: ${SITE_URL}"
echo " Root: /var/www/${SITE_NAME}/"
echo ""
# Track overall success
FAILED_STEPS=()
#
# Step 1: Install FrankenPHP from GitHub releases (FIXED)
#
# ORIGINAL BUG: The script tried to use deb.henderkes.com repository which returns 404:
# curl -fsSL https://deb.henderkes.com/gpg.key | sudo gpg --dearmor ...
# Error: curl: (22) The requested URL returned error: 404
# gpg: no valid OpenPGP data found.
#
# FIX: Download the static binary directly from GitHub releases.
# FrankenPHP is a single static binary that includes PHP with all common extensions.
#
info "Step 1/10: Installing FrankenPHP from GitHub releases..."
apt-get update -qq
apt-get install -y -qq unzip curl sqlite3
if [[ -x /usr/local/bin/frankenphp ]]; then
warn "FrankenPHP already installed, checking version"
else
# Get the latest release download URL
ARCH=$(uname -m)
if [[ "$ARCH" == "x86_64" ]]; then
FRANKENPHP_BINARY="frankenphp-linux-x86_64"
elif [[ "$ARCH" == "aarch64" ]]; then
FRANKENPHP_BINARY="frankenphp-linux-aarch64"
else
error "Unsupported architecture: $ARCH"
fi
info " Downloading FrankenPHP for $ARCH..."
curl -sLo /tmp/frankenphp "https://github.com/dunglas/frankenphp/releases/latest/download/${FRANKENPHP_BINARY}"
chmod +x /tmp/frankenphp
mv /tmp/frankenphp /usr/local/bin/frankenphp
fi
# Create frankenphp user if it doesn't exist
if ! id -u frankenphp &>/dev/null; then
useradd --system --no-create-home --shell /usr/sbin/nologin frankenphp
fi
# Create config directory
mkdir -p /etc/frankenphp
# FrankenPHP binary includes PHP - no separate php packages needed
# It has PDO, SQLite, and other common extensions built in
# Verify Step 1
echo " Verifying step 1:"
verify "FrankenPHP binary exists" "[[ -x /usr/local/bin/frankenphp ]]" || FAILED_STEPS+=("1:frankenphp-binary")
verify "FrankenPHP can run" "/usr/local/bin/frankenphp version" || FAILED_STEPS+=("1:frankenphp-exec")
#
# Step 2: Install WP-CLI
#
info "Step 2/10: Installing WP-CLI..."
if [[ -f /usr/local/bin/wp ]]; then
verbose "WP-CLI already installed"
else
curl -sO https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar
mv wp-cli.phar /usr/local/bin/wp
fi
# Verify Step 2
echo " Verifying step 2:"
verify "WP-CLI binary exists" "[[ -x /usr/local/bin/wp ]]" || FAILED_STEPS+=("2:wp-cli-binary")
#
# Step 2b: Create PHP shim for WP-CLI compatibility
#
# WP-CLI requires a 'php' binary in PATH. FrankenPHP includes PHP but exposes it
# via 'frankenphp php-cli' rather than a standalone 'php' command.
#
# This step creates a shim script at /usr/local/bin/php that redirects php calls
# to 'frankenphp php-cli'. This allows WP-CLI and other PHP tools to work.
#
if [[ -x /usr/local/bin/php ]]; then
# Check if existing php is already our shim or a different PHP installation
if grep -q 'frankenphp php-cli' /usr/local/bin/php 2>/dev/null; then
info " PHP shim for FrankenPHP already exists at /usr/local/bin/php"
else
warn "Existing PHP binary found at /usr/local/bin/php"
warn "Skipping PHP shim creation to avoid overwriting existing installation"
warn "WP-CLI will use the existing PHP installation"
fi
elif command -v php &>/dev/null; then
EXISTING_PHP=$(command -v php)
warn "Existing PHP binary found at ${EXISTING_PHP}"
warn "Skipping PHP shim creation - WP-CLI will use existing PHP"
else
info " Creating PHP shim at /usr/local/bin/php"
info " This shim redirects 'php' commands to 'frankenphp php-cli'"
info " This enables WP-CLI and other PHP tools to work with FrankenPHP"
cat > /usr/local/bin/php << 'PHPSHIM'
#!/bin/bash
#
# PHP shim for FrankenPHP
#
# FrankenPHP bundles PHP internally but doesn't expose a standalone 'php' binary.
# This shim translates standard 'php' calls to 'frankenphp php-cli' so that
# tools like WP-CLI work transparently.
#
# Created by wordpress-frankenphp-sqlite setup script.
# Safe to remove if you install a standalone PHP.
#
exec /usr/local/bin/frankenphp php-cli "$@"
PHPSHIM
chmod +x /usr/local/bin/php
echo " Verifying PHP shim:"
verify "PHP shim created" "[[ -x /usr/local/bin/php ]]" || FAILED_STEPS+=("2b:php-shim")
verify "PHP shim works" "/usr/local/bin/php -r 'echo PHP_VERSION;' | grep -q '8'" || FAILED_STEPS+=("2b:php-shim-works")
fi
#
# Step 3: Create directory structure
#
info "Step 3/10: Creating directory structure..."
mkdir -p /var/www/${SITE_NAME}/public
mkdir -p /var/www/${SITE_NAME}/database
mkdir -p /var/lib/frankenphp
chown -R frankenphp:frankenphp /var/www/${SITE_NAME}/
chown -R frankenphp:frankenphp /var/lib/frankenphp
# Verify Step 3
echo " Verifying step 3:"
verify "Public directory exists" "[[ -d /var/www/${SITE_NAME}/public ]]" || FAILED_STEPS+=("3:public-dir")
verify "Database directory exists" "[[ -d /var/www/${SITE_NAME}/database ]]" || FAILED_STEPS+=("3:database-dir")
verify "Directories owned by frankenphp" "[[ \$(stat -c %U /var/www/${SITE_NAME}/) == 'frankenphp' ]]" || FAILED_STEPS+=("3:ownership")
#
# Step 4: Download WordPress
#
info "Step 4/10: Downloading WordPress..."
if [[ -f /var/www/${SITE_NAME}/public/wp-load.php ]]; then
warn "WordPress already exists, skipping download"
else
# Download WordPress directly since WP-CLI needs PHP
curl -sLo /tmp/wordpress.tar.gz https://wordpress.org/latest.tar.gz
tar -xzf /tmp/wordpress.tar.gz -C /tmp/
cp -r /tmp/wordpress/* /var/www/${SITE_NAME}/public/
rm -rf /tmp/wordpress /tmp/wordpress.tar.gz
chown -R frankenphp:frankenphp /var/www/${SITE_NAME}/public/
fi
# Verify Step 4
echo " Verifying step 4:"
verify "WordPress wp-load.php exists" "[[ -f /var/www/${SITE_NAME}/public/wp-load.php ]]" || FAILED_STEPS+=("4:wp-load")
verify "WordPress wp-includes exists" "[[ -d /var/www/${SITE_NAME}/public/wp-includes ]]" || FAILED_STEPS+=("4:wp-includes")
verify "WordPress wp-admin exists" "[[ -d /var/www/${SITE_NAME}/public/wp-admin ]]" || FAILED_STEPS+=("4:wp-admin")
#
# Step 5: Create wp-config.php manually
#
info "Step 5/10: Creating wp-config.php..."
if [[ ! -f /var/www/${SITE_NAME}/public/wp-config.php ]]; then
# Generate random salts
SALTS=$(curl -s https://api.wordpress.org/secret-key/1.1/salt/)
cat > /var/www/${SITE_NAME}/public/wp-config.php << WPCONFIG
<?php
/**
* WordPress configuration for SQLite database
*/
// SQLite configuration
define( 'DB_ENGINE', 'sqlite' );
define( 'DB_DIR', '/var/www/${SITE_NAME}/database/' );
define( 'DB_FILE', 'wordpress.db' );
// These are not used with SQLite but required by WordPress
define( 'DB_NAME', 'wordpress' );
define( 'DB_USER', '' );
define( 'DB_PASSWORD', '' );
define( 'DB_HOST', '' );
define( 'DB_CHARSET', 'utf8' );
define( 'DB_COLLATE', '' );
// Authentication Unique Keys and Salts
${SALTS}
\$table_prefix = 'wp_';
define( 'WP_DEBUG', false );
// Site URLs
define( 'WP_HOME', '${SITE_URL}' );
define( 'WP_SITEURL', '${SITE_URL}' );
// Handle reverse proxy HTTPS
if ( isset(\$_SERVER['HTTP_X_FORWARDED_PROTO']) && \$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https' ) {
\$_SERVER['HTTPS'] = 'on';
}
/* That's all, stop editing! Happy publishing. */
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}
require_once ABSPATH . 'wp-settings.php';
WPCONFIG
chown frankenphp:frankenphp /var/www/${SITE_NAME}/public/wp-config.php
fi
# Verify Step 5
echo " Verifying step 5:"
verify "wp-config.php exists" "[[ -f /var/www/${SITE_NAME}/public/wp-config.php ]]" || FAILED_STEPS+=("5:wp-config")
verify "DB_ENGINE set to sqlite" "grep -q \"define.*DB_ENGINE.*sqlite\" /var/www/${SITE_NAME}/public/wp-config.php" || FAILED_STEPS+=("5:db-engine")
verify "DB_DIR configured" "grep -q \"define.*DB_DIR\" /var/www/${SITE_NAME}/public/wp-config.php" || FAILED_STEPS+=("5:db-dir")
#
# Step 6: Install SQLite plugin
#
info "Step 6/10: Installing SQLite Database Integration plugin..."
if [[ -d /var/www/${SITE_NAME}/public/wp-content/plugins/sqlite-database-integration ]]; then
warn "SQLite plugin already exists, skipping"
else
rm -f /tmp/sqlite-database-integration.zip
curl -sLo /tmp/sqlite-database-integration.zip https://downloads.wordpress.org/plugin/sqlite-database-integration.zip
unzip -q -o /tmp/sqlite-database-integration.zip -d /var/www/${SITE_NAME}/public/wp-content/plugins/
chown -R frankenphp:frankenphp /var/www/${SITE_NAME}/public/wp-content/plugins/sqlite-database-integration
rm -f /tmp/sqlite-database-integration.zip
fi
# Install db.php drop-in
cp /var/www/${SITE_NAME}/public/wp-content/plugins/sqlite-database-integration/db.copy \
/var/www/${SITE_NAME}/public/wp-content/db.php
chown frankenphp:frankenphp /var/www/${SITE_NAME}/public/wp-content/db.php
# Verify Step 6
echo " Verifying step 6:"
verify "SQLite plugin directory exists" "[[ -d /var/www/${SITE_NAME}/public/wp-content/plugins/sqlite-database-integration ]]" || FAILED_STEPS+=("6:plugin-dir")
verify "db.php drop-in exists" "[[ -f /var/www/${SITE_NAME}/public/wp-content/db.php ]]" || FAILED_STEPS+=("6:db-dropin")
verify "SQLite db.php contains expected code" "grep -q 'sqlite' /var/www/${SITE_NAME}/public/wp-content/db.php" || FAILED_STEPS+=("6:db-dropin-content")
#
# Step 7: Move wp-config.php outside document root
#
info "Step 7/10: Securing wp-config.php..."
# Move wp-config.php one level up (outside document root)
if [[ -f /var/www/${SITE_NAME}/public/wp-config.php ]]; then
mv /var/www/${SITE_NAME}/public/wp-config.php /var/www/${SITE_NAME}/wp-config.php
# Update ABSPATH since wp-config.php is now one level up
sed -i "s|define( 'ABSPATH', __DIR__ . '/' );|define( 'ABSPATH', __DIR__ . '/public/' );|" \
/var/www/${SITE_NAME}/wp-config.php
fi
chown frankenphp:frankenphp /var/www/${SITE_NAME}/wp-config.php
# Verify Step 7
echo " Verifying step 7:"
verify "wp-config.php moved outside public/" "[[ -f /var/www/${SITE_NAME}/wp-config.php ]]" || FAILED_STEPS+=("7:wp-config-moved")
verify "wp-config.php not in public/" "[[ ! -f /var/www/${SITE_NAME}/public/wp-config.php ]]" || FAILED_STEPS+=("7:wp-config-removed")
verify "ABSPATH points to public/" "grep -q \"ABSPATH.*public/\" /var/www/${SITE_NAME}/wp-config.php" || FAILED_STEPS+=("7:abspath")
verify "WP_HOME configured" "grep -q \"WP_HOME\" /var/www/${SITE_NAME}/wp-config.php" || FAILED_STEPS+=("7:wp-home")
#
# Step 8: Set permissions
#
# Two permission models are supported:
#
# 1. SHARED (default when DEV_USER is set):
# - Files owned by frankenphp:frankenphp
# - DEV_USER added to frankenphp group
# - Group write permissions enabled
# - Both DEV_USER and web server can modify all files
# - WordPress admin UI can install plugins/themes
# - Good for development
#
# 2. SECURE (default when DEV_USER is not set, or PERMISSIONS=secure):
# - Files owned by DEV_USER:frankenphp (or frankenphp:frankenphp if no DEV_USER)
# - Web server can only write to uploads/ and database/
# - Core files, plugins, themes are read-only to web server
# - Plugins/themes must be installed via WP-CLI or deployment
# - Good for production
#
info "Step 8/10: Setting permissions..."
# Determine permission model
DEV_USER="${DEV_USER:-}"
PERMISSIONS="${PERMISSIONS:-}"
if [[ -n "$DEV_USER" && -z "$PERMISSIONS" ]]; then
PERMISSIONS="shared"
elif [[ -z "$PERMISSIONS" ]]; then
PERMISSIONS="secure"
fi
# Validate DEV_USER exists if specified
if [[ -n "$DEV_USER" ]]; then
if ! id "$DEV_USER" &>/dev/null; then
error "DEV_USER '$DEV_USER' does not exist"
fi
fi
info " Permission model: ${PERMISSIONS}"
[[ -n "$DEV_USER" ]] && info " Dev user: ${DEV_USER}"
if [[ "$PERMISSIONS" == "shared" ]]; then
#
# SHARED: Both DEV_USER and frankenphp can write everywhere
#
if [[ -z "$DEV_USER" ]]; then
warn "PERMISSIONS=shared but DEV_USER not set"
warn "Set DEV_USER to grant a user write access, e.g.: DEV_USER=\$(whoami)"
else
# Add DEV_USER to frankenphp group
if id -nG "$DEV_USER" | grep -qw frankenphp; then
verbose "$DEV_USER already in frankenphp group"
else
info " Adding ${DEV_USER} to frankenphp group"
usermod -aG frankenphp "$DEV_USER"
fi
fi
# Files owned by frankenphp with group write
chown -R frankenphp:frankenphp /var/www/${SITE_NAME}/
chmod -R g+w /var/www/${SITE_NAME}/
# Set setgid on directories so new files inherit group
find /var/www/${SITE_NAME}/ -type d -exec chmod g+s {} \;
# Database directory slightly more restrictive
chmod 770 /var/www/${SITE_NAME}/database
echo " Verifying step 8 (shared permissions):"
verify "Files owned by frankenphp" "[[ \$(stat -c %U /var/www/${SITE_NAME}/) == 'frankenphp' ]]" || FAILED_STEPS+=("8:ownership")
verify "Group write enabled" "[[ \$(stat -c %A /var/www/${SITE_NAME}/public | cut -c6) == 'w' ]]" || FAILED_STEPS+=("8:group-write")
verify "Setgid on directories" "[[ \$(stat -c %A /var/www/${SITE_NAME}/public | cut -c7) == 's' ]]" || FAILED_STEPS+=("8:setgid")
if [[ -n "$DEV_USER" ]]; then
verify "${DEV_USER} in frankenphp group" "id -nG '$DEV_USER' | grep -qw frankenphp" || FAILED_STEPS+=("8:dev-user-group")
fi
verify "Database directory is mode 2770" "[[ \$(stat -c %a /var/www/${SITE_NAME}/database) == '2770' ]]" || FAILED_STEPS+=("8:database-perms")
else
#
# SECURE: Web server has minimal write access
#
if [[ -n "$DEV_USER" ]]; then
# DEV_USER owns most files, frankenphp group for read access
chown -R "${DEV_USER}:frankenphp" /var/www/${SITE_NAME}/
else
# No DEV_USER, frankenphp owns everything but with restrictive perms
chown -R frankenphp:frankenphp /var/www/${SITE_NAME}/
fi
# Base permissions: owner rwx, group rx, other none
chmod -R 750 /var/www/${SITE_NAME}/
# wp-config.php readable by group (frankenphp)
chmod 640 /var/www/${SITE_NAME}/wp-config.php
# Web server needs write access to uploads and database only
chown -R frankenphp:frankenphp /var/www/${SITE_NAME}/public/wp-content/uploads 2>/dev/null || mkdir -p /var/www/${SITE_NAME}/public/wp-content/uploads && chown -R frankenphp:frankenphp /var/www/${SITE_NAME}/public/wp-content/uploads
chown -R frankenphp:frankenphp /var/www/${SITE_NAME}/database
chmod 750 /var/www/${SITE_NAME}/database
echo " Verifying step 8 (secure permissions):"
if [[ -n "$DEV_USER" ]]; then
verify "Files owned by ${DEV_USER}" "[[ \$(stat -c %U /var/www/${SITE_NAME}/public/index.php) == '${DEV_USER}' ]]" || FAILED_STEPS+=("8:ownership")
fi
verify "wp-config.php not world-readable" "[[ \$(stat -c %a /var/www/${SITE_NAME}/wp-config.php) == '640' ]]" || FAILED_STEPS+=("8:wp-config-perms")
verify "uploads owned by frankenphp" "[[ \$(stat -c %U /var/www/${SITE_NAME}/public/wp-content/uploads) == 'frankenphp' ]]" || FAILED_STEPS+=("8:uploads-owner")
verify "Database owned by frankenphp" "[[ \$(stat -c %U /var/www/${SITE_NAME}/database) == 'frankenphp' ]]" || FAILED_STEPS+=("8:database-owner")
verify "Database directory is mode 750" "[[ \$(stat -c %a /var/www/${SITE_NAME}/database) == '750' ]]" || FAILED_STEPS+=("8:database-perms")
fi
verify "Database directory not world-readable" "! stat -c %A /var/www/${SITE_NAME}/database | grep -q 'r.\$'" || FAILED_STEPS+=("8:database-secure")
#
# Step 9: Configure FrankenPHP
#
info "Step 9/10: Configuring FrankenPHP and systemd..."
cat > /etc/frankenphp/Caddyfile << CADDYFILE
{
frankenphp
admin off
}
:${PORT} {
root * /var/www/${SITE_NAME}/public/
encode zstd br gzip
php_server
}
CADDYFILE
# Create systemd service
cat > /etc/systemd/system/frankenphp.service << SERVICE
[Unit]
Description=FrankenPHP Server
After=network.target
[Service]
Type=simple
User=frankenphp
Group=frankenphp
ExecStart=/usr/local/bin/frankenphp run --config /etc/frankenphp/Caddyfile
WorkingDirectory=/var/www/${SITE_NAME}
Restart=on-failure
RestartSec=5s
# Security settings
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/var/www/${SITE_NAME}/public/wp-content
ReadWritePaths=/var/www/${SITE_NAME}/database
ReadOnlyPaths=/var/www/${SITE_NAME}/public
ReadOnlyPaths=/var/www/${SITE_NAME}/wp-config.php
[Install]
WantedBy=multi-user.target
SERVICE
systemctl daemon-reload
# Verify Step 9
echo " Verifying step 9:"
verify "Caddyfile exists" "[[ -f /etc/frankenphp/Caddyfile ]]" || FAILED_STEPS+=("9:caddyfile")
verify "Caddyfile has correct port" "grep -q ':${PORT}' /etc/frankenphp/Caddyfile" || FAILED_STEPS+=("9:caddyfile-port")
verify "Caddyfile has correct root" "grep -q '/var/www/${SITE_NAME}/public/' /etc/frankenphp/Caddyfile" || FAILED_STEPS+=("9:caddyfile-root")
verify "Systemd service exists" "[[ -f /etc/systemd/system/frankenphp.service ]]" || FAILED_STEPS+=("9:systemd-service")
#
# Step 10: Start FrankenPHP and verify
#
info "Step 10/10: Starting FrankenPHP..."
systemctl enable frankenphp
systemctl restart frankenphp
# Wait for service to start
sleep 3
# Verify Step 10
echo " Verifying step 10:"
verify "FrankenPHP service is active" "systemctl is-active --quiet frankenphp" || FAILED_STEPS+=("10:service-active")
verify "FrankenPHP listening on port ${PORT}" "ss -tlnp | grep -q ':${PORT}'" || FAILED_STEPS+=("10:port-listening")
# Test HTTP response
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:${PORT}/ 2>/dev/null || echo "000")
verify "WordPress responds (HTTP ${HTTP_STATUS})" "[[ '$HTTP_STATUS' == '302' || '$HTTP_STATUS' == '200' || '$HTTP_STATUS' == '500' ]]" || FAILED_STEPS+=("10:http-response")
#
# Summary
#
echo ""
echo "========================================"
if [[ ${#FAILED_STEPS[@]} -eq 0 ]]; then
echo -e "${GREEN} All verification checks passed!${NC}"
else
echo -e "${RED} Some verification checks failed:${NC}"
for step in "${FAILED_STEPS[@]}"; do
echo -e " ${RED}✗${NC} $step"
done
echo ""
echo " Check logs: journalctl -u frankenphp -n 50"
fi
echo "========================================"
echo ""
echo " Site URL: ${SITE_URL}"
echo " Admin URL: ${SITE_URL}/wp-admin/"
echo ""
echo " Files: /var/www/${SITE_NAME}/"
echo " Database: /var/www/${SITE_NAME}/database/wordpress.db"
echo " Config: /var/www/${SITE_NAME}/wp-config.php"
echo " Caddyfile: /etc/frankenphp/Caddyfile"
echo ""
echo " Permissions: ${PERMISSIONS}"
if [[ "$PERMISSIONS" == "shared" && -n "$DEV_USER" ]]; then
echo " Dev user: ${DEV_USER} (in frankenphp group)"
echo ""
echo " NOTE: Run 'newgrp frankenphp' or log out/in to activate group membership."
echo " Then you can edit files directly without sudo."
elif [[ "$PERMISSIONS" == "secure" ]]; then
echo ""
echo " NOTE: Secure permissions enabled. Web server can only write to:"
echo " - /var/www/${SITE_NAME}/public/wp-content/uploads/"
echo " - /var/www/${SITE_NAME}/database/"
echo " Install plugins/themes via WP-CLI: sudo -u frankenphp wp plugin install <name>"
fi
echo ""
echo " Complete WordPress installation via the web browser."
echo ""
# Exit with error if any checks failed
[[ ${#FAILED_STEPS[@]} -gt 0 ]] && exit 1
exit 0

Socket Activation with Idle Shutdown

This setup uses systemd socket activation to:

  1. Start FrankenPHP on-demand when traffic arrives
  2. Stop FrankenPHP after idle (no active connections)

How it works

┌─────────────────────┐     traffic      ┌────────────────────────────┐
│ frankenphp.socket   │ ───────────────> │ frankenphp.service         │
│ (always listening)  │                  │ (starts on demand)         │
└─────────────────────┘                  └────────────────────────────┘
                                                      │
                                                      │ idle?
                                                      ▼
                                         ┌────────────────────────────┐
                                         │ frankenphp-idle.timer      │
                                         │ (checks every N minutes)   │
                                         └────────────────────────────┘

Files

  • frankenphp.socket - Listens on port 8000, activates service on connection
  • frankenphp-socket.service - The FrankenPHP service (rename to frankenphp.service when installing)
  • frankenphp-idle.timer - Periodic timer to check for idle
  • frankenphp-idle.service - Checks connections, stops service if idle
  • Caddyfile.socket-activated - Uses bind fd/3 for socket activation

Installation

# Stop and disable the regular service
sudo systemctl stop frankenphp
sudo systemctl disable frankenphp

# Copy socket-activated Caddyfile
sudo cp Caddyfile.socket-activated /etc/frankenphp/Caddyfile
# Edit to set your site root path

# Install units (note: service must be named frankenphp.service to match socket)
sudo cp frankenphp.socket /etc/systemd/system/
sudo cp frankenphp-socket.service /etc/systemd/system/frankenphp.service
sudo cp frankenphp-idle.service /etc/systemd/system/
sudo cp frankenphp-idle.timer /etc/systemd/system/

# Reload and enable
sudo systemctl daemon-reload
sudo systemctl enable --now frankenphp.socket
sudo systemctl enable --now frankenphp-idle.timer

Verify

# Socket should be listening
sudo systemctl status frankenphp.socket
ss -tlnp | grep 8000

# Service should NOT be running yet
sudo systemctl is-active frankenphp.service  # expect: inactive

# Make a request - this triggers the service
curl http://localhost:8000/

# Now the service should be active
sudo systemctl is-active frankenphp.service  # expect: active

# Check timer
sudo systemctl list-timers frankenphp-idle.timer

# Watch idle service logs
sudo journalctl -u frankenphp-idle.service -f

Tuning

Edit frankenphp-idle.timer to change the check interval:

[Timer]
OnBootSec=5min
OnUnitActiveSec=5min  # Check every 5 minutes (production)

For testing, 1 minute is useful:

[Timer]
OnBootSec=1min
OnUnitActiveSec=1min

Why not StopWhenUnneeded?

StopWhenUnneeded=true only works when other systemd units depend on the service. When those units stop, the service stops. It does NOT monitor network traffic or connection counts.

For idle detection based on actual network connections, we need the timer + idle-check approach.

Caveats

  1. First request latency: Cold start adds ~500ms-1s for FrankenPHP startup
  2. Long-lived connections: WebSocket/SSE connections keep the service alive
  3. Timer granularity: Service stays up for at least one timer interval after last request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment