Overview #
My GitLab Setup #
In this tutorial I’m using the following setup based on Ubuntu 22.04 servers, GitLab is containerized:
192.168.70.4 # GitLab Server
192.168.70.6 # Deployment Server, with Docker platform installed
Container Image Name #
The build container image name in the GitLab CI pipeline follows this naming convention: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG:$CI_COMMIT_SHA
- 
$CI_REGISTRY_IMAGEPoints to the container registry for the project.
- 
$CI_COMMIT_REF_SLUGRepresents the branch or tag.
- 
$CI_COMMIT_SHAEnsures every image is uniquely tied to a specific commit for traceability and reproducibility.
In my project named “simple-java-maven-web-project”, the final image name looks like this:
# Container image name
gitlab-registry.jklug.work/java/simple-java-maven-web-project/main:aefb34cb0913963e4ea76968ab7d528fe2ba274a
- 
Registry: gitlab-registry.jklug.work/java
- 
Project: simple-java-maven-web-project
- 
Branch: main
- 
Commit: aefb34cb0913963e4ea76968ab7d528fe2ba274a
 
Java Maven Web Application #
Create Project #
# Create a new Maven project: Maven webapp structure
mvn archetype:generate \
-DgroupId=work.jklug \
-DartifactId=Maven-Web-App \
-DarchetypeArtifactId=maven-archetype-webapp \
-DarchetypeVersion=1.5 \
-DinteractiveMode=false
- 
-DgroupIdUnique identifier for the project, typically your domain name in reverse.
- 
-DartifactId=Maven-Web-AppThe name of your project / project folder name.
- 
-DarchetypeArtifactIdSpecifies the type of project template.
- 
-DarchetypeVersionThe version of the web application template.
- 
-DinteractiveMode=falseDisables prompts, so everything runs automatically
Verify Project Structure #
# Navigate into the project
cd Maven-Web-App
Maven-Web-App
├── pom.xml  # Maven configuration file
└── src
    └── main
        └── webapp
            ├── index.jsp  # The default website, written in JSP (JavaServer Pages)
            └── WEB-INF
                └── web.xml  # Deployment descriptor for the web application, defining servlets, filters, and mappings
Application Files #
Website: index.jsp #
Adapt the default website index.jsp to output the current date:
- src/main/webapp/index.jsp
Original version:
<html>
<body>
<h2><%= "Hello World!" %></h2>
</body>
</html>
Adapted version:
<!-- Import the JSTL tag library for formatting -->
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%>
<!-- Import the Java Date class -->
<%@ page import="java.util.Date" %>
<!-- Set the current date as a request attribute -->
<% request.setAttribute("today", new java.util.Date()); %>
<html>
<body>
    <!-- Format and display the current date -->
    <h2>Date: <fmt:formatDate value="${today}" pattern="yyyy-MM-dd" /></h2>
</body>
</html>
Dependencies: pom.xml #
Add the “JSTL Dependency”:
- pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>work.jklug</groupId>
  <artifactId>Maven-Web-App</artifactId>
  <version>1.0-SNAPSHOT</version>
  <packaging>war</packaging>
  <name>Maven-Web-App Maven Webapp</name>
  <!-- FIXME change it to the project's website -->
  <url>http://www.example.com</url>
  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>8</maven.compiler.source>
    <maven.compiler.target>8</maven.compiler.target>
  </properties>
  <dependencies>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.13.1</version>
      <scope>test</scope>
    </dependency>
    <!-- JSTL Dependency -->
    <dependency>
      <groupId>javax.servlet</groupId>
      <artifactId>jstl</artifactId>
      <version>1.2</version>
    </dependency>
  </dependencies>
  <build>
    <finalName>Maven-Web-App</finalName>
    <pluginManagement><!-- lock down plugins versions to avoid using Maven defaults (may be moved to parent pom) -->
      <plugins>
        <plugin>
          <artifactId>maven-clean-plugin</artifactId>
          <version>3.4.0</version>
        </plugin>
        <!-- see http://maven.apache.org/ref/current/maven-core/default-bindings.html#Plugin_bindings_for_war_packaging -->
        <plugin>
          <artifactId>maven-resources-plugin</artifactId>
          <version>3.3.1</version>
        </plugin>
        <plugin>
          <artifactId>maven-compiler-plugin</artifactId>
          <version>3.13.0</version>
        </plugin>
        <plugin>
          <artifactId>maven-surefire-plugin</artifactId>
          <version>3.3.0</version>
        </plugin>
        <plugin>
          <artifactId>maven-war-plugin</artifactId>
          <version>3.4.0</version>
        </plugin>
        <plugin>
          <artifactId>maven-install-plugin</artifactId>
          <version>3.1.2</version>
        </plugin>
        <plugin>
          <artifactId>maven-deploy-plugin</artifactId>
          <version>3.1.2</version>
        </plugin>
      </plugins>
    </pluginManagement>
  </build>
