How We Included WordPress in Our Google Cloud Migration
The approach and code snippets in this Google Cloud migration article reflect our use of macOS. If you are using Windows, Linux, etc., ensure that you source the correct configuration for your system.
When we decided to move our corporate website, xmatters.com, to Google Cloud Platform (GCP) more than a year ago, we also wanted to set up a pipeline for deployment. Specifically, our goals were to:
- Have a repeatable, controlled deployment process
- Automatically deploy to different environments
- Improve stability and performance
- Use engineering best practices (for us, this meant a corporate site where we could demo and test on a dev and staging server that was the same as production)
Both the Google Cloud migration and our goals presented a number of challenges. So if you’re looking to make a similar website move to GCP, you might save some time (and frustration!) by learning about our approach – and by liberally borrowing from the technical solutions we used to achieve our migration goals.
Dancing With Docker
The problem:
Using the default WordPress Docker image, we initially had a very complicated Docker file using gcsfuse for storage and running cloudsql proxy locally… and that was all really slow. We didn’t want to use a docker-compose.yml file because it was overkill – for example, GCP already handles our SQL/DB needs on the web. So, we simply installed MySQL to our machines for local development.
The solution:
Using GCP required us to add our credentials to the wp-config.php file on WordPress. Problematically, the WordPress Docker image creates the wp-config file but doesn’t have a very straightforward way to modify it without using a docker-compose.yml file…which we didn’t want to use. Ultimately, the solution was to modify the base Docker image file. That was a bit hacky and less-than-pretty, but it’s far less bloated and more effective than the alternative.
Technical details:
Our Docker image is based on the default WordPress image with some minor tweaks: we load from the standard WordPress:{version}-php:{version} image but add the entrypoint manually so we can enter our GOOGLE_APPLICATION_CREDENTIALS into wp-config.php:
sed -i "39i putenv('GOOGLE_APPLICATION_CREDENTIALS=/credentials/creds.json');" wp-config.php
We use entrypoint.sh from the base Docker WordPress image, adding our gcloud authentication so we can access our cloud SQL/DB.
All of our custom code is in a custom WordPress theme, so we check this into Git and handle the plugin installation with Composer and wpackagist.
#!/bin/bash
#!/bin/bash
set -euo pipefail
# usage: file_env VAR [DEFAULT]
# ie: file_env 'XYZ_DB_PASSWORD' 'example'
# (will allow for "$XYZ_DB_PASSWORD_FILE" to fill in the value of
# "$XYZ_DB_PASSWORD" from a file, especially for Docker's secrets feature)
file_env() {
local var="$1"
local fileVar="${var}_FILE"
local def="${2:-}"
if [ "${!var:-}" ] && [ "${!fileVar:-}" ]; then
echo >&2 "error: both $var and $fileVar are set (but are exclusive)"
exit 1
fi
local val="$def"
if [ "${!var:-}" ]; then
val="${!var}"
elif [ "${!fileVar:-}" ]; then
val="$(< "${!fileVar}")"
fi
export "$var"="$val"
unset "$fileVar"
}
if [[ "$1" == apache2* ]] || [ "$1" == php-fpm ]; then
if ! [ -e index.php -a -e wp-includes/version.php ]; then
echo >&2 "WordPress not found in $PWD - copying now..."
if [ "$(ls -A)" ]; then
echo >&2 "WARNING: $PWD is not empty - press Ctrl+C now if this is an error!"
( set -x; ls -A; sleep 10 )
fi
tar cf - --one-file-system -C /usr/src/wordpress . | tar xf -
echo >&2 "Complete! WordPress has been successfully copied to $PWD"
if [ ! -e .htaccess ]; then
# NOTE: The "Indexes" option is disabled in the php:apache base image
cat > .htaccess <<-'EOF'
# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPress
EOF
chown www-data:www-data .htaccess
fi
fi
# TODO handle WordPress upgrades magically in the same way, but only if wp-includes/version.php's $wp_version is less than /usr/src/wordpress/wp-includes/version.php's $wp_version
# allow any of these "Authentication Unique Keys and Salts." to be specified via
# environment variables with a "WORDPRESS_" prefix (ie, "WORDPRESS_AUTH_KEY")
uniqueEnvs=(
AUTH_KEY
SECURE_AUTH_KEY
LOGGED_IN_KEY
NONCE_KEY
AUTH_SALT
SECURE_AUTH_SALT
LOGGED_IN_SALT
NONCE_SALT
)
envs=(
WORDPRESS_DB_HOST
WORDPRESS_DB_USER
WORDPRESS_DB_PASSWORD
WORDPRESS_DB_NAME
"${uniqueEnvs[@]/#/WORDPRESS_}"
WORDPRESS_TABLE_PREFIX
WORDPRESS_DEBUG
)
haveConfig=
for e in "${envs[@]}"; do
file_env "$e"
if [ -z "$haveConfig" ] && [ -n "${!e}" ]; then
haveConfig=1
fi
done
# linking backwards-compatibility
if [ -n "${!MYSQL_ENV_MYSQL_*}" ]; then
haveConfig=1
# host defaults to "mysql" below if unspecified
: "${WORDPRESS_DB_USER:=${MYSQL_ENV_MYSQL_USER:-root}}"
if [ "$WORDPRESS_DB_USER" = 'root' ]; then
: "${WORDPRESS_DB_PASSWORD:=${MYSQL_ENV_MYSQL_ROOT_PASSWORD:-}}"
else
: "${WORDPRESS_DB_PASSWORD:=${MYSQL_ENV_MYSQL_PASSWORD:-}}"
fi
: "${WORDPRESS_DB_NAME:=${MYSQL_ENV_MYSQL_DATABASE:-}}"
fi
# only touch "wp-config.php" if we have environment-supplied configuration values
if [ "$haveConfig" ]; then
: "${WORDPRESS_DB_HOST:=mysql}"
: "${WORDPRESS_DB_USER:=root}"
: "${WORDPRESS_DB_PASSWORD:=}"
: "${WORDPRESS_DB_NAME:=wordpress}"
# version 4.4.1 decided to switch to windows line endings, that breaks our seds and awks
# https://github.com/docker-library/wordpress/issues/116
# https://github.com/WordPress/WordPress/commit/1acedc542fba2482bab88ec70d4bea4b997a92e4
sed -ri -e 's/\r$//' wp-config*
if [ ! -e wp-config.php ]; then
awk '/^\/\*.*stop editing.*\*\/$/ && c == 0 { c = 1; system("cat") } { print }' wp-config-sample.php > wp-config.php <<'EOPHP'
// If we're behind a proxy server and using HTTPS, we need to alert WordPress of that fact
// see also http://codex.wordpress.org/Administration_Over_SSL#Using_a_Reverse_Proxy
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$_SERVER['HTTPS'] = 'on';
}
EOPHP
chown www-data:www-data wp-config.php
fi
# see http://stackoverflow.com/a/2705678/433558
sed_escape_lhs() {
echo "$@" | sed -e 's/[]\/$*.^|[]/\\&/g'
}
sed_escape_rhs() {
echo "$@" | sed -e 's/[\/&]/\\&/g'
}
php_escape() {
php -r 'var_export(('$2') $argv[1]);' -- "$1"
}
set_config() {
key="$1"
value="$2"
var_type="${3:-string}"
start="(['\"])$(sed_escape_lhs "$key")\2\s*,"
end="\);"
if [ "${key:0:1}" = '$' ]; then
start="^(\s*)$(sed_escape_lhs "$key")\s*="
end=";"
fi
sed -ri -e "s/($start\s*).*($end)$/\1$(sed_escape_rhs "$(php_escape "$value" "$var_type")")\3/" wp-config.php
}
set_config 'DB_HOST' "$WORDPRESS_DB_HOST"
set_config 'DB_USER' "$WORDPRESS_DB_USER"
set_config 'DB_PASSWORD' "$WORDPRESS_DB_PASSWORD"
set_config 'DB_NAME' "$WORDPRESS_DB_NAME"
for unique in "${uniqueEnvs[@]}"; do
uniqVar="WORDPRESS_$unique"
if [ -n "${!uniqVar}" ]; then
set_config "$unique" "${!uniqVar}"
else
# if not specified, let's generate a random value
currentVal="$(sed -rn -e "s/define\((([\'\"])$unique\2\s*,\s*)(['\"])(.*)\3\);/\4/p" wp-config.php)"
if [ "$currentVal" = 'put your unique phrase here' ]; then
set_config "$unique" "$(head -c1m /dev/urandom | sha1sum | cut -d' ' -f1)"
fi
fi
done
if [ "$WORDPRESS_TABLE_PREFIX" ]; then
set_config '$table_prefix' "$WORDPRESS_TABLE_PREFIX"
fi
if [ "$WORDPRESS_DEBUG" ]; then
set_config 'WP_DEBUG' 1 boolean
fi
TERM=dumb php -- <<'EOPHP'
<?php
// database might not exist, so let's try creating it (just to be safe)
$stderr = fopen('php://stderr', 'w');
// https://codex.wordpress.org/Editing_wp-config.php#MySQL_Alternate_Port
// "hostname:port"
// https://codex.wordpress.org/Editing_wp-config.php#MySQL_Sockets_or_Pipes
// "hostname:unix-socket-path"
list($host, $socket) = explode(':', getenv('WORDPRESS_DB_HOST'), 2);
$port = 0;
if (is_numeric($socket)) {
$port = (int) $socket;
$socket = null;
}
$user = getenv('WORDPRESS_DB_USER');
$pass = getenv('WORDPRESS_DB_PASSWORD');
$dbName = getenv('WORDPRESS_DB_NAME');
$maxTries = 10;
do {
$mysql = new mysqli($host, $user, $pass, '', $port, $socket);
if ($mysql->connect_error) {
fwrite($stderr, "\n" . 'MySQL Connection Error: (' . $mysql->connect_errno . ') ' . $mysql->connect_error . "\n");
--$maxTries;
if ($maxTries <= 0) {
exit(1);
}
sleep(3);
}
} while ($mysql->connect_error);
if (!$mysql->query('CREATE DATABASE IF NOT EXISTS `' . $mysql->real_escape_string($dbName) . '`')) {
fwrite($stderr, "\n" . 'MySQL "CREATE DATABASE" Error: ' . $mysql->error . "\n");
$mysql->close();
exit(1);
}
$mysql->close();
EOPHP
fi
# now that we're definitely done writing configuration, let's clear out the relevant environment variables (so that stray "phpinfo()" calls don't leak secrets from our code)
for e in "${envs[@]}"; do
unset "$e"
done
fi
sed -i "39i putenv('GOOGLE_APPLICATION_CREDENTIALS=/credentials/creds.json');" wp-config.php
touch /var/www/html/health.html
exec "$@"
We have a DOCKERFILE that doesn’t download the latest WP version – we do that manually so no breaks occur:
FROM wordpress:5.4.1-php7.3
RUN apt-get update && \
apt-get -y upgrade && \
apt-get -y install sudo && \
rm -rf /var/lib/apt/lists/*
EXPOSE 80
EXPOSE 443
RUN a2enmod ssl && \
a2enmod headers && \
a2ensite default-ssl
RUN touch /var/log/apache2/php_err.log && \
chown www-data:www-data /var/log/apache2/php_err.log
COPY ./php/php_error.ini /usr/local/etc/php/conf.d/php_error.ini
COPY ./src/.htaccess /usr/src/wordpress/.htaccess
COPY ./src/robots.txt /usr/src/wordpress/robots.txt
COPY ./build/wp-content/ /usr/src/wordpress/wp-content/
COPY entrypoint.sh /usr/local/bin/
RUN chmod 777 /usr/local/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
CMD ["apache2-foreground"]
Next, we add a Makefile to create the project, push to gcloud, and run composer-update to install plugins.
version := $(shell git rev-parse --short HEAD)
gcr_proj := xmatters-eng-mgmt
build_number := $$(if [ "$${BUILD_NUMBER}" == "" ]; then BUILD_NUMBER=1; fi; echo $${BUILD_NUMBER})
git_branch := $$(if [ "$${GIT_BRANCH}" == "" ]; then GIT_BRANCH=$$(echo $$(git rev-parse --abbrev-ref HEAD)); fi; echo $${GIT_BRANCH} | sed 's/^origin\/\(remote\/\)\?//g')
git_branch_sanatized := $$(if [ "$${GIT_BRANCH}" == "" ]; then GIT_BRANCH=$$(echo $$(git rev-parse --abbrev-ref HEAD)); fi; echo $${GIT_BRANCH,,} | sed 's/[-\/]/_/g' | sed 's/^origin_\(remote_\)\?//g')
image_name := gcr.io/${gcr_proj}/mktgwebsite
full_image := ${image_name}:${version}
local_port := 8080
create_properties_file:
echo "VERSION=${version}" > gcpBuildVersion.properties
echo "GIT_HASH=${version}" >> gcpBuildVersion.properties
echo "BUILD=${build_number}" >> gcpBuildVersion.properties
echo "BRANCH=${git_branch}" >> gcpBuildVersion.properties
echo "TAG=${version}" >> gcpBuildVersion.properties
docker-image: create_properties_file
docker build --squash -t "${full_image}" .
docker-run: build docker-image
docker run --rm --name wordpress \
-e WORDPRESS_DB_NAME=your_db_name \
-e WORDPRESS_DB_HOST=docker.for.mac.localhost \
-e WORDPRESS_DB_USER=root \
-e WORDPRESS_DB_PASSWORD= \
-e WORDPRESS_TABLE_PREFIX=wp_ \
-v ${PWD}/build/wp-content:/var/www/html/wp-content \
-v ${PWD}/../credentials:/credentials \
-p 80:80 -p 443:443 "${full_image}"
docker-push: docker-image
docker push "${image_name}:${version}"
composer-update: grunt-build
docker pull composer:1.6 && docker run --rm -v `pwd`:`pwd` -w `pwd` composer:1.6 update -vvv
grunt-build: create-build-image
docker run --rm -v `pwd`:`pwd` -w `pwd` xm_corp_site/build /bin/bash -c "grunt build:release"
create-build-image:
docker build -t xm_corp_site/build ./build-env/
clean:
rm -rf build/*
build: composer-update
build/:
mkdir -p build
We make a Composer file for handling our plugins. Fortunately, most of the plugins our site uses were available on wpackagist… for the ones that aren’t, we have them checked into Git. Yes, manually updating plugins sux. 🙁
We have a Gruntfile.js file to handle creating the build/ directory after Docker starts. We run our new build:release command which recreates the build folder completely, including all the plugins, and we have a build:dev command for local development. Build:dev doesn’t remove the plugins (which take a while to reinstall). We use Sass and JS, and compile and minify them using Grunt to keep the code base clean and centralized.
'use strict';
module.exports = function(grunt) {
var buildThemePath ='build/wp-content/themes/theme/';
var GruntConfig = {
clean:{
release: ['build/wp-content/*'],
dev: ['build/wp-content/themes/*']
},
uglify: {
options: {
mangle: true,
compress: true,
warnings: false,
preserveComments: false
},
release: {
files: {
'build/wp-content/themes/theme/js/app.js': ['src/themes/theme/js/app.js'],
}
}
},
copy: {
main: {
files: [{
expand: true,
cwd: 'src/themes/theme',
src: ['**/*', '!.sass-cache/*', '!sass', '!sass/**', '!js/**', '!js_src/**'],
dest: buildThemePath ,
nonull: true
},{
expand: true,
cwd: 'src/themes/theme/js/',
src: ['**/*'],
dest: buildThemePath + 'js/',
nonull: true
},{
expand: true,
cwd: 'src/plugins',
src: ['**/*'],
dest: 'build/wp-content/plugins/' ,
nonull: true
}]
}
},
autoprefixer: {
options: {
browsers: ['Last 2 versions', '> 0.5%', 'ie 10', 'firefox 54', 'safari 10'],
safe: true,
diff: false
},
multiple_files: {
expand: true,
flatten: true,
src: 'src/themes/theme/css/*.css',
dest: 'src/themes/theme/css/'
}
},
sass: {
options: {
sourceMap: true,
outputStyle: 'expanded',
sourceComments: false
},
dist: {
files: {
'src/themes/theme/css/styles.css': 'src/themes/theme/sass/styles.scss',
'build/wp-content/themes/theme/css/styles.css': 'src/themes/theme/sass/styles.scss',
},
}
},
cssmin: {
target: {
files: {
'build/wp-content/themes/theme/css/styles.css' : 'src/themes/theme/css/styles.css'
}
}
},
concat: {
options: {
seperator: ";"
},
dev: {
src: ['src/themes/theme/js_src/_*.js', 'src/themes/theme/js_src/app.js'],
dest: 'src/themes/theme/js/app.js',
nonull: true
},
release: {
src: ['src/themes/theme/js_src/_*.js', 'src/themes/theme/js_src/app.js'],
dest: 'src/themes/theme/js/app.js',
nonull: true
}
},
};
grunt.initConfig(GruntConfig);
grunt.loadNpmTasks('grunt-sass');
grunt.loadNpmTasks('grunt-contrib-concat');
grunt.loadNpmTasks('grunt-contrib-clean');
grunt.loadNpmTasks('grunt-contrib-copy');
grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.loadNpmTasks('grunt-autoprefixer');
grunt.loadNpmTasks('grunt-contrib-cssmin');
//tasks
grunt.registerTask('build:dev', ['clean:dev', 'copy', 'concat:dev', 'sass', 'uglify']);
grunt.registerTask('build:release', ['clean:release', 'copy', 'concat:release', 'sass', 'autoprefixer', 'cssmin', 'uglify']);
grunt.registerTask('build', ['build:dev']);
grunt.registerTask('default', ['build:dev']);
};
We make a couple of jenkinsfiles that control our pipeline deployment. I won’t go into the details of this but in a nutshell, we set up YAML templates and connect with Stash.
GCP connection
We connect to the Google Cloud SQL instance using cloud SQL proxy.
CDN image hosting
Lastly, to host our images on a Content Delivery Network (CDN), we use the WP-Stateless plugin to connect to our GCP bucket.
Migrate-ness!
All that upfront setup and configuration work has resulted in some great long-term gains:
- Database is in Cloud SQL and connects through cloudsql proxy container
- Cloud SQL does lots: automatic backups, replication, one-button failover
- We move our content to a CDN using WP-Stateless plugin
- All our Plugins are moved to being installed with Composer
For our unique setup, we created a pipeline that’s leaner than the basic Docker/YML set up, uses GCP for both the DB and the CDN, and automates the installation of plugins, scripts, and styles, and is also easy to use for local development. While your setup is very likely different than ours, you might face some of the same challenges we did. We hope that learning some of the technical details about our Google Cloud migration makes yours just a bit smoother!