CI/CD Pipeline With GitHub Actions: A Practical Guide
Hey guys! Today, we're diving into creating a robust CI/CD pipeline using GitHub Actions. We'll focus on automating the build and deployment of both a frontend and backend application to production, triggering these actions whenever changes occur in their respective directories. Plus, we'll tackle the crucial aspect of version bumping. Let's get started!
Understanding the Goal
Our primary objective is to set up an automated system that streamlines the software delivery process. This means that whenever a developer pushes changes to the frontend or backend codebase, our pipeline should automatically:
- Build the application.
 - Run tests to ensure code quality.
 - If all tests pass, deploy the application to the production environment.
 
Additionally, we need a mechanism to automatically update the application's version number following semantic versioning principles (x.x.patch, x.minor.patch, major.minor.patch). This is essential for tracking releases and managing dependencies.
Project Structure and Assumptions
Before we begin, let's assume the following project structure:
/
├── frontend/
│   ├── Dockerfile
│   └── ...
├── backend/
│   ├── Dockerfile
│   └── ...
├── .github/
│   └── workflows/
│       ├── frontend.yml
│       └── backend.yml
├── docker-compose.yml (Optional)
└── ...
We have separate directories for the frontend and backend, each containing its own Dockerfile for containerization. The .github/workflows directory will house our GitHub Actions workflow files.
Key Components:
- Frontend Application: A web application that interacts with the backend.
 - Backend Application: An API or server-side application that serves data to the frontend.
 - Docker: Used to containerize both applications for consistent deployment.
 - GitHub Actions: Our CI/CD platform for automating the build, test, and deployment processes.
 
Step 1: Setting Up the Backend CI/CD Pipeline
Let's start by configuring the CI/CD pipeline for the backend application. This involves creating a workflow file in .github/workflows directory named backend.yml.
Creating the backend.yml Workflow
Here's a sample backend.yml file:
name: Backend CI/CD
on:
  push:
    branches:
      - main # or your main branch name
    paths:
      - 'backend/**'
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
      - name: Set up Node.js (if applicable)
        uses: actions/setup-node@v3
        with:
          node-version: '16'
      - name: Install dependencies
        run: cd backend && npm install # or yarn install
      - name: Run tests
        run: cd backend && npm test # or yarn test
      - name: Build Docker image
        run: |
          cd backend
          docker build -t jauntdetour-backend:latest .
          docker tag jauntdetour-backend:latest jauntdetour-backend:${GITHUB_SHA}
      - name: Push Docker image to Docker Hub (or other registry)
        if: github.ref == 'refs/heads/main'
        run: |
          docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_PASSWORD }}
          docker push jauntdetour-backend:latest
          docker push jauntdetour-backend:${GITHUB_SHA}
      - name: Deploy to Production (example using SSH)
        if: github.ref == 'refs/heads/main'
        uses: appleboy/ssh-action@v0.1.10
        with:
          host: ${{ secrets.PRODUCTION_HOST }}
          username: ${{ secrets.PRODUCTION_USER }}
          key: ${{ secrets.PRODUCTION_SSH_KEY }}
          script: |
            docker stop jauntdetour-backend || true
            docker rm jauntdetour-backend || true
            docker pull jauntdetour-backend:latest
            docker run -d --name jauntdetour-backend -p 8000:8000 jauntdetour-backend:latest
