Preview deployments with ember-cli-deploy and Github actions

We recently set up a way to create deployments for our Ember.js app's pull requests, so you can not only review the code diffs, but also preview how the changes look and behave in the browser. But first let me explain the context, as there is obviously no one-size-fits-all approach.

So for a lot of apps we build for startups and medium-sized companies, which are not very large in terms of user volume or traffic (e.g. B2B apps), we mostly choose to deploy them to classic dedicated or shared/virtual LAMP-stack servers, rather than the Cloud-based world like AWS (which we also do when appropriate). This is for diverse reasons, mostly though based on backend and maintenance concerns. So deployment in that context basically boils down to copying files to the server via SSH/SCP/SFTP.

Then on our development side we transitioned over the years from hosting our code on a self-hosted Bitbucket server, accompanied by a Bamboo server for CI/CD, to Bitbucket Cloud and Bitbucket Pipelines and then finally to Github and Github Actions.

So the task at hand here is to set up a system that will automatically create a preview deployment for any pull request in Github using a Github Actions workflow that pushes the build onto our server using an SSH-based Ember CLI Deploy setup. This is how we did it…

Basic setup

We use ember-cli-deploy-simply-ssh as the SSH-based deployment plugin. Its basic setup is quite simple:

// config/deploy.js
module.exports = function (deployTarget) {
  const ENV = {
    build: {},
    'simply-ssh': {
      connection: {
        // parameter hash accepted by SSH2, see https://github.com/mscdex/ssh2 for details
        host: process.env.DEPLOY_HOST,
        username: process.env.DEPLOY_USER,
        privateKey: process.env.DEPLOY_SSH_KEY,
      },
      dir: process.env.DEPLOY_PATH,
    },
   // ...
  };
  return ENV;
};

To trigger the deployment in Github Actions, we set up our workflow to deploy to our staging environment on every push to the develop branch (we follow GitFlow), and to deploy to production for every release tag (vX.Y.Z).

To be honest this is quite limiting, and the biggest complain I have about Github Actions is, that it does not have out-of-the-box deployment features that we were used to coming from Bitbucket, things like manual deployments ("push the button!"), approvals, rollbacks, history of deployments (per environment), a nice UI for all of that, environment-specific settings/secrets etc. I am currently trying to figure out a better way to do this, but this is out of scope for now, and maybe a topic for one of the next blog posts.

A basic deployment workflow based on this could look like this. Note how we don't have hard-coded settings in the deploy.js but pass them via environment variables:

# .github/workflows/deploy.yml
name: CD

on:
  push:
    branches:
      - develop
    tags:
      - 'v*'

jobs:
  staging:
    name: Deploy to staging
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/develop'
    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Setup node.js
        uses: actions/setup-node@v1
        with:
          node-version: 12

      - name: Install dependencies
        uses: bahmutov/npm-install@v1

      - name: Deploy to staging
        run: yarn deploy staging
        env:
          DEPLOY_HOST: "example.com"
          DEPLOY_USER: "user"
          DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY_STAGING }}
          DEPLOY_PATH: "/path/to/vhost/root"
          EMBER_API_HOST: "https://api.staging.example.com"

  production:
    name: Deploy to production
    runs-on: ubuntu-latest
    if: startsWith(github.ref, 'refs/tags/v')
    steps:
      # except for env vars same as staging...

Deploying pull requests

Now to the actual task of setting up deployments for pull requests. What do we want to achieve here?

  • the deployment workflow should run for every pull request, fully automated.
  • as we are not operating in a fully virtualized cloud environment, we don't want to have to set up additional server accounts per pull request or something like that.
  • a PR deployment must not override another deployment, neither the regular staging one nor that of any other PR.
  • the deployment should preferably be accessible under its own subdomain, so we don't have to fiddle with the app's routing. Something like preview-<something>.staging.example.com.
  • it must be obvious where to find the new deployment URL

Fortunately all of this was possible, and not too hard to achieve actually. But step by step…

Introducing revisions

First how can we deploy something to the same staging environment, without overriding another deployment. As this is what currently is happening: the SSH Ember CLI Deploy plugin will override any existing index.html. Luckily the primitive for this is readily available: introducing "revisions".

