How to Set Up a CI/CD Pipeline with GitHub Actions (Node.js Example)

What Is GitHub Actions?GitHub

Introduction to CI/CD and GitHub Actions

Continuous Integration and Continuous Delivery (CI/CD) is a DevOps practice that automates the build, test, and deployment of code whenever changes are pushed to a repository. In simple terms, Continuous Integration (CI) means new code changes are automatically tested (and often merged), ensuring that integration issues are caught early, while Continuous Delivery/Deployment (CD) automates releasing the tested code to an environment so the software is always ready for deployment. This automation makes software releases faster and less error-prone, eliminating the tedious manual steps that slow down development. Without CI/CD, developers must manually test and deploy code – a process that is time-consuming, error-prone, and inefficient. Adopting a CI/CD pipeline speeds up development cycles, reduces bugs, and ensures smooth, reliable deployments.

GitHub Actions is GitHub’s integrated CI/CD platform that allows you to automate your workflow directly in your GitHub repository. With GitHub Actions, you can build, test, and deploy your code right from GitHub, making it a convenient choice for CI/CD. It supports a wide range of languages and frameworks (Node.js, Python, Java, etc.), so you can use it for virtually any project. In this guide, we’ll walk through setting up a CI/CD pipeline for a Node.js project using GitHub Actions, including stages for testing, building, and deploying the application. (Note: The concepts discussed apply to other languages as well — we choose Node.js here for clarity and popularity.)

Setting Up the Project Repository

1. Create a Node.js Project: Begin by creating a simple Node.js application. For example, you might use Express to set up a basic “Hello World” web server. Initialize a new Node project with npm init -y, install any dependencies (e.g. npm install express), and create an entry point file (say, server.js for an Express app). Ensure your app can start (perhaps add a "start": "node server.js" script in your package.json). If needed (for Heroku deployment), add a Procfile with the start command (e.g. web: npm start) so the host knows how to run your app. Once your Node.js app is set up, test it locally: run npm start and open http://localhost:3000 in your browser to verify you see the expected output (for example, “Hello World!” from your app). This local verification ensures everything works before automating the pipeline.

2. Initialize Git and push to GitHub: Next, create a new GitHub repository for your project. Inside your project folder, initialize a git repo and add your files. Commit your code and push it to GitHub (e.g. on the main branch). This remote repository will be where GitHub Actions runs your CI/CD pipeline. (If you’re starting from an existing repo, make sure your latest code is pushed to GitHub.)

3. Configure repository secrets (for deployment credentials): If your pipeline will need credentials (for example, a Heroku API key or Docker Hub credentials for deployment), store them as Secrets in your GitHub repository – never hard-code passwords or API keys in your code. In your GitHub repo, go to Settings > Secrets and variables > Actions, then click “New repository secret”. Add secrets for each credential your pipeline will need (for instance, HEROKU_API_KEYHEROKU_APP_NAMEDOCKER_USERNAMEDOCKER_PASSWORD, etc.). Secrets are encrypted and will be available to your GitHub Actions workflow as environment variables (e.g. secrets.HEROKU_API_KEY).

Figure: GitHub repository secrets management (each secret has a name and is stored securely, allowing the workflow to use it without exposing sensitive values).

With the Node.js project on GitHub and secrets configured, you’re ready to create the CI/CD workflow file.

Creating a GitHub Actions Workflow

GitHub Actions workflows are defined in YAML files within your repository. Create a folder called .github/workflows in the root of your repo (if it doesn’t exist already) and add a YAML file (e.g. ci-cd.yml) inside it. This YAML file will describe the pipeline: when to run it, what jobs it includes, and what steps each job will execute. Let’s break down a simple workflow file targeting our Node.js project:

Workflow Trigger: We want the pipeline to run on certain events. For CI/CD of a main branch, a common trigger is on: push to the main branch. You might also add on: pull_request for CI runs on pull requests (to run tests before merging) or triggers like workflow_dispatch (manual trigger) if needed. Here we’ll trigger on pushes to the main branch (you can adjust the branch name as needed):

name: Node.js CI/CD Pipeline

on:
  push:
    branches: [ "main" ]

This means whenever code is pushed to the main branch, the workflow will start. (In a larger team, you might run CI on pull requests to main, and only deploy when changes are merged to main or when a tag/release is created – choose triggers that fit your release process.)

Jobs and Runners: A workflow can have one or more jobs that run in parallel or sequence. Each job runs on a runner (e.g., an Ubuntu virtual machine by default). For clarity, we will define two jobs: one for “build and test” (the CI part) and another for “deploy” (the CD part). The deploy job will depend on the success of the build/test job. You can also do everything in a single job (for simpler pipelines), but separating jobs can help logically and can allow parallelism or gated deployment.

Let’s define the first job to build and test our Node.js app:

jobs:
  build-test:
    runs-on: ubuntu-latest  # use a Linux runner
    steps:
      - name: Check out code
        uses: actions/checkout@v3

      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'   # Use Node.js 18 (LTS) for the pipeline

      - name: Install dependencies
        run: npm install

      - name: Run tests
        run: npm test

      - name: Build application
        run: npm run build

Let’s unpack this: Under jobs, we defined a job named build-test that uses the latest Ubuntu runner. The steps in this job do the following:

  • Checkout code: Uses the official actions/checkout@v3 action to pull your repository’s code onto the runner. (Without this, your workflow runs in an empty VM with no code!).
  • Set up Node.js: Uses the official actions/setup-node@v3 to install Node.js on the runner, specifying a version. Here we choose Node 18 (a current LTS) – you can specify any version or even test a matrix of versions. After this step, Node and npm are available.
  • Install dependencies: Runs npm install to install packages from your package.json. This uses the default npm registry to pull down dependencies.
  • Run tests: Runs your test suite with npm test. This assumes you have a test script defined (perhaps running a tool like Jest or Mocha). This stage is crucial – it ensures your code changes don’t break existing tests. If tests fail, the workflow will stop here (and GitHub will mark the run as failed).
  • Build application: Runs npm run build to build the app (if your app has a build step). For example, if this is a front-end project, this might create an optimized production bundle. If it’s a Node back-end, this step might transpile TypeScript or otherwise prepare assets. If your project doesn’t require a build (e.g. if you just run the app directly), you can omit this step. It’s shown here for completeness as many real projects have a build stage. (For instance, in a Node/React app, tests would ensure code quality and then npm run build would produce static files for deployment.)

At this point, we have a job that checks out the code, sets up Node, installs dependencies, tests, and builds the project. This covers the CI portion. The next job will handle deployment (CD). We will configure it to run only if the build-test job passed (all tests are green and build succeeded), so we don’t deploy broken code.

Adding a Deployment Job: We’ll create a second job called deploy that will depend on the first job. This job will use the output of the build (in our case, the latest code) and deploy the application to a hosting environment. For this example, let’s deploy to Heroku, a popular platform for Node.js apps. We’ll use a pre-built GitHub Action from the Marketplace that handles Heroku deployment, to keep things simple. (Alternatively, one could deploy to other targets: e.g. deploy a static site to GitHub Pages, or build a Docker image and push to Docker Hub – we’ll note those later.) Ensure you have added the required secrets for deployment: for Heroku you’d need your Heroku API key, and we’ll also use the app name and (optionally) email. These should be in the repository secrets (e.g. HEROKU_API_KEY, and we can also set HEROKU_APP_NAME or just hardcode the name in the workflow file, but using a secret or variable is often better).

Add the deploy job below the first job in the YAML:

  deploy:
    runs-on: ubuntu-latest
    needs: build-test   # run this job only after build-test job succeeds

    steps:
      - name: Check out code
        uses: actions/checkout@v3

      - name: Deploy to Heroku
        uses: akhileshns/[email protected]
        with:
          heroku_api_key: ${{ secrets.HEROKU_API_KEY }}
          heroku_app_name: "<your-heroku-app-name>"
          heroku_email: "<your-heroku-account-email>"

Let’s explain the deploy job: We again check out the code (each job runs in a fresh VM, so we need to pull the code again – or, alternatively, use artifacts from the first job; for simplicity we just fetch the repo). Then we use the Heroku Deploy action (akhileshns/[email protected]) with the required inputs. This action wraps the steps needed to push your code to Heroku. Under the hood, it uses the Heroku CLI to log in using the API key and push the code to the Heroku git remote. We provide it the API key (from secrets), the Heroku app name, and our Heroku account email. The action will take care of deploying the current repository to the specified Heroku app. Once this step runs, if successful, our latest code is live on Heroku.

Note: Make sure the Heroku app name is created and matches the name you specify. Also, your repository should have a Procfile (e.g. web: npm start) so Heroku knows how to start the app. If you’re using another deployment target, the steps would differ: for example, deploying to GitHub Pages might use an action to deploy the build folder to the gh-pages branch, and deploying a Docker container would involve building the Docker image and pushing it to a registry. In fact, you could add another job to this workflow to build and push a Docker image to Docker Hub as part of CD. For instance, after the build step you might use the Docker official actions to log in (docker/login-action) and then run docker build and docker push commands to deliver a container image. The approach will depend on your specific deployment needs.

At this stage, our YAML workflow has two jobs: build-test and deploy. The deploy job uses the needs keyword to ensure it only runs if build-test completed successfully (GitHub Actions won’t run it if the prior job failed). This establishes a clear pipeline: code is built and tested, and only upon success is it deployed. You can further guard deployments by using environments in GitHub Actions. For example, you can assign the deploy job to a protected environment (like “production”) that requires manual approval or that has secrets scoped to it. This would let you insert a manual checkpoint before deployment. (GitHub’s workflow visualization will show such a job waiting for approval – a “gate”.) In our simple case, we’ll assume automated deploy on every push to main, but be aware that in real projects you might want to deploy only on certain triggers (like a git tag for a release) or require someone to approve a production deployment.

Testing the Workflow (Pipeline Execution)

Once you commit and push the workflow file (ci-cd.yml) to the repository, the CI/CD pipeline will automatically trigger whenever its conditions are met (e.g. a push to main). It’s a good idea to do a trial run: for instance, push a small change (like a README update or a trivial code change) to the main branch to see the pipeline in action.

Head over to the Actions tab in your GitHub repository. You should see your workflow listed (by the name we gave it, e.g. “Node.js CI/CD Pipeline”) with a running instance for your recent push. GitHub provides a live log of each step. You can click on the running workflow, then expand each step (Checkout, Install, Test, etc.) to see the console output in real time. If all goes well, the test and build steps will pass, and the deploy job will run and succeed. If any step fails (say tests fail), the workflow will stop and that failure will be reported.

Figure: GitHub Actions workflow visualization for a CI/CD pipeline with multiple jobs (Build/Test and Deploy). In this example, the pipeline has stages for Build and Test (which ran successfully on multiple platforms) and a final Deploy stage that is waiting for an approval – illustrating how workflows can include manual gates for deployment. The GitHub Actions UI shows each job’s status and duration, helping you track the pipeline’s progress.

After the workflow finishes, you can verify the deployment. For our Heroku example, go to your Heroku app’s URL (e.g. https://your-app-name.herokuapp.com) to ensure the app is live and the changes are reflected. In the GitHub repo, you’ll also see checks on commits or pull requests. For instance, if this workflow runs on pull requests, GitHub will show a status like “All checks have passed” when the pipeline succeeds, which acts as a signal that the code is safe to merge. You can click on the “Details” of each check to view the logs if needed. Monitoring the Actions run (and reviewing logs or artifacts) is an important part of using CI/CD – it helps diagnose failures quickly and gives confidence when everything is green.

(If your workflow did not trigger as expected, double-check that the YAML file is in the correct path (.github/workflows), the on: conditions match your push (e.g., branch name), and that the syntax is correct. Common YAML issues like incorrect indentation can cause the workflow to be ignored or fail to parse.)

Best Practices and Common Pitfalls

Setting up a basic pipeline is only the first step. To make your CI/CD robust and efficient, consider these best practices and be aware of common pitfalls:

  • Use Secrets and Secure Credentials: Always store sensitive data (API keys, credentials) in GitHub Secrets, not in plain text in the repo. We did this for the Heroku API key. This keeps secrets encrypted and out of your code. Only expose them to jobs that need them. For example, you can restrict secrets to only be available in the deploy job (using environments or context protections). Exposing secrets accidentally (e.g. in logs or by committing them) is a serious security risk – avoid it at all costs.
  • Pin Action Versions: In our workflow, we referenced actions like actions/checkout@v3 by a version tag. It’s good practice to pin third-party actions to a specific version or commit SHA. GitHub recommends pinning actions to a full commit SHA to ensure the exact code you expect is run. This prevents breaking changes or malicious updates in an action from impacting your workflow unexpectedly. At minimum, use versioned tags (v1, v2, etc.) or specific releases rather than a floating @main for community actions.
  • Optimize with Caching and Artifacts: Each run, our job installed Node dependencies from scratch. You can speed this up by caching dependencies. GitHub Actions offers a cache action – for Node, you can cache your ~/.npm or node_modules based on your lockfile, so that subsequent runs reuse downloaded packages. Caching can significantly reduce build times. Similarly, if your build produces artifacts (like a compiled binary or a bundle of static files), you can store those as artifacts or pass them between jobs. For example, the build job could upload a build artifact and the deploy job could download it, rather than rebuilding or checking out the code again. Ignoring caching or artifact reuse is a common pitfall that slows down pipelines. Implement caching carefully (keyed by file hashes or versions) to ensure you use cache when appropriate and bust it when things change.
  • Run Tests in Parallel (if possible): If your test suite is time-consuming, consider splitting or running tests in parallel. GitHub Actions allows multiple jobs to run concurrently. For example, you might run tests on multiple Node versions in parallel using a matrix strategy, or split test files across parallel jobs. Parallelizing jobs can drastically cut down pipeline time. Just ensure that jobs which depend on each other use the needs: syntax (as we did for deploy) so that, for example, deploy waits for all test jobs to finish. In our example, we kept things sequential for simplicity, but you could easily test on Node 16 and 18 simultaneously by defining a matrix in the build-test job.
  • Use Branch/Environment Protections: In a real project, you typically don’t want to deploy every commit to production automatically. It’s wise to protect certain branches or use deployment environments. For instance, you could have the CI (build & test) run on every push, but the CD (deploy) only run on the main branch or only when a version tag is pushed. You can also require human approval for the deploy step by using Environments in GitHub Actions with protection rules. In GitHub, you might set up an environment named “production” and mark that it requires manual approval. Then if your deploy job specifies environment: production, it will pause and wait for a maintainer to approve the deployment in the Actions UI. This adds an extra safety check, especially for deploying to user-facing production systems. (The visualization in the figure above showed an example deploy stage pending approval.) Designing your pipeline with the appropriate gates and triggers is a best practice to prevent unwanted deployments.
  • Maintain Clean and Modular Workflows: As your pipeline grows, keep the YAML file maintainable. Use meaningful names for your jobs and steps (so the Actions UI is easy to follow). Group related tasks into composite actions or reusable workflows if needed. Add comments to explain non-obvious steps. A clean, well-documented pipeline is easier to troubleshoot and modify later. Avoid duplicating code by using action functions or matrix strategies where appropriate. Also, periodically review your workflow for any steps that are no longer needed as your project evolves.
  • Monitor and Fail Fast: Treat your CI pipeline as an integral part of your development cycle. Monitor the pipeline results for each commit. If a build fails or a test fails, address it promptly rather than ignoring it. It’s a common anti-pattern to “ignore a red build”; instead, fix failures as soon as they occur so that your main branch is always in a deployable state. Configure notifications (GitHub can alert you on failing workflows via email or pull request status) so that you’re aware of issues. Also, prefer to fail fast – e.g., run linters or static analysis early, run tests in parallel – so feedback comes quickly. Fast feedback helps maintain developer confidence in the CI system.
  • Avoid Unnecessary Workflow Runs: By default, our workflow runs on every push to main. In some cases, you might want to prevent duplicate or unnecessary runs – for example, if your pushes are frequent or if certain files (like docs) changed that don’t require the pipeline. You can use the paths: or paths-ignore: filter under the trigger to only run on changes to certain directories. Also, if you have both push and pull_request triggers, use pull_request: [branches: main] to avoid running the pipeline twice (once on push, once on PR merge) for the same commit. Being deliberate about triggers ensures you’re not wasting CI resources on needless runs.

By following these best practices, you’ll create a CI/CD pipeline that is efficient, secure, and reliable. A good pipeline will give your team confidence to merge changes and deploy rapidly, knowing that automated tests guard against regressions and deployments are executed consistently. GitHub Actions provides a powerful framework to achieve this, right alongside your code.

Posts Carousel

Leave a Comment

You must be logged in to post a comment.

Latest Posts

Most Commented

Featured Videos