You are currently viewing Step-by-Step Guide: Building CI/CD with GitHub Actions for React Projects Deployment (Part 2)

Step-by-Step Guide: Building CI/CD with GitHub Actions for React Projects Deployment (Part 2)

In this tutorial, I’ll show you how to build CI/CD with GitHub Actions and Docker-compose and deploy it to DigitalOcean.

In the first part of this tutorial, I set up a Client project based on React, and a Server project based on Node JS.
Dockerized them with docker-compose(development/production), and set up Nginx as a proxy server for the Client and Server(API) apps.

Setting up GitHub Action

So, let’s create a new Workflow for our CI/CD.
Open the Actions tab in your GitHub repo and press the “New workflow” button.

CICD (Github Actions)

On this page, you will find different workflows for any kind of thing.

CICD (Create Github Action)

Search simple workflow and press Configure.

CICD (Simple Github Action)

You’ll see the example of the workflow with the default name and simple job.

Step 1: Client Job

CICD (Github Action React)

First, change the default name to CICD.yml.
Then replace the simple workflow with mine.

# This is a basic workflow to help you get started with Actions

name: CICD

# Controls when the workflow will run
on:
  push:
    branches: [ main ]
  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  # This workflow contains a single job called "build"
  test:
    # The type of runner that the job will run on
    runs-on: ubuntu-latest

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
      - uses: actions/checkout@v3
      # Set up the node 16 version
      - name: Set up node
        uses: actions/setup-node@v3
        with:
          node-version: 16
      # Run Test script
      # We need to go to the client folder as in the root of the project we have only Client and API folders. Then install npm packages and run tests.
      - name: Test Client
        run: |
          cd client
          npm install
          npm run test

Before we run this workflow we need to make some changes to be able to run the client and server tests from the workflow.
As you remember when we run client tests we see the menu and we have to press “a” to run all tests.
But in our case, we need to run tests without this menu. For this, we need to add CI=true to the test script.

Let’s go to the project on your pk and add this parameter.

"scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "CI=true react-scripts test",
    "eject": "react-scripts eject"
 },

Now let’s run client tests.

CICD (React jest results)

Okay, now we don’t have that menu.

Let’s also add this parameter to the server test script and commit your changes and push it to GitHub.

"scripts": {
    "start": "node src/app.js",
    "dev": "nodemon -L src/app.js",
    "test": "CI=true jest --config ./jest.config.json"
 },

After that let’s go to the GitHub workflow and commit it.

CICD (Github Actions)

Now our workflow will start automatically or we can start it manually.

CICD (start github action manualy)

After our workflow started you can see its status.

CICD (Github Action proccess)

After the finish of the process, you’ll see a green success mark.
If you have a problem with this step, you need to make sure that you made the same changes as me.
You also can reach me in the comments or on Instagram.

Let’s change out a test to make sure that our workflow works.
To do this open App.test.js in your local project and change the text, I added “learn react 11” to getByText.

test('renders learn react link', () => {
  render(<App />);
  const linkElement = screen.getByText(/learn react 11/i);
  expect(linkElement).toBeInTheDocument();
});

Okay, commit and push your changes.
Then open GitHub workflow in the browser and go back to the previous page.
You’ll see that your workflow has already started, but it must finish with a failure status.
If you open the workflow details you’ll see that your test failed because of getByText didn’t find the text (“learn react 11”) on the page.

CICD (Github Action Jest)

Okay, fix the test and commit it.

Step 2: Api (server) Job

So, let’s add a script for running server tests.
Edit workflow and add the following script to the test job.

- name: Test Client
  ...      
# Go to the api folder, install packages and tun tests
- name: Test Api
  run: |
      cd api
      npm install
      npm run test

To edit your workflow from GitHub go back to Action, select CICD workflow on the left menu, and press CICD.yml

CICD (Github Actions)

You can also edit the workflow in your IDE.
To do this pull your code from GitHub, then you’ll see the .github folder, inside this folder you’ll find your workflow.

CICD (Github Actions)

I prefer to make changes right on the GitHub page.

After you commit your changes let’s run the workflow one more time to check the server tests.

CICD (Github Actions)

Okay, we’ve finished the test job, the next step is a “build job.

Setting up DigitalOcean

