In today’s fast-paced development environment, deploying applications efficiently and reliably is essential. Manual deployments are time-consuming and prone to errors, making Continuous Integration and Continuous Deployment (CI/CD) practices essential. By automating build, test, and deployment processes, developers can ship code faster and with greater confidence.
This article walks through setting up a CI/CD pipeline using GitHub Actions to automate the deployment of a Node.js Express app. We’ll configure it to automatically build, publish a Docker image, and deploy the app to a remote server with minimal intervention. This setup enables you to focus on code improvement while ensuring updates reliably reach production.
The Problem:
For teams managing multiple applications, manual deployments can become a bottleneck, especially when multiple branches, environments, and code versions need frequent updates. A CI/CD pipeline using GitHub Actions helps streamline this process by automatically handling builds, Docker image creation, cleanup of old deployments, and final deployment to the server.
By the end of this article, you’ll have a functional pipeline that not only builds and packages your Express app into a Docker container but also deploys it to a live server, ensuring a smooth, continuous flow from code to production.
Prerequisites:
Before starting, ensure you have the following:
• EC2 Ubuntu Machine (or another Linux server) with Docker installed and SSH access configured.
• GitHub Repository with a master branch and optionally a development branch.
• Docker Hub Account for storing Docker images.
• Basic familiarity with GitHub Actions and Docker.
Setting Up the Project
We’ll use a simple Express application as the basis of our CI/CD pipeline. This app will serve as a lightweight example, but the setup can be applied to any Node.js application.
1. Creating a Basic Express App
Create a new directory for your project and initialize it:
mkdir cicd-express-app
cd cicd-express-app
npm init -y
nstall Express:
npm install express
Create an index.js file for the app:
const express = require('express')
const app = express()
const port = 3000
app.get('/', (req, res) => {
res.send('Hi There, This application is deployed on EC2 using GitHub Acton! ')
})
app.listen(port, () => {
console.log(`Example app listening on port ${port}`)
})
To test, run the app with:
node index.js
You should see Hello from Express app! on http://localhost:3000.
2. Adding a Dockerfile
To containerize the application, create a Dockerfile:
# Use an official Nde.js runtime as a parent image
FROM node:14
# Set the working directory
WORKDIR /app
# Copy package.json and install dependencies
COPY package*.json ./
RUN npm install
# Copy the rest of the app’s code
COPY . .
# Expose the port
EXPOSE 3000
# Command to run the app
CMD ["node", "index.js"]
To build and test the Docker image locally:
docker build -t my-express-app .
docker run -p 3000:3000 my-express-app
Setting Up GitHub Actions Workflow
To automate the build, Dockerization, and deployment, we’ll use a GitHub Actions YAML configuration file. This file contains all the instructions that GitHub Actions needs to execute whenever you push code changes to the master branch.
- Create a Workflow Directory:
In your project root, create a .github/workflows directory for GitHub Actions configurations.
mkdir -p .github/workflows
- Add a Workflow File:
Create a file named ci-cd.yml in .github/workflows. This YAML file will define the steps of our CI/CD pipeline.
- Define Triggers:
Set the workflow to trigger on pushes to the master, or any branch you want, branch.
name: CI/CD Pipeline
on:
push:
branches:
- master
- Set Up Build Job:
In the build job, we will set up Node.js, check out the code, and install dependencies.
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Node.js
uses: actions/setup-node@v2
with:
node-version: '14'
- name: Install dependencies
run: npm install
- Build and Publish Docker Image:
Log in to Docker Hub, build the Docker image, and push it to your Docker Hub repository.
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
run: |
docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/my-express-app:latest .
docker push ${{ secrets.DOCKERHUB_USERNAME }}/my-express-app:latest
- Clean Up Old Docker Containers:
Use SSH to connect to the server and remove old containers to avoid conflicts during deployment.
cleanDocker:
runs-on: ubuntu-latest
steps:
- name: Clean up old Docker containers
uses: appleboy/ssh-action@v0.1.1
with:
host: ${{ secrets.HOST_IP }}
username: ${{ secrets.SSH_USERNAME }}
key: ${{ secrets.HOST_SSH_PRIVATE_KEY }}
script: |
docker stop my-express-app || true
docker rm my-express-app || true
- Deploy the New Docker Container:
Connect via SSH to the server, pull the latest Docker image, and run it.
deployDocker:
runs-on: ubuntu-latest
needs: cleanDocker
steps:
- name: Deploy new Docker container
uses: appleboy/ssh-action@v0.1.1
with:
host: ${{ secrets.HOST_IP }}
username: ${{ secrets.SSH_USERNAME }}
key: ${{ secrets.HOST_SSH_PRIVATE_KEY }}
script: |
docker pull ${{ secrets.DOCKERHUB_USERNAME }}/my-express-app:latest
docker run -d -p 80:3000 --name my-express-app ${{ secrets.DOCKERHUB_USERNAME }}/my-express-app:latest
Detailed YAML Configuration Walkthrough
- on Trigger: The workflow runs whenever there’s a push to the master branch.
- Build Job: Sets up Node.js, checks out the code, and installs and builds the project. It verifies the application’s compatibility before proceeding.
- Docker Publish Job: This job logs into Docker Hub, builds the Docker image, and pushes it to Docker Hub.
- Clean Docker Job: SSHes into the server to stop and remove any previous containers, preventing conflicts and freeing resources for the new deployment.
- Deploy Docker Job: SSHes into the server, pulls the latest Docker image from Docker Hub, and runs it in detached mode (-d). It maps the app’s port 3000 to port 80, making it accessible publicly.
- Using appleboy/ssh-action for Remote Deployment
The appleboy/ssh-action GitHub Action is a powerful tool that lets you securely execute commands on a remote server over SSH directly from your GitHub Actions workflow. This action is particularly useful for deploying applications, managing infrastructure, or running maintenance scripts on remote servers without needing additional CI/CD tools.
Key Features:
- Secure Remote Access: Establishes SSH connections with credentials stored in GitHub Secrets.
- Command Execution: Allows any command to be run on the remote server, such as Docker commands, filesystem operations, and application startup scripts.
- Flexible Authentication: Supports password-based, private key, and multi-server authentication configurations, making it adaptable for various deployment environments.
In this workflow, the appleboy/ssh-action action is used in both cleanDocker and deployDocker jobs to perform remote tasks like stopping and removing containers and running the latest Docker image on the server.
- GitHub Secrets Management
Sensitive information, such as SSH credentials, Docker Hub credentials, and server IPs, are stored in GitHub Secrets to keep credentials secure. To set secrets:
- Go to your GitHub repository.
- Click on Settings > Secrets > Actions > New Repository Secret.
- Add secrets like DOCKERHUB_USERNAME, DOCKERHUB_TOKEN, HOST_IP, and HOST_SSH_PRIVATE_KEY.
Testing the Workflow
Once configured, you can test the workflow by pushing a commit to the master branch. GitHub Actions should trigger and display the workflow’s progress under the Actions tab. After a successful workflow run:
- The app will be built and tested.
- A Docker image will be created and pushed to Docker Hub.
- The server will pull and run the new image, making your updates live.
Conclusion
With this setup, you have a functional CI/CD pipeline using GitHub Actions and Docker. By integrating automated builds, containerization, and secure deployment, you can deliver updates faster and more reliably. This approach saves time and ensures consistent, high-quality deployments across different environments.
This setup is easily adaptable, allowing you to apply it across multiple applications and environments as your projects grow.
Optional Reference Links: