Overivew #
GitLab CI Pipeline #
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
GitLab CI pipeline that containerizes and deploys a Node.js webserver with Express framework. Jest integration test results are published via GitLab Pages.