Post

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/CD Pipeline for Java Applications

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)
This post is licensed under CC BY 4.0 by the author.