</project>
Deployment Descriptor: web.xml #
The automatically generated web.xml file looks like this:
- src/main/webapp/WEB-INF/web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app
    version="4.0"
    xmlns="http://xmlns.jcp.org/xml/ns/javaee"
    xmlns:javaee="http://xmlns.jcp.org/xml/ns/javaee"
    xmlns:xml="http://www.w3.org/XML/1998/namespace"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd">
  <display-name>Archetype Created Web Application</display-name>
</web-app>
 
Local Build #
Package (WAR) the Maven Web App #
Optional, package the application into a WAR package:
# Compile the source code & create WAR package
mvn clean package
# Shell output:
[INFO] Building war: /home/ubuntu/Java-Projects/Maven-Web-App/target/Maven-Web-App.war
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  2.411 s
[INFO] Finished at: 2024-12-03T15:55:51Z
[INFO] ------------------------------------------------------------------------
- cleanDeletes old build artifacts
Dockerfile #
- Dockerfile
### Stage 1: Build Stage
# Use a lightweight JRE image as the base image / define as build stage
FROM maven:3.9.4-eclipse-temurin-17 AS builder
# Set the working directory inside the build stage
WORKDIR /app
# Copy the Maven project files into the container
COPY pom.xml .
COPY src ./src
# Run Maven to compile the project and package it as a WAR file
RUN mvn clean package
### Stage 2: Runtime Stage
# Use Apache Tomcat container
FROM tomcat:9.0.78-jdk17
# Create a non-root user and group
RUN groupadd -r tomcat && useradd -r -g tomcat tomcat
# Set the working directory inside the runtime stage
WORKDIR /usr/local/tomcat/webapps
# Copy the WAR file from the builder stage into the Tomcat webapps directory
COPY --from=builder /app/target/Maven-Web-App.war ./Maven-Web-App.war
# Set the appropriate permissions for the Tomcat user
RUN chown -R tomcat:tomcat /usr/local/tomcat
# Switch to the non-root user
USER tomcat
# Expose Tomcat's default port
EXPOSE 8080
# Start Tomcat when the container launches
ENTRYPOINT ["catalina.sh", "run"]
Build Image & Run Container #
# Build the image
docker build -t maven-web-app .
# Run the container
docker run -d -p 8080:8080 maven-web-app
Test Maven Web Application #
# Access the web application
curl localhost:8080/Maven-Web-App/
# Shell output:
<!-- Import the JSTL tag library for formatting -->
<!-- Import the Java Date class -->
<!-- Set the current date as a request attribute -->
<html>
<body>
    <!-- Format and display the current date -->
    <h2>Date: Tue Dec 03 17:18:54 UTC 2024</h2>
</body>
</html>
 
