GitLab CI/CD Pipeline for Java Applications
Setting up a GitLab CI/CD pipeline to build, test, and deploy a Java application to VMs and Kubernetes.
GitLab CI is configured through a .gitlab-ci.yml file in the root of your repo. Every push triggers a pipeline run. GitLab reads the file, figures out which stages to run, and hands the work to a GitLab Runner — which can be a VM, a Docker container, or a pod in a Kubernetes cluster.
The thing I like about GitLab CI over Jenkins is that the pipeline definition lives in the repo alongside the code. No clicking around in a UI to see what the build actually does. Everything is version-controlled.
Basic pipeline: build and test
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
image: maven:3.8.6-eclipse-temurin-11
variables:
MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
cache:
key: "$CI_JOB_NAME"
paths:
- .m2/repository
stages:
- build
- test
- docker
- deploy
build:
stage: build
script:
- mvn clean package -DskipTests
artifacts:
paths:
- target/*.jar
expire_in: 1 hour
test:
stage: test
script:
- mvn test
artifacts:
reports:
junit: target/surefire-reports/TEST-*.xml
The cache block persists the local Maven repository between runs. Without it, every pipeline downloads every dependency from scratch. With it, subsequent runs only fetch what’s new. On a project with 200+ dependencies this made a visible difference.
artifacts passes files between stages. The jar built in build is available in test, docker, and deploy stages.
Building and pushing a Docker image
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
docker-build:
stage: docker
image: docker:24
services:
- docker:24-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
IMAGE_TAG: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- docker build -t $IMAGE_TAG .
- docker push $IMAGE_TAG
- |
if [ "$CI_COMMIT_BRANCH" == "main" ]; then
docker tag $IMAGE_TAG $CI_REGISTRY_IMAGE:latest
docker push $CI_REGISTRY_IMAGE:latest
fi
only:
- main
- merge_requests
$CI_REGISTRY, $CI_REGISTRY_USER, $CI_REGISTRY_PASSWORD, and $CI_REGISTRY_IMAGE are all provided automatically by GitLab when the built-in container registry is enabled. No manual configuration needed.
Deploying to a VM over SSH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
deploy-vm:
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 $DEPLOY_HOST >> ~/.ssh/known_hosts
script:
- ssh $DEPLOY_USER@$DEPLOY_HOST "
docker pull $CI_REGISTRY_IMAGE:latest &&
docker stop my-service || true &&
docker rm my-service || true &&
docker run -d --name my-service --restart unless-stopped
-p 8080:8080 $CI_REGISTRY_IMAGE:latest
"
only:
- main
$SSH_PRIVATE_KEY, $DEPLOY_HOST, and $DEPLOY_USER are CI/CD variables set in GitLab under Settings → CI/CD → Variables. Mark the private key as type “File” so GitLab stores it correctly.
Deploying to Kubernetes
1
2
3
4
5
6
7
8
9
10
11
12
13
deploy-k8s:
stage: deploy
image: bitnami/kubectl:latest
before_script:
- echo "$KUBECONFIG_CONTENT" | base64 -d > /tmp/kubeconfig
- export KUBECONFIG=/tmp/kubeconfig
script:
- kubectl set image deployment/my-service
my-service=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
--namespace=production
- kubectl rollout status deployment/my-service --namespace=production --timeout=120s
only:
- main
kubectl rollout status waits for the deployment to complete and exits non-zero if it doesn’t succeed within the timeout. This means if the new pods fail to start (bad config, image pull error, crashing on startup), the pipeline fails — which is exactly what you want. Beats discovering the issue when a user complains.
Protected branches and protected variables
Set main as a protected branch. Then mark your production credentials (SSH keys, kubeconfig) as protected variables. Protected variables are only injected into pipelines running on protected branches — so developers working on feature branches can’t access production credentials even if they modify .gitlab-ci.yml.
Useful built-in variables
1
2
3
4
5
6
$CI_COMMIT_BRANCH # branch name
$CI_COMMIT_SHORT_SHA # 8-character commit SHA
$CI_COMMIT_TAG # tag name (only in tag pipelines)
$CI_PIPELINE_ID # unique pipeline ID
$CI_PROJECT_DIR # workspace path on the runner
$CI_MERGE_REQUEST_IID # MR number (only in MR pipelines)