Skip to main content

GitLab CI Pipeline - Containerize and Deploy Node.js Express Webserver Application, Publish Jest Integration Test Results via GitLab Pages; Mermaid Pipeline Flowchart

2005 words·
GitLab GitLab CI CI Pipeline GitLab Pages Node.js Jest Jest HTML Reporter Multistage Dockerfile Mermaid Flowchart Webserver Unprivileged Container
Table of Contents
GitHub Repository Available



Overivew
#

GitLab CI Pipeline
#


graph TD %% Push Code Push((Push Code)) -.->|Trigger Pipeline| TestStage Push((Push Code)) -.-> Source %% Test Stage subgraph TestStage["Test Stage"] S1A("Jest integration tests") -.-> S1B("Create HTML report") S1B -.-> S1C("Artifacts: test-report.html") end %% Pages Stage TestStage -.-> J1["Job Dependency"] -.-> PagesStage subgraph PagesStage["Pages Stage"] S1C -.-> S2A("Deploy HTML report to GitLab Pages") S2A -.-> S2B("GitLab Page") end %% Build Stage TestStage -.-> J2["Job Dependency"] -.-> BuildStageGroup subgraph BuildStageGroup["Build Stage"] S3A("Login to GitLab Container Registry") -.-> S3B("Build image from Dockerfile") S3B -.-> S3C("Push image to GitLab Container Registry") end %% Dockerfile S3B -.-> D1 D2 -.->|Containerized Application| S3C subgraph Dockerfile["Dockerfile"] D1("Build Image") -.-> D2("Runtime Image") end %% Source Code D1 -.-> Source["Source Code Dependencies"] Source -.->|Compiled Source| D2 %% Deploy Stage BuildStageGroup -.-> J3["Job Dependency"] -.-> DeployStageGroup subgraph DeployStageGroup["Deploy Stage"] S4A("Set private SSH key permissions") -.-> S4B("SSH into the deployment server") end %% Deployment Server S4B -.-> DeploymentServerGroup subgraph DeploymentServerGroup["Deployment Server"] S5A("Login to the GitLab container registry") -.-> S5B("Pull image from the registry") S5B -.-> S5C("Remove the existing container") S5C -.-> S5D("Run the new container") end %% Registry Connection S3C -.-> Registry["GitLab Container Registry"] S5A -.-> Registry Registry -.-> S5B %% Styling for Bold and Larger Text classDef boldLargeText font-weight:bold class TestStage,PagesStage,BuildStageGroup,DeploymentServerGroup,DeployStageGroup,Registry boldLargeText %% Dash style classDef dashedOutline stroke-dasharray: 5,5; class J1,J2,J3,Push dashedOutline %% Bigger Front Size style Registry font-size:24px %% Apply custom style to the dashed lines %%linkStyle 0 stroke-width:3px %%linkStyle 1 stroke-width:3px linkStyle 2 stroke-width:3px linkStyle 3 stroke-width:3px linkStyle 7 stroke-width:3px linkStyle 10 stroke-width:3px linkStyle 11 stroke-width:3px linkStyle 14 stroke-width:3px linkStyle 19 stroke-width:3px linkStyle 21 stroke-width:3px linkStyle 22 stroke-width:3px linkStyle 23 stroke-width:3px %% Bigger Border classDef themeColor stroke-width:3px; class TestStage,PagesStage,BuildStageGroup,DeployStageGroup themeColor %% Dashed Border classDef themeColor2 stroke-dasharray:5 5; class Dockerfile,DeploymentServerGroup themeColor2



This GitLab CI pipeline performs the following steps:

  • Runs Jest-based integration tests.

  • Generates an HTML test report with jest-html-reporter.

  • Publishes the test report to GitLab Pages.

  • Builds a container image using a multi-stage Dockerfile.

  • Deploys the container to the deployment server.


I’m using the same setup for the deployment via SSH as described in my blog post: GitLab CI Pipeline - Deploy website with Nginx Docker Container

