WordPress Staging on FreeBSD with Safe Cutover

Why this staging workflow matters
If your WordPress site sells products, takes bookings, or supports agency clients, you need a staging copy that behaves like production. You also need a cutover path you can reverse quickly.
This tutorial shows how to build WordPress staging on FreeBSD with a separate document root, a separate database, and a controlled switch to the live site after you verify updates and checkout behavior.
You will start from a fresh FreeBSD server, create a non-root admin account, install the required packages, and deploy Nginx, PHP-FPM, and MariaDB. You will also protect staging with HTTP basic authentication so search engines and customers do not index it.
For hosting teams running production on Hostperl, this release process helps avoid late-night rollback calls and half-finished launches. If you are planning a VPS for a WordPress workload, Hostperl VPS gives you the control needed for this workflow.
What you need before you start
- A FreeBSD 14.2-RELEASE or newer server.
- A domain such as
example.comalready pointing to the server. - Root SSH access to the VPS.
- A second SSH session or terminal for testing the new sudo-capable account.
- Backups of the live WordPress files and database before you copy anything.
For a deeper look at rollback planning around application releases, Hostperl’s rollback tutorial and the WooCommerce recovery guide show the same discipline applied to different stacks.
Connect to the server and confirm FreeBSD
On your local computer
ssh root@203.0.113.10Replace 203.0.113.10 with the public IP assigned to your FreeBSD server. Keep this root session open until the new admin login is confirmed in a second terminal.
On the VPS as root
freebsd-versionYou should see a FreeBSD release string such as 14.2-RELEASE. That confirms the OS track for this tutorial.
Create a non-root admin account
FreeBSD does not use Linux groups like sudo or wheel in exactly the same way as Linux distributions, but the wheel group remains the standard path for privileged access with doas or su.
The safest pattern is to create the account, add it to wheel, and verify the login before you change any root SSH settings.
On the VPS as root
adduserFollow the prompts and create a user named deploy. When asked for groups, include wheel.
Set a strong password if you intend to use password login temporarily, or leave password-based SSH off if you already have keys ready.
Now create a minimal doas policy so the new account can administer the server.
On the VPS as root
mkdir -p /usr/local/etc/doas.conf
cat > /usr/local/etc/doas.conf <<'EOF'
permit persist keepenv :wheel
EOF
chmod 0400 /usr/local/etc/doas.confThis file lets users in wheel run privileged commands with doas. The permission mode matters; FreeBSD ignores a policy file if it is too open.
Next, prepare SSH access for the new account.
On the VPS as root
mkdir -p /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys 2>/dev/null || true
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keysIf root did not already have an authorized_keys file, copy your public key into /home/deploy/.ssh/authorized_keys manually from your local computer. The permissions above are required for SSH to accept the key.
On your local computer
ssh deploy@203.0.113.10Open this in a second terminal. Log in as deploy, then test privilege escalation.
On the VPS as the non-root sudo user
doas idThe output should show uid=0(root). If it does, the admin handoff is ready.
Update packages and enable the services you need
FreeBSD uses pkg for software management and rc.conf for persistent service startup. Update package metadata first, then install Nginx, PHP-FPM, MariaDB, and the PHP extensions WordPress needs.
On the VPS as the non-root sudo user
doas pkg update
doas pkg install -y nginx php83 php83-fpm php83-mysqli php83-curl php83-gd php83-mbstring php83-xml php83-zip php83-opcache mariadb106-server mariadb106-client wget tar unzipPackage names can change with major PHP or MariaDB updates. If your repository offers newer branches, choose the matching phpXX and mariadbXX families available on your server.
Enable the services at boot.
On the VPS as the non-root sudo user
doas sysrc nginx_enable="YES"
doas sysrc php_fpm_enable="YES"
doas sysrc mysql_enable="YES"Start the services and confirm they are running.
On the VPS as the non-root sudo user
doas service php-fpm start
doas service mysql-server initdb
doas service mysql-server start
doas service nginx start
doas service nginx status
doas service php-fpm status
doas service mysql-server statusAt this point, the services should report running. If MariaDB fails on the first start, the init database step is usually the missing piece.
Create the production and staging database pair
For a safe staging cutover, use separate databases. That lets you test imports and schema changes without touching live data until you are ready.
On the VPS as the non-root sudo user
doas mysql_secure_installationAnswer the prompts to set a root password, remove anonymous users, disable remote root login, and remove the test database. That hardens the database before WordPress touches it.
Now create two databases and one WordPress user with access to both.
On the VPS as the non-root sudo user
doas mysql -u root -pAt the MariaDB prompt, run the following SQL, then exit with quit.
CREATE DATABASE wordpress_live CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE wordpress_stage CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'wpadmin'@'localhost' IDENTIFIED BY 'Use-A-Long-Random-Password-Here';
GRANT ALL PRIVILEGES ON wordpress_live.* TO 'wpadmin'@'localhost';
GRANT ALL PRIVILEGES ON wordpress_stage.* TO 'wpadmin'@'localhost';
FLUSH PRIVILEGES;Replace the password with a unique secret. Do not reuse a production panel password or an email password.
Prepare the document roots and PHP-FPM socket
Keep the live and staging files separate. That makes rollback easier because you can switch the web root without rewriting the application itself.
On the VPS as the non-root sudo user
doas mkdir -p /usr/local/www/wordpress-live /usr/local/www/wordpress-stage /usr/local/www/example.com/public_html
doas chown -R www:www /usr/local/www/wordpress-live /usr/local/www/wordpress-stage /usr/local/www/example.comCreate a dedicated PHP-FPM pool for WordPress if the default pool is too generic. This example uses the default pool path, which is acceptable for a single-site server.
On the VPS as the non-root sudo user
doas cp /usr/local/etc/php-fpm.d/www.conf /usr/local/etc/php-fpm.d/wordpress.conf
doas sed -i '' 's/^;*listen = .*/listen = \/var\/run\/php-fpm-wordpress.sock/' /usr/local/etc/php-fpm.d/wordpress.conf
doas sed -i '' 's/^user = .*/user = www/' /usr/local/etc/php-fpm.d/wordpress.conf
doas sed -i '' 's/^group = .*/group = www/' /usr/local/etc/php-fpm.d/wordpress.confVerify the file if your editor or defaults differ. Some FreeBSD package builds keep the default pool enabled, so a separate pool file is optional if the standard socket already works for your setup.
Restart PHP-FPM after editing, then confirm the socket exists.
On the VPS as the non-root sudo user
doas service php-fpm restart
ls -l /var/run/php-fpm-wordpress.sockInstall WordPress files for live and staging
Download WordPress once, then copy it into the live and staging trees. That keeps the base code identical while the databases remain separate.
On the VPS as the non-root sudo user
cd /tmp
fetch https://wordpress.org/latest.tar.gz
tar -xzf latest.tar.gz
cp -R wordpress/. /usr/local/www/wordpress-live/
cp -R wordpress/. /usr/local/www/wordpress-stage/
chown -R www:www /usr/local/www/wordpress-live /usr/local/www/wordpress-stageNext, create the two configuration files. The live site should point to wordpress_live; staging should point to wordpress_stage.
On the VPS as the non-root sudo user
cd /usr/local/www/wordpress-live
cp wp-config-sample.php wp-config.php
cd /usr/local/www/wordpress-stage
cp wp-config-sample.php wp-config.phpEdit the live configuration first.
On the VPS as the non-root sudo user
cd /usr/local/www/wordpress-live
vi wp-config.phpReplace the database section with the following values, then save and exit vi with :wq.
define( 'DB_NAME', 'wordpress_live' );
define( 'DB_USER', 'wpadmin' );
define( 'DB_PASSWORD', 'Use-A-Long-Random-Password-Here' );
define( 'DB_HOST', 'localhost' );
define( 'DB_CHARSET', 'utf8mb4' );
define( 'DB_COLLATE', '' );Repeat the same change in the staging config, but set DB_NAME to wordpress_stage. Then generate unique salts for both files from WordPress.org’s secret-key service and paste them into each configuration file.
Configure Nginx for live and staging hosts
Use two server blocks: one for the production hostname and one for staging. Put HTTP basic auth on staging so customers and search engines do not reach it accidentally.
On the VPS as the non-root sudo user
doas mkdir -p /usr/local/etc/nginx/conf.d /usr/local/etc/nginx/htpasswdCreate a password file for staging.
On the VPS as the non-root sudo user
doas sh -c 'printf "staginguser:$(openssl passwd -apr1 StrongStagingPassword)\n" > /usr/local/etc/nginx/htpasswd/example.com'Now create the Nginx site configuration.
On the VPS as the non-root sudo user
doas vi /usr/local/etc/nginx/conf.d/example.com.confPaste this content, then save and exit with :wq.
server {
listen 80;
server_name example.com www.example.com;
root /usr/local/www/wordpress-live;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/var/run/php-fpm-wordpress.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location ~* \.(log|sql|bak|ini)$ {
deny all;
}
}
server {
listen 80;
server_name staging.example.com;
root /usr/local/www/wordpress-stage;
index index.php index.html;
auth_basic "Staging Area";
auth_basic_user_file /usr/local/etc/nginx/htpasswd/example.com;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/var/run/php-fpm-wordpress.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}Test the configuration before you reload Nginx.
On the VPS as the non-root sudo user
doas nginx -tYou want to see syntax is ok and test is successful. If that passes, reload Nginx.
On the VPS as the non-root sudo user
doas service nginx reloadOpen the firewall and keep the old path available
FreeBSD commonly uses pf for packet filtering. Before you enable any restrictive rule set, make sure SSH remains open. Do not cut off your own session.
On the VPS as the non-root sudo user
doas sysrc pf_enable="YES"
doas sysrc pf_rules="/etc/pf.conf"Edit /etc/pf.conf so it allows SSH and web traffic.
On the VPS as the non-root sudo user
doas vi /etc/pf.confUse this starter policy and save it with :wq.
ext_if="vtnet0"
set skip on lo
block in all
pass out all keep state
pass in on $ext_if proto tcp to port { 22, 80, 443 } keep stateTest and load the firewall only after you have a second session ready.
On the VPS as the non-root sudo user
doas pfctl -nf /etc/pf.conf
doas service pf restart
doas pfctl -srThe ruleset should list SSH, HTTP, and HTTPS access. If you are using a Hostperl deployment on a VPS that already has traffic, this is the point where you would add the new rule before removing the old one.
Test the staging site and copy the live database
Before any cutover, confirm that staging renders and that the database import works. If you already have a production database dump, import it into wordpress_stage first. That lets you test a realistic copy of your data.
On the VPS as the non-root sudo user
doas mysql -u root -p wordpress_stage < /path/to/your-production-backup.sqlReplace the dump path with the file from your backup workflow. Then browse to http://staging.example.com from your client browser and log in with the staging basic-auth credentials plus your WordPress admin account.
If the WordPress login page loads but your theme or plugins error out, check the PHP-FPM and Nginx logs.
On the VPS as the non-root sudo user
doas tail -n 50 /var/log/nginx/error.log
doas tail -n 50 /var/log/messagesTypical clues include a bad SCRIPT_FILENAME path, a wrong socket name, or a plugin expecting a missing table. Fix the file path or restore the missing database object, then retest.
Cut over from staging to live with a reversible switch
When staging is clean, update the live site. Keep a rollback copy of the old configuration and database dump before you change anything.
On the VPS as the non-root sudo user
doas cp /usr/local/www/wordpress-live/wp-config.php /usr/local/www/wordpress-live/wp-config.php.bak
doas mysqldump -u root -p wordpress_live > /root/wordpress_live_backup.sqlIf the site is simple, the cutover may be just a database import from staging or a path switch. For a content refresh, you might replace the live database with the verified staging data after a final snapshot.
On the VPS as the non-root sudo user
doas mysql -u root -p wordpress_live < /path/to/verified-staging-dump.sqlFor a rollback, restore wordpress_live_backup.sql and the .bak config file, then reload Nginx and PHP-FPM.
Verification from server and client
On the VPS as the non-root sudo user
doas service nginx status
doas service php-fpm status
doas service mysql-server status
doas sockstat -4 -6 | grep -E '(:80|:443|php-fpm|mysql)'You should see Nginx, PHP-FPM, and MariaDB running, with the web server listening on port 80. If you later add TLS, repeat the same check for port 443.
On your local computer
curl -I http://example.com
curl -I http://staging.example.comThe production host should return a normal HTTP response, and the staging host should return 401 Unauthorized until you supply the basic-auth credentials. That confirms the staging barrier is working.
Finally, reboot the server and confirm the services come back automatically.
On the VPS as the non-root sudo user
doas rebootAfter the server returns, reconnect with SSH and rerun the status commands. The site should still answer on the same hostnames after the reboot.
Troubleshooting the most likely failures
1. Nginx will not start. Run doas nginx -t first. If the output points to a syntax error or an unknown directive, open the referenced file and fix the line number before reloading.
2. WordPress shows a database connection error. Check the credentials in wp-config.php and test the account with doas mysql -u wpadmin -p -e "SHOW DATABASES;". If that fails, reset the password and update the config file.
3. Staging prompts for basic auth but the browser still shows the live site. Confirm the hostname resolves to the right server with host staging.example.com from your local computer. A stale DNS record is a common cause during migrations.
4. Plugin updates break the site. Restore /usr/local/www/wordpress-live/wp-config.php.bak and your database backup, then disable the last plugin you changed inside the staging copy first. This is why the tutorial keeps staging and live separate.
Where Hostperl fits this workflow
Hostperl is a good fit when you need a WordPress-ready server you can manage directly, test safely, and roll back without waiting on a ticket queue. For smaller production sites, a managed VPS hosting plan is usually the simplest starting point. For larger stores or agencies handling multiple client cutovers, a dedicated deployment path can be the better fit.
If your team also works with content-heavy stores, the WooCommerce cart recovery article pairs well with this tutorial because it follows the same staging-first approach.
Use this staging pattern before every major WordPress plugin, theme, or PHP upgrade. Hostperl can provide the VPS foundation for that workflow, and the same cutover discipline applies whether you are launching a single site or a client portfolio. If you want a server you can control end to end, start with Hostperl VPS or scale up to a larger platform when your checkout traffic grows.
FAQ
Can I use this on a live FreeBSD WordPress server without staging?
Yes, but you should not. Staging lets you test updates and database imports before customers see them.
Why separate databases instead of copying the live database over the top?
Separate databases make rollback faster and reduce the chance of overwriting live content during a test cycle.
Do I need TLS before going live?
Yes. This tutorial keeps the focus on staging and cutover, but you should add Let’s Encrypt or another certificate before production traffic is public.
What if my theme depends on hardcoded URLs?
Search and replace the old domain inside the staging database first, then recheck menus, images, and checkout links before cutover.
