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

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

In this tutorial, I’ll show you how to Build, Test, Dockerize, and Deploy Full-Stack App to DigitalOcean.

We’ll create a complete CI/CD process utilizing GitHub Actions. The front end will be developed using React while Node.js will power the back end. Jest will be used for comprehensive testing. Docker and Docker-compose will be utilized to package the application into containers. Nginx will act as a routing solution between the React and Node.js servers and also serve as an HTTP server for the React build. The final deployment will be handled by GitHub Actions which will perform builds, tests, containerization, push Docker images to the DigitalOcean Registry and finally deploy to a DigitalOcean Droplet.

Structure of the Project

Let’s get started by creating a root directory for our project. Within this root directory, we will create two subdirectories, client for our client-side and api for the server-side.


├───  client  
└───  api 
   

Build the React App (Client)

React is a powerful JavaScript library that revolutionizes UI development with its declarative approach. With React, developers can efficiently build dynamic, responsive user interfaces that bring web applications to life.

Step 1: Create an App

So, let’s create a new React App inside the client folder.

cd client
npx create-react-app . 

Step 2: Project Structure

└─── client
    │
    ├─── public
    │ index.html
    ├─── src
    │       App.css
    │       App.js
    │       App.test.js
    │       ...
    └─── package.json    

After finishing the NPX process you’ll see the simple structure of React app.

  • In the src folder, you can see App.js, a root component of the React Project.
  • App.css – is a stylesheet file.
  • In App.test.js you’ll see your first test. The Jest library is used by default for testing React applications.

Step 3: Run App

So, now let’s run our application. To do this you need to run npm start in the terminal and open http://localhost:3000/

CICD (react)

Step 4: Run Tests

Now, let’s run the testing script, npm run test in the terminal and press a to run all tests.

CICD (react jest)

Okay, everything is good, now let’s create a server project.

Build the Node js App (API)

Step 1: Create a server project

We need to initialize the project with npm. To do this go to the api folder and run npm init -y in the terminal.
This command will create package.json for our new Node JS project.

Step 2: Install dependencies

  • Express – is a Node.js framework that allows us to build API endpoints and make server requests for data. We can develop several functions that will aid in processing extra CRUD requests with the aid of Express.
  • CORS – Cross-Origin Resource Sharing is an HTTP header-based mechanism implemented by the browser that allows you to use a server or API specifying any origins other than the origin, from an unknown origin, and get permission to access and download resources.
  • Nodemon – is a popular tool used for the development of Node JS applications. It observes the working directory of your project and restarts the node application whenever it has any changes.
  • Jest – is a JavaScript Testing Framework.

So, let’s install them.
Nodemon and Jest need only for development, so, let’s install them with a --save-dev flag.

npm install express cors
npm install nodemon jest --save-dev

Step 3: Project structure

Okay, let’s add app files.
app.js – it is a main file. In this file we’ll create express server, add cors to express app, add test router and run the server on 8080 port.

//src/app.js

//import dependencies
const express = require('express');
const cors = require('cors');

// define the port
const PORT = 8080;

// create the express app.
const app = express();

// enable CORS security headers.
app.use(cors());

// add a test route
app.get('/test', (req, res) => {
    res.json({success: true, message: "API"})
})
// Run app on the 8080 port
app.listen(PORT, () => {
    console.log(`Running on http://localhost:${PORT}`);
})

And let’s add script for run the our server.
Just add "dev": "nodemon -L src/app.js" to scripts block in package.json.
To start our server type npm run dev in the terminal and open http://localhost:8080/test.

CICD (node js)

Okay, it works.
Now let’s create app.test.js file.
In the future you’ll be able to add more complex test case to testing you server code.

//src/app.test.js

describe('Test Suite', () => {
  it('Test Case', () => {
    expect(true).toEqual(true);
  });
});

And let’s also add jest.config.json.

//jest.config.json

{
    "testRegex": "((\\.|/*.)(test))\\.js?$"
}

And last thing, add start and test scripts to package.json.

"start": "node src/app.js",
"dev": "nodemon -L src/app.js",
"test": "jest --config ./jest.config.json"

To run tests type npm run test in the terminal.

CICD (node js jest)

Okay, let’s look at the structure.

└─── api
    │
    ├─── src
    │       app.js
    │       app.test.js
    ├─── jest.config.json
    └─── package.json 