The main requirements for the deployment via SSH key are the following:

  • Create a SSH key pair on the server where GitLab is deployed

  • Copy the public SSH key to the authorized keys file of the Deployment server

  • SSH into the deployment servers to add the fingerprint

  • Add the private SSH key as variable to the GitLab Repository CI/CD settings

  • On the deployment server, a user named “gitlab-deployment”, that is member of the Docker group, is used for the authentication of for the GitLab CI pipeline


My GitLab Setup
#

192.168.70.4 # GitLab Server
192.168.70.6 # Deployment Server, Ubuntu 22.04 with Docker platform installed



GitLab Repository
#

Overview & GitHub Repository
#

For more details about the “Node.js Express Webserver” application, check out my previous blog post: “JavaScript Programming Language”

I have uploaded this repository to GitHub:
https://github.com/jueklu/gitlab-ci-deploy-node.js-webserver


File and Folder Structure
#

The file and folder structure of the new project should look like this:

deploy-node.js-webserver
├── Dockerfile  # Multistage-Dockerfile
├── .env  # Variable for Node.js webserver port
├── .gitignore
├── .gitlab-ci.yml  # GitLab CI Pipeline
├── package.json  # Project metadata and dependencies
├── package-lock.json  # Locked dependency versions
├── public  # Static files (HTML, CSS, JS)
│   └── index.html  # Example HTML file
├── README.md
├── src  # Application source code
│   ├── app.js  # Main application
│   ├── routes
│   │   └── index.js  # Define route handlers
│   └── server.js  # Main application entry-point
└── tests
    └── app.test.js  # Integration test

GitLab CI Pipeline
#

  • .gitlab-ci.yml
### Variables
variables:
  DEPLOY_IP: "192.168.70.6"  # Deployment server IP
  DEPLOY_USER: "gitlab-deployment"  # Deployment server SSH user
  DEPLOY_PORT: 8080  # Port for the container to expose
  CONTAINER_NAME: "node.js-webserver"  # Name of the deployed container


### Stages
stages:
  - test
  - pages
  - build
  - deploy

### Run Just integration test
test:
  image: node:23-slim
  stage: test
  before_script:
    - mkdir -p just
    - npm install
  script:
    - npm test
  artifacts:
    paths:
      - test-report.html   # Correct path to the generated report
    when: always
    expire_in: 1 week


### Deploy Just integration test results to GitLab Pages
pages:
  image: node:23-slim
  stage: pages
  needs:
    # Run this job only if the 'test' job succeeds
    - test
  dependencies:
    # Use artifacts generated by the 'test' stage
    - test
  script:
    # Move the report to the 'public/' directory for GitLab Pages
    - mv test-report.html public/index.html
  artifacts:
    paths:
      # Specify the 'public/' directory to be published via GitLab Pages
      - public/

    
### Build Container Image
build_image:
  image: docker:stable
  stage: build
  needs:
    # Run this job only if the 'test' job succeeds
    - test
  services:
    - docker:dind
  variables:
    DOCKER_TLS_CERTDIR: ""
  before_script:
    # Login to GitLab Container Registry using predefined CI/CD variables
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
  script:
    # Build the Docker image from the Dockerfile in the current directory
    - docker build --pull -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" .
    # Push the built Docker image to the GitLab Container Registry
    - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG"
  rules: 
    # Rule: Run this job only for main branch and if the Dockerfile exists
    - if: $CI_COMMIT_BRANCH == "main"
      exists:
        - Dockerfile


