Configuration and Implementation of GitLab CI/CD Pipelines for Node.js Applications using PM2 and Self-Hosted Runners

The transition from manual software deployment to fully automated Continuous Integration and Continuous Deployment (CI/CD) represents a fundamental shift in the modern DevOps lifecycle. In traditional development environments, engineers frequently encounter the "it works on my machine" phenomenon, a catastrophic scenario where code functions perfectly in a local development context but fails upon deployment to a production or staging environment. This discrepancy often arises from variations in local runtime environments, missing dependencies, or divergent system configurations. GitLab CI/CD addresses this volatility by providing a structured, repeatable, and version-controlled mechanism for executing build, test, and deployment tasks. By leveraging a configuration file that resides directly within the repository, the entire pipeline becomes an integral part of the codebase, ensuring that any historical commit can be reliably rebuilt and redeployed with the exact same logic used at the time of its creation.

For Node.js developers, this automation is particularly critical due to the heavy reliance on package managers like npm and process managers like PM2 to maintain application stability. Implementing a pipeline that orchestrates the installation of dependencies, execution of test suites, and the eventual restarting of application processes ensures that the software delivery lifecycle is both rapid and resilient. This article provides an exhaustive technical blueprint for establishing such a system, ranging from the initialization of an Express.js API to the complex configuration of self-hosted GitLab runners on Linux environments.

The Architectural Philosophy of GitLab CI/CD

GitLab CI/CD operates on a paradigm of automation and repeatability. The primary objective is to eliminate manual, repetitive tasks—such as pulling code, running build scripts, and manually restarting servers—by encoding these actions into a machine-readable format. This approach transforms the deployment process from a human-driven series of commands into a deterministic sequence of operations triggered by specific repository events.

The central mechanism of GitLab CI/CD is the .gitlab-ci.yml file. This file must be located in the root directory of the repository to be recognized by the GitLab engine. The intelligence of the system lies in the fact that the CI/CD for any given commit is executed against the specific version of the .gitlab-ci.yml file that existed at the time of that commit. This ensures total temporal consistency; if a developer needs to roll back to a state from six months ago, the pipeline will use the configuration that was valid six months ago, preventing configuration drift from breaking historical deployments.

The Fundamental Unit: The Job

In the GitLab ecosystem, the "job" is the smallest atomic unit of work. A job is essentially a construct that executes a bash script within a specific context. Jobs are the vehicles through which testing, building, and deployment occur. For instance, a developer might define one job to execute unit tests, another job to compile assets for a staging environment, and a final job to deploy the resulting artifacts to production servers.

Global Configuration and Reserved Maps

Within the .gitlab-ci.yml file, jobs are represented as top-level maps or objects. However, certain keys are reserved by GitLab to define the global behavior of the pipeline. Understanding these reserved maps is essential for constructing complex workflows:

  • image: Defines the Docker image that will serve as the execution environment for the jobs.
  • services: Specifies additional Docker images that must run alongside the primary job image (useful for databases or caching layers).
  • before_script: Contains commands that are executed immediately before every individual job script.
  • after_script: Contains commands that are executed immediately after every individual job script, regardless of whether the job succeeded or failed.
  • stages: Defines the sequence and naming of the various phases of the pipeline.
  • variables: Establishes environment variables that are globally accessible to all jobs within the pipeline.
  • cache: Controls the persistence of specific files (such as node_modules) between different CI/CD runs to optimize execution speed.

Pipeline Stages and Parallelism

Jobs are organized into stages, which dictate the order of execution. If a job is not explicitly assigned to a stage, it defaults to the test stage. The standard sequence for a robust pipeline follows the order of build, test, and then deploy.

The execution model is designed for efficiency: while stages are run sequentially, all jobs contained within a single stage are executed with the maximum available parallelism. This means if the test stage contains five different test jobs, GitLab will attempt to run all five simultaneously, provided there are enough available runners, significantly reducing the total "wall clock" time of the pipeline.

Configuration Level Scope Impact
Top-level Map Global Affects every job in the entire pipeline.
Job-level Map Local Overrides the top-level configuration for that specific job only.

Constructing a Node.js Express Application for CI/CD

Before a pipeline can be configured, a functional application must exist. A minimal Express.js application serves as the perfect candidate for demonstrating how CI/CD can manage process lifecycles.

Project Initialization and Dependency Management

The process begins with the creation of a dedicated workspace and the initialization of the Node.js environment.

  1. Create the project directory:
    mkdir node-cicd-pm2
  2. Navigate into the directory:
    cd node-cicd-pm2
  3. Initialize the npm project:
    npm init -y

The npm init -y command generates a package.json file, which acts as the manifest for the project, documenting dependencies and metadata. To build a functional API, the necessary libraries must be installed:

npm i --save express dotenv

In this context, express provides the web framework capabilities, while dotenv allows the application to interact with environment variables, a critical requirement for secure CI/CD workflows where sensitive data (like ports or API keys) should not be hardcoded.

Application Logic and Process Management Configuration

The application core is established in an index.js file. The implementation must utilize the dotenv configuration to ensure it can dynamically adapt to the environment provided by the GitLab runner.

```javascript
const express = require('express');
const dotenv = require('dotenv');
const app = express();
dotenv.config();

app.get('', (req, res) => {
res.status(200).send('Hello World!');
})

app.listen(process.env.PORT, () => {
console.log(Server is running on port http://localhost:${process.env.PORT});
})
```