Explanation of the Workflow
name: Defines the name of the workflow.on: Specifies the trigger for the workflow. In this case, it's triggered onpushevents to themainbranch when files in thebackend/**directory are modified.jobs: Defines the jobs to be executed.build: This job runs on an Ubuntu runner.steps: A sequence of tasks to be performed.Checkout code: Checks out the code from the repository.Set up Node.js: Sets up Node.js environment (if your backend uses Node.js).Install dependencies: Installs the backend dependencies.Run tests: Executes the backend tests.Build Docker image: Builds the Docker image for the backend.Push Docker image to Docker Hub: Pushes the Docker image to Docker Hub (you'll need to configure secrets for your Docker Hub username and password).Deploy to Production: Deploys the application to the production server using SSH (you'll need to configure secrets for your production host, username, and SSH key). This step pulls the latest image from the container registry, stops the currently running container, removes it, and then starts a new container with the updated image. Make sure the port mapping-p 8000:8000matches your application's port.
Setting Up Secrets
You'll need to configure the following secrets in your GitHub repository settings (Settings -> Secrets -> Actions):
DOCKERHUB_USERNAME: Your Docker Hub username.DOCKERHUB_PASSWORD: Your Docker Hub password.PRODUCTION_HOST: The hostname or IP address of your production server.PRODUCTION_USER: The username for SSH access to your production server.PRODUCTION_SSH_KEY: The SSH private key for accessing your production server.
Important Considerations:
- Security: Never commit your secrets directly to the repository. Always use GitHub Secrets.
 - Deployment Strategy: The deployment step uses a simple SSH command to stop, remove, pull, and run the Docker container. Consider more sophisticated deployment strategies for zero-downtime deployments in production environments, such as blue-green deployments or rolling updates.
 - Error Handling: Add error handling to your deployment script to ensure that deployments are rolled back if they fail.
 
Step 2: Setting Up the Frontend CI/CD Pipeline
The process for setting up the frontend CI/CD pipeline is very similar to the backend. Create a new workflow file named frontend.yml in the .github/workflows directory.
Creating the frontend.yml Workflow
name: Frontend CI/CD
on:
  push:
    branches:
      - main # or your main branch name
    paths:
      - 'frontend/**'
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '16'
      - name: Install dependencies
        run: cd frontend && npm install # or yarn install
      - name: Run tests
        run: cd frontend && npm test # or yarn test
      - name: Build Docker image
        run: |
          cd frontend
          docker build -t jauntdetour-frontend:latest .
          docker tag jauntdetour-frontend:latest jauntdetour-frontend:${GITHUB_SHA}
      - name: Push Docker image to Docker Hub
        if: github.ref == 'refs/heads/main'
        run: |
          docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_PASSWORD }}
          docker push jauntdetour-frontend:latest
          docker push jauntdetour-frontend:${GITHUB_SHA}
      - name: Deploy to Production (example using SSH)
        if: github.ref == 'refs/heads/main'
        uses: appleboy/ssh-action@v0.1.10
        with:
          host: ${{ secrets.PRODUCTION_HOST }}
          username: ${{ secrets.PRODUCTION_USER }}
          key: ${{ secrets.PRODUCTION_SSH_KEY }}
          script: |
            docker stop jauntdetour-frontend || true
            docker rm jauntdetour-frontend || true
            docker pull jauntdetour-frontend:latest
            docker run -d --name jauntdetour-frontend -p 80:80 jauntdetour-frontend:latest
