Part 10: A Web Development VM for WordPress and Astro Staging
The first nine parts made a platform. This part makes the platform pay for itself. Client work is WordPress and Astro, and client work needs staging sites — real copies of live sites, safe to break, reachable from anywhere. Forge now hosts them on a dedicated virtual machine.
This post covers the VM, the scripts that make a new staging site a one-command task, and the deployment pipeline from Forgejo. It also covers two surprises: a certificate limit that changed the hostname plan, and a plugin that flooded the PHP error log until the web server returned 502 errors.
Why a VM and Not More Containers
Every service so far runs as a container in the Docker LXC. The staging workload got a full virtual machine instead. The reasons are practical.
A WordPress staging site must behave like its production server. Production for these sites is a standard Linux web stack: nginx, PHP-FPM, and MariaDB on a virtual server. A VM with the same stack gives the same behavior, the same file layout, and the same tools. A chain of containers gives an approximation.
Isolation also matters. Client sites run third-party plugin code that the platform does not control. That code stays inside one VM, away from the Docker LXC that runs Git, backups, and automation. Part 2 chose Proxmox partly because real VMs will one day be necessary. This is the day.
The VM
The VM is Proxmox guest 120, name webdev. It runs Debian 13 from the official cloud image, with 4 cores, 8GB of RAM, and an 80GB thin disk on the ssd-fast tier. A tracked script creates it on the Proxmox host. A second script bootstraps the stack inside it: nginx, MariaDB, PostgreSQL, PHP-FPM, wp-cli, Node 22, pnpm, Composer, a deploy user, and a /srv site layout.
One version decision is worth its reason. The plan targeted PHP 8.5. Debian 13 packages PHP 8.4, and the VM runs 8.4. The alternative was a third-party package repository, which adds an update source that the platform does not control. A packaged version with security updates from Debian beats a newer version with more moving parts.
One Command per Site
Staging sites are script products, not hand-built artifacts. Three scripts do the work:
# New Bedrock WordPress site: Composer scaffold, database,
# .env, nginx vhost, wp core install
scripts/webdev-new-wordpress-site.sh <site>
# Pull a live site: code, uploads, database, URL rewrite
scripts/webdev-pull-live-site.sh <site> <ssh-host> <path> <url>
# New static Astro project with an nginx vhost
scripts/webdev-new-astro-site.sh <project>
The pull script deserves the most attention, because it touches live client servers. Its safety rules are strict. It is read-only against the live server. It backs up the staging database before each import. It reads the live table prefix with wp config get table_prefix and writes it to the staging .env. It rewrites URLs after import. You can run it again at any time for fresh content.
Two details prevent quiet failures. The live server SSH key never leaves my Mac — the VM reaches the live server through SSH agent forwarding. And the sync excludes server-specific files such as object-cache.php and host-specific mu-plugins, because a staging server with a production cache configuration fails in confusing ways.
The Hostname Plan That Certificates Rejected
The original plan gave staging sites two-level hostnames: <site>.wp.example.com. Cloudflare rejected the plan. Universal SSL issues certificates for example.com and *.example.com only. It cannot cover a second-level wildcard such as *.wp.example.com. Every two-level hostname needs a paid certificate product.
The fix costs nothing: keep the hostnames single-level and put the site type in a suffix. WordPress staging sites are <site>-wp.example.com. Astro staging sites are <site>-astro.example.com. Cloudflare Universal SSL covers all of them, and the suffix keeps the site type visible in the name.
Caddy routes these hostnames to nginx on the VM through the same wildcard tunnel as everything else. The wildcard block stays an allowlist, in the pattern from Part 8:
http://*.example.com {
@wordpress_staging header_regexp wp Host ^[a-z0-9-]+-wp[.]example[.]com$
handle @wordpress_staging {
reverse_proxy <vm-ip>:80 {
header_up X-Forwarded-Proto https
}
}
handle {
respond "Not found" 404
}
}
A new WordPress staging site needs no Caddy change — the pattern matches it. An unknown hostname gets an explicit 404. Cloudflare Access fronts every staging hostname, so client work in progress is not public. The database admin tool is not on the tunnel at all. It answers only on the LAN and Tailscale.
Deploys from Forgejo
The development loop uses the platform Git service from Part 7:
- Pull the live site once with the pull script.
- Clone the repository, develop locally, and push to the
stagingbranch. - A Forgejo Actions workflow deploys the push:
rsyncto the VM, thencomposer install -o. - When the work is ready, deploy to production through the existing production host.
The workflow template is deliberately narrow. It never touches .env, uploads, or the database. Code moves through Git. Content and secrets stay on the VM. A deployment that can only change code is a deployment that cannot destroy a staging database that took an hour to import.
What Broke: The Plugin That Flooded FastCGI
Two pulled sites came from one WordPress multisite, split into two single sites. Both use a media offload plugin that serves uploads from an external CDN bucket. On PHP 8.4, that plugin emits a flood of deprecation warnings.
With WP_ENV=development, WordPress displays debug output. The deprecation flood went into the HTTP response headers, overflowed the FastCGI buffers, and nginx returned 502 errors for the whole site. The failure looked like a server problem. The cause was a logging problem.
Two changes fixed it. The sites now run WP_ENV=staging, which stops debug display. And nginx carries larger fastcgi_buffer settings in a tracked configuration file. The lesson generalizes: a staging site with maximum verbosity is not more honest than production — it is a different system with different failure modes.
One CAUTION from the same sites, recorded in the build notes: do not upload media on a staging site while the media offload plugin is active. The plugin writes to the live production bucket. Staging isolation ends where a plugin holds production credentials.
What This Stage Delivered
Five sites run on the VM: one Astro proof site, one clean plugin-development install, and three pulled client sites. A new staging site is one command plus a Git repository. A fresh copy of live content is one more command. The platform now carries real client work end to end: canonical Git on Forgejo, CI on the platform runner, staging on the VM, production deploys unchanged.
Next in the Series
Part 11 turns to the personal services that the storage layout in Part 3 was designed for. Navidrome went first because music is replaceable. Immich waited because photos are not. The next post covers both, and the import of 18,905 photos that proved the pattern.