GitLab Repository #
File and Folder Structure #
The file and folder structure of the GitLab repository looks like this:
Maven-Web-App
├── Dockerfile  # Dockerfile
├── .gitlab-ci.yml  # CI Pipeline manifest
├── Maven-Web-App  # Simple Java Maven Webapp
│   ├── .mvn
│   │   ├── jvm.config
│   │   └── maven.config
│   ├── pom.xml  # Maven configuration file
│   └── src
│       └── main
│           └── webapp
│               ├── index.jsp  # The default website, written in JSP (JavaServer Pages)
│               └── WEB-INF
│                   └── web.xml  # Deployment descriptor for the web application, defining servlets, filters, and mappings
└── README.md
CI Pipeline Manifest #
- .gitlab-ci.yml
### Variables
variables:
  DEPLOY_IP: "192.168.70.6"  # Deployment server IP
  DEPLOY_USER: "gitlab-deployment"  # Deployment server SSH user
  DEPLOY_PORT_HOST: 8080  # Host port
  DEPLOY_PORT_CONT: 8080  # Container port
  CONTAINER_NAME: "simple-javen-maven-web-project"  # Name of the deployed container
  # Define the image name, tagging it with the GitLab CI registry and the current commit SHA
  APACHE_IMAGE_SHA: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG:$CI_COMMIT_SHA
### Stages
stages:
  - build
  - deploy
### Build Container Image
build_image:
  image: docker:stable
  stage: build
  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 $APACHE_IMAGE_SHA .
    # Push the built Docker image to the GitLab Container Registry
    - docker push $APACHE_IMAGE_SHA
  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 $APACHE_IMAGE_SHA"
    # 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_HOST:$DEPLOY_PORT_CONT --restart=unless-stopped --name $CONTAINER_NAME $APACHE_IMAGE_SHA"
  rules:
    # Rule: Run this job only for main branch
    - if: $CI_COMMIT_BRANCH == "main"
Dockerfile #
- Dockerfile
### Stage 1: Build Stage
# Use a lightweight JRE image as the base image / define as build stage
FROM maven:3.9.4-eclipse-temurin-17 AS builder
# Set the working directory inside the build stage
WORKDIR /app
# Copy the Maven project files into the container
COPY Maven-Web-App/pom.xml .
COPY Maven-Web-App/src ./src
# Run Maven to compile the project and package it as a WAR file
RUN mvn clean package
### Stage 2: Runtime Stage
# Use Apache Tomcat container
FROM tomcat:9.0.78-jdk17
# Create a non-root user and group
RUN groupadd -r tomcat && useradd -r -g tomcat tomcat
# Set the working directory inside the runtime stage
WORKDIR /usr/local/tomcat/webapps
# Copy the WAR file from the builder stage into the Tomcat webapps directory
COPY --from=builder /app/target/Maven-Web-App.war ./Maven-Web-App.war
# Set the appropriate permissions for the Tomcat user
RUN chown -R tomcat:tomcat /usr/local/tomcat
# Switch to the non-root user
USER tomcat
# Expose Tomcat's default port
EXPOSE 8080
# Start Tomcat when the container launches
ENTRYPOINT ["catalina.sh", "run"]
 
Verify Deployment #
Verify the Container #
# List containers
docker ps
# Shell output:
CONTAINER ID   IMAGE                                                                                                         COMMAND             CREATED              STATUS              PORTS                                       NAMES
accb0451da82   gitlab-registry.jklug.work/java/simple-java-maven-web-project/main:aefb34cb0913963e4ea76968ab7d528fe2ba274a   "catalina.sh run"   About a minute ago   Up About a minute   0.0.0.0:8080->8080/tcp, :::8080->8080/tcp   simple-javen-maven-web-project
Verify the Container runs Unprivileged #
# Access the container terminal
docker exec -it accb0451da82 sh
# List information of current user
id
# Shell output:
uid=999(tomcat) gid=999(tomcat) groups=999(tomcat)
Verify Java Maven Application #
# Access the web application
curl localhost:8080/Maven-Web-App/
# Shell output:
<!-- Import the JSTL tag library for formatting -->
<!-- Import the Java Date class -->
<!-- Set the current date as a request attribute -->
<html>
<body>
    <!-- Format and display the current date -->
    <h2>Date: Tue Dec 03 17:50:54 UTC 2024</h2>
</body>
</html>
 
Links #
GitLab CI pipeline that containerizes and deploys a Java Maven Web application.