Revisions are a concept of Ember CLI Deploy of separately deployed builds, where one of them is "activated". ember-cli-deploy-simply-ssh already supports this mode out of the box, we just need to install the additional plugin
ember-cli-deploy-revision-data. Now our deployment will not override our index.html on every deployment, but upload each deployed release into its separate directory under ./releases. Only the active one will then have a current symlink, that points to the active revision directory.

A pleasant side effect of this is that the frontend deployment using an atomic symlink switch is now very similar to what we do for our PHP / Symfony based deployments using Deployer.

Trigger workflow from PR

We now have to add another job to our deploy.yml workflow, that is mostly the same as our staging job. We just have to restrict it to only run for a pull request: if: startsWith(github.ref, 'refs/pull/'). Also for the staging and production jobs we have to add the --activate flag when invoking ember deploy, so the deployed revision gets automatically activated (symlinked), while this is not the case for the pull request deployment.

Domains, DNS and Rewrite rules

We now have the deployment part hooked up nicely. But we still have no means to access the deployed (but not activated!) revisions from a browser. This is the next part…

First we need to set up DNS, so that our preview subdomain (e.g. preview-12345.staging.example.com) gets routed to the same server also handling staging.example.com. For this we create a wildcard CNAME record, so something like this:

*.staging IN CNAME staging

This will direct any HTTP requests for *.staging.example.com to our server, but the server still does not know what to do with this. We need to configure the domains (aka virtual host) root folder correctly. So for staging.example.com this needs to be the current symlink, so something like /var/www/staging/current. But now we also have to configure our server to handle any request for *.staging.example.com using the base directory as the vhost root (e.g. /var/www/staging).

How to do this configuration depends highly on your server setup, if you have some admin frontend from your provider, or you deal with raw config files, or something else. So not going into details here.

Our server will now receive traffic for preview-<something>.staging.example.com, and try to serve content from the /var/www/staging directory. But as you can imagine there is noting in there, no index.html, as the real content is in /var/www/staging/releases/<something>. The missing piece of the puzzle is now to find a way to dynamically serve the content from the right releases/* subdirectory, based on the used subdomain.

For our good ole Apache server I created the following mod_rewrite rule by placing a .htaccess file into the base directory:

RewriteEngine on

# serve revision if it exists
RewriteCond %{HTTP_HOST} ^preview-(\w+)\.staging\.example\.com$ [NC]
RewriteCond /var/www/releases/%1 -d
RewriteRule ^(.*)$ /releases/%1/$1 [L,NC]

# otherwise return a 404 response
RewriteRule ^ - [R=404,L,NS]

So for a request to http://preview-abcdef.staging.example.com/ this will now tell Apache to serve the content from the releases/abcdef directory, so the directory matching the uploaded Ember CLI Deploy revision.

This example is assuming you are running Apache, but a similar configuration should also be possible for other servers like nginx.

And that's it: we have now deployed our pull request commit to our staging server, and configured it so that it can serve that build without affecting the actual staging environment! 🎉

What's missing?

There is just one thing left we haven't solved yet from our initial requirements:

it must be obvious where to find the new deployment URL

For this we will make use of an excellent feature of Github Actions: that it can not only be used for traditional test and deployment tasks, but also to create experiences interacting with the diverse Github APIs itself. And through shared Github Actions, this can be done really easily, without reinventing the wheel.

So for our case I used an existing action to post a PR comment as soon as the PR has been successfully deployed:

PR is deployed and ready for preview!

For the deployment URL to be more deterministic I switched the ember-cli-deploy-revision-data hash type to git-commit, i.e. identifying a revision by the corresponding git commit SHA instead of a MD5 hash of index.html, so we can easily construct the matching URL in the Github workflow. The following two simple steps (the first one was needed to cut the SHA to the seven characters used by the deployment plugin) were all what was needed to post the comment:

  - uses: benjlevesque/short-sha@v1.0
    id: short-sha
    with:
      length: 7
  - name: Post PR comment
    uses: thollander/actions-comment-pull-request@1.0.0
    with:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      message: "PR is deployed and ready for [preview](http://preview-${{ steps.short-sha.outputs.sha }}.staging.example.com/)! 👀"

Mission accomplished! 🚀

I hope this was a useful read! Should you want to try this out by yourself, instead of listing all the final config files here, I'll just refer you to the public demo app repo which I used to experiment with this:

Should you have any questions, then feel free to get in touch!

Vereinbaren Sie direkt ein unverbindliches und kostenfreies Beratungsgespräch.