To manage the application as a persistent background process, PM2 (Process Manager 2) is utilized. This is vital because, in a CI/CD context, simply running node index.js would cause the application to terminate as soon as the CI job finishes. PM2 ensures the process stays alive and can be restarted seamlessly during updates.

A configuration file named ecosystem.config.js is created to define how PM2 should handle the application:

javascript module.exports = { apps: [{ name: "node-cicd-pm2", script: "./index.js" }] }

Environment Configuration

For the application to function correctly during local testing and in the eventual deployment, a .env file is required to define the runtime port.

text PORT="3001"

Implementing Self-Hosted GitLab Runners on Linux

While GitLab provides shared runners, many professional DevOps workflows require self-hosted runners. Self-hosted runners provide greater control over the hardware, specialized software requirements, and security parameters.

System Preparation and User Management

A common practice is to run the GitLab runner under a dedicated system user for security isolation. If the gitlab-runner user does not have a known password, it must be reset to allow for manual intervention and setup.

  1. Reset the password for the runner user:
    passwd gitlab-runner
  2. Switch to the runner user context:
    su gitlab-runner

Environment Setup for Node.js and PM2

Once logged in as the gitlab-runner user, the environment must be prepared to execute Node.js commands. Using nvm (Node Version Manager) is the recommended approach for managing Node versions without interfering with system-wide packages.

  1. Install NVM using the official script:
    curl https://raw.githubusercontent.com/creationix/nvm/master/install.sh | bash
  2. Refresh the shell configuration:
    source ~/.bashrc
  3. Install a specific Node.js version (e.g., 16.13.2):
    nvm install 16.13.2
  4. Install PM2 globally to ensure it is accessible to the runner:
    npm i -g pm2

Runner Registration and Tagging

The final step in establishing a self-hosted runner is linking the local machine to the GitLab instance. This process requires a registration token, which is located in the GitLab UI under Settings > CI/CD > Runners.

  1. Initiate the registration command:
    sudo gitlab-runner register
  2. Enter the GitLab instance URL (e.g., https://gitlab.com).
  3. Paste the provided registration token.
  4. Provide a description for the runner to identify its purpose.
  5. Assign tags to the runner.

Tags are a critical component of the orchestration logic. If a job in the .gitlab-ci.yml file specifies a tag, such as local_runner, only runners associated with that specific tag will pick up and execute the job. This allows for heterogeneous runner pools where certain jobs (like heavy builds) are routed to high-performance machines, while others (like simple tests) are routed to lighter instances.

For a shell-based executor, the user should choose shell during the registration prompt, which allows the runner to execute commands directly on the host machine's terminal.

Securing the Pipeline with Environment Variables and Protected Branches

A significant security risk in CI/CD is the exposure of sensitive credentials. Because files like .env are never committed to version control, GitLab provides a mechanism to inject these values into the pipeline dynamically.

Injecting Variables via GitLab UI

Sensitive data, such as the PORT variable required by our Express application, should be added via the GitLab interface:

  1. Navigate to Settings > CI/CD in the project repository.
  2. Expand the Variables section.
  3. Add a new variable (e.g., Key: PORT, Value: 3001).
  4. Mark the variable as protected to ensure it is only passed to pipelines running on protected branches.

Protecting the Deployment Branch

The use of "protected" variables necessitates the use of "protected" branches. If the dev branch—which handles our automated deployments—is not protected, the GitLab runner will refuse to inject the protected environment variables into the job, leading to application failure.

To secure the branch:

  1. Go to Settings > Repository.
  2. Expand the Protected branches section.
  3. Select the dev branch and confirm the protection settings.

This creates a two-factor security gate: only authorized users can push to the dev branch, and only that branch can access the sensitive environment variables necessary to run the production-grade application.

Technical Summary of the Deployment Workflow

The following table outlines the lifecycle of a deployment triggered by a push to the dev branch.

Phase Action Tool/Command Expected Outcome
Trigger Git Push git push origin dev GitLab detects change and initiates pipeline.
Environment Variable Injection GitLab CI/CD Variables PORT is made available to the runner.
Execution Installation npm install Dependencies are loaded into the runner.
Execution Deployment pm2 start ecosystem.config.js PM2 starts/restarts the Express process.
Verification Process Check pm2 list The application is confirmed running on the specified port.

Analytical Conclusion

The implementation of GitLab CI/CD for Node.js applications, particularly when integrated with PM2 and self-hosted runners, represents a sophisticated approach to modern software engineering. The architecture described moves away from the fragility of manual intervention and towards a state of "Infrastructure as Code," where the deployment logic is as durable and versioned as the application logic itself.

By utilizing the .gitlab-ci.yml file, developers gain the ability to maintain historical deployment integrity, ensuring that the environment remains consistent across the entire lifecycle of the project. The use of self-hosted runners provides the necessary granularity for controlling the execution environment, while the integration of PM2 solves the fundamental challenge of process persistence in a non-interactive shell environment.

However, the success of this architecture relies heavily on the rigorous application of security protocols, specifically the coordination between protected environment variables and protected branches. Without this synchronization, the pipeline remains vulnerable to configuration errors and credential exposure. Ultimately, this workflow creates a seamless, invisible background process that allows engineers to focus on feature development while the automated pipeline handles the complexities of testing, building, and maintaining the live application environment.

Sources

  1. Making CI easier with GitLab
  2. Setup GitLab CI/CD for Node.js Express API with PM2
  3. Structuring a CI/CD workflow in GitLab Node.js example

Related Posts