DigitalOcean is an American technology company and cloud service provider.
You can find all the services on the official website.

Step 1: Create a new Project

If you just registered you’ll have the Started Project. I’ll create a new project as I have another project.

CICD (Dgitalocean project)

Step 2: Create a Droplet

Droplets are flexible Linux-based virtual machines that run on top of virtualized hardware. Each Droplet you create is a new cloud server you can use.

Let’s create a new droplet, to do this press on Get Started with a Droplet

CICD (Digitalocean project)

Choose Region: you need to choose the region, I selected Frankfurt.

Choose an image: in the marketplace tab, select Docker on the Ubuntu. We need a virtual machine with preinstalled docker as we’ll run our docker container with apps.

CICD (Digitalocean project)

Choose Size: for this tutorial, I chose Basic Type and Regular CPU.

CICD (Digitalocean project)

Choose Authentication Method: I prefer the ssh method.

For this select SSH Key and press Add SSH Key.

CICD (Digitalocean project)

In the opened modal you need to add your public ssh and name.

To copy your ssh key to the clipboard run this code in the terminal.

pbcopy < ~/.ssh/id_rsa.pub
CICD (Digitalocean project)

After you set up the ssh press Create Droplet.

The creation process can take more than a minute.

CICD (Digitalocean project)

Great, now you can see your new droplet.

Step 3: Setting up Docker Container Registry

Since we will be using Docker, we’ll need a Docker Registry to store our images.

CICD (Digitalocean project)

To create it you need to type a unique name, select the region, and plan.

CICD (Digitalocean project)

I chose the Basic plan because for this tutorial we need 3 repo for Client, Server, and Nginx images.

After creating you’ll see Registry Page.

CICD (Digitalocean project)

Step 4: Generate API Token

Open the API page and generate a new Access Token, we need it to be able to push Docker images from GitHub Workflow.

CICD (Digitalocean project)

In the opened modal you need to type the name and select Expiration.
For this tutorial, I selected 90 days, but you can choose No Expiry.

CICD (Digitalocean project)

Now you need to copy this token somewhere because after refreshing the page you won’t be able to copy it.

CICD (Digitalocean project)

GitHub Actions Secrets

Encrypted secrets allow you to store sensitive information in your repository environments.
To add the secrets open the Repo Settings > Secrets > Actions and press “new repository secret“.

CICD (Github secrets)

DIGITALOCEAN_ACCESS_TOKEN – it is a DigitalOcean API Token we created earlier.
For example dop_v1_3d57ae57f8dd77b020c46a0ed45ca3bba3ea3d70bbe6d9239e5028ba0a63008f

HOST – it is a Droplet host (IP).

CICD (Digitalocean project)

SSHKEY – it is a private ssh key (NOT PUBLIC). To copy it run the following command in the terminal.

pbcopy < ~/.ssh/id_rsa

PASSPHRASE – it is the password of your ssh.

USERNAME – it is the default name (root).

GitHub Actions (Build and Push)

So, we’ve already added secrets and now we can create a new job for building Docker images with our apps and push them to the DigitalOcean Registry.

Step 1: Setting up environments

First of all, we need to add this environment before the jobs section in CICD.yml and replace REGISTRY with your registry URL

env:
  REGISTRY: "registry.digitalocean.com/cicd"
  CLIENT_IMAGE: "client"
  API_IMAGE: "api"
  GATEWAY_IMAGE: "gateway"

Step 2: Add build_and_push Job

Let’s create a new job.
This job will also be run on the Ubuntu Machine.
In the needs field, I added a test job, which means that this job will wait for the test job.
And I also added a script for checkout of the repo.

build_and_push:
     needs: test
     runs-on: ubuntu-latest
     
     steps:
      - name: Checkout the repo 
        uses: actions/checkout@v3

Step 2: Build Containers

So now let’s add the next command to make a new build using docker-compose.

 - name: Build container image
   run: docker-compose -f docker-compose.yml build 

Step 3: Add Tags for Containers

Then, we need to add a tag for each image. You can find more information about tags on the official website.

- name: Add Tag
  run: |
    docker tag $CLIENT_IMAGE $REGISTRY/$CLIENT_IMAGE:latest
    docker tag $API_IMAGE $REGISTRY/$API_IMAGE:latest
    docker tag $GATEWAY_IMAGE $REGISTRY/$GATEWAY_IMAGE:latest