Setting up the docker-compose for development

So, now we can dockerize our client and server applications.

Step 1: Create Client Dockerfile

In the client folder create Dockerfile for development Dockerfile.dev.

//client/Dockerfile.dev

# Pull in from the official node image.
FROM node:16.14.0-alpine
# Create app folder
WORKDIR /app
# Set environment
ENV NODE_ENV development
# Copy package.json and package-lock.json to the app folder
COPY package*.json ./
# Install packages
RUN npm install
# Copy all project files to app folder
COPY . .
# Make port 3000 available for links and/or publish
EXPOSE 3000
# Start our react app when launching the container
CMD ["npm", "run", "start"]

Step 2: Create docker-compose.dev.yml

Let’s create docker compose config in the root of the project and add a client service.

//docker-compose.dev.yml

version: "3.8"

services:
  client:
    container_name: client
    image: client
    build:
      context: ./client
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
    volumes:
      - "./client:/app"
      - "/app/node_modules"
    restart: always
  • container_name – is the name of our container.
  • image – is our own image.
  • build – is a build config with a path to Dockerfile we created above.
  • ports – this container will be available on 3000 port.
  • volumes – needs for development to reload the container when we change some files in the client project.
  • restart – means that docker-compose will restart the container if it stops or crashes.

Step 3: Run Docker containers

Let’s start our client container. To do this, enter the code below in the terminal (you must be in the root folder).

docker-compose -f docker-compose.dev.yml up 
CICD (docker-compose react)

If you open the docker client you’ll see our client container which runs on the 3000 port.

CICD (docker client)

Okay, open http://localhost:3000.

CICD (react app docker-compose)

Great!
Now let’s containerize out server side.

Step 4: Create Api Dockerfile

Create a Dockerfile.dev file for development.

// api/Dockerfile.dev

# Pull in from the official node image.
FROM node:16.14.0-alpine
# Create app folder
WORKDIR /app
# Set environment
ENV NODE_ENV development
# Copy package.json and package-lock.json to the app folder
COPY package*.json ./
# Install packages
RUN npm install
# Copy all project files to app folder
COPY . .
# Make port 8080 available for links and/or publish
EXPOSE 8080
# Start our app with nodemon when launching the container
CMD ["npm", "run", "dev"]

And we also need to add api service to docker-compose.dev.yml.

//docker-compose.dev.yml

version: "3.8"

services:
  client:
    ...
  api:
    container_name: api
    image: api
    build:
      context: ./api
      dockerfile: Dockerfile.dev
    ports:
      - "8080:8080"
    volumes:
      - "./api:/app"
      - "/app/node_modules"
    restart: always

Great, let’s start our containers. Run the code below in the terminal.

Step 5: Run Docker containers

docker-compose -f docker-compose.dev.yml up 
CICD (docker-compose node js)

And now, if you open docker app you’ll see two containers, client and api.

CICD (docker client)

Let’s check it. Open localhost:8080/test

CICD (docker node js)

Great. And if you change something in app.js you’ll see that container reloaded.

Setting up Nginx server

Let’s create nginx folder in the root of the project and create a config.

Step 1: Nginx Configs

First, let’s create a config folder configs and inside it create default.conf file.

//nginx/configs/default.conf

upstream api {
  server api:8080;
}

upstream client {
  server client:3000;
}

server {
  listen 80;

  location /api {
    rewrite /api/(.*) /$1 break;
    proxy_pass http://api;
  }

  location / {
    proxy_pass http://client;
  }
}

In this config we have the two applications upstreams, client and server(API) and Nginx server listening to port 80.
Now you’ll be able open localhost without any port. If we open localhost, Nginx will redirect us to the client server.

Step 2: Nginx Dockerfile

Inside nginx create Dockerfile.

//nginx/Dockerfile

# Pull in from the official nginx image.
FROM nginx:stable-alpine

