The following post was created as part of the Github Actions Hackathon 2021 on dev.to.

Motivation

I’ve been looking to revamp the deployment process for several projects that I’m working on and start building towards my preferred method of deployment.

My biggest requirements are simplicity and speed. I have used Docker, Kubernetes, Docker Swarm, and various other methods of deployment in the past. I recognize these tools have their advantages, but have found that for small to medium sized projects they are more effort than they are worth to maintain.

At the end of the day, all I need to do is build the code and copy the built files to the server. Before starting the project I told myself to get it under a minute, but I’m happy to report that Github Actions starts up much faster than Travis CI and brought this down to 15 seconds to deploy a React frontend and express.js backend.

I’ve provided full instructions for how to recreate this entire project, but if you’re just interested in the workflow part skip ahead to the My Workflow section.

Creating a Simple App to Demonstrate

Simple App Screenshot

Before I can demonstrate the workflow, we need to have something to deploy. Below are instructions for how the simple app is structured. Most of you are probably used to the templates provided by Create React App, but here I provide some opinionated alternatives for how to structure the app. The same principles should be possible to transfer over to any existing setup.

Creating a Basic React App

mkdir github-actions-tutorial
cd github-actions-tutorial
yarn init
yarn add react react-dom
yarn add --dev @types/react @types/react-dom
mkdir -p client/src

Create index.tsx

// client/src/index.tsx
import React from "react";
import ReactDom from "react-dom";
import { App } from "./App";

ReactDom.render(<App />, document.getElementById("root"));

Create App.tsx

// client/src/App.tsx
import React, { useEffect, useState } from "react";

export const App: React.FC = () => {
  return (
    <>
      <div>Hello Github Actions!</div>
    </>
  );
};

Building React App with esbuild

Now that we have a simple React app we are going to output a minified production build using esbuild.

Install esbuild

yarn add --dev esbuild

Add client:build script to package.json

// package.json
{
  "name": "github-actions-tutorial",
  "version": "1.0.0",
  "main": "index.js",
  "repository": "git@github.com:adamjberg/github-actions-tutorial.git",
  "author": "Adam Berg <adam@xyzdigital.com>",
  "license": "MIT",
  "scripts": {
    "client:build": "esbuild client/src/index.tsx --bundle --minify --outfile=built/app.js",
  },
  "dependencies": {
    "react": "^17.0.2",
    "react-dom": "^17.0.2"
  },
  "devDependencies": {
    "@types/react": "^17.0.37",
    "@types/react-dom": "^17.0.11",
    "esbuild": "^0.14.1"
  }
}

You can test this is working correctly by running yarn client:build and you should see a built/app.js file in the folder tree with the minified output.

You are probably used to have a yarn start script as well, but for the purposes of this tutorial we’re going to skip it and test this all out directly in “production”.

Create public/index.html

<html>

<head>
  <script src="/js/app.js" defer async></script>
</head>

<body>
  <div id="root"></div>
</body>

</html>

This will be the file that is served by our nginx static file server when clients hit the http://github-actions-tutorial.devtails.xyz URL.

Prepping a Server

I’m going to assume the reader has some knowledge about how to register a domain and create a server on some hosting platform. I already have a domain devtails.xyz with Namecheap and I have created a droplet with Digital Ocean.

In the example below, I have mapped github-actions-tutorial.devtails.xyz to my Digital Ocean IP: 143.198.32.125

As long as you have the ability to ssh into your server, the following instructions should suffice regardless of your hosting platform.

SSH into server

ssh root@143.198.32.125

Create github-actions-tutorial User

To prevent our Github Action from getting root access to our server, we will create a sub-user called github-actions-tutorial

useradd -s /bin/bash -d /home/github-actions-tutorial -m github-actions-tutorial

Install nginx

apt-get install nginx

Create Virtual Host File

# /etc/nginx/sites-available
server {
  listen 80;
  server_name github-actions-tutorial.devtails.xyz;

  location / {
    root /home/github-actions-tutorial/static;
  }
}

This tells nginx to route requests to the github-actions-tutorial.devtails.xyz subdomain to the static folder under our github-actions-tutorial user.

Create static folder on github-actions-tutorial user

su github-actions-tutorial
mkdir static

This allows us to avoid having our Github Action ssh into the server just to create this folder. This folder will house the js/app.js and index.html. The virtual host file set up previously tells nginx to serve files from the static folder.

Creating a Basic Express REST API

Install express

yarn add express
yarn add @types/express

Create server/src/server.tsx

// server/src/server.tsx
import express from "express";

const app = express();

app.get("/api/message", (_, res) => {
  return res.json({
    data: "Hello from the server!",
  });
});

app.listen(8080);

This creates a basic REST API with a single /api/message route that we will use to demonstrate that it is running correctly.

Add server:build script to package.json

We will re-use the esbuild package to build a bundle for our server code as well. For more details on this approach please see this post.

"server:build": "esbuild server/src/server.ts --bundle --minify --outfile=built/server.js --platform=node"

Add this right below the client:build script. You can then run it to confirm working as expected with yarn server:build. It should output a bundled file to built/server.js.

Add build script that runs both client and server builds

"build": "yarn client:build && yarn server:build"

Prepare the Server to Run the API