Explanation of the Workflow
This workflow is almost identical to the backend workflow, with the following key differences:
name:Frontend CI/CD.paths: Triggers on changes in thefrontend/**directory.Docker image name: Usesjauntdetour-frontendfor the Docker image.Port mapping: Thedocker runcommand maps port 80 to port 80. Adjust this to match your frontend application's port if needed.
Reusing Secrets
You can reuse the same secrets (DOCKERHUB_USERNAME, DOCKERHUB_PASSWORD, PRODUCTION_HOST, PRODUCTION_USER, PRODUCTION_SSH_KEY) that you configured for the backend workflow.
Important Considerations:
- Frontend Build Process: Ensure that your frontend build process generates optimized and production-ready assets. This might involve bundling, minification, and other optimization techniques.
 - Caching: Consider caching dependencies and build artifacts to speed up the build process. GitHub Actions provides caching mechanisms that can be used to store and retrieve these items.
 
Step 3: Implementing Version Bumping
Now, let's address the crucial part of automating version bumping. We want to automatically increment the version number (x.x.patch) whenever changes are pushed to the repository, unless it's explicitly specified that the release is a major or minor release.
Versioning Strategy
We'll use a simple semantic versioning strategy:
- Patch Release (x.x.patch): Incremented for bug fixes and minor changes.
 - Minor Release (x.minor.patch): Incremented for new features that are backward compatible.
 - Major Release (major.minor.patch): Incremented for breaking changes.
 
Implementation Approach
We'll use a combination of tools and techniques to achieve this:
standard-version: A popular Node.js package that automates version bumping based on commit messages following the Conventional Commits specification.- Commit Message Analysis: Analyze commit messages to determine if a major or minor release is required. Keywords like 
BREAKING CHANGEorfeat:can be used to trigger major or minor version bumps, respectively. - GitHub Actions Workflow Modifications: Modify the workflow files to incorporate the version bumping logic.
 
Detailed Steps
- 
Install
standard-version:Add
standard-versionas a dev dependency to both your frontend and backend projects.cd frontend npm install --save-dev standard-version cd backend npm install --save-dev standard-version - 
Modify Workflow Files:
Update your
backend.ymlandfrontend.ymlfiles to include the version bumping steps.name: Backend CI/CD on: push: branches: - main # or your main branch name paths: - 'backend/**' jobs: build: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 with: fetch-depth: 0 # Important for standard-version - name: Set up Node.js uses: actions/setup-node@v3 with: node-version: '16' - name: Install dependencies run: cd backend && npm install # or yarn install - name: Run tests run: cd backend && npm test # or yarn test - name: Determine Version Bump id: version-bump run: | cd backend if git log -1 --pretty=%B | grep -q "BREAKING CHANGE"; then echo "::set-output name=release_type::major" elif git log -1 --pretty=%B | grep -q "feat:"; then echo "::set-output name=release_type::minor" else echo "::set-output name=release_type::patch" fi - name: Bump version and create Changelog run: | cd backend if [ "${{ steps.version-bump.outputs.release_type }}" = "major" ]; then npm run standard-version -- --release-as major elif [ "${{ steps.version-bump.outputs.release_type }}" = "minor" ]; then npm run standard-version -- --release-as minor else npm run standard-version fi - name: Update package.json version id: update-version uses: actions/github-script@v6 with: script: | const fs = require('fs'); const path = require('path'); const packageJsonPath = path.join(__dirname, 'backend', 'package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); const newVersion = packageJson.version; core.setOutput('new-version', newVersion); - name: Commit changes run: | cd backend git config --local user.email "actions@github.com" git config --local user.name "GitHub Actions" git add package.json CHANGELOG.md git commit -m "chore(release): v${{ steps.update-version.outputs.new-version }}" git push - name: Create Git tag run: | cd backend git tag v${{ steps.update-version.outputs.new-version }} git push origin v${{ steps.update-version.outputs.new-version }} - name: Build Docker image run: | cd backend docker build -t jauntdetour-backend:latest . docker tag jauntdetour-backend:latest jauntdetour-backend:v${{ steps.update-version.outputs.new-version }} - name: Push Docker image to Docker Hub if: github.ref == 'refs/heads/main' run: | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_PASSWORD }} docker push jauntdetour-backend:latest docker push jauntdetour-backend:v${{ steps.update-version.outputs.new-version }} - name: Deploy to Production (example using SSH) if: github.ref == 'refs/heads/main' uses: appleboy/ssh-action@v0.1.10 with: host: ${{ secrets.PRODUCTION_HOST }} username: ${{ secrets.PRODUCTION_USER }} key: ${{ secrets.PRODUCTION_SSH_KEY }} script: | docker stop jauntdetour-backend || true docker rm jauntdetour-backend || true docker pull jauntdetour-backend:latest docker run -d --name jauntdetour-backend -p 8000:8000 jauntdetour-backend:latest - 
Explanation of the Version Bumping Steps:
fetch-depth: 0: This is crucial forstandard-versionto analyze the commit history.Determine Version Bump: Analyzes the latest commit message forBREAKING CHANGEorfeat:keywords to determine the release type (major, minor, or patch).Bump version and create Changelog: Runsstandard-versionto bump the version number inpackage.jsonand generate aCHANGELOG.mdfile.Update package.json version: Gets the version frompackage.jsonand stores as the output variable.Commit changes: Commits the updatedpackage.jsonandCHANGELOG.mdfiles.Create Git tag: Creates a Git tag for the new version.Build Docker image: Builds the Docker image with the new version tag.Push Docker image to Docker Hub: Pushes the Docker image to Docker Hub with the new version tag.
 
Important Considerations:
- Conventional Commits:  Encourage your team to follow the Conventional Commits specification for writing commit messages. This ensures that 
standard-versioncan accurately determine the appropriate version bump. - Customizable Versioning:  
standard-versionis highly customizable. You can configure it to use different commit message patterns or versioning schemes. 
Step 4: Testing the Pipeline
Now that we've set up the CI/CD pipelines, it's time to test them out! Make a small change to either the frontend or backend codebase and push it to the main branch.  Monitor the GitHub Actions tab in your repository to see the pipeline in action.  Verify that the application is successfully built, tested, and deployed to production. Also, check your container registry to confirm that the Docker images are being pushed with the correct tags.
Conclusion
That's it! You've successfully created a CI/CD pipeline using GitHub Actions for your frontend and backend applications. This pipeline automates the build, test, and deployment processes, making it easier to deliver high-quality software quickly and efficiently. Remember to adapt these examples to fit your specific project needs and explore the many other features and capabilities of GitHub Actions.
By implementing these steps, you'll have a fully automated CI/CD pipeline that streamlines your development workflow and ensures that your applications are always up-to-date. This not only saves you time and effort but also reduces the risk of errors and improves the overall quality of your software.