# Delete the default welcome page.
RUN rm /usr/share/nginx/html/*

# Copy over the custom default configs.
COPY configs/default.conf /etc/nginx/conf.d/default.conf

# Make port 80 available for links and/or publish
EXPOSE 80

# Start nginx in the foreground to play nicely with Docker.
CMD ["nginx", "-g", "daemon off;"]

Step 3: Add gateway to docker-compose config

Let’s add nginx to docker-compose.dev.yml

//docker-compose.dev.yml

version: "3.8"

services:
  nginx:
    container_name: gateway
    image: gateway
    build:
      context: ./nginx
      dockerfile: Dockerfile
    ports:
      - "80:80"
    depends_on:
      - client
      - api
    restart: always
  client:
    ...
  api:
    ...

This is almost the same service as client or api except depends_on.
depends_on – it sets the order in which services should start and stop.

Step 4: Run Docker containers

docker-compose -f docker-compose.dev.yml up 
CICD (docker-compose)

Okay, all containers started, let’s open docker app.

CICD (docker client)

Yes, now you can see three containers, api, client and gateway.
Let’s check nginx in the browser. Open http://localhost

CICD (docker nginx)

As you can see we don’t use port to open our app.
Let’s check api server. Open http://localhost/api/test.

CICD (docker nginx)

Yes, everything works correctly.
I want to note that reload the client and server will also work with nginx.

Setting up the docker-compose for production

At the root of your project, create a docker-compose.yml file.

Step 1: Client Dockerfile

Create Dockerfile in the client folder.

//client/Dockerfile

# Pull in from the official node image.
FROM node:16.14.0-alpine AS builder
# Create app folder
WORKDIR /app
# Set environment
ENV NODE_ENV production
# Copy package.json and package-lock.json to the app folder
COPY package*.json ./
# Install packages with production flag
RUN npm ci --only=production
# Copy all project files to app folder
COPY . .
# Make a new production build
RUN npm run build

# Pull in from the official nginx image.
FROM nginx:stable-alpine
# Copy all built files to nginx folder
COPY --from=builder /app/build /usr/share/nginx/html
# Copy over the custom default configs.
COPY --from=builder /app/nginx/default.conf /etc/nginx/conf.d/default.conf
# Make port 3000 available for links and/or publish
EXPOSE 3000
# Start nginx in the foreground to play nicely with Docker.
CMD ["nginx", "-g", "daemon off;"]

Unlike the development configuration, we install npm packages with production flag and make a production build.
The production build creates a build folder, there are index.html, css, js scripts, and other media files.
Then we copy the all files from build folder to /app/build directory.
Then we run Nginx on the 3000 port.
Nginx will serve the content from the build folder whenever it receives a request on the root path (http://localhost).

So, let’s add nginx/default.conf file.

//client/nginx/default.conf

server {
  listen 3000;

  location / {
    root /usr/share/nginx/html;
    index index.html index.htm;
    try_files $uri $uri/ /index.html;
  }
}

Step 2: Server (API) Dockerfile

Okay, now let’s create a production Dockerfile for server.

//api/Dockerfile

# Pull in from the official node image.
FROM node:16.14.0-alpine
# Create app folder
WORKDIR /app
# Set environment
ENV NODE_ENV production
# Copy package.json and package-lock.json to the app folder
COPY package*.json ./
# Install packages with production flag
RUN npm ci --only=production
# Copy all project files to app folder
COPY . .
# Make port 8080 available for links and/or publish
EXPOSE 8080
# Start our command when launching the container
CMD ["npm", "run", "start"]

Unlike the development configuration, we also install prodiction packages and run “start” script, not “dev”.
In production, we don’t need nodemon as we have a new build we need to stop the old container and start a new one.

Step 3: Create docker-compose.yml for production

And the lst thing, let’s create docker-compose.yml file

//docker-compose.yml

version: "3.8"

services:
  nginx:
    container_name: gateway
    image: gateway
    build:
      context: ./nginx
      dockerfile: Dockerfile
    ports:
      - "80:80"
    depends_on:
      - client
      - api
    restart: always
  client:
    container_name: client
    image: client
    build:
      context: ./client
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    restart: always
  api:
    container_name: api
    image: api
    build:
      context: ./api
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    restart: always

As you can notice that this config is almost the same as the docker-compose config for dev except for one thing, in this config we don’t need volumes as we don’t need source code for real-time reloading.

Let’s check it.
Before running the docker containers make sure that you stopped and removed old containers and images.

docker-compose -f docker-compose.yml up -d

Let’s open docker app.

CICD (docker client)

Great! Now you can check client and api services in the browser. open http://localhost and http://localhost/api/test.

This is the end of the first part of this article.
In the next article, you will find a detailed guide to building CI/CD using GitHub Action and DigitalOcean.

Thank you for reading 🙂

Leave a Reply