Continuous Deployments with Github Actions for WordPress & WooCommerce

This is part 4 of my article series 25+ Tutorials on How to boost the performance of your WooCommerce store. This article shows you how to get started with automated Wordpress Deployments via GitHub Actions.

For the last couple of years, I have been working with Ruby-based Capistrano to deploy my projects to staging and production environments. In my experience the tool is not  hassle-free but minor problems and hickups are solvable.

Although Capistrano allows you to deploy any given branch, to any given environment at any given time, there’s way more server preparation and maintenance and debugging involved.

Goodbye Capistrano.

Hello GitHub Actions.

In 2019 GitHub launched a new feature called GitHub actions which allows users to automate tests, code validations and so on, based on events which are triggered by interacting with the code repository. Getting started is easy because one can use pre-made actions, shared by the community.

Step 1: The build (our GitHub action)

Let’s create our first GitHub action. Create the folder .github/workflows in our repository. Now create a YAML file in it. Github will automatically parse all workflows and execute them if applicable. Something important to mention here is that the YAML syntax requires exact indentation, otherwise it cannot be processed properly. So just keep that in mind.

.github/workflows/production.yml
name: Deploy Action on: push: branches: - main jobs: deploy: name: Deploy runs-on: ubuntu-latest steps: - name: Cancel Previous Runs uses: "styfle/cancel-workflow-action@0.5.0" with: access_token: ${{ github.token }} - uses: "actions/checkout@v2" - uses: "shivammathur/setup-php@v2" with: php-version: "8.0" extensions: mbstring, intl, gd, zip, xml, mysql - uses: "ramsey/composer-install@v1" with: composer-options: "--optimize-autoloader --ignore-platform-reqs" - name: Run grunt uses: "elstudio/actions-js-build/build@v2" - name: Zip entire project uses: "montudor/action-zip@master" with: args: zip -qq -r ./latest.zip . - name: Send zip to remote server uses: "horochx/deploy-via-scp@master" with: local: "./latest.zip" remote: "/var/www/my-website.com" host: ${{ secrets.PRODUCTION_IP_ADDRESS }} port: "22" user: "deploy" key: ${{ secrets.PRODUCTION_SSH_KEY }} - name: Execute post-deploy.sh uses: "appleboy/ssh-action@master" with: host: ${{ secrets.PRODUCTION_IP_ADDRESS }} username: "deploy" key: ${{ secrets.PRODUCTION_SSH_KEY }} port: "22" script: sh /home/deploy/deploy.sh
  1. The first bit, defines a name for our action and defines that this action will only be executed when we push to our main branch. The runs-on: directive tells GitHub which underlying Virtual Machine it shall use to build your release. It should be the operating system of your production server. You can use ubuntu-latest which uses the last stable LTS distribution or define a specific version ubuntu-18.04 or ubuntu-20.10< and so on.
  2. Then comes the first step. The Cancel Workflows Action cancels queued or running workflows. This way the last one wins.
  3. Afterwards we checkout the whole project into Githubs Virtual Machine.
  4. Then we want to execute composer install. That way we will have all of our projects dependencies in place. For this we use ramsey/composer-install which is a GitHub Action to streamline installation of Composer dependencies in workflows. Here we need to define the PHP Version our project will run with and might need to add PHP extensions, that are required in the composer.json or are required by composer scripts to run. Another way is to ignore platform requirements as also seen above.
  5. Now there’s elstudio/actions-js-build to run build tools like Gulp or Grunt. In part 7 of this tutorial series Optimising Frontend Resources with Perfmatters & Grunt we will add Grunt and the Perfmatters Plugin to our installation in order to process Sass files, join and minify all JS & CSS resources needed by our store. For the moment you can either remove this section, comment this part out with # and later head to part 7.
  6. Now we will zip everything into latest.zip
  7. Which then gets sent to the remote production server via scp. Similarly you can set up more workflows for example a deploy.yml which builds and deploys your code to a staging server. In case you don’t deploy to a single production server but to a stack of servers behind a load balancer you can take care of file replication between the servers with lsyncd or rsync just to name two helpers. Note the ${{ secrets.SECRET_NAME }} syntax here, which is used by GitHub to inject credentials into the Action. You can create and store these in GitHub by opening your repository, clicking Settings and then chose Secrets from the menu. 

    Note that we need to provide a username, an SSH key and of course the servers’ IP address in order for having GitHub interact with the server – just like you would when you SSH into your server.  
  8. In the last step we will execute a file called `/home/deploy/post-deploy.sh` on our remote server. This shell script will take care of  unpacking of the zip file, setting file permissions, symlinking asset folders and finally reloading our server. Before looking at the post-deploy.sh 

