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:
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:
- Repo: https://github.com/kaliber5/deploytest
- Workflow config: https://github.com/kaliber5/deploytest/blob/develop/.github/workflows/deploy.yml
- Deploy config: https://github.com/kaliber5/deploytest/blob/develop/config/deploy.js
- Example PR: https://github.com/kaliber5/deploytest/pull/9
- staging deployment: http://deploytest.kaliber5.de/
- PR deployment: http://preview-1fa2c5b.deploytest.kaliber5.de/
Should you have any questions, then feel free to get in touch!