### Deploy Container to Virtual Machine
deploy_container:
  stage: deploy
  image: alpine:latest
  needs:
    # Run this job only if the 'build_image' job succeeds
    - build_image
  before_script:
    # Update the package index, install the OpenSSH client for SSH connections
    - apk update && apk add openssh-client
    # If the private SSH key file ($ID_RSA) exists, set secure permissions (read/write for the owner only)
    - if [ -f "$ID_RSA" ]; then chmod og= $ID_RSA; fi
  script:
    #  SSH into the deployment server, log in to the GitLab Container registry
    - ssh -i $ID_RSA -o StrictHostKeyChecking=no $DEPLOY_USER@$DEPLOY_IP "docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY"
     # SSH into the deployment server, pull the image from the registry
    - ssh -i $ID_RSA -o StrictHostKeyChecking=no $DEPLOY_USER@$DEPLOY_IP "docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG"
    # SSH into the deployment server, remove the existing container (if it exists)
    - ssh -i $ID_RSA -o StrictHostKeyChecking=no $DEPLOY_USER@$DEPLOY_IP "docker container rm -f $CONTAINER_NAME || true"
    # SSH into the deployment server, run the new container
    - ssh -i $ID_RSA -o StrictHostKeyChecking=no $DEPLOY_USER@$DEPLOY_IP "docker run -d -p $DEPLOY_PORT:$DEPLOY_PORT --restart=unless-stopped --name $CONTAINER_NAME $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG"
  rules:
    # Rule: Run this job only for main branch
    - if: $CI_COMMIT_BRANCH == "main"

Dockerfile
#

### Stage 1: Build

# Use the Node.js base image / Define as build stage
FROM node:23-slim AS build

# Set the working directory inside the container
WORKDIR /app

# Copy package.json and package-lock.json for dependency installation
COPY package*.json ./

# Install only production dependencies
RUN npm install --production

# Copy the rest of the application's source code
COPY . .



### Stage 2: Runtime

# Use the Alpine based Node.js image
FROM node:23-alpine3.20 AS runtime

# Create system user "appuser" / no PW
RUN adduser -S -D -H -h /app appuser

# Switch to "appuser"
USER appuser

# Set the working directory inside the container
WORKDIR /app

# Copy the application
COPY --from=build /app /app

# Expose the port your application listens on
EXPOSE 8080

# Run the application
ENTRYPOINT ["npm", "start"]

Package.json
#

Overview
#

To export the Jest test report as HTML file, install the jest-html-reporter package in the local repository:

# Install Jest HTML Reporter package
npm install jest-html-reporter --save-dev

Configure Jest to process the test results:

  "jest": {
    "reporters": [
      "default",
      [
        "./node_modules/jest-html-reporter",
        {
          "pageTitle": "Test Report"
        }
      ]
    ]
  }

Optional an output path can be defined:

  "jest": {
    "reporters": [
      "default",
      [
        "./node_modules/jest-html-reporter",
        {
          "pageTitle": "Test Report",
          "outputPath": "./path/to/test-report.html"
        }
      ]
    ]
  }

Package.json
#

  • package.json
{
  "name": "example-express-webserver",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "test": "jest",
    "start": "node src/server.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "dotenv": "^16.4.5",
    "express": "^4.21.1"
  },
  "devDependencies": {
    "jest": "^29.7.0",
    "jest-html-reporter": "^3.10.2",
    "supertest": "^7.0.0"
  },
  "jest": {
    "reporters": [
      "default",
      [
        "./node_modules/jest-html-reporter",
        {
          "pageTitle": "Test Report"
        }
      ]
    ]
  }
}

Application Source Code
#

Webserver Main Block: app.js
#

Webserver main code block:

  • src/app.js
// Import the Express framework for web applications
const express = require('express'); 

// Create an instance of the Express application
const app = express(); 

// Import routes
const routes = require('./routes');

// Import environment variables from .env
require('dotenv').config();

// Define the port, using an environment variable if available, or default to 80
const port = process.env.PORT || 80; 


// Use routes from "src/routes/index.js"
app.use('/', routes);


// Export the app for testing and server setup
module.exports = { app, port };

Webserver Entry-Point: server.js
#

Starting block for the webserver:

  • src/server.js
// Import the Express app instance and port configuration from app.js
const { app, port } = require('./app');

// Start the server
app.listen(port, () => console.log(`Server listening on port ${port}`));

Webserver Routes: index.js
#

Webserver routes:

  • src/routes/index.js
// Import the built-in 'fs' (File System) module to work with files
const fs = require('fs');

// Import the Express framework for web applications
const express = require('express'); 

// Import the 'path' module for handling and manipulating file paths
const path = require('path'); 