Step 4: Connect to the Registry

In the next step, we need to connect to DigitalOcean Registry.
To do this I’ll use doctl package with Access Token in the params, and after that, we can log in to the Registry.

- name: Install doctl
  uses: digitalocean/action-doctl@v2
  with:
    token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
    
 - name: Log in to DigitalOcean Registry
   run: doctl registry login --expiry-seconds 600

Step 5: Push Images to Registry

In the previous step, we logged in to the Registry and now we just need to push our images there.

- name: Push images to DigitalOcean
  run: |
    docker push $REGISTRY/$CLIENT_IMAGE:latest
    docker push $REGISTRY/$API_IMAGE:latest
    docker push $REGISTRY/$GATEWAY_IMAGE:latest

Full Job

build_and_push:
     needs: test
     runs-on: ubuntu-latest
     
     steps:
      - name: Checkout the repo 
        uses: actions/checkout@v3
        
      - name: Build container image
        run: docker-compose -f docker-compose.yml build 
      
      - name: Add Tag
        run: |
         docker tag $CLIENT_IMAGE $REGISTRY/$CLIENT_IMAGE:latest
         docker tag $API_IMAGE $REGISTRY/$API_IMAGE:latest
         docker tag $GATEWAY_IMAGE $REGISTRY/$GATEWAY_IMAGE:latest
        
      - name: Install doctl
        uses: digitalocean/action-doctl@v2
        with:
          token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
    
      - name: Log in to DigitalOcean Registry
        run: doctl registry login --expiry-seconds 600
        
      - name: Push images to DigitalOcean
        run: |
         docker push $REGISTRY/$CLIENT_IMAGE:latest
         docker push $REGISTRY/$API_IMAGE:latest
         docker push $REGISTRY/$GATEWAY_IMAGE:latest

Okay, now let’s commit the workflow and run it.

If everything is okay you’ll see your images on the Registry page.

CICD (digitalocean project)

GitHub Actions (Deploy to Droplet)

Now it remains to deploy our application to DigitalOcean Droplet.

Step 1: Add docker-compose.ci.yml

To deploy to Droplet we need to create a docker-compose.ci.yml in the root of the project.
We need it to pull images from the Registry and run containers in the droplet.

version: "3.8"

services:
  nginx:
    container_name: gateway
    image: registry.digitalocean.com/cicd/gateway:latest
    ports:
      - "80:80"
    restart: always
  client:
    container_name: client
    image: registry.digitalocean.com/cicd/client:latest
    restart: always
  api:
    container_name: api
    image: registry.digitalocean.com/cicd/api:latest
    restart: always

In this file, we don’t need to pass build settings as we need them only to pull and run our containers.
We also need to replace the image name with a full path to our Image.

And after that commit and push this file to GitHub.

Step 2: Add deploy Job

So, let’s create a new job for deploying our images to Droplet.
For now, you can comment on the previous jobs to test only this job.

deploy:
#    needs: build_and_push
    runs-on: ubuntu-latest
    
    steps:
      - name: "Checkout repository"
        uses: actions/checkout@v3  

I also commented “needs” field because I commented on all previous jobs.

Step 3: Copy docker-compose.ci.yml to the Droplet

To use our docker-compose config we need to copy it to the Droplet.
To do this I’ll use the “SCP-action” package.
This package needs our secrets to connect to the Droplet via ssh.

- name: Copy file via ssh key
  uses: appleboy/scp-action@master
  with:
    host: ${{ secrets.HOST }}
    username: ${{ secrets.USERNAME }}
    key: ${{ secrets.SSHKEY }}
    passphrase: ${{ secrets.PASSPHRASE }}
    source: "docker-compose.ci.yml"
    target: "./"

Commit these changes and run the Workflow.

CICD (Github Actions)

If everything is okay we can connect to the Droplet via ssh to check it.
Run the following command in the terminal. Don’t forget to replace 165.22.95.194 with your host.

ssh root@165.22.95.194

If you connect to Droplet the first time you’ll see this message, just type yes.

Are you sure you want to continue connecting (yes/no/[fingerprint])? yes

Then you need to type your ssh password.

