GitLab CI/CD transforms your development workflow by automating builds, tests, and deployments. At its heart lies the .gitlab-ci.yml file—your pipeline's blueprint that defines what happens when code changes.
Understanding the Basics ⭐
CI/CD is a continuous method of software development, where you continuously build, test, deploy, and monitor iterative code changes. Every GitLab CI pipeline starts with a YAML configuration file placed in your repository's root. This file tells GitLab what to do, when to do it, and how to do it.
Key Configuration Elements ⭐
Step 1: Create a .gitlab-ci.yml file
To use GitLab CI/CD, you start with a .gitlab-ci.yml file at the root of your project. This file specifies the stages, jobs, and scripts to be executed during your CI/CD pipeline. It is a YAML file with its own custom syntax.
In this file, you define variables, dependencies between jobs, and specify when and how each job should be executed.
Step 2: Find or create runners
Runners are the agents that run your jobs. These agents can run on physical machines or virtual instances. In your .gitlab-ci.yml file, you can specify a container image you want to use when running the job. The runner loads the image, clones your project, and runs the job either locally or in the container.
Step 3: Define your pipelines
A pipeline is what you’re defining in the .gitlab-ci.yml file, and is what happens when the contents of the file are run on a runner.
Pipelines are made up of jobs and stages:
**Stages **define the order of execution. Typical stages might be build, test, and deploy.
Jobs specify the tasks to be performed in each stage. For example, a job can compile or test code.
Pipelines can be triggered by various events, like commits or merges, or can be on schedule. In your pipeline, you can integrate with a wide range of tools and platforms.
Step 4: Use CI/CD variables as part of jobs
GitLab CI/CD variables are key-value pairs you use to store and pass configuration settings and sensitive information, like passwords or API keys, to jobs in a pipeline.
Use CI/CD variables to customize jobs by making values defined elsewhere accessible to jobs. You can hard-code CI/CD variables in your .gitlab-ci.yml file, set them in your project settings, or generate them dynamically. You can define them for the project, group, or instance.
Two types of variables exist: custom variables and predefined.
Custom variables are user-defined. Create and manage them in the GitLab UI, API, or in configuration files.
Predefined variables are automatically set by GitLab and provide information about the current job, pipeline, and environment.
Step 5: Use CI/CD components
A CI/CD component is a reusable pipeline configuration unit. Use a CI/CD component to compose an entire pipeline configuration or a small part of a larger pipeline.
You can add a component to your pipeline configuration with include:component.
Reusable components help reduce duplication, improve maintainability, and promote consistency across projects. Create a component project and publish it to the CI/CD Catalog to share your component across multiple projects.
Excellent! Let's dive into concrete examples to make the "Building Your First GitLab Pipeline" tutorial truly hands-on.
For our examples, we'll use a very simple Spring Boot project. This keeps the focus on the GitLab CI/CD concepts rather than complex application logic.
Create a New GitLab Project: ⭐
- Go to your GitLab dashboard.
- Click "New project".
- Select "Create blank project".
- Project name: my-first-gitlab-pipeline-java
- Visibility Level: "Public" (or "Private" if preferred).
- Leave "Initialize repository with a README" unchecked for now.
- Click "Create project".
Once the users have this Spring Boot project pushed to their GitLab repository, we can proceed to the .gitlab-ci.yml examples tailored for Java/Maven!
Creating Your First .gitlab-ci.yml for Spring Boot (Step-by-Step) ⭐
.gitlab-ci.yml:
include:
- template: Code-Quality.gitlab-ci.yml
- template: Jobs/SAST.gitlab-ci.yml
- template: Jobs/Secret-Detection.gitlab-ci.yml
# Define stages
stages:
- validate
- build
- test
- deploy
- release
# Global variables
variables:
MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
MAVEN_CLI_OPTS: "--batch-mode --errors --fail-at-end --show-version"
# Cache Maven dependencies
cache:
key: "$CI_COMMIT_REF_SLUG"
paths:
- .m2/repository/
- target/
Include:
The include section imports predefined GitLab CI/CD templates that provide additional functionality:
1. Code-Quality.gitlab-ci.yml
Adds code quality analysis jobs to the pipeline
Typically includes static code analysis tools that check for code smells, maintainability issues, and coding standard violations
Generates code quality reports that can be viewed in GitLab merge requests
2. Jobs/SAST.gitlab-ci.yml
SAST stands for Static Application Security Testing
Adds security scanning jobs that analyze source code for potential security vulnerabilities
Scans for common security issues like SQL injection, XSS, insecure dependencies, etc.
Runs without executing the application (static analysis)
3. Jobs/Secret-Detection.gitlab-ci.yml
Adds jobs that scan the repository for accidentally committed secrets
Looks for patterns that match API keys, passwords, tokens, certificates, and other sensitive information
Helps prevent security breaches from exposed credentials in code.
These templates automatically add their respective jobs to your pipeline mostly on test stage without you having to define them manually.
For more details, please check out gitlab templates
Pipeline Stages
The stages section defines the execution order of your CI/CD pipeline. Jobs will run in this sequence.
validate: Check project structure and configuration
build: Build and create deployable artifacts and upload (JAR/WAR files)
test: Run unit tests, integration tests, and code analysis
deploy: Deploy the application to different environments
release: Publish final releases (typically to artifact repositories)
Jobs within the same stage run in parallel, while stages execute sequentially. A stage only starts if all jobs in the previous stage have completed successfully (unless configured otherwise).
Global variables
Global variables are defined in the variables: section of your .gitlab-ci.yml file and are accessible across all jobs in your pipeline. These variables are part of GitLab's variable hierarchy and can be referenced using the syntax $variable, ${variable}, or %variable%
MAVEN_OPTS
Value : "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
Configures Maven to use a custom local repository location
$CI_PROJECT_DIR is a GitLab CI predefined variable that points to the project directory.
This redirects Maven's local repository to .m2/repository/ within the project directory.
Benefit: Enables caching of Maven dependencies between pipeline runs, significantly speeding up builds.
MAVEN_CLI_OPTS
Value : "--batch-mode --errors --fail-at-end --show-version"
Sets standard command-line options for Maven commands
- --batch-mode: Runs Maven in non-interactive mode (no user prompts)
- --errors: Shows detailed error messages
- --fail-at-end: Continues building other modules even if one fails, then fails at the end
- --show-version: Displays Maven version information
Cache
This is the top-level directive that tells GitLab CI to cache certain files and directories to speed up subsequent pipeline executions.
key: "$CI_COMMIT_REF_SLUG"
A GitLab CI predefined variable that contains a "slugified" version of the branch or tag name. Defines a unique identifier for the cache. i.e.
- main branch has its own cache
- feature/user-auth branch has its own cache
- v1.2.3 tag has its own cache.
paths:
Specifies which directories should be cached:
.m2/repository/
This is Maven's local repository where downloaded dependencies are stored
Caching this prevents re-downloading the same JAR files (Spring Boot, JUnit, etc.) on every build
Significant time savings, especially for projects with many dependenciestarget/ Maven's default build output directory
Contains compiled classes, test results, and generated artifacts
Caching this can speed up incremental builds.
Validate stage
Creates a job named validate that runs in the validate stage. Uses a Docker image with Maven 3.9 and Eclipse Temurin JDK 17. Provides the necessary Java and Maven environment for the validation. Runs Mavens validate goal with predefined CLI options. Mavens validate goal checks if the project structure is correct and all necessary information is available.
The job runs when any of these conditions are met:
- A merge request is created/updated
- Code is pushed to the default branch (usually main or master)
- Code is pushed to branches starting with feature/, hotfix/, or release/
- A Git tag is created
# Validate stage - check project structure
validate:
stage: validate
image: maven:3.9-eclipse-temurin-17
script:
- mvn $MAVEN_CLI_OPTS validate
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_BRANCH =~ /^(feature|hotfix|release)\/.*/
- if: $CI_COMMIT_TAG
Build stage
The build stage in a GitLab CI/CD pipeline is the foundational step where your source code is transformed into a runnable application, a distributable library, or a deployable artifact. A well-configured build stage is crucial for an efficient and reliable continuous integration process.
Maven command: Runs multiple Maven goals:
- compile - Compiles the main source code
- test-compile - Compiles the test source code
- package - Packages the compiled code into JAR/WAR files
Required CI/CD Variables
Add these variables in your GitLab project settings (Settings > CI/CD > Variables):
- ARTIFACTORY_URL: Your Artifactory base URL (e.g., https://your-company.jfrog.io)
- ARTIFACTORY_USER: Username for Artifactory authentication
- ARTIFACTORY_PASSWORD: Password or API token (mark as Protected and Masked)
What the upload logic does:
- Checks credentials: Verifies Artifactory variables are set
- Finds JAR file: Locates the built JAR in the target directory
- Uploads via curl: Uses HTTP PUT to upload the JAR
- Error handling: Provides feedback if upload fails or JAR isn't found
- Graceful fallback: Continues without error if Artifactory isn't configured
Preserves build outputs for downstream jobs:
- Compiled classes: Main and test class files
- Generated sources: Auto-generated code
- JAR files: Packaged applications
- Expiration: Artifacts are kept for 1 hour to save storage
The job runs when:
- A merge request is created/updated
- Code is pushed to the default branch (usually main or master)
- Code is pushed to branches starting with feature/, hotfix/, or release/
- A Git tag is created
# Build stage - compile and prepare artifacts
build:
stage: build
image: maven:3.9-eclipse-temurin-17
script:
- echo "Compiling application..."
- mvn $MAVEN_CLI_OPTS compile test-compile package
- echo "Build completed successfully"
# Upload JAR to Artifactory
- |
if [ -n "$ARTIFACTORY_URL" ] && [ -n "$ARTIFACTORY_USER" ] && [ -n "$ARTIFACTORY_PASSWORD" ]; then
echo "Uploading JAR to Artifactory..."
JAR_FILE=$(find target -name "*.jar" -type f | head -1)
if [ -f "$JAR_FILE" ]; then
JAR_NAME=$(basename "$JAR_FILE")
#curl -u $ARTIFACTORY_USER:$ARTIFACTORY_PASSWORD \
#-T "$JAR_FILE" \
#"$ARTIFACTORY_URL/artifactory/maven-local/com/example/myapp/$CI_COMMIT_SHA/$JAR_NAME"
echo "JAR uploaded successfully: $JAR_NAME"
else
echo "No JAR file found to upload"
fi
else
echo "Artifactory credentials not configured, skipping upload"
fi
artifacts:
paths:
- target/classes/
- target/test-classes/
- target/generated-sources/
- target/*.jar
expire_in: 1 hour
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_BRANCH =~ /^(feature|hotfix|release)\/.*/
- if: $CI_COMMIT_TAG
Build logs:
Test stage
In a typical GitLab CI/CD pipeline, the test stage follows the build stage. Once the application has been successfully compiled, the test stage is triggered to execute a suite of automated tests.
Executes Maven commands to:
- clean: Remove previous build artifacts
- compile: Compile the source code
- test: Run unit tests (typically JUnit tests)
this stage expects the JaCoCo Maven plugin to be configured in your pom.xml to work properly.
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.8</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
What happens without JaCoCo:
- Job still runs: Tests execute normally
- Missing artifacts: target/site/jacoco/ directory won't exist
- No coverage data: GitLab won't display coverage percentage
- Artifact warnings: GitLab may warn about missing artifact paths
Alternative without JaCoCo:
If you don't want code coverage, remove these lines:
paths:
- target/site/jacoco/
coverage: '/Total.*?([0-9]{1,3})%/'
- when: always: Collects artifacts even if tests fail
- JUnit reports: Captures test results from:
- surefire-reports: Unit test results
- failsafe-reports: Integration test results
- Paths: Preserves JaCoCo code coverage reports
- Expiration: Artifacts are kept for 30 days
The job runs when:
- A merge request is created/updated
- Code is pushed to the default branch (usually main or master)
- Code is pushed to branches starting with feature/, hotfix/, or release/
- A Git tag is created
# Test stage - run unit and integration tests
test:
stage: test
image: maven:3.9-eclipse-temurin-17
script:
- mvn $MAVEN_CLI_OPTS clean compile test
artifacts:
when: always
reports:
junit:
- target/surefire-reports/TEST-*.xml
- target/failsafe-reports/TEST-*.xml
paths:
- target/site/jacoco/
expire_in: 30 days
coverage: '/Total.*?([0-9]{1,3})%/'
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_BRANCH =~ /^(feature|hotfix|release)\/.*/
- if: $CI_COMMIT_TAG
deploy stage
This stage handles deployment to a staging/prod environments.
- Job name: deploy_staging
- Stage: Runs during the deploy stage of the pipeline
- Docker image: Uses Alpine Linux (lightweight distribution) as the runtime environment
prepares SSH connectivity:
- Install SSH client on the Alpine container
- Start SSH agent to manage SSH keys
- Load private key from environment variable $SSH_PRIVATE_KEY (removes Windows line endings with tr -d '\r')
- Create SSH directory with proper permissions (700 = read/write/execute for owner only)
- Add staging server to known hosts to avoid host verification prompts
deployment process:
Copy JAR files from the target/directory to /opt/app/ on the staging server using SCP.
Restart the application service using systemctl via SSH
Environment Configuration
This job expects these variables to be configured in GitLab:
- $SSH_PRIVATE_KEY: Private SSH key for server access
- $SSH_USER: Username for SSH connection
- $STAGING_SERVER: Hostname/IP of the staging server
url: https://staging.example.com
Defines this as the "staging" environment in GitLab
Provides a URL link to access the staging environment.
Dependencies: Requires the build job to complete successfully first
-
Rules: Job runs manually (when: manual) for:
- Commits to the main branch
- Commits to branches starting with feature/, hotfix/, or release/
Manual execution only - The job never runs automatically. It requires manual intervention in the GitLab UI.
# Deploy to staging
deploy_staging:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- ssh-keyscan -H $STAGING_SERVER >> ~/.ssh/known_hosts
script:
- echo "Deploying to staging server"
- scp target/*.jar $SSH_USER@$STAGING_SERVER:/opt/app/
- ssh $SSH_USER@$STAGING_SERVER "sudo systemctl restart myapp"
environment:
name: staging
url: https://staging.example.com
dependencies:
- build
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
- if: $CI_COMMIT_BRANCH =~ /^(feature|hotfix|release)\/.*/
when: manual
release stage
This job is designed for production releases - it takes the built artifacts and deploys them to a production artifact repository, but only when: A version tag is created (indicating a release)
<distributionManagement>
<repository>
<id>releases</id>
<name>Release Repository</name>
<url>https://your-nexus-server/repository/maven-releases/</url>
</repository>
<snapshotRepository>
<id>snapshots</id>
<name>Snapshot Repository</name>
<url>https://your-nexus-server/repository/maven-snapshots/</url>
</snapshotRepository>
</distributionManagement>
the Distribution Management configuration in a Maven POM file defines where Maven should deploy/publish the built artifacts when using commands like mvn deploy.
How it works:
When you run mvn deploy, Maven checks the project version
If version ends with -SNAPSHOT (like "0.0.1-SNAPSHOT" in this POM), it uses the snapshot repository
If version doesn't end with -SNAPSHOT, it uses the release repository
Maven uses the repository IDs to look up authentication credentials from your local settings.xml file.
settings.xml: in project root or .mvn/ directory
<settings xmlns="http://maven.apache.org/SETTINGS/1.1.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.1.0 http://maven.apache.org/xsd/settings-1.1.0.xsd">
<servers>
<server>
<id>releases</id>
<username>${env.MAVEN_USERNAME}</username>
<password>${env.MAVEN_PASSWORD}</password>
</server>
<server>
<id>snapshots</id>
<username>${env.MAVEN_USERNAME}</username>
<password>${env.MAVEN_PASSWORD}</password>
</server>
</servers>
</settings>
Note:
The URLs shown (https://your-nexus-server/repository/maven-releases/) are placeholders and need to be replaced with actual repository URLs before deployment will work.
Execution Rules
- Trigger condition: Only runs when a Git tag is pushed ($CI_COMMIT_TAG exists)
- Manual execution: Requires manual approval to run, even when conditions are met
- This prevents accidental releases and gives control over when releases are published.
Since I am using release:prepare release:perform You need to add the Maven Release Plugin to your pom.xml file for this to work properly.
Add this to your pom.xml in the section:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-release-plugin</artifactId>
<version>3.0.1</version>
<configuration>
<tagNameFormat>v@{project.version}</tagNameFormat>
<autoVersionSubmodules>true</autoVersionSubmodules>
<releaseProfiles>release</releaseProfiles>
<goals>deploy</goals>
<checkModificationExcludes>
<checkModificationExclude>pom.xml</checkModificationExclude>
</checkModificationExcludes>
</configuration>
</plugin>
Additional Requirements:
SCM Configuration in pom.xml:
<scm>
<connection>scm:git:${CI_REPOSITORY_URL}</connection>
<developerConnection>scm:git:${CI_REPOSITORY_URL}</developerConnection>
<url>${CI_PROJECT_URL}</url>
<tag>HEAD</tag>
</scm>
the maven-release-plugin try to commit the modified POM files as part of the release process.
Configure Git user identity in the CI/CD pipeline:
Add the following commands to your GitLab CI/CD pipeline configuration (.gitlab-ci.yml) before running the Maven release command:
before_script:
- git config user.email "${GITLAB_USER_EMAIL}"
- git config user.name "${GITLAB_USER_NAME}"
Note that you should use a real email address and name that makes sense for your organization, possibly creating a dedicated service account for CI/CD operations.
# Release job for tagged versions
release:
stage: release
image: maven:3.9-eclipse-temurin-17
script:
- echo "Starting release deployment..."
- mvn $MAVEN_CLI_OPTS release:prepare release:perform -DskipTests --settings settings.xml
- echo "Release deployment completed successfully"
dependencies:
- build
rules:
- if: $CI_COMMIT_TAG
when: manual
Release pipeline:
See the complete file for details .gitlab-ci.yml ⭐
include:
- template: Code-Quality.gitlab-ci.yml
- template: Jobs/SAST.gitlab-ci.yml
- template: Jobs/Secret-Detection.gitlab-ci.yml
# Define stages
stages:
- validate
- build
- test
- deploy
- release
# Global variables
variables:
MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
MAVEN_CLI_OPTS: "--batch-mode --errors --fail-at-end --show-version"
# Cache Maven dependencies
cache:
key: "$CI_COMMIT_REF_SLUG"
paths:
- .m2/repository/
- target/
# Validate stage - check project structure
validate:
stage: validate
image: maven:3.9-eclipse-temurin-17
script:
- mvn $MAVEN_CLI_OPTS validate
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_BRANCH =~ /^(feature|hotfix|release)\/.*/
- if: $CI_COMMIT_TAG
# Build stage - compile and prepare artifacts
build:
stage: build
image: maven:3.9-eclipse-temurin-17
script:
- echo "Compiling application..."
- mvn $MAVEN_CLI_OPTS compile test-compile package
- echo "Build completed successfully"
# Upload JAR to Artifactory
- |
if [ -n "$ARTIFACTORY_URL" ] && [ -n "$ARTIFACTORY_USER" ] && [ -n "$ARTIFACTORY_PASSWORD" ]; then
echo "Uploading JAR to Artifactory..."
JAR_FILE=$(find target -name "*.jar" -type f | head -1)
if [ -f "$JAR_FILE" ]; then
JAR_NAME=$(basename "$JAR_FILE")
#curl -u $ARTIFACTORY_USER:$ARTIFACTORY_PASSWORD \
#-T "$JAR_FILE" \
#"$ARTIFACTORY_URL/artifactory/maven-local/com/example/myapp/$CI_COMMIT_SHA/$JAR_NAME"
echo "JAR uploaded successfully: $JAR_NAME"
else
echo "No JAR file found to upload"
fi
else
echo "Artifactory credentials not configured, skipping upload"
fi
artifacts:
paths:
- target/classes/
- target/test-classes/
- target/generated-sources/
- target/*.jar
expire_in: 1 hour
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_BRANCH =~ /^(feature|hotfix|release)\/.*/
- if: $CI_COMMIT_TAG
# Test stage - run unit and integration tests
test:
stage: test
image: maven:3.9-eclipse-temurin-17
script:
- mvn $MAVEN_CLI_OPTS clean compile test
artifacts:
when: always
reports:
junit:
- target/surefire-reports/TEST-*.xml
- target/failsafe-reports/TEST-*.xml
paths:
- target/site/jacoco/
expire_in: 30 days
coverage: '/Total.*?([0-9]{1,3}(?:\.[0-9]+)?)%/'
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_BRANCH =~ /^(feature|hotfix|release)\/.*/
- if: $CI_COMMIT_TAG
# Release job for tagged versions
release:
stage: release
image: maven:3.9-eclipse-temurin-17
script:
- echo "Starting release deployment..."
- mvn $MAVEN_CLI_OPTS release:prepare release:perform -DskipTests --settings settings.xml
- echo "Release deployment completed successfully"
dependencies:
- build
rules:
- if: $CI_COMMIT_TAG
when: manual
# Deploy to staging
deploy_staging:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- ssh-keyscan -H $STAGING_SERVER >> ~/.ssh/known_hosts
script:
- echo "Deploying to staging server"
- scp target/*.jar $SSH_USER@$STAGING_SERVER:/opt/app/
- ssh $SSH_USER@$STAGING_SERVER "sudo systemctl restart myapp"
environment:
name: staging
url: https://staging.example.com
dependencies:
- build
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
- if: $CI_COMMIT_BRANCH =~ /^(feature|hotfix|release)\/.*/
when: manual
References: ⭐
- GitLab Documentation. Available at: https://docs.gitlab.com/
- GitLab Duo (acknowledged for relevant insights and features discussed)
Top comments (0)