Skip to main content

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.

Why Bedrock?

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 (.env file), 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
UsagevCPURAMStorage
Showcase site / blog1-22 GB20 GB NVMe
Medium-traffic site, several sites2-44 GB40 GB NVMe
WooCommerce / high traffic4+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.

Larger projects

For several high-traffic sites, the same procedure applies to Eco and Performance dedicated servers.

The GitOps principle applied to WordPress

ElementWhere it livesVersioned in Git?
WordPress core, public plugins and themescomposer.json + composer.lockYes (declaration + exact versions)
Theme and plugins you developweb/app/themes/, web/app/plugins/Yes (source code)
Configuration (constants, environments)config/Yes
Secrets (passwords, keys, salts).env on each serverNo
Media (uploads)web/app/uploads/ on the serverNo (to be backed up)
Content (posts, pages, settings)MySQL/MariaDB databaseNo (to be backed up)

The life cycle becomes:

  1. You change the code or update a dependency locally
  2. You commit and push to the main branch
  3. The CI pipeline installs the dependencies and deploys a new release to the server
  4. If there's a problem, you revert to the previous release (git revert or a symlink switch)
What stays outside Git

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 A DNS record points to the VPS's IP address
No Composer in production

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

  1. Create the project:

    composer create-project roots/bedrock mon-site
    cd mon-site
  2. 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.php
    info

    With Bedrock, the admin panel is located at https://your-domain.com/wp/wp-admin and wp-content is replaced by web/app.

  3. Create your local .env file from the template:

    cp .env.example .env
    .env
    DB_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'
  4. 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; do
    echo "$k='$(openssl rand -base64 48 | tr -d '\n')'"
    done

    Copy the result into your .env, replacing the generateme lines. You can also use the Roots generator.

Local environment

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.

  1. Install a plugin or theme:

    composer require wpackagist-plugin/wordpress-seo
    composer require wpackagist-plugin/wp-mail-smtp
    composer require wpackagist-theme/twentytwentyfive

    The package name matches the WordPress.org slug (https://wordpress.org/plugins/<slug>/).

  2. Update the dependencies:

    # Update everything according to the constraints in composer.json
    composer update

    # Update only the WordPress core
    composer update roots/wordpress --with-all-dependencies

    # See available updates
    composer outdated
  3. Remove a plugin:

    composer remove wpackagist-plugin/wp-mail-smtp
Always commit composer.lock

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:

    .gitignore
    web/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/application.php
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
/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.

/etc/nginx/sites-available/mon-site
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
$realpath_root

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:

SecretValue
SSH_HOSTIP address of your HMS VPS
SSH_USERdeploy
SSH_PRIVATE_KEYContents of the deploy_key file (private key)
SSH_KNOWN_HOSTSOutput of the ssh-keyscan command

Then delete the deploy_key and deploy_key.pub files from your machine.

Create the workflow

.github/workflows/deploy.yml
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.

GitLab variant

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
Staging not indexed

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
caution

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

  1. List the plugins and themes installed on the old site: wp plugin list and wp theme list

  2. Add them to the Bedrock project with composer require wpackagist-plugin/<slug> (and version your own custom development)

  3. Copy the old wp-content/uploads/ folder into /var/www/mon-site/shared/uploads/

  4. Import the database, then fix the media paths, which change from wp-content to app:

    cd /var/www/mon-site/current
    sudo -u www-data wp db import /path/to/backup.sql
    sudo -u www-data wp search-replace '/wp-content/uploads' '/app/uploads' --all-tables
  5. If the table prefix is not wp_, adjust DB_PREFIX in the .env

tip

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.lock and pin sensitive versions in composer.json
  • Keep secrets out of the repository (.env, auth.json) and use CI secrets
  • Protect the main branch (mandatory pull request review)
  • Regularly back up the database and the shared/uploads folder, 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.log and /var/log/php8.3-fpm.log logs, as well as the presence of the .env symlink in the active release
  • Database connection error: check the credentials in the shared/.env file
  • 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/uploads belongs to www-data and that the web/app/uploads link points to it correctly
  • The pipeline fails to connect over SSH: check the SSH_PRIVATE_KEY and SSH_KNOWN_HOSTS secrets, and test ssh deploy@vps_ip_address from your machine
  • No plugin installation from the admin panel: this is expected in production (DISALLOW_FILE_MODS), use Composer instead