// Create a router instance
const router = express.Router();

// Respond with plain text
router.get('/', (req, res) => {
  res.send('Hi there.\n');
});

// Respond with JSON
router.get('/json', (req, res) => {
  res.json({ text: 'Hi there.', numbers: [1, 2, 3] });
});

// Serve static files directly from the 'public' directory
router.use('/static', express.static(path.join(__dirname, '../../public')));

// Respond with custom 404 error for unmatched paths
router.use((req, res) => {
  res.status(404).send('Not found\n');
});


// Export the router so it can be used in other files
module.exports = router;

Integration Test
#

  • tests/app.test.js
const request = require('supertest'); // Import Supertest to simulate HTTP requests
const { app } = require('../src/app'); // Import the app instance

describe('Basic Web Server Tests', () => {
  it('should return "Hi there." for GET /', async () => {
    const response = await request(app).get('/');
    expect(response.status).toBe(200); // Expect a 200 OK status
    expect(response.text).toBe('Hi there.\n'); // Expect the response body
  });

  it('should return JSON for GET /json', async () => {
    const response = await request(app).get('/json');
    expect(response.status).toBe(200); // Expect a 200 OK status
    expect(response.body).toEqual({ text: 'Hi there.', numbers: [1, 2, 3] }); // Expect the JSON response
  });

  it('should return 404 for an unknown route', async () => {
    const response = await request(app).get('/unknown');
    expect(response.status).toBe(404); // Expect a 404 Not Found status
    expect(response.text).toContain('Not found'); // Check the custom 404 message
  });
});

Env File
#

Environment file:

  • .env
# Define webserver port
PORT=8080

HTML File
#

Example HTML file:

  • public/index.html
<!DOCTYPE html>
<html>

<head>
	<title>jklug.work</title>
</head>

<body>
	<h1>Hi there</h1>
  <p>Some HTML content </p>
</body>

</html>



GitLab Pages Test Report
#

  • Go to: (Project) “Deploy” > “Pages”

  • IF enabled, uncheck “Use unique domain” and click “Save changes”

  • Open the “Access pages” URL

# Open the GitLab Pages test report
https://root.gitlab-pages.jklug.work/deploy-node.js-webserver/

The Jest report looks like this:



Verify the Deployment
#

Verify the Container
#

SSH into the deployment server:

# List running containers
docker ps

# Shell output:
CONTAINER ID   IMAGE                                                           COMMAND       CREATED          STATUS          PORTS                                       NAMES
a51b13956d89   gitlab-registry.jklug.work/root/deploy-node.js-webserver:main   "npm start"   19 minutes ago   Up 19 minutes   0.0.0.0:8080->8080/tcp, :::8080->8080/tcp   node.js-webserver

Verify the container runs as non root user:

# Access the container terminal
docker exec -it a51b13956d89 sh
# List current user details
id

# Shell output:
uid=100(appuser) gid=65533(nogroup) groups=65533(nogroup)

Test the Application
#

Curl the different URL Paths in a new shell:

# Curl the webserver: Simple text response
curl localhost:8080/

# Shell output:
Hi there.
# Curl the webserver: Json response (Use browser for JSON output in a readable format)
curl localhost:8080/json

# Shell output:
{"text":"Hi there.","numbers":[1,2,3]}
# Curl the webserver: Stream static content
curl localhost:8080/static/index.html

# Shell output:
<!DOCTYPE html>
<html>

<head>
        <title>jklug.work</title>
</head>
# Curl the webserver: Stream static content / Error not found
curl localhost:8080/static/djkgnsdg

# Shell output:
Not Found
# Curl the webserver: Not found response
curl localhost:8080/djkgnsdg

# Shell output:
Not Found



Links #

# Jest HTML Reporter package
https://www.npmjs.com/package/jest-html-reporter
jueklu/gitlab-ci-deploy-node.js-webserver

GitLab CI pipeline that containerizes and deploys a Node.js webserver with Express framework. Jest integration test results are published via GitLab Pages.

JavaScript
0
0