For all of this magic to happen, we need to make sure a) the deploy user exists, b) we create the Github secrets c) the destination folders exist and d) the post-deploy.sh script is in place.

Step 2: User, Credentials and Destination Folders

I recommend that – similar to working with Capistrano – you create a deploy user, with limited rights and permissions on your server, like this: 

root@remote $
adduser deploy passwd -l deploy sudo su - deploy ssh-keygen -t rsa -C 'deploy@my-website.com'

What we do here is: create a user called deploy, switch to that user and create a public and private SSH Key pair.

Open your repository on Github, click on the Settings tab, then choose Secrets from the menu. Click the “New Repository Secret” Button in the upper right corner. Now create both secrets. The servers public IP address, and the private SSH Key – name them exactly like we did in the production.yml file we created earlier. The contents of the file /home/deploy/.ssh/id_rsa is what you will save as PRODUCTION_SSH_KEY secret in GitHub.

Creating the destination folders is easy, too. This will give us a project folder with 2 subdirectories: releases and shared. Just like one would do with Capistrano.

root@remote $
deploy_to=/var/www/my-website.com mkdir -p ${deploy_to} chown deploy:deploy ${deploy_to} umask 0002 chmod g+s ${deploy_to} mkdir ${deploy_to}/{releases,shared} chown deploy ${deploy_to}/{releases,shared}

Step 3: The post-deploy Shell Script

In step 1 we told Github how to build our release, zip it and send it to our production server via scp. Now we need to make sure, that our code ends up in the correct folders on your server.

Before we can jump into our little deploy.sh script we need to allow our deploy user to execute certain required commands. For that we add the user to our /etc/sudoers file and append the following line of code.

/etc/sudoers
deploy ALL=NOPASSWD:/etc/init.d/nginx, /etc/init.d/php8.0-fpm, /usr/bin/chmod, /usr/bin/chown, /usr/bin/rm, /usr/bin/ln, /usr/bin/unzip, /usr/bin/mkdir, /usr/bin/php, /usr/bin/find

This tells Linux that the deploy user can execute the commands with sudo. Now we will create the post deploy script:

/home/deploy/deploy.sh
#!/bin/sh # create release folder datestring=$(date +"%Y-%m-%d-%H-%M") sudo mkdir -p /var/www/my-website.com/releases/$datestring # unzip to release sudo unzip -o -qq /var/www/my-website.com/latest.zip -d /var/www/my-website.com/releases/$datestring # symlink uploads folder into release sudo rm -rf /var/www/my-website.com/releases/$datestring/wordpress/wp-content/uploads sudo ln -s /var/www/my-website.com/shared/uploads /var/www/my-website.com/releases/$datestring/wordpress/wp-content/uploads # symlink .env sudo ln -s /var/www/my-website.com/shared/.env /var/www/my-website.com/releases/$datestring/.env # set version touch /var/www/my-website.com/releases/$datestring/wordpress/version.txt echo $datestring > /var/www/my-website.com/releases/$datestring/wordpress/version.txt # symlink the new release sudo rm /var/www/my-website.com/current sudo ln -s /var/www/my-website.com/releases/$datestring /var/www/my-website.com/current # set permissions sudo chown -R www-data:www-data /var/www/my-website.com/current sudo chmod -R 0755 /var/www/my-website.com/current # refresh nginx and php sudo /etc/init.d/nginx reload sudo /etc/init.d/php8.0-fpm reload # Remove releases older than 3 days /usr/bin/find /var/www/my-website.com/releases/* -ctime +3 -exec sudo rm -rf {} \;

As you can see we use a couple of folders here:

  • /var/www/my-website.com/releases where our release are extracted to
  • /var/www/my-website.com/shared which is used to store user uploads, assets and our .env file we created in part 2 of this tutorial series Using an .env file for database and other credentials. If you haven’t done so yet, copy it into the shared folder and adjust your settings to your production servers needs (like environment, database credentials, debug settings, etc.)
  • We add a version.txt to our wordpress folder for later use in our theme. The purpose of this file is to later check which release we’re on.
  • /var/www/my-website.com/current is a symlink pointing to the latest release, so in our servers config file we would set a document root like /var/www/my-website.com/current/wordpress.
  • Depending on your setup and CMS you might need to adjust things here and there, but I think this a good setup to get you up and running.

    After about half a year of using this technique, I have to say that this way of doing continuous code deploys with GitHub Actions runs smooth and stable even in more complex setups where you rsync/lsyncd complete folder structures between servers.