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.ymlname: Deploy Actionon:    push:      branches:         - mainjobs:  deploy:     name: Deploy     runs-on: ubuntu-latest     steps:       - name: Cancel Previous Runs         uses: "styfle/[email protected]"         with:            access_token: ${{ github.token }}       - uses: "actions/[email protected]"       - uses: "shivammathur/[email protected]"         with:           php-version: "8.0"           extensions: mbstring, intl, gd, zip, xml, mysql       - uses: "ramsey/[email protected]"         with:           composer-options: "--optimize-autoloader --ignore-platform-reqs"       - name: Run grunt          uses: "elstudio/actions-js-build/[email protected]"       - name: Zip entire project         uses: "montudor/[email protected]"         with:             args: zip -qq -r ./ .       - name: Send zip to remote server         uses: "horochx/[email protected]"         with:           local: "./"           remote: "/var/www/"           host: ${{ secrets.PRODUCTION_IP_ADDRESS }}           port: "22"           user: "deploy"           key: ${{ secrets.PRODUCTION_SSH_KEY }}       - name: Execute         uses: "appleboy/[email protected]"         with:           host: ${{ secrets.PRODUCTION_IP_ADDRESS }}           username: "deploy"           key: ${{ secrets.PRODUCTION_SSH_KEY }}            port: "22"           script: sh /home/deploy/
  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
  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/` 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

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 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:

[email protected] $adduser deploypasswd -l deploysudo su - deployssh-keygen -t rsa -C ''

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.

[email protected] $deploy_to=/var/www/my-website.commkdir -p ${deploy_to}chown deploy:deploy ${deploy_to}umask 0002chmod 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 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/sudoersdeploy 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

Now we will create the deploy script:

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

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

  • /var/www/ where our release are extracted to
  • /var/www/ 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/ is a symlink pointing to the latest release, so in our servers config file we would set a document root like /var/www/
  • 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.

Reverting a deploy

If your using Pull Requests to merge a feature branch back into its parent branch, then it’s a good advice to squash the branches’ commit history into a single commit before merging it back. This way on your main or master branch you can do a simple git revert to get back to the previous version.

To do a quick and dirty emergency revert directly on the server, just delete and re-create the symlink /var/www/ which points to your current release. Do a ls -lart /var/www/ to list all of your releases. The last line is your current erroneous deploy, the one above is the last working release. So simply rm /var/www/ && ln -s /var/www/<working release> /var/www/ So it’s really simple – with this one-liner your site is back to the previous state.

Download Tutorial: Continuous Deployments with Github Actions (9.5 MB)