WordPress in GitOps with Bedrock on an HMS VPS
This guide explains how to host a WordPress site on a HostMyServers VPS and manage it in GitOps fashion using Bedrock, the WordPress boilerplate from Roots. Your Git repository becomes the source of truth: the WordPress core, plugins and themes are declared as code, versioned, then automatically deployed to your VPS on every git push.
A VPS gives you root access, SSH and full control of the stack (Nginx, PHP-FPM, MariaDB) that this deployment method requires, which shared hosting does not allow.
A classic WordPress install mixes code (core, plugins, themes), configuration (wp-config.php) and data (uploads) in the same folder, and updates itself from the admin interface. This makes it hard to version and reproduce. Bedrock brings:
- Composer to manage the WordPress core, plugins and themes as dependencies (versions locked in
composer.lock) - Configuration via environment variables (
.envfile), with no secrets in Git - Per-environment configuration files (development, staging, production)
- A safer folder structure: only the
web/folder is exposed by the web server - Admin-side modifications disabled in production (
DISALLOW_FILE_MODS)
Order a HostMyServers VPS
This guide is designed for a HostMyServers VPS. Choose the plan according to your traffic:
- NVMe VPS - Excellent value for money, ideal for starting a showcase site or a blog
- Performance VPS - Recommended for WooCommerce stores and high-traffic sites
| Usage | vCPU | RAM | Storage |
|---|---|---|---|
| Showcase site / blog | 1-2 | 2 GB | 20 GB NVMe |
| Medium-traffic site, several sites | 2-4 | 4 GB | 40 GB NVMe |
| WooCommerce / high traffic | 4+ | 8 GB+ | 80 GB NVMe+ |
When ordering, select Ubuntu 24.04 LTS or Debian 12 as the operating system. Once the VPS is delivered, the IP address and root credentials are sent to you by e-mail and are available in your HostMyServers client area.
For several high-traffic sites, the same procedure applies to Eco and Performance dedicated servers.
The GitOps principle applied to WordPress
| Element | Where it lives | Versioned in Git? |
|---|---|---|
| WordPress core, public plugins and themes | composer.json + composer.lock | Yes (declaration + exact versions) |
| Theme and plugins you develop | web/app/themes/, web/app/plugins/ | Yes (source code) |
| Configuration (constants, environments) | config/ | Yes |
| Secrets (passwords, keys, salts) | .env on each server | No |
| Media (uploads) | web/app/uploads/ on the server | No (to be backed up) |
| Content (posts, pages, settings) | MySQL/MariaDB database | No (to be backed up) |
The life cycle becomes:
- You change the code or update a dependency locally
- You commit and push to the
mainbranch - The CI pipeline installs the dependencies and deploys a new release to the server
- If there's a problem, you revert to the previous release (
git revertor a symlink switch)
The database and media are data, not code. They are not managed by Git: set up regular backups (wp db export, backing up the shared/uploads folder, VPS snapshots).
Prerequisites
On your development machine:
- Git
- PHP ≥ 8.1 (8.3 recommended) and Composer 2
- A GitHub (or GitLab) account to host the repository
On your HMS VPS (Ubuntu 24.04 LTS / Debian 12):
- Root SSH access or a user with sudo
- A hardened VPS (non-root user, SSH, firewall): see Securing your server
- A domain name whose
ADNS record points to the VPS's IP address
In this guide, composer install is run by the CI pipeline. The VPS only receives files that are already ready: it needs neither Composer, nor Git, nor access to package repositories.
Step 1: create the Bedrock project locally
-
Create the project:
composer create-project roots/bedrock mon-sitecd mon-site -
Explore the folder structure:
mon-site/├── composer.json # WordPress core, plugins and themes declared├── composer.lock # Exact versions installed├── .env.example # Configuration template├── wp-cli.yml # Tells WP-CLI where WordPress is located (web/wp)├── config/│ ├── application.php # Main configuration (replaces wp-config.php)│ └── environments/│ ├── development.php│ └── staging.php├── vendor/ # Composer dependencies (not versioned)└── web/ # Web server root (document root)├── app/ # Equivalent of wp-content│ ├── mu-plugins/│ ├── plugins/│ ├── themes/│ └── uploads/├── wp/ # WordPress core (not versioned)├── wp-config.php└── index.phpinfoWith Bedrock, the admin panel is located at
https://your-domain.com/wp/wp-adminandwp-contentis replaced byweb/app. -
Create your local
.envfile from the template:cp .env.example .env.envDB_NAME='wordpress_dev'DB_USER='wordpress_user'DB_PASSWORD='local_password'DB_HOST='localhost'DB_PREFIX='wp_'WP_ENV='development'WP_HOME='http://mon-site.test'WP_SITEURL="${WP_HOME}/wp"AUTH_KEY='generateme'SECURE_AUTH_KEY='generateme'LOGGED_IN_KEY='generateme'NONCE_KEY='generateme'AUTH_SALT='generateme'SECURE_AUTH_SALT='generateme'LOGGED_IN_SALT='generateme'NONCE_SALT='generateme' -
Generate unique keys and salts (to be done for every environment):
for k in AUTH_KEY SECURE_AUTH_KEY LOGGED_IN_KEY NONCE_KEY AUTH_SALT SECURE_AUTH_SALT LOGGED_IN_SALT NONCE_SALT; doecho "$k='$(openssl rand -base64 48 | tr -d '\n')'"doneCopy the result into your
.env, replacing thegeneratemelines. You can also use the Roots generator.
To run the site locally, use the tool of your choice (DDEV, Lando, Laravel Valet, Docker…) pointing the web root at the web/ folder.
Step 2: manage plugins and themes with Composer
Bedrock is preconfigured with WPackagist, a Composer mirror of every plugin and theme from the official WordPress.org directory.
-
Install a plugin or theme:
composer require wpackagist-plugin/wordpress-seocomposer require wpackagist-plugin/wp-mail-smtpcomposer require wpackagist-theme/twentytwentyfiveThe package name matches the WordPress.org slug (
https://wordpress.org/plugins/<slug>/). -
Update the dependencies:
# Update everything according to the constraints in composer.jsoncomposer update# Update only the WordPress corecomposer update roots/wordpress --with-all-dependencies# See available updatescomposer outdated -
Remove a plugin:
composer remove wpackagist-plugin/wp-mail-smtp
The composer.lock file is what guarantees that production installs exactly the same versions that were tested locally. It must always be versioned.
Premium or private plugins
Paid plugins are not on WPackagist. Two solutions:
-
Recommended: use the Composer repository provided by the vendor (ACF Pro, Gravity Forms, WPML… all offer one) with an authentication key stored in
auth.json(not versioned) and in your CI's secrets. -
Alternative: version the plugin directly in the repository by adding an exception to
.gitignore:.gitignoreweb/app/plugins/*!web/app/plugins/.gitkeep!web/app/plugins/mon-plugin-premium
Your own theme (in web/app/themes/) is versioned by default.
Step 3: adjust the configuration
The shared configuration is in config/application.php. Per-environment overrides are in config/environments/<WP_ENV>.php. By default, Bedrock:
- disables the file editor and plugin installation / updates from the admin panel (
DISALLOW_FILE_MODS) in production - allows them in
development - hides PHP errors in production
This behavior is at the heart of GitOps: in production, every code change goes through Git. If a plugin shows "update available", perform the update with Composer locally, test it, then commit.
Example of adding a constant for all environments:
Config::define('WP_POST_REVISIONS', 10);
Config::define('WP_MEMORY_LIMIT', '256M');
Step 4: initialize the Git repository
Bedrock ships with a suitable .gitignore: vendor/, web/wp/, plugins installed by Composer, uploads and the .env file are ignored.
git init -b main
git add .
git commit -m "Initial Bedrock project"
git remote add origin git@github.com:your-account/mon-site.git
git push -u origin main
Check that no secret is versioned:
git ls-files | grep -E '(^|/)\.env$|auth\.json' || echo "OK : aucun secret versionné"
Step 5: prepare the HMS VPS
Connect to the VPS
Connect using the IP address and credentials received upon delivery:
ssh root@vps_ip_address
Install Nginx, PHP-FPM and MariaDB
sudo apt update && sudo apt upgrade -y
sudo apt install -y nginx mariadb-server \
php-fpm php-mysql php-curl php-gd php-intl php-mbstring php-xml php-zip php-imagick
php -v
Ubuntu 24.04 installs PHP 8.3 (Debian 12: PHP 8.2). Throughout the rest of the guide, adapt php8.3-fpm to the version shown by php -v. To secure MariaDB, follow the Install and secure MariaDB guide.
If the UFW firewall is active, open the web ports:
sudo ufw allow 'Nginx Full'
Create the deployment user and folder structure
The pipeline will connect with a dedicated deploy user. The code belongs to deploy and is read-only for PHP (www-data); only the uploads folder is writable by PHP.
sudo adduser --disabled-password --gecos "" deploy
sudo usermod -aG www-data deploy
sudo mkdir -p /var/www/mon-site/{releases,shared/uploads}
sudo chown -R deploy:www-data /var/www/mon-site
sudo chown -R www-data:www-data /var/www/mon-site/shared/uploads
sudo chmod 2775 /var/www/mon-site/shared/uploads
The deployment folder structure will be as follows:
/var/www/mon-site/
├── current -> releases/<sha> # Symlink pointing to the active release
├── releases/
│ ├── 3f2a9c1.../ # One release per deployed commit
│ └── 8be41d0.../
└── shared/
├── .env # Production configuration (outside Git)
└── uploads/ # Media persistent across releases
Create the database
sudo mysql -u root -p
CREATE DATABASE wordpress_prod DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'wordpress_user'@'localhost' IDENTIFIED BY 'secure_password';
GRANT ALL PRIVILEGES ON wordpress_prod.* TO 'wordpress_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;
Create the production .env file
sudo -u deploy nano /var/www/mon-site/shared/.env
DB_NAME='wordpress_prod'
DB_USER='wordpress_user'
DB_PASSWORD='secure_password'
DB_HOST='localhost'
DB_PREFIX='wp_'
WP_ENV='production'
WP_HOME='https://your-domain.com'
WP_SITEURL="${WP_HOME}/wp"
# Keys and salts generated with the openssl command from step 1
AUTH_KEY='...'
SECURE_AUTH_KEY='...'
LOGGED_IN_KEY='...'
NONCE_KEY='...'
AUTH_SALT='...'
SECURE_AUTH_SALT='...'
LOGGED_IN_SALT='...'
NONCE_SALT='...'
sudo chown deploy:www-data /var/www/mon-site/shared/.env
sudo chmod 640 /var/www/mon-site/shared/.env
Configure Nginx
The web root points to current/web: the code, vendor/ and .env are never exposed.
server {
listen 80;
server_name your-domain.com www.your-domain.com;
root /var/www/mon-site/current/web;
index index.php;
client_max_body_size 64M;
location / {
try_files $uri $uri/ /index.php?$args;
}
# Prevent PHP execution inside uploads
location ~* ^/app/uploads/.*\.php$ {
deny all;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
# Resolve the "current" symlink on every request
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
}
location ~ /\. {
deny all;
}
}
sudo ln -s /etc/nginx/sites-available/mon-site /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
Using $realpath_root (instead of $document_root) ensures that PHP-FPM loads files from the new release as soon as the current link is switched, without serving cached old paths.
Then enable HTTPS, for example with Certbot (see also Install an SSL certificate):
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
Allow reloading PHP-FPM
The pipeline reloads PHP-FPM after each deployment to flush OPcache. Authorize only this command for the deploy user:
echo 'deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload php8.3-fpm' | sudo tee /etc/sudoers.d/deploy
sudo chmod 440 /etc/sudoers.d/deploy
sudo visudo -c
Create the deployment SSH key
On your machine, generate a key pair dedicated to the pipeline:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./deploy_key -N ""
Add the public key to the VPS:
sudo mkdir -p /home/deploy/.ssh
sudo nano /home/deploy/.ssh/authorized_keys # paste the contents of deploy_key.pub
sudo chown -R deploy:deploy /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh && sudo chmod 600 /home/deploy/.ssh/authorized_keys
Retrieve the VPS's SSH fingerprint (to prevent any man-in-the-middle attack):
ssh-keyscan -H vps_ip_address
Step 6: automate deployment with GitHub Actions
Declare the secrets
In your GitHub repository, go to Settings → Secrets and variables → Actions and create:
| Secret | Value |
|---|---|
SSH_HOST | IP address of your HMS VPS |
SSH_USER | deploy |
SSH_PRIVATE_KEY | Contents of the deploy_key file (private key) |
SSH_KNOWN_HOSTS | Output of the ssh-keyscan command |
Then delete the deploy_key and deploy_key.pub files from your machine.
Create the workflow
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: production
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
env:
BASE_DIR: /var/www/mon-site
TARGET: ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2
- name: Install dependencies
run: composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
- name: Configure SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
echo "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/id_ed25519
- name: Send the release
run: |
rsync -az --delete \
--exclude='.git' --exclude='.github' \
--exclude='.env' --exclude='web/app/uploads' \
./ "$TARGET:$BASE_DIR/releases/$GITHUB_SHA/"
- name: Activate the release
run: |
ssh "$TARGET" bash -s -- "$BASE_DIR" "$GITHUB_SHA" <<'EOF'
set -euo pipefail
BASE_DIR="$1"
RELEASE="$BASE_DIR/releases/$2"
# Link the shared configuration and media
ln -sfn "$BASE_DIR/shared/.env" "$RELEASE/.env"
rm -rf "$RELEASE/web/app/uploads"
ln -sfn "$BASE_DIR/shared/uploads" "$RELEASE/web/app/uploads"
# Atomic switch of the "current" symlink
ln -sfn "$RELEASE" "$BASE_DIR/current.tmp"
mv -Tf "$BASE_DIR/current.tmp" "$BASE_DIR/current"
# Flush OPcache
sudo systemctl reload php8.3-fpm
# Keep the last 5 releases
cd "$BASE_DIR/releases"
ls -1t | tail -n +6 | xargs -r rm -rf
EOF
Commit and push this file: the first deployment starts automatically. Follow its progress in the repository's Actions tab.
The same principle applies with GitLab CI: a composer:2 image for composer install, then rsync and ssh with protected CI/CD variables instead of GitHub secrets.
First run
Once the first deployment succeeds, install WordPress. Either via the browser at https://your-domain.com/wp/wp-admin/install.php, or with WP-CLI from the server:
cd /var/www/mon-site/current
sudo -u www-data wp core install \
--url="https://your-domain.com" \
--title="Your Site Title" \
--admin_user="admin" \
--admin_password="StrongPassword123!" \
--admin_email="your@email.com"
See the Install WordPress with WP-CLI guide to install WP-CLI on the server.
XML sitemap and SEO
WordPress natively generates a sitemap at https://your-domain.com/wp-sitemap.xml. With Bedrock and the Nginx configuration above (try_files ... /index.php?$args), it works with no extra setup. Verify it:
curl -sI https://your-domain.com/wp-sitemap.xml | head -n 1
If you install an SEO plugin via Composer (for example composer require wpackagist-plugin/wordpress-seo), it replaces the native sitemap with its own (/sitemap_index.xml for Yoast SEO). The sitemap remains dynamically generated from the database: there is nothing to version in Git.
Then remember to:
- Declare the sitemap URL in Google Search Console and Bing Webmaster Tools
- Check that Settings → Reading → Search engine visibility is not checked in production
Bedrock includes the bedrock-disallow-indexing mu-plugin: on an environment where WP_ENV is staging or development, the site asks search engines not to index it (DISALLOW_INDEXING). Only production therefore appears in search results.
Day-to-day work
Update WordPress and plugins
composer update
# Test the site locally, then:
git add composer.json composer.lock
git commit -m "chore: update WordPress and plugins"
git push
Deployment happens automatically. To be notified of new versions, enable Dependabot (composer ecosystem) or Renovate on the repository: they will open update pull requests that you'll only need to approve.
Using a staging branch
To validate changes before production, create a second environment (subdomain staging.your-domain.com, separate folder, separate database, WP_ENV='staging') and duplicate the workflow, triggering it on a staging branch. The config/environments/staging.php file then applies automatically.
Rolling back
GitOps method (recommended): revert the offending commit, the pipeline redeploys the previous state.
git revert <commit_sha>
git push
Emergency method: manually switch the current link back to the previous release, directly on the server.
cd /var/www/mon-site
ls -1t releases/ # identify the previous release
ln -sfn "$PWD/releases/<previous_sha>" current.tmp && mv -Tf current.tmp current
sudo systemctl reload php8.3-fpm
A code rollback does not restore the database. If an update changed the database schema, also restore an SQL backup taken before the deployment.
Migrating an existing WordPress site to Bedrock
-
List the plugins and themes installed on the old site:
wp plugin listandwp theme list -
Add them to the Bedrock project with
composer require wpackagist-plugin/<slug>(and version your own custom development) -
Copy the old
wp-content/uploads/folder into/var/www/mon-site/shared/uploads/ -
Import the database, then fix the media paths, which change from
wp-contenttoapp:cd /var/www/mon-site/currentsudo -u www-data wp db import /path/to/backup.sqlsudo -u www-data wp search-replace '/wp-content/uploads' '/app/uploads' --all-tables -
If the table prefix is not
wp_, adjustDB_PREFIXin the.env
Do a trial run with --dry-run before any wp search-replace to check the number of replacements.
Best practices
- Never modify files directly on the server: everything goes through Git
- Always commit
composer.lockand pin sensitive versions incomposer.json - Keep secrets out of the repository (
.env,auth.json) and use CI secrets - Protect the
mainbranch (mandatory pull request review) - Regularly back up the database and the
shared/uploadsfolder, and supplement with your VPS's snapshots/backups - Deploy to a staging environment first
Troubleshooting
- Blank page or 500 error: check the
sudo tail -f /var/log/nginx/error.logand/var/log/php8.3-fpm.loglogs, as well as the presence of the.envsymlink in the active release - Database connection error: check the credentials in the
shared/.envfile - The old version still shows after deployment: check that PHP-FPM was actually reloaded and that Nginx uses
$realpath_root - Unable to upload media: check that
shared/uploadsbelongs towww-dataand that theweb/app/uploadslink points to it correctly - The pipeline fails to connect over SSH: check the
SSH_PRIVATE_KEYandSSH_KNOWN_HOSTSsecrets, and testssh deploy@vps_ip_addressfrom your machine - No plugin installation from the admin panel: this is expected in production (
DISALLOW_FILE_MODS), use Composer instead