There are a few one time configurations that need to be applied in order to prepare our server for deployment.

Switch to github-actions-tutorial user

su github-actions-tutorial

Install NVM

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash

Install Node

nvm install 16

Install pm2

npm i -g pm2

Update Virtual Host File to Route to API

Again ssh into the root user and update /etc/nginx/sites-available/github-actions-tutorial.devtails.xyz file

# /etc/nginx/sites-available/github-actions-tutorial.devtails.xyz
upstream github-actions-tutorial-api {
  server localhost:8080;
}

server {
  listen 80;
  server_name github-actions-tutorial.devtails.xyz;

  location /api {
    proxy_pass http://localhost:8080;
  }

  location / {
    root /home/github-actions-tutorial/static;
  }
}

This tells nginx to route any URLs that start with /api to the express app that we added.

Bootstrapping the pm2 process

Before the final step - run: ssh github-actions-tutorial "pm2 reload all" can run, you must first manually start your server with pm2.

After running the Github Action for the first time, it should have copied the built server.js file to ~/api/server.js. You can then start this process with pm2 start api/server.js.

Now that it is running, the pm2 reload all command will reload this server process so it can pick up the changes in your server code.

My Workflow

Phew, with all that set up out of the way, we can now look at what our Deploy workflow does.

Below I’ll break it down section by section

Define workflow name and triggers

name: Deploy

on:
  push:
    branches: [ main ]

This creates a workflow called “Deploy” that will be run whenever a push is made to the main branch.

Define build-and-deploy job

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

This creates a job called build-and-deploy that will run the latest ubuntu distribution.

env:
  SSH_KEY: $

This adds a Github Secret to the environment. We will use this in a later step to allow us to rsync to our specified server.

steps:
  - uses: actions/checkout@v2

This checks out the code for the current commit.

- name: Use Node.js 16
  uses: actions/setup-node@v2
  with:
    node-version: 16
    cache: 'yarn'

This installs node 16 and specifies that the Workflow should cache files for yarn. This cache ensures that if no packages are added or removed, yarn install won’t have to do anything. This saves a significant amount of time.

- run: yarn install
- run: yarn build

These lines run the install and build which ultimately outputs all the files that we would like to deploy.

- run: mkdir ~/.ssh
- run: 'echo "$SSH_KEY" >> ~/.ssh/github-action'
- run: chmod 400 ~/.ssh/github-action
- run: echo -e "Host static\n\tUser github-actions-tutorial\n\tHostname 143.198.32.125\n\tIdentityFile ~/.ssh/github-action\n\tStrictHostKeyChecking No" >> ~/.ssh/config

This is the most complicated section. What’s happening here is that we are adding the SSH_KEY secret to the ~/.ssh/github-action file. The final line creates a ~/.ssh/config file that looks like the following:

Host static
  User github-actions-tutorial
  IdentityFile ~/.ssh/github-action
  StrictHostKeyChecking No

With that set up, the rsync commands look quite simple:

- run: rsync -e ssh public static:~/static
- run: rsync -e ssh built/app.js static:~/static/js/app.js
- run: rsync -e ssh built/server.js static:~/api/server.js

The -e ssh specifies to use rsync over ssh. We copy over all files from the public folder. Then we copy over the built/app.js to ~/static/js/app.js. Finally we copy built/server.js to ~/api/server.js.

- run: ssh github-actions-tutorial "pm2 reload all"

This final line uses pm2 (which we installed earlier) to reload the server process.

Conclusion

While I could get an even faster deployment just by running this on my local machine, having this run as a Github Action provides a big benefit for my open source projects. In order to deploy a contributor’s changes, I can simply merge their pull request in to the main branch without having to give direct server access to anyone else.

There’s plenty more that could be tidied up or improved, but in the spirit of a hackathon, I’m calling this “done” for now. I now have a baseline of how long I should expect an app to be built and deployed using Github Actions.

name: Deploy

on:
  push:
    branches: [ main ]

jobs:
  build-and-deploy:

    runs-on: ubuntu-latest

    env:
      SSH_KEY: $

    steps:
    - uses: actions/checkout@v2
    - name: Use Node.js 16
      uses: actions/setup-node@v2
      with:
        node-version: 16
        cache: 'yarn'
    - run: yarn install
    - run: yarn build
    - run: mkdir ~/.ssh
    - run: 'echo "$SSH_KEY" >> ~/.ssh/github-action'
    - run: chmod 400 ~/.ssh/github-action
    - run: echo -e "Host github-actions-tutorial\n\tUser github-actions-tutorial\n\tHostname 143.198.32.125\n\tIdentityFile ~/.ssh/github-action\n\tStrictHostKeyChecking No" >> ~/.ssh/config
    - run: rsync -e ssh public github-actions-tutorial:~/static
    - run: rsync -e ssh built/app.js github-actions-tutorial:~/static/js/app.js
    - run: rsync -e ssh built/server.js github-actions-tutorial:~/api/server.js
    - run: ssh github-actions-tutorial "pm2 reload all"

Additional Resources / Info

The full code for this tutorial can be found on Github

engram is an Open Source project where I first prototyped this style of deploy. It currently takes 3-4 minutes to deploy, which is why I’ll be switching over to a workflow closer to the one provided here.