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!