Enter passphrase for key '/Users/artem/.ssh/id_rsa': 

If you entered the right host and password you’ll see the next screen inside your terminal.

CICD (Digitalocean droplet)

Now let’s check if our docker-compose.ci.yml exists in the Droplet. To see all files run ls in the terminal.

CICD (Digitalocean droplet)

Yes, now you can see this file.

Step 4: Install docker-compose to DigitalOcean Droplet

By default, the Droplet doesn’t have a docker-compose package. To make sure, run docker-compose -v in the terminal, and you’ll see that docker-compose isn’t installed yet.

root@docker-ubuntu-s-1vcpu-1gb-fra1-01:~# docker-compose -v
Command 'docker-compose' not found, but can be installed with:
snap install docker          # version 20.10.17, or
apt  install docker-compose  # version 1.29.2-1
See 'snap info docker' for additional versions.
root@docker-ubuntu-s-1vcpu-1gb-fra1-01:~# 

To install it you need to run the following commands in the Droplet terminal. You can visit the official website.

sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose

sudo chmod +x /usr/local/bin/docker-compose

After that let’s check the docker-compose version. Run docker-compose -v one more time.

CICD (Digitalocean droplet)

Yes, it works. As you can see now we have the 1.29.2 version.

docker-compose version 1.29.2, build 5becea4c

Now let’s exit from our Droplet. To exit type exit in the terminal and press enter.

Step 5: Deploy

So, we’ve completed all the previous steps and now we can start deploying the application.
To do this I’ll use “ssh-action” package. This package also requires our secrets as it’ll make a connection to the Droplet by ssh.
Add the following code to the deploy job.

- name: Deploy to Digital Ocean droplet via SSH action
  uses: appleboy/ssh-action@master
  with:
    host: ${{ secrets.HOST }}
    username: ${{ secrets.USERNAME }}
    key: ${{ secrets.SSHKEY }}
    passphrase: ${{ secrets.PASSPHRASE }}
    envs: REGISTRY,{{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
    script: |
      # Login to registry
      docker login -u ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} -p ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} registry.digitalocean.com
      # Stop old containers
      docker stop $(docker ps -a -q)
      # Remove old containers
      docker rm $(docker ps -a -q)
      # Pull our images from Registry
      docker-compose -f docker-compose.ci.yml -p prod pull
      # Run containers
      docker-compose -f docker-compose.ci.yml -p prod up -d

And let’s commit the workflow file and run it.

CICD (Github Action)

if you did everything right you can open your host(for example http://165.22.95.194) in a browser.

CICD (React)

And Server app. host/api/test.

CICD (Node js Digitalocean)

Okay, it works.

Step 6: Use dynamic Registry in docker-compose.ci.yml

I don’t want to change the docker-compose file if we changed our Registry.
To do this open the docker-compose.ci.yml file in your IDE and change the registry and image versions.

version: "3.8"

services:
  nginx:
    container_name: gateway
    image: ${REGISTRY}/gateway:${GATEWAY_VERSION:-latest}
    ports:
      - "80:80"
    restart: always
  client:
    container_name: client
    image: ${REGISTRY}/client:${CLIENT_VERSION:-latest}
    restart: always
  api:
    container_name: api
    image: ${REGISTRY}/api:${API_VERSION:-latest}
    restart: always

Then, commit the changes.

And now we need to add .env file with Registry and Version.
You can add this file to the root of the project and copy it to the Droplet like docker-compose.ci.yml.
Or you can create it right in GitHub Workflow. I prefer to create it in GitHub Workflow.

So, let’s add a creating file script at the top of the script.

 - name: Deploy to Digital Ocean droplet via SSH action
   uses: appleboy/ssh-action@master
   with:
     host: ${{ secrets.HOST }}
     username: ${{ secrets.USERNAME }}
     key: ${{ secrets.SSHKEY }}
     passphrase: ${{ secrets.PASSPHRASE }}
     envs: REGISTRY,{{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
     script: |
       touch .env
       echo REGISTRY=$REGISTRY > .env
       echo GATEWAY_VERSION=latest >> .env
       echo CLIENT_VERSION=latest >> .env
       echo API_VERSION=latest >> .env
       # Login to registry
       docker login -u ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} -p ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} registry.digitalocean.com
       # Stop old containers
       docker stop $(docker ps -a -q)
       # Remove old containers
       docker rm $(docker ps -a -q)
       # Pull our images from Registry
       docker-compose -f docker-compose.ci.yml -p prod pull
       # Run containers
       docker-compose -f docker-compose.ci.yml -p prod up -d

Commit the Workflow and check if it works correctly.

Step 7: Garbage Collection

If you open the Registry page you’ll see that you have old images, without tags and some build garbage.

CICD (Digitalocean docker container)

To clean it you can press the Empty garbage button, but I want to make it automatic when we make a new build.
To do this let’s add a new step at the bottom of the build_and_push job.

- name: Run garbage collection
  run: doctl registry garbage-collection start --force --include-untagged-manifests

This script will run Garbage Collection and remove untagged images.

Full Workflow file

# This is a basic workflow to help you get started with Actions

name: CICD

# Controls when the workflow will run
on:
  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

env:
  REGISTRY: "registry.digitalocean.com/cicd"
  CLIENT_IMAGE: "client"
  API_IMAGE: "api"
  GATEWAY_IMAGE: "gateway"

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  # This workflow contains a single job called "build"
  test:
    # The type of runner that the job will run on
    runs-on: ubuntu-latest

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
      - uses: actions/checkout@v3

      - name: Set up node
        uses: actions/setup-node@v3
        with:
          node-version: 16
          
      - name: Test Client
        run: |
          cd client
          npm install
          npm run test
          
      - name: Test Api
        run: |
          cd api
          npm install
          npm run test
  build_and_push:
     needs: test
     runs-on: ubuntu-latest
     
     steps:
      - name: Checkout the repo 
        uses: actions/checkout@v3
        
      - name: Build container image
        run: docker-compose -f docker-compose.yml build 
      
      - name: Add Tag
        run: |
         docker tag $CLIENT_IMAGE $REGISTRY/$CLIENT_IMAGE:latest
         docker tag $API_IMAGE $REGISTRY/$API_IMAGE:latest
         docker tag $GATEWAY_IMAGE $REGISTRY/$GATEWAY_IMAGE:latest
        
      - name: Install doctl
        uses: digitalocean/action-doctl@v2
        with:
          token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
    
      - name: Log in to DigitalOcean Registry
        run: doctl registry login --expiry-seconds 600
        
      - name: Push images to DigitalOcean
        run: |
         docker push $REGISTRY/$CLIENT_IMAGE:latest
         docker push $REGISTRY/$API_IMAGE:latest
         docker push $REGISTRY/$GATEWAY_IMAGE:latest
     
      - name: Run garbage collection
        run: doctl registry garbage-collection start --force --include-untagged-manifests
        
  deploy:
    needs: build_and_push
    runs-on: ubuntu-latest
    
    steps:
      - name: "Checkout repository"
        uses: actions/checkout@v3  
        
      - name: Copy file via ssh key
        uses: appleboy/scp-action@master
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USERNAME }}
          key: ${{ secrets.SSHKEY }}
          passphrase: ${{ secrets.PASSPHRASE }}
          source: "docker-compose.ci.yml"
          target: "./"
    
      - name: Deploy to Digital Ocean droplet via SSH action
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USERNAME }}
          key: ${{ secrets.SSHKEY }}
          passphrase: ${{ secrets.PASSPHRASE }}
          envs: REGISTRY,{{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
          script: |
            touch .env
            echo REGISTRY=$REGISTRY > .env
            echo GATEWAY_VERSION=latest >> .env
            echo CLIENT_VERSION=latest >> .env
            echo API_VERSION=latest >> .env
            # Login to registry
            docker login -u ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} -p ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} registry.digitalocean.com
            docker stop $(docker ps -a -q)
            docker rm $(docker ps -a -q)
            sudo docker-compose -f docker-compose.ci.yml -p prod pull
            sudo docker-compose -f docker-compose.ci.yml -p prod up -d

So now, we need to uncomment our jobs, commit all changes and run our Workflow.

CICD (Github Action)

After the process is finished, open the Registry page.
As you can see our Garbage Collection was started.

CICD (Digitalocean docker registry)

That’s all, if you have some problems with this step please reach out on comments or Instagram.

Thank you for reading 🙂

Leave a Reply