[{"data":1,"prerenderedAt":815},["ShallowReactive",2],{"/en-us/blog/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes":3,"navigation-en-us":34,"banner-en-us":444,"footer-en-us":454,"blog-post-authors-en-us-Marco Lenzo":696,"blog-related-posts-en-us-continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes":710,"blog-promotions-en-us":752,"next-steps-en-us":805},{"id":4,"title":5,"authorSlugs":6,"body":8,"categorySlug":9,"config":10,"content":14,"description":8,"extension":22,"isFeatured":12,"meta":23,"navigation":24,"path":25,"publishedDate":20,"seo":26,"stem":31,"tagSlugs":32,"__hash__":33},"blogPosts/en-us/blog/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes.yml","Continuous Delivery Of A Spring Boot Application With Gitlab Ci And Kubernetes",[7],"marco-lenzo",null,"engineering",{"slug":11,"featured":12,"template":13},"continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes",false,"BlogPost",{"title":15,"description":16,"authors":17,"heroImage":19,"date":20,"body":21,"category":9},"Continuous delivery of a Spring Boot application with GitLab CI and Kubernetes","Create a Continuous Delivery pipeline to deploy a Spring Boot app with GitLab CI and Kubernetes to Google Cloud Container Engine",[18],"Marco Lenzo","https://res.cloudinary.com/about-gitlab-com/image/upload/v1749672314/Blog/Hero%20Images/dew-leaf.jpg","2016-12-14","[Continuous integration, continuous deployment and continuous delivery](/topics/ci-cd/) are increasingly popular topics among modern development teams. Together they enable a team to build, test and deploy the source code at any commit. The main benefit of these approaches is the ability to release more quality code more frequently through the means of automated pipelines. The tough part is building such pipelines. There is a myriad of tools available which we would need to choose, learn, install, integrate, and maintain.\n\nRecently, I literally fell in love with [GitLab](https://gitlab.com/)! It offers a fully featured ecosystem of tools which enable us to create an automated pipeline in minutes! From source control to issue tracking and CI, we find everything under one roof, fully integrated and ready to use.\n\n\u003C!-- more -->\n\nIn this tutorial, we will create a [Spring Boot](https://projects.spring.io/spring-boot/) application built, tested, and deployed with [GitLab CI](/solutions/continuous-integration/) on a [Kubernetes](http://kubernetes.io/) cluster.\n\n## What are Spring Boot and Kubernetes?\n\nSpring Boot (sometimes called Java Spring Boot) is the leading [microservice chassis](http://microservices.io/patterns/microservice-chassis.html) for Java. It allows a developer to build a production-grade stand-alone application, like a typical [CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete) application exposing a [RESTful API](https://en.wikipedia.org/wiki/Representational_state_transfer), with minimal configuration, reducing the learning curve required for using the [Spring Framework](https://spring.io/) drastically.\n\nKubernetes is an open-source container orchestrator inspired by [Google Borg](http://static.googleusercontent.com/media/research.google.com/en//pubs/archive/43438.pdf) that schedules, scales and manages containerized applications.\n\n\n## Create a GitLab project\n\nLet's start by [creating a new project](https://gitlab.com/projects/new) in GitLab named `actuator-sample`. Then we follow the command line instructions displayed in the project's home page to clone the repository on our machine and perform the first commit.\n\n```shell\ngit clone git@gitlab.com:marcolenzo/actuator-sample.git\ncd actuator-sample\ntouch README.md\ngit add README.md\ngit commit -m \"add README\"\ngit push -u origin master\n```\n\nAlways replace `marcolenzo` with your own GitLab username whenever copying a snippet of code from this tutorial.\n\n\n## Create a Spring Boot application\n\nTo bootstrap the Spring Boot application we navigate to the [Spring Initializr](https://start.spring.io) web page and generate a **Maven Project** with the pre-selected Spring Boot **Version**. [Maven](https://maven.apache.org/index.html) is a project management tool commonly used in Java projects to define dependencies and the build lifecycle. We leave `com.example` as **Group** and set `actuator-sample` as the **Artifact** name. We select the `Web` dependency, which supports full stack web development with [Tomcat](http://tomcat.apache.org/) and [Spring MVC](http://docs.spring.io/spring/docs/current/spring-framework-reference/html/mvc.html), and the `Actuator` dependency which implements some production-grade features useful for monitoring and managing our application like health-checks and HTTP requests traces.\n\nFinally, we generate the project and a Zip file named `actuator-sample.zip` will be downloaded to our machine.\n\n![Spring Initializr](https://about.gitlab.com/images/blogimages/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes/initializr.png){: .shadow}\n\nWe can now unzip the archive and launch the application immediately. Spring Initializr has already created everything for us. We just need to have a [Java JDK](http://openjdk.java.net/install/) 1.7 or later installed on our machine and the `JAVA_HOME` environment variable set accordingly. [OpenJDK](http://openjdk.java.net/) is the preferred option for most Linux distributions since it is readily available on their repositories. You can alternatively install [Oracle JDK](http://www.oracle.com/technetwork/java/javase/downloads/index.html) if it is a strict requirement for your team.\n\n\n```shell\n### Installing OpenJDK 8 on Debian, Ubuntu, etc.\n\nsudo apt-get install openjdk-8-jre\n\n### Installing OpenJDK 8 on Fedora, Oracle Linux, Red Hat Enteprise, CentOS, etc.\n\nsu -c \"yum install java-1.8.0-openjdk\"\n\n### Setting the JAVA_HOME environment variable\n\nexport JAVA_HOME=/path/to/your/java/home # e.g. /usr/lib/jvm/java-8-openjdk-amd64/\n\n### Extracting and launching the application\n\n~/git/actuator-sample$ unzip ~/Downloads/actuator-sample.zip -d ../\n~/git/actuator-sample$ ./mvnw spring-boot:run\n\n[...]\n\n2016-12-02 22:41:14.376  INFO 10882 --- [           main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat started on port(s): 8080 (http)\n2016-12-02 22:41:14.420  INFO 10882 --- [           main] com.example.ActuatorSampleApplication    : Started ActuatorSampleApplication in 17.924 seconds (JVM running for 87.495)\n```\n\nThe application is up and running and we did not write one line of code! Spring Boot is opinionated and auto-configures the application with sane default values and beans. It also scans the classpath for known dependencies and initializes them. In our case, we immediately enjoy all the production-grade services offered by [Spring Actuator](http://docs.spring.io/spring-boot/docs/current/reference/html/production-ready-endpoints.html).\n\n```shell\n~$ curl http://localhost:8080/health\n{\"status\":\"UP\",\"diskSpace\":{\"status\":\"UP\",\"total\":981190307840,\"free\":744776503296,\"threshold\":10485760}}\n```\n\nIf you wish to learn Spring Boot in greater detail, have a look at their [reference documentation](http://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/) and [guides](https://spring.io/guides).\n\n\nIt is time to commit our changes and push them to `origin`. To simplify things a bit, we commit directly on `master` without using [feature branches](https://docs.gitlab.com/ee/topics/gitlab_flow.html#github-flow-as-a-simpler-alternative) since collaboration is not the focus of this tutorial. Later, we will use [environment branches](https://docs.gitlab.com/ee/topics/gitlab_flow.html#environment-branches-with-gitlab-flow) as specified in the [GitLab Flow](https://docs.gitlab.com/ee/topics/gitlab_flow.html) to deploy to different environments selectively, e.g. staging and production. If you are not familiar with the [GitLab Flow](/topics/version-control/what-are-gitlab-flow-best-practices/), I strongly recommend you to read its documentation.\n\n```shell\ngit add --all\ngit commit -m \"Creates actuator-example application\"\ngit push origin master\n```\n\n## Creating a continuous delivery pipeline with GitLab CI\n\nWhile our code is now safe on GitLab, we still need to automate its integration and deployment. We need to verify each commit with an automated build and set of tests in order to discover issues as early as possible and, if the build is successful, deploy to a target environment. A few years ago, our only option was to install, configure and maintain a CI Server like [Jenkins](https://jenkins.io/) and possibly automate our deployment with a set of bash scripts. While the number of options has grown significantly, whether hosted or on the cloud, we still need to find a way to integrate our source control system with the CI Server of our choice.\n\nNot anymore though! GitLab has [fully integrated CI and CD Pipelines](/topics/ci-cd/) in its offering, allowing us to [build, test and deploy](/topics/version-control/what-is-gitlab-flow/) our code with ease.\n\nFor the purpose of this tutorial we will deploy to the [Google Cloud Container Engine](https://cloud.google.com/container-engine/) which is a cluster management and orchestration system built on the open source [Kubernetes](http://kubernetes.io/). Kubernetes is supported by all main cloud providers and can be [easily installed on any Linux server](http://kubernetes.io/docs/getting-started-guides/kubeadm/) in minutes. That said, we will be able to re-use this configuration virtually on any environment running Kubernetes.\n\nBefore we can proceed to the creation of the pipeline, we need to add a couple of files to our repository to package our application as a Docker container and to describe the target deployment in Kubernetes terms.\n\n### Packaging a Spring Boot application as a Docker container\n\nLet's start by creating the `Dockerfile` in the root directory of our project.\n\n```shell\nFROM openjdk:8u111-jdk-alpine\nVOLUME /tmp\nADD /target/actuator-sample-0.0.1-SNAPSHOT.jar app.jar\nENTRYPOINT [\"java\",\"-Djava.security.egd=file:/dev/./urandom\",\"-jar\",\"/app.jar\"]\n```\n\nThe `FROM` keyword defines the base Docker image of our container. We chose [OpenJDK](http://openjdk.java.net/) installed on [Alpine Linux](https://alpinelinux.org/) which is a lightweight Linux distribution. The `VOLUME` instruction creates a mount point with the specified name and marks it as holding externally mounted volumes from the native host or other containers. `ADD` copies the executable JAR generated during the build to the container root directory. Finally `ENTRYPOINT` defines the command to execute when the container is started. Since Spring Boot produces an executable JAR with embedded Tomcat, the command to execute is simply `java -jar app.jar`. The additional flag `java.security.edg=file:/dev/./urandom` is used to speed up the application start-up and avoid possible freezes. By default, Java uses `/dev/random` to seed its `SecureRandom` class which is known to block if its entropy pool is empty.\n\nTime to commit.\n\n```shell\ngit add Dockerfile\ngit commit -m \"Adds Dockerfile\"\ngit push origin master\n```\n\n### Define the Kubernetes deployment\n\nLet's create a file named `deployment.yml` in the root directory of our project.\n\n```yml\napiVersion: extensions/v1beta1\nkind: Deployment\nmetadata:\n  name: actuator-sample\nspec:\n  replicas: 2\n  template:\n    metadata:\n      labels:\n        app: actuator-sample\n    spec:\n      containers:\n      - name: actuator-sample\n        image: registry.gitlab.com/marcolenzo/actuator-sample\n        imagePullPolicy: Always\n        ports:\n        - containerPort: 8080\n      imagePullSecrets:\n        - name: registry.gitlab.com\n\n```\n\nThis is the definition of a Kubernetes [`Deployment`](http://kubernetes.io/docs/user-guide/deployments/) named `actuator-sample`. The `replicas` element defines the target number of [`Pods`](http://kubernetes.io/docs/user-guide/pods/). Kubernetes performs automated binpacking and self-healing of the system to comply with the deployment specifications while achieving optimal utilization of compute resources. A Pod can be composed of multiple containers. In this scenario, we only include the `actuator-sample` image stored on our private [GitLab Container Registry](/blog/gitlab-container-registry/). For this reason, we need to set an entry under the `imagePullSecrets` which is used to authenticate to the GitLab Container Registry.\n\nFor a detailed explanation of Kubernetes resources and concepts refer to the [official documentation](http://kubernetes.io/).\n\n\nTime to commit again and we are ready to define our GitLab CI pipeline.\n\n```shell\ngit add deployment.yml\ngit commit -m \"Adds Kubernetes Deployment definition\"\ngit push origin master\n```\n\n### Creating the GitLab CI pipeline\n\nIn order to make use of [GitLab CI](/solutions/continuous-integration/) we need to add the [`.gitlab-ci.yml`](https://docs.gitlab.com/ee/ci/yaml/) configuration file to the root directory of our repository. This file is used by [GitLab Runners](https://docs.gitlab.com/ee/ci/runners/) to manage our project's builds and deployments. Therein we can define an unlimited number of [Jobs](https://docs.gitlab.com/ee/ci/jobs/) and their role in the whole build lifecycle.\n\n```yml\nimage: docker:latest\nservices:\n  - docker:dind\n\nvariables:\n  DOCKER_DRIVER: overlay\n  SPRING_PROFILES_ACTIVE: gitlab-ci\n\nstages:\n  - build\n  - package\n  - deploy\n\nmaven-build:\n  image: maven:3-jdk-8\n  stage: build\n  script: \"mvn package -B\"\n  artifacts:\n    paths:\n      - target/*.jar\n\ndocker-build:\n  stage: package\n  script:\n  - docker build -t registry.gitlab.com/marcolenzo/actuator-sample .\n  - docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN registry.gitlab.com\n  - docker push registry.gitlab.com/marcolenzo/actuator-sample\n\nk8s-deploy:\n  image: google/cloud-sdk\n  stage: deploy\n  script:\n  - echo \"$GOOGLE_KEY\" > key.json\n  - gcloud auth activate-service-account --key-file key.json\n  - gcloud config set compute/zone europe-west1-c\n  - gcloud config set project actuator-sample\n  - gcloud config set container/use_client_certificate True\n  - gcloud container clusters get-credentials actuator-sample\n  - kubectl delete secret registry.gitlab.com\n  - kubectl create secret docker-registry registry.gitlab.com --docker-server=https://registry.gitlab.com --docker-username=marcolenzo --docker-password=$REGISTRY_PASSWD --docker-email=lenzo.marco@gmail.com\n  - kubectl apply -f deployment.yml\n\n```\n\nLet's break the file in pieces to understand what is going on.\n\n#### Image and Services\n\n```yml\nimage: docker:latest\nservices:\n  - docker:dind\n\n```\n\nThe [GitLab Runner](https://docs.gitlab.com/ee/ci/runners/) can [use Docker images](https://docs.gitlab.com/ee/ci/docker/using_docker_images.html) to support our pipelines. The [`image` element](https://docs.gitlab.com/ee/ci/yaml/#image) defines the name of the Docker image we want to use. Valid images are those hosted in the local Docker Engine or on [Docker Hub](https://hub.docker.com/). The `services` element defines additional Docker images which are linked to the main container. In our case the main container is a plain Docker image while the linked container is enabled for running Docker in Docker.\n\n#### Variables\n\n```yml\nvariables:\n  DOCKER_DRIVER: overlay\n  SPRING_PROFILES_ACTIVE: gitlab-ci\n\n```\n\nThis is the definition of [`variables`](https://docs.gitlab.com/ee/ci/yaml/#variables) to be set on our build environment. The `DOCKER_DRIVER` signals the Docker Engine which storage driver to use. We use `overlay` for [performance reasons](https://docs.gitlab.com/ee/ci/docker/using_docker_build.html#using-the-overlayfs-driver). The `SPRING_PROFILES_ACTIVE` is very useful when dealing with Spring Boot applications. It activates [Spring Profiles](http://docs.spring.io/autorepo/docs/spring-boot/current/reference/html/boot-features-profiles.html), which provide a way to segregate parts of our application configuration and make it available only in certain environments. For instance, we can define different database URIs per environment, e.g. `localhost` when running on the developer machine and `mongo` when running within GitLab CI.\n\n#### Stages\n\n```yml\nstages:\n  - build\n  - package\n  - deploy\n\n```\n\nThe [`stages` element](https://docs.gitlab.com/ee/ci/yaml/#stages) defines the lifecycle of our build. We associate each [job](https://docs.gitlab.com/ee/ci/jobs/) with one stage. All jobs within a stage are run in parallel and stages are triggered sequentially in the order we define them, i.e. the next stage is initiated only when the previous one is complete.\n\n#### The `maven-build` job\n\n```yml\nmaven-build:\n  image: maven:3-jdk-8\n  stage: build\n  script: \"mvn package -B\"\n  artifacts:\n    paths:\n      - target/*.jar\n\n```\n\nThis is a job definition. Jobs can have any name except keywords. Have a look at the `.gitlab-ci.yml` [documentation](https://docs.gitlab.com/ee/ci/yaml/) for the complete list of keywords.\n\nThe scope of this job is to perform a [Maven](https://maven.apache.org/index.html) build. For this reason, we define the `maven:3-jdk-8` as the Docker image on which this job should execute. This image comes with Maven 3 and the Java JDK 8 pre-installed for us.\n\nWe then specify `build` as the `stage` of this job. Jobs associated with the same stage run concurrently. This is extremely useful if you need to cross-compile your application. For instance, if we wanted to compile and test our application also on Java JDK 7, we could simply create another job with a different name and use the image `maven:3-jdk-7`.\n\n```yml\nmaven-test-jdk-7:\n  image: maven:3-jdk-7\n  stage: build\n  script: \"mvn package -B\"\n  artifacts:\n    paths:\n      - target/*.jar\n\n```\n\nAs previously said, the `maven-test-jdk-7` job runs in parallel with the `maven-build`. Hence, it does not have an impact on the pipeline execution time.\n\nThe [`script`](https://docs.gitlab.com/ee/ci/yaml/#script) is a shell command to be executed by the GitLab Runner. The `mvn package -B` triggers a non-interactive Maven build up to the `package` phase. This phase is specific to the [Maven build lifecycle](https://maven.apache.org/guides/introduction/introduction-to-the-lifecycle.html) and it includes also the `validate`, `compile` and `test` phases. That means that our Maven project will be validated, compiled and (unit) tested as well. Tests are to be included in the `src/test/java` folder. In our specific case, Spring Initializr has already created a unit test which verifies that the application context loads without errors. We are free to add as many unit tests as we like. Finally, the `package` phase creates the executable JAR.\n\nTo persist the executable JAR and share it across jobs, we specify job [`artifacts`](https://docs.gitlab.com/ee/ci/yaml/#artifacts). These are files or directories that are attached to the build after success and made downloadable from the UI in the Pipelines screen.\n\n![Downloading artifacts from pipelines](https://about.gitlab.com/images/blogimages/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes/artifacts.png){: .shadow}\n\n\n#### The `docker-build` job\n\n```yml\ndocker-build:\n  stage: package\n  script:\n  - docker build -t registry.gitlab.com/marcolenzo/actuator-sample .\n  - docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN registry.gitlab.com\n  - docker push registry.gitlab.com/marcolenzo/actuator-sample\n\n```\n\nThe `docker-build` job packages the application into a Docker container. We define `package` as the build `stage` since we need the `maven-build` job to produce the executable JAR beforehand.\n\nThe scripts are a typical sequence of `docker` commands used to build an image, log in to a private registry and push the image to it. We will be pushing images to the [GitLab Container Registry](/blog/gitlab-container-registry/).\n\nThe [`$CI_BUILD_TOKEN`](https://docs.gitlab.com/ee/user/project/new_ci_build_permissions_model.html#container-registry) is a pre-defined variable which is injected by GitLab CI into our build environment automatically. It is used to log in to the GitLab Container Registry.\n\nFor a complete list of pre-defined variables, have a look at the [variables documentation](https://docs.gitlab.com/ee/ci/variables/).\n\n\n#### The `k8s-deploy` job\n\nThis job is responsible for deploying our application to the [Google Kubernetes Engine](https://cloud.google.com/container-engine/). I purposely decided to make use of the [Google Cloud SDK](https://cloud.google.com/sdk/gcloud/) (`gcloud`) because it gives us the possibility to programmatically create and manage Google Container Engine clusters and other products of the Google Cloud ecosystem. In this tutorial, we will simplify things a bit by creating the Google Container Engine cluster beforehand through the GUI.\n\nFirst, we create a Google Cloud Project named `actuator-sample`. Take note of the `Project ID` since it sometimes differs from the project name we specify. Then we create a Google Kubernetes Engine cluster named `actuator-sample` as well. We can choose any machine type and any number of nodes. For the purpose of this tutorial one node and a small machine are sufficient. Let's take note of the `zone`.\n\n![Create a container cluster](https://about.gitlab.com/images/blogimages/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes/create-gce-cluster.png){: .shadow}\n\nFinally we need to create a service account which is necessary to perform a non-interactive login with `gcloud`. Navigate to Google Cloud **API Manager** > **Credentials** > **Create Credentials** and create a JSON key for the `Compute Engine default service account`.\n\nWe can now analyze the configuration.\n\n```yml\nk8s-deploy:\n  image: google/cloud-sdk\n  stage: deploy\n  script:\n  - echo \"$GOOGLE_KEY\" > key.json # Google Cloud service account key\n  - gcloud auth activate-service-account --key-file key.json\n  - gcloud config set compute/zone europe-west1-c\n  - gcloud config set project actuator-sample\n  - gcloud config set container/use_client_certificate True\n  - gcloud container clusters get-credentials actuator-example\n  - kubectl delete secret registry.gitlab.com\n  - kubectl create secret docker-registry registry.gitlab.com --docker-server=https://registry.gitlab.com --docker-username=marcolenzo --docker-password=$REGISTRY_PASSWD --docker-email=lenzo.marco@gmail.com\n  - kubectl apply -f deployment.yml\n\n```\n\nWe use the `google/cloud-sdk` image for this process since it comes preloaded with `gcloud` and all components and dependencies of the Google Cloud SDK including alpha and beta components. We obviously chose `deploy` as the `stage` since we want our application to be packaged beforehand and its container pushed to the GitLab Container Registry. Then we execute a set of scripts.\n\nThe `echo \"$GOOGLE_KEY\" > key.json` script injects the Google Cloud service account key in the container. `$GOOGLE_KEY` is a Secure Variable having the content of the Google Cloud service account key as its value. [Secure Variables](https://docs.gitlab.com/ee/ci/variables/#user-defined-variables-secure-variables) are user-defined variables that should not be shown in the `.gitlab-ci.yml` file. They are set per project by navigating to **Project** > **Variables** > **Add Variable** in GitLab.\n\n![Secure Variables](https://about.gitlab.com/images/blogimages/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes/secure-variables.png){: .shadow}\n\nThe `gcloud auth activate-service-account --key-file key.json` script performs the non-interactive authentication process. The `gcloud config set ...` scripts are selecting the target project, zone and cluster. Make sure these values correspond to those you jotted down before. The `gcloud container clusters get-credentials actuator-example` script downloads the `kubectl` configuration file. If we wanted to use Kubernetes on another cloud provider or custom installation, we would source the `kubectl` configuration `~/.kube/config` without the need to interact with `gcloud`.\n\nThe `kubectl create secret docker-registry ...` script creates the `imagePullSecret` we had defined in the `deployment.yml`. This is used by Kubernetes to authenticate with our private GitLab Container Registry and download the container images. The `kubectl delete secret` is necessary because the Kubernetes API is lacking the `replace` operation for `docker-registry` secrets. In a real-world scenario, I would suggest handling [Kubernetes secrets](http://kubernetes.io/docs/user-guide/secrets/) that can affect multiple pipelines (such as a password for a private Docker registry) in a separate pipeline or through configuration management tools like [Ansible](https://www.ansible.com/), [Salt](https://saltstack.com/), [Puppet](https://puppet.com/) or [Chef](https://www.chef.io/). The reason is that such secrets should be rotated periodically for security reasons and updated in each GitLab project using them. There is also the risk of interference between pipelines because of the `kubectl delete` command. Note that `$REGISTRY_PASSWD` is another Secure Variable.\n\nTime to check if everything is in order on our cluster.\n\n```shell\n$ kubectl get deployments\nNAME              DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE\nactuator-sample   2         2         2            2           2m\n$ kubectl get pods\nNAME                               READY     STATUS    RESTARTS   AGE\nactuator-sample-3641958612-3e5xy   1/1       Running   0          2m\nactuator-sample-5542343546-fr4gh   1/1       Running   0          2m\n```\n\n![Kubernetes](https://about.gitlab.com/images/blogimages/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes/kubernetes.png){: .shadow}\n\nDeployed!\n\n#### GitLab Environments\n\nBefore concluding the tutorial, we will learn about [GitLab Environments](https://docs.gitlab.com/ee/ci/environments/index.html) which enable us to track environments and deployments.\n\nLet's refactor the `k8s-deploy` job and split it in two. One job will target the staging environment and the other the production environment.\n\n```yml\nk8s-deploy-staging:\n  image: google/cloud-sdk\n  stage: deploy\n  script:\n  - echo \"$GOOGLE_KEY\" > key.json\n  - gcloud auth activate-service-account --key-file key.json\n  - gcloud config set compute/zone europe-west1-c\n  - gcloud config set project actuator-sample\n  - gcloud config set container/use_client_certificate True\n  - gcloud container clusters get-credentials actuator-example\n  - kubectl delete secret registry.gitlab.com\n  - kubectl create secret docker-registry registry.gitlab.com --docker-server=https://registry.gitlab.com --docker-username=marcolenzo --docker-password=$REGISTRY_PASSWD --docker-email=lenzo.marco@gmail.com\n  - kubectl apply -f deployment.yml --namespace=staging\n  environment:\n    name: staging\n    url: https://example.staging.com\n  only:\n  - master\n\nk8s-deploy-production:\n  image: google/cloud-sdk\n  stage: deploy\n  script:\n  - echo \"$GOOGLE_KEY\" > key.json\n  - gcloud auth activate-service-account --key-file key.json\n  - gcloud config set compute/zone europe-west1-c\n  - gcloud config set project actuator-sample\n  - gcloud config set container/use_client_certificate True\n  - gcloud container clusters get-credentials actuator-example\n  - kubectl delete secret registry.gitlab.com\n  - kubectl create secret docker-registry registry.gitlab.com --docker-server=https://registry.gitlab.com --docker-username=marcolenzo --docker-password=$REGISTRY_PASSWD --docker-email=lenzo.marco@gmail.com\n  - kubectl apply -f deployment.yml --namespace=production\n  environment:\n    name: production\n    url: https://example.production.com\n  when: manual\n  only:\n  - production\n\n```\n\nThe `environment` keyword associates the job with a specific environment while the `url` element is used to generate a handy hyperlink to our application on the GitLab Environments page (found under your project's `Pipelines > Environments`). The `only` keyword signals to GitLab CI that the job should be executed only when the pipeline is building the listed branches. Finally, `when: manual` is used to turn the job execution from automatic to manual. Turning the execution of this job to `automatic` would project us in the world of [Continuous Deployment](/blog/continuous-integration-delivery-and-deployment-with-gitlab/#continuous-deployment) rather than [Continuous Delivery](/blog/continuous-integration-delivery-and-deployment-with-gitlab/#continuous-delivery). From a Kubernetes perspective, we are making use of `namespaces` to segregate the different environments.\n\nBy committing on `master` and `production` we [trigger a pipeline per environment](/blog/ci-deployment-and-environments/). As mentioned before, we are not making use of any collaboration tool because it is out of the scope of this tutorial. In real-world scenarios, we would use [merge requests](/topics/version-control/what-is-gitlab-flow/#merge-request) with [Review Apps](https://docs.gitlab.com/ee/ci/review_apps/) to move code across branches. Merge requests allow the team to review and discuss the changes before they get merged into the target branch. [Review Apps](https://docs.gitlab.com/ee/ci/review_apps/) take that one step further by spinning up dynamic environments for our merge requests, offering the team access to a deployed instance of our application without the need of checking out the branch. This is extremely useful not only for non-technical members of the team, but also to collaborators and project managers to preview the changes without having to clone and install the app and its dependencies when evaluating a proposal.\n\n```shell\ngit commit -am \"Showcasing Pipelines\"\ngit push origin master\ngit checkout -b production\ngit push origin production\n```\n![Pipelines](https://about.gitlab.com/images/blogimages/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes/pipelines.png){: .shadow}\n\nThe Pipelines screen details all pipeline executions. We can gather information about the branch and the individual result of each stage. In the case of the `production` pipeline the `k8s-deploy-production` is not executed automatically as expected but can be triggered from the GUI from where we can also download the build artifacts.\n\n![Environments](https://about.gitlab.com/images/blogimages/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes/environments.png){: .shadow}\n\nEnvironments are listed on a separate page, from which it is possible to redeploy the latest version of an environment or to roll back to a particular version of the environment by accessing the relative details page.\n\n![Rollbacks](https://about.gitlab.com/images/blogimages/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes/rollbacks.png){: .shadow}\n\n\n## Conclusion\n\nIn this tutorial, we were able to create a [Continuous Delivery](https://en.wikipedia.org/wiki/Continuous_delivery) pipeline with ease thanks to the suite of [GitLab](/) products that supported us at every stage. [Spring Boot](https://projects.spring.io/spring-boot/) gave us agility by auto-configuring the application context and offering production-grade services out of the box. [Kubernetes](http://kubernetes.io/) abstracted us from the compute resources and orchestration duties allowing us to define only the desired deployment state. [GitLab CI](/solutions/continuous-integration/) was the core engine of our pipeline. Its declarative [`.gitlab-ci.yml`](https://docs.gitlab.com/ee/ci/yaml/) file allowed us to define, version and manage our pipelines while the GUI gave us full visibility and control.\n\nWhile this is a basic example, it clearly shows the immense benefits any team or company can gain by using the unified GUI of GitLab for issues, code review, CI and CD.\n\n## About Guest Author\n\n[Marco Lenzo](https://twitter.com/marco_lenzo) is a Software Architect always up for a challenge. He has expertise in transaction processing and platform as a service (PaaS). Java, Spring, Go and Kubernetes are currently his bread and butter.\n\n\u003C!-- closes https://gitlab.com/gitlab-com/blog-posts/issues/309 -->\n\u003C!-- cover image: https://unsplash.com/photos/G86MS2ZsiJA -->\n\n\u003Cstyle>\n  .h4 {\n    font-weight: bold;\n  }\n\u003C/style>\n","yml",{},true,"/en-us/blog/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes",{"title":27,"description":16,"ogTitle":27,"ogDescription":16,"noIndex":12,"ogImage":19,"ogUrl":28,"ogSiteName":29,"ogType":30,"canonicalUrls":28},"Spring Boot delivery with GitLab CI and Kubernetes","https://about.gitlab.com/blog/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes","https://about.gitlab.com","article","en-us/blog/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes",[],"1WWk11bk89vvVALgqmw9sdPkUMUTWXq20tHuMDbHNhs",{"data":35},{"logo":36,"freeTrial":41,"sales":46,"login":51,"items":56,"search":364,"minimal":395,"duo":414,"switchNav":423,"pricingDeployment":434},{"config":37},{"href":38,"dataGaName":39,"dataGaLocation":40},"/","gitlab logo","header",{"text":42,"config":43},"Get free trial",{"href":44,"dataGaName":45,"dataGaLocation":40},"https://gitlab.com/-/trial_registrations/new?glm_source=about.gitlab.com&glm_content=default-saas-trial/","free trial",{"text":47,"config":48},"Talk to sales",{"href":49,"dataGaName":50,"dataGaLocation":40},"/sales/","sales",{"text":52,"config":53},"Sign in",{"href":54,"dataGaName":55,"dataGaLocation":40},"https://gitlab.com/users/sign_in/","sign in",[57,84,179,184,285,345],{"text":58,"config":59,"cards":61},"Platform",{"dataNavLevelOne":60},"platform",[62,68,76],{"title":58,"description":63,"link":64},"The intelligent orchestration platform for DevSecOps",{"text":65,"config":66},"Explore our Platform",{"href":67,"dataGaName":60,"dataGaLocation":40},"/platform/",{"title":69,"description":70,"link":71},"GitLab Duo Agent Platform","Agentic AI for the entire software lifecycle",{"text":72,"config":73},"Meet GitLab Duo",{"href":74,"dataGaName":75,"dataGaLocation":40},"/gitlab-duo-agent-platform/","gitlab duo agent platform",{"title":77,"description":78,"link":79},"Why GitLab","See the top reasons enterprises choose GitLab",{"text":80,"config":81},"Learn more",{"href":82,"dataGaName":83,"dataGaLocation":40},"/why-gitlab/","why gitlab",{"text":85,"left":24,"config":86,"link":88,"lists":92,"footer":161},"Product",{"dataNavLevelOne":87},"solutions",{"text":89,"config":90},"View all Solutions",{"href":91,"dataGaName":87,"dataGaLocation":40},"/solutions/",[93,117,140],{"title":94,"description":95,"link":96,"items":101},"Automation","CI/CD and automation to accelerate deployment",{"config":97},{"icon":98,"href":99,"dataGaName":100,"dataGaLocation":40},"AutomatedCodeAlt","/solutions/delivery-automation/","automated software delivery",[102,106,109,113],{"text":103,"config":104},"CI/CD",{"href":105,"dataGaLocation":40,"dataGaName":103},"/solutions/continuous-integration/",{"text":69,"config":107},{"href":74,"dataGaLocation":40,"dataGaName":108},"gitlab duo agent platform - product menu",{"text":110,"config":111},"Source Code Management",{"href":112,"dataGaLocation":40,"dataGaName":110},"/solutions/source-code-management/",{"text":114,"config":115},"Automated Software Delivery",{"href":99,"dataGaLocation":40,"dataGaName":116},"Automated software delivery",{"title":118,"description":119,"link":120,"items":125},"Security","Deliver code faster without compromising security",{"config":121},{"href":122,"dataGaName":123,"dataGaLocation":40,"icon":124},"/solutions/application-security-testing/","security and compliance","ShieldCheckLight",[126,130,135],{"text":127,"config":128},"Application Security Testing",{"href":122,"dataGaName":129,"dataGaLocation":40},"Application security testing",{"text":131,"config":132},"Software Supply Chain Security",{"href":133,"dataGaLocation":40,"dataGaName":134},"/solutions/supply-chain/","Software supply chain security",{"text":136,"config":137},"Software Compliance",{"href":138,"dataGaName":139,"dataGaLocation":40},"/solutions/software-compliance/","software compliance",{"title":141,"link":142,"items":147},"Measurement",{"config":143},{"icon":144,"href":145,"dataGaName":146,"dataGaLocation":40},"DigitalTransformation","/solutions/visibility-measurement/","visibility and measurement",[148,152,156],{"text":149,"config":150},"Visibility & Measurement",{"href":145,"dataGaLocation":40,"dataGaName":151},"Visibility and Measurement",{"text":153,"config":154},"Value Stream Management",{"href":155,"dataGaLocation":40,"dataGaName":153},"/solutions/value-stream-management/",{"text":157,"config":158},"Analytics & Insights",{"href":159,"dataGaLocation":40,"dataGaName":160},"/solutions/analytics-and-insights/","Analytics and insights",{"title":162,"items":163},"GitLab for",[164,169,174],{"text":165,"config":166},"Enterprise",{"href":167,"dataGaLocation":40,"dataGaName":168},"/enterprise/","enterprise",{"text":170,"config":171},"Small Business",{"href":172,"dataGaLocation":40,"dataGaName":173},"/small-business/","small business",{"text":175,"config":176},"Public Sector",{"href":177,"dataGaLocation":40,"dataGaName":178},"/solutions/public-sector/","public sector",{"text":180,"config":181},"Pricing",{"href":182,"dataGaName":183,"dataGaLocation":40,"dataNavLevelOne":183},"/pricing/","pricing",{"text":185,"config":186,"link":188,"lists":192,"feature":272},"Resources",{"dataNavLevelOne":187},"resources",{"text":189,"config":190},"View all resources",{"href":191,"dataGaName":187,"dataGaLocation":40},"/resources/",[193,226,244],{"title":194,"items":195},"Getting started",[196,201,206,211,216,221],{"text":197,"config":198},"Install",{"href":199,"dataGaName":200,"dataGaLocation":40},"/install/","install",{"text":202,"config":203},"Quick start guides",{"href":204,"dataGaName":205,"dataGaLocation":40},"/get-started/","quick setup checklists",{"text":207,"config":208},"Learn",{"href":209,"dataGaLocation":40,"dataGaName":210},"https://university.gitlab.com/","learn",{"text":212,"config":213},"Product documentation",{"href":214,"dataGaName":215,"dataGaLocation":40},"https://docs.gitlab.com/","product documentation",{"text":217,"config":218},"Best practice videos",{"href":219,"dataGaName":220,"dataGaLocation":40},"/getting-started-videos/","best practice videos",{"text":222,"config":223},"Integrations",{"href":224,"dataGaName":225,"dataGaLocation":40},"/integrations/","integrations",{"title":227,"items":228},"Discover",[229,234,239],{"text":230,"config":231},"Customer success stories",{"href":232,"dataGaName":233,"dataGaLocation":40},"/customers/","customer success stories",{"text":235,"config":236},"Blog",{"href":237,"dataGaName":238,"dataGaLocation":40},"/blog/","blog",{"text":240,"config":241},"Remote",{"href":242,"dataGaName":243,"dataGaLocation":40},"https://handbook.gitlab.com/handbook/company/culture/all-remote/","remote",{"title":245,"items":246},"Connect",[247,252,257,262,267],{"text":248,"config":249},"GitLab Services",{"href":250,"dataGaName":251,"dataGaLocation":40},"/services/","services",{"text":253,"config":254},"Community",{"href":255,"dataGaName":256,"dataGaLocation":40},"/community/","community",{"text":258,"config":259},"Forum",{"href":260,"dataGaName":261,"dataGaLocation":40},"https://forum.gitlab.com/","forum",{"text":263,"config":264},"Events",{"href":265,"dataGaName":266,"dataGaLocation":40},"/events/","events",{"text":268,"config":269},"Partners",{"href":270,"dataGaName":271,"dataGaLocation":40},"/partners/","partners",{"backgroundColor":273,"textColor":274,"text":275,"image":276,"link":280},"#2f2a6b","#fff","Insights for the future of software development",{"altText":277,"config":278},"the source promo card",{"src":279},"https://res.cloudinary.com/about-gitlab-com/image/upload/v1758208064/dzl0dbift9xdizyelkk4.svg",{"text":281,"config":282},"Read the latest",{"href":283,"dataGaName":284,"dataGaLocation":40},"/the-source/","the source",{"text":286,"config":287,"lists":289},"Company",{"dataNavLevelOne":288},"company",[290],{"items":291},[292,297,303,305,310,315,320,325,330,335,340],{"text":293,"config":294},"About",{"href":295,"dataGaName":296,"dataGaLocation":40},"/company/","about",{"text":298,"config":299,"footerGa":302},"Jobs",{"href":300,"dataGaName":301,"dataGaLocation":40},"/jobs/","jobs",{"dataGaName":301},{"text":263,"config":304},{"href":265,"dataGaName":266,"dataGaLocation":40},{"text":306,"config":307},"Leadership",{"href":308,"dataGaName":309,"dataGaLocation":40},"/company/team/e-group/","leadership",{"text":311,"config":312},"Team",{"href":313,"dataGaName":314,"dataGaLocation":40},"/company/team/","team",{"text":316,"config":317},"Handbook",{"href":318,"dataGaName":319,"dataGaLocation":40},"https://handbook.gitlab.com/","handbook",{"text":321,"config":322},"Investor relations",{"href":323,"dataGaName":324,"dataGaLocation":40},"https://ir.gitlab.com/","investor relations",{"text":326,"config":327},"Trust Center",{"href":328,"dataGaName":329,"dataGaLocation":40},"/security/","trust center",{"text":331,"config":332},"AI Transparency Center",{"href":333,"dataGaName":334,"dataGaLocation":40},"/ai-transparency-center/","ai transparency center",{"text":336,"config":337},"Newsletter",{"href":338,"dataGaName":339,"dataGaLocation":40},"/company/contact/#contact-forms","newsletter",{"text":341,"config":342},"Press",{"href":343,"dataGaName":344,"dataGaLocation":40},"/press/","press",{"text":346,"config":347,"lists":348},"Contact us",{"dataNavLevelOne":288},[349],{"items":350},[351,354,359],{"text":47,"config":352},{"href":49,"dataGaName":353,"dataGaLocation":40},"talk to sales",{"text":355,"config":356},"Support portal",{"href":357,"dataGaName":358,"dataGaLocation":40},"https://support.gitlab.com","support portal",{"text":360,"config":361},"Customer portal",{"href":362,"dataGaName":363,"dataGaLocation":40},"https://customers.gitlab.com/customers/sign_in/","customer portal",{"close":365,"login":366,"suggestions":373},"Close",{"text":367,"link":368},"To search repositories and projects, login to",{"text":369,"config":370},"gitlab.com",{"href":54,"dataGaName":371,"dataGaLocation":372},"search login","search",{"text":374,"default":375},"Suggestions",[376,378,382,384,388,392],{"text":69,"config":377},{"href":74,"dataGaName":69,"dataGaLocation":372},{"text":379,"config":380},"Code Suggestions (AI)",{"href":381,"dataGaName":379,"dataGaLocation":372},"/solutions/code-suggestions/",{"text":103,"config":383},{"href":105,"dataGaName":103,"dataGaLocation":372},{"text":385,"config":386},"GitLab on AWS",{"href":387,"dataGaName":385,"dataGaLocation":372},"/partners/technology-partners/aws/",{"text":389,"config":390},"GitLab on Google Cloud",{"href":391,"dataGaName":389,"dataGaLocation":372},"/partners/technology-partners/google-cloud-platform/",{"text":393,"config":394},"Why GitLab?",{"href":82,"dataGaName":393,"dataGaLocation":372},{"freeTrial":396,"mobileIcon":401,"desktopIcon":406,"secondaryButton":409},{"text":397,"config":398},"Start free trial",{"href":399,"dataGaName":45,"dataGaLocation":400},"https://gitlab.com/-/trials/new/","nav",{"altText":402,"config":403},"Gitlab Icon",{"src":404,"dataGaName":405,"dataGaLocation":400},"https://res.cloudinary.com/about-gitlab-com/image/upload/v1758203874/jypbw1jx72aexsoohd7x.svg","gitlab icon",{"altText":402,"config":407},{"src":408,"dataGaName":405,"dataGaLocation":400},"https://res.cloudinary.com/about-gitlab-com/image/upload/v1758203875/gs4c8p8opsgvflgkswz9.svg",{"text":410,"config":411},"Get Started",{"href":412,"dataGaName":413,"dataGaLocation":400},"https://gitlab.com/-/trial_registrations/new?glm_source=about.gitlab.com/get-started/","get started",{"freeTrial":415,"mobileIcon":419,"desktopIcon":421},{"text":416,"config":417},"Learn more about GitLab Duo",{"href":74,"dataGaName":418,"dataGaLocation":400},"gitlab duo",{"altText":402,"config":420},{"src":404,"dataGaName":405,"dataGaLocation":400},{"altText":402,"config":422},{"src":408,"dataGaName":405,"dataGaLocation":400},{"button":424,"mobileIcon":429,"desktopIcon":431},{"text":425,"config":426},"/switch",{"href":427,"dataGaName":428,"dataGaLocation":400},"#contact","switch",{"altText":402,"config":430},{"src":404,"dataGaName":405,"dataGaLocation":400},{"altText":402,"config":432},{"src":433,"dataGaName":405,"dataGaLocation":400},"https://res.cloudinary.com/about-gitlab-com/image/upload/v1773335277/ohhpiuoxoldryzrnhfrh.png",{"freeTrial":435,"mobileIcon":440,"desktopIcon":442},{"text":436,"config":437},"Back to pricing",{"href":182,"dataGaName":438,"dataGaLocation":400,"icon":439},"back to pricing","GoBack",{"altText":402,"config":441},{"src":404,"dataGaName":405,"dataGaLocation":400},{"altText":402,"config":443},{"src":408,"dataGaName":405,"dataGaLocation":400},{"title":445,"button":446,"config":451},"See how agentic AI transforms software delivery",{"text":447,"config":448},"Watch GitLab Transcend now",{"href":449,"dataGaName":450,"dataGaLocation":40},"/events/transcend/virtual/","transcend event",{"layout":452,"icon":453,"disabled":24},"release","AiStar",{"data":455},{"text":456,"source":457,"edit":463,"contribute":468,"config":473,"items":478,"minimal":685},"Git is a trademark of Software Freedom Conservancy and our use of 'GitLab' is under license",{"text":458,"config":459},"View page source",{"href":460,"dataGaName":461,"dataGaLocation":462},"https://gitlab.com/gitlab-com/marketing/digital-experience/about-gitlab-com/","page source","footer",{"text":464,"config":465},"Edit this page",{"href":466,"dataGaName":467,"dataGaLocation":462},"https://gitlab.com/gitlab-com/marketing/digital-experience/about-gitlab-com/-/blob/main/content/","web ide",{"text":469,"config":470},"Please contribute",{"href":471,"dataGaName":472,"dataGaLocation":462},"https://gitlab.com/gitlab-com/marketing/digital-experience/about-gitlab-com/-/blob/main/CONTRIBUTING.md/","please contribute",{"twitter":474,"facebook":475,"youtube":476,"linkedin":477},"https://twitter.com/gitlab","https://www.facebook.com/gitlab","https://www.youtube.com/channel/UCnMGQ8QHMAnVIsI3xJrihhg","https://www.linkedin.com/company/gitlab-com",[479,526,580,624,651],{"title":180,"links":480,"subMenu":495},[481,485,490],{"text":482,"config":483},"View plans",{"href":182,"dataGaName":484,"dataGaLocation":462},"view plans",{"text":486,"config":487},"Why Premium?",{"href":488,"dataGaName":489,"dataGaLocation":462},"/pricing/premium/","why premium",{"text":491,"config":492},"Why Ultimate?",{"href":493,"dataGaName":494,"dataGaLocation":462},"/pricing/ultimate/","why ultimate",[496],{"title":497,"links":498},"Contact Us",[499,502,504,506,511,516,521],{"text":500,"config":501},"Contact sales",{"href":49,"dataGaName":50,"dataGaLocation":462},{"text":355,"config":503},{"href":357,"dataGaName":358,"dataGaLocation":462},{"text":360,"config":505},{"href":362,"dataGaName":363,"dataGaLocation":462},{"text":507,"config":508},"Status",{"href":509,"dataGaName":510,"dataGaLocation":462},"https://status.gitlab.com/","status",{"text":512,"config":513},"Terms of use",{"href":514,"dataGaName":515,"dataGaLocation":462},"/terms/","terms of use",{"text":517,"config":518},"Privacy statement",{"href":519,"dataGaName":520,"dataGaLocation":462},"/privacy/","privacy statement",{"text":522,"config":523},"Cookie preferences",{"dataGaName":524,"dataGaLocation":462,"id":525,"isOneTrustButton":24},"cookie preferences","ot-sdk-btn",{"title":85,"links":527,"subMenu":536},[528,532],{"text":529,"config":530},"DevSecOps platform",{"href":67,"dataGaName":531,"dataGaLocation":462},"devsecops platform",{"text":533,"config":534},"AI-Assisted Development",{"href":74,"dataGaName":535,"dataGaLocation":462},"ai-assisted development",[537],{"title":538,"links":539},"Topics",[540,545,550,555,560,565,570,575],{"text":541,"config":542},"CICD",{"href":543,"dataGaName":544,"dataGaLocation":462},"/topics/ci-cd/","cicd",{"text":546,"config":547},"GitOps",{"href":548,"dataGaName":549,"dataGaLocation":462},"/topics/gitops/","gitops",{"text":551,"config":552},"DevOps",{"href":553,"dataGaName":554,"dataGaLocation":462},"/topics/devops/","devops",{"text":556,"config":557},"Version Control",{"href":558,"dataGaName":559,"dataGaLocation":462},"/topics/version-control/","version control",{"text":561,"config":562},"DevSecOps",{"href":563,"dataGaName":564,"dataGaLocation":462},"/topics/devsecops/","devsecops",{"text":566,"config":567},"Cloud Native",{"href":568,"dataGaName":569,"dataGaLocation":462},"/topics/cloud-native/","cloud native",{"text":571,"config":572},"AI for Coding",{"href":573,"dataGaName":574,"dataGaLocation":462},"/topics/devops/ai-for-coding/","ai for coding",{"text":576,"config":577},"Agentic AI",{"href":578,"dataGaName":579,"dataGaLocation":462},"/topics/agentic-ai/","agentic ai",{"title":581,"links":582},"Solutions",[583,585,587,592,596,599,603,606,608,611,614,619],{"text":127,"config":584},{"href":122,"dataGaName":127,"dataGaLocation":462},{"text":116,"config":586},{"href":99,"dataGaName":100,"dataGaLocation":462},{"text":588,"config":589},"Agile development",{"href":590,"dataGaName":591,"dataGaLocation":462},"/solutions/agile-delivery/","agile delivery",{"text":593,"config":594},"SCM",{"href":112,"dataGaName":595,"dataGaLocation":462},"source code management",{"text":541,"config":597},{"href":105,"dataGaName":598,"dataGaLocation":462},"continuous integration & delivery",{"text":600,"config":601},"Value stream management",{"href":155,"dataGaName":602,"dataGaLocation":462},"value stream management",{"text":546,"config":604},{"href":605,"dataGaName":549,"dataGaLocation":462},"/solutions/gitops/",{"text":165,"config":607},{"href":167,"dataGaName":168,"dataGaLocation":462},{"text":609,"config":610},"Small business",{"href":172,"dataGaName":173,"dataGaLocation":462},{"text":612,"config":613},"Public sector",{"href":177,"dataGaName":178,"dataGaLocation":462},{"text":615,"config":616},"Education",{"href":617,"dataGaName":618,"dataGaLocation":462},"/solutions/education/","education",{"text":620,"config":621},"Financial services",{"href":622,"dataGaName":623,"dataGaLocation":462},"/solutions/finance/","financial services",{"title":185,"links":625},[626,628,630,632,635,637,639,641,643,645,647,649],{"text":197,"config":627},{"href":199,"dataGaName":200,"dataGaLocation":462},{"text":202,"config":629},{"href":204,"dataGaName":205,"dataGaLocation":462},{"text":207,"config":631},{"href":209,"dataGaName":210,"dataGaLocation":462},{"text":212,"config":633},{"href":214,"dataGaName":634,"dataGaLocation":462},"docs",{"text":235,"config":636},{"href":237,"dataGaName":238,"dataGaLocation":462},{"text":230,"config":638},{"href":232,"dataGaName":233,"dataGaLocation":462},{"text":240,"config":640},{"href":242,"dataGaName":243,"dataGaLocation":462},{"text":248,"config":642},{"href":250,"dataGaName":251,"dataGaLocation":462},{"text":253,"config":644},{"href":255,"dataGaName":256,"dataGaLocation":462},{"text":258,"config":646},{"href":260,"dataGaName":261,"dataGaLocation":462},{"text":263,"config":648},{"href":265,"dataGaName":266,"dataGaLocation":462},{"text":268,"config":650},{"href":270,"dataGaName":271,"dataGaLocation":462},{"title":286,"links":652},[653,655,657,659,661,663,665,669,674,676,678,680],{"text":293,"config":654},{"href":295,"dataGaName":288,"dataGaLocation":462},{"text":298,"config":656},{"href":300,"dataGaName":301,"dataGaLocation":462},{"text":306,"config":658},{"href":308,"dataGaName":309,"dataGaLocation":462},{"text":311,"config":660},{"href":313,"dataGaName":314,"dataGaLocation":462},{"text":316,"config":662},{"href":318,"dataGaName":319,"dataGaLocation":462},{"text":321,"config":664},{"href":323,"dataGaName":324,"dataGaLocation":462},{"text":666,"config":667},"Sustainability",{"href":668,"dataGaName":666,"dataGaLocation":462},"/sustainability/",{"text":670,"config":671},"Diversity, inclusion and belonging (DIB)",{"href":672,"dataGaName":673,"dataGaLocation":462},"/diversity-inclusion-belonging/","Diversity, inclusion and belonging",{"text":326,"config":675},{"href":328,"dataGaName":329,"dataGaLocation":462},{"text":336,"config":677},{"href":338,"dataGaName":339,"dataGaLocation":462},{"text":341,"config":679},{"href":343,"dataGaName":344,"dataGaLocation":462},{"text":681,"config":682},"Modern Slavery Transparency Statement",{"href":683,"dataGaName":684,"dataGaLocation":462},"https://handbook.gitlab.com/handbook/legal/modern-slavery-act-transparency-statement/","modern slavery transparency statement",{"items":686},[687,690,693],{"text":688,"config":689},"Terms",{"href":514,"dataGaName":515,"dataGaLocation":462},{"text":691,"config":692},"Cookies",{"dataGaName":524,"dataGaLocation":462,"id":525,"isOneTrustButton":24},{"text":694,"config":695},"Privacy",{"href":519,"dataGaName":520,"dataGaLocation":462},[697],{"id":698,"title":18,"body":8,"config":699,"content":701,"description":8,"extension":22,"meta":705,"navigation":24,"path":706,"seo":707,"stem":708,"__hash__":709},"blogAuthors/en-us/blog/authors/marco-lenzo.yml",{"template":700},"BlogAuthor",{"name":18,"config":702},{"headshot":703,"ctfId":704},"https://res.cloudinary.com/about-gitlab-com/image/upload/v1749659488/Blog/Author%20Headshots/gitlab-logo-extra-whitespace.png","Marco-Lenzo",{},"/en-us/blog/authors/marco-lenzo",{},"en-us/blog/authors/marco-lenzo","fjO3SQv6yZkg2dO9SrHRmJN-ehG8IOEmmPMCf-0kzGo",[711,726,739],{"content":712,"config":724},{"body":713,"title":714,"description":715,"authors":716,"heroImage":718,"date":719,"category":9,"tags":720},"Most CI/CD tools can run a build and ship a deployment. Where they diverge is what happens when your delivery needs get real: a monorepo with a dozen services, microservices spread across multiple repositories, deployments to dozens of environments, or a platform team trying to enforce standards without becoming a bottleneck.\n  \nGitLab's pipeline execution model was designed for that complexity. Parent-child pipelines, DAG execution, dynamic pipeline generation, multi-project triggers, merge request pipelines with merged results, and CI/CD Components each solve a distinct class of problems. Because they compose, understanding the full model unlocks something more than a faster pipeline. In this article, you'll learn about the five patterns where that model stands out, each mapped to a real engineering scenario with the configuration to match.\n  \nThe configs below are illustrative. The scripts use echo commands to keep the signal-to-noise ratio low. Swap them out for your actual build, test, and deploy steps and they are ready to use.\n\n\n## 1. Monorepos: Parent-child pipelines + DAG execution\n\n\nThe problem: Your monorepo has a frontend, a backend, and a docs site. Every commit triggers a full rebuild of everything, even when only a README changed.\n\n\nGitLab solves this with two complementary features: [parent-child pipelines](https://docs.gitlab.com/ci/pipelines/downstream_pipelines/#parent-child-pipelines) (which let a top-level pipeline spawn isolated sub-pipelines) and [DAG execution via `needs`](https://docs.gitlab.com/ci/yaml/#needs) (which breaks rigid stage-by-stage ordering and lets jobs start the moment their dependencies finish).\n\n\nA parent pipeline detects what changed and triggers only the relevant child pipelines:\n\n```yaml\n# .gitlab-ci.yml\nstages:\n  - trigger\n\ntrigger-services:\n  stage: trigger\n  trigger:\n    include:\n      - local: '.gitlab/ci/api-service.yml'\n      - local: '.gitlab/ci/web-service.yml'\n      - local: '.gitlab/ci/worker-service.yml'\n    strategy: depend\n```\n\n\nEach child pipeline is a fully independent pipeline with its own stages, jobs, and artifacts. The parent waits for all of them via [strategy: depend](https://docs.gitlab.com/ci/pipelines/downstream_pipelines/#wait-for-downstream-pipeline-to-complete) so you get a single green/red signal at the top level, with full drill-down into each service's pipeline. This organizational separation is the bigger win for large teams: each service owns its pipeline config, changes in one cannot break another, and the complexity stays manageable as the repo grows.\n\n\nOne thing worth knowing: when you pass [multiple files to a single `trigger: include:`](https://docs.gitlab.com/ci/pipelines/downstream_pipelines/#combine-multiple-child-pipeline-configuration-files), GitLab merges them into a single child pipeline configuration. This means jobs defined across those files share the same pipeline context and can reference each other with `needs:`, which is what makes the DAG optimization possible. If you split them into separate trigger jobs instead, each would be its own isolated pipeline and cross-file `needs:` references would not work.\n\n\nCombine this with `needs:` inside each child pipeline and you get DAG execution. Your integration tests can start the moment the build finishes, without waiting for other jobs in the same stage.\n\n```yaml\n# .gitlab/ci/api-service.yml\nstages:\n  - build\n  - test\n\nbuild-api:\n  stage: build\n  script:\n    - echo \"Building API service\"\n\ntest-api:\n  stage: test\n  needs: [build-api]\n  script:\n    - echo \"Running API tests\"\n```\n\n\nWhy it matters: Teams with large monorepos typically report significant reductions in pipeline runtime after switching to DAG execution, since jobs no longer wait on unrelated work in the same stage. Parent-child pipelines add the organizational layer that keeps the configuration maintainable as the repo and team grow.\n\n![Local downstream pipelines](https://res.cloudinary.com/about-gitlab-com/image/upload/v1775738759/Blog/Imported/hackathon-fake-blog-post-s/image3_vwj3rz.png \"Local downstream pipelines\")\n\n## 2. Microservices: Cross-repo, multi-project pipelines\n\n\nThe problem: Your frontend lives in one repo, your backend in another. When the frontend team ships a change, they have no visibility into whether it broke the backend integration and vice versa.\n\n\nGitLab's [multi-project pipelines](https://docs.gitlab.com/ci/pipelines/downstream_pipelines/#multi-project-pipelines) let one project trigger a pipeline in a completely separate project and wait for the result. The triggering project gets a linked downstream pipeline right in its own pipeline view.\n\n\nThe frontend pipeline builds an API contract artifact and publishes it, then triggers the backend pipeline. The backend fetches that artifact directly using the [Jobs API](https://docs.gitlab.com/ee/api/jobs.html#download-a-single-artifact-file-from-specific-tag-or-branch) and validates it before allowing anything to proceed. If a breaking change is detected, the backend pipeline fails and the frontend pipeline fails with it.\n\n```yaml\n# frontend repo: .gitlab-ci.yml\nstages:\n  - build\n  - test\n  - trigger-backend\n\nbuild-frontend:\n  stage: build\n  script:\n    - echo \"Building frontend and generating API contract...\"\n    - mkdir -p dist\n    - |\n      echo '{\n        \"api_version\": \"v2\",\n        \"breaking_changes\": false\n      }' > dist/api-contract.json\n    - cat dist/api-contract.json\n  artifacts:\n    paths:\n      - dist/api-contract.json\n    expire_in: 1 hour\n\ntest-frontend:\n  stage: test\n  script:\n    - echo \"All frontend tests passed!\"\n\ntrigger-backend-pipeline:\n  stage: trigger-backend\n  trigger:\n    project: my-org/backend-service\n    branch: main\n    strategy: depend\n  rules:\n    - if: $CI_COMMIT_BRANCH == \"main\"\n```\n\n```yaml\n# backend repo: .gitlab-ci.yml\nstages:\n  - build\n  - test\n\nbuild-backend:\n  stage: build\n  script:\n    - echo \"All backend tests passed!\"\n\nintegration-test:\n  stage: test\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"pipeline\"\n  script:\n    - echo \"Fetching API contract from frontend...\"\n    - |\n      curl --silent --fail \\\n        --header \"JOB-TOKEN: $CI_JOB_TOKEN\" \\\n        --output api-contract.json \\\n        \"${CI_API_V4_URL}/projects/${FRONTEND_PROJECT_ID}/jobs/artifacts/main/raw/dist/api-contract.json?job=build-frontend\"\n    - cat api-contract.json\n    - |\n      if grep -q '\"breaking_changes\": true' api-contract.json; then\n        echo \"FAIL: Breaking API changes detected - backend integration blocked!\"\n        exit 1\n      fi\n      echo \"PASS: API contract is compatible!\"\n```\n\n\nA few things worth noting in this config. The `integration-test` job uses `$CI_PIPELINE_SOURCE == \"pipeline\"` to ensure it only runs when triggered by an upstream pipeline, not on a standalone push to the backend repo. The frontend project ID is referenced via `$FRONTEND_PROJECT_ID`, which should be set as a [CI/CD variable](https://docs.gitlab.com/ci/variables/) in the backend project settings to avoid hardcoding it.\n\n\nWhy it matters: Cross-service breakage that previously surfaced in production gets caught in the pipeline instead. The dependency between services stops being invisible and becomes something teams can see, track, and act on.\n\n\n![Cross-project pipelines](https://res.cloudinary.com/about-gitlab-com/image/upload/v1775738762/Blog/Imported/hackathon-fake-blog-post-s/image4_h6mfsb.png \"Cross-project pipelines\")\n\n\n## 3. Multi-tenant / matrix deployments: Dynamic child pipelines\n\n\nThe problem: You deploy the same application to 15 customer environments, or three cloud regions, or dev/staging/prod. Updating a deploy stage across all of them one by one is the kind of work that leads to configuration drift. Writing a separate pipeline for each environment is unmaintainable from day one.\n\n\nGitLab's [dynamic child pipelines](https://docs.gitlab.com/ci/pipelines/downstream_pipelines/#dynamic-child-pipelines) let you generate a pipeline at runtime. A job runs a script that produces a YAML file, and that YAML becomes the pipeline for the next stage. The pipeline structure itself becomes data.\n\n\n```yaml\n# .gitlab-ci.yml\nstages:\n  - generate\n  - trigger-environments\n\ngenerate-config:\n  stage: generate\n  script:\n    - |\n      # ENVIRONMENTS can be passed as a CI variable or read from a config file.\n      # Default to dev, staging, prod if not set.\n      ENVIRONMENTS=${ENVIRONMENTS:-\"dev staging prod\"}\n      for ENV in $ENVIRONMENTS; do\n        cat > ${ENV}-pipeline.yml \u003C\u003C EOF\n      stages:\n        - deploy\n        - verify\n      deploy-${ENV}:\n        stage: deploy\n        script:\n          - echo \"Deploying to ${ENV} environment\"\n      verify-${ENV}:\n        stage: verify\n        script:\n          - echo \"Running smoke tests on ${ENV}\"\n      EOF\n      done\n  artifacts:\n    paths:\n      - \"*.yml\"\n    exclude:\n      - \".gitlab-ci.yml\"\n\n.trigger-template:\n  stage: trigger-environments\n  trigger:\n    strategy: depend\n\ntrigger-dev:\n  extends: .trigger-template\n  trigger:\n    include:\n      - artifact: dev-pipeline.yml\n        job: generate-config\n\ntrigger-staging:\n  extends: .trigger-template\n  needs: [trigger-dev]\n  trigger:\n    include:\n      - artifact: staging-pipeline.yml\n        job: generate-config\n\ntrigger-prod:\n  extends: .trigger-template\n  needs: [trigger-staging]\n  trigger:\n    include:\n      - artifact: prod-pipeline.yml\n        job: generate-config\n  when: manual\n```\n\n\nThe generation script loops over an `ENVIRONMENTS` variable rather than hardcoding each environment separately. Pass in a different list via a CI variable or read it from a config file and the pipeline adapts without touching the YAML. The trigger jobs use [extends:](https://docs.gitlab.com/ci/yaml/#extends) to inherit shared configuration from `.trigger-template`, so `strategy: depend` is defined once rather than repeated on every trigger job. Add a new environment by updating the variable, not by duplicating pipeline config. Add [when: manual](https://docs.gitlab.com/ci/yaml/#when) to the production trigger and you get a promotion gate baked right into the pipeline graph.\n\n\nWhy it matters: SaaS companies and platform teams use this pattern to manage dozens of environments without duplicating pipeline logic. The pipeline structure itself stays lean as the deployment matrix grows.\n\n\n![Dynamic pipeline](https://res.cloudinary.com/about-gitlab-com/image/upload/v1775738765/Blog/Imported/hackathon-fake-blog-post-s/image7_wr0kx2.png \"Dynamic pipeline\")\n\n\n## 4. MR-first delivery: Merge request pipelines, merged results, and workflow routing\n\n\nThe problem: Your pipeline runs on every push to every branch. Expensive tests run on feature branches that will never merge. Meanwhile, you have no guarantee that what you tested is actually what will land on `main` after a merge.\n\n\nGitLab has three interlocking features that solve this together:\n\n\n*   [Merge request pipelines](https://docs.gitlab.com/ci/pipelines/merge_request_pipelines/) run only when a merge request exists, not on every branch push. This alone eliminates a significant amount of wasted compute.\n\n*   [Merged results pipelines](https://docs.gitlab.com/ci/pipelines/merged_results_pipelines/) go further. GitLab creates a temporary merge commit (your branch plus the current target branch) and runs the pipeline against that. You are testing what will actually exist after the merge, not just your branch in isolation.\n\n*   [Workflow rules](https://docs.gitlab.com/ci/yaml/workflow/) let you define exactly which pipeline type runs under which conditions and suppress everything else. The `$CI_OPEN_MERGE_REQUESTS` guard below prevents duplicate pipelines firing for both a branch and its open MR simultaneously.\n\n\nWith those three working together, here is what a tiered pipeline looks like:\n\n```yaml\n# .gitlab-ci.yml\nworkflow:\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n    - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS\n      when: never\n    - if: $CI_COMMIT_BRANCH\n    - if: $CI_PIPELINE_SOURCE == \"schedule\"\n\nstages:\n  - fast-checks\n  - expensive-tests\n  - deploy\n\nlint-code:\n  stage: fast-checks\n  script:\n    - echo \"Running linter\"\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"push\"\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n    - if: $CI_COMMIT_BRANCH == \"main\"\n\nunit-tests:\n  stage: fast-checks\n  script:\n    - echo \"Running unit tests\"\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"push\"\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n    - if: $CI_COMMIT_BRANCH == \"main\"\n\nintegration-tests:\n  stage: expensive-tests\n  script:\n    - echo \"Running integration tests (15 min)\"\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n    - if: $CI_COMMIT_BRANCH == \"main\"\n\ne2e-tests:\n  stage: expensive-tests\n  script:\n    - echo \"Running E2E tests (30 min)\"\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n    - if: $CI_COMMIT_BRANCH == \"main\"\n\nnightly-comprehensive-scan:\n  stage: expensive-tests\n  script:\n    - echo \"Running full nightly suite (2 hours)\"\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"schedule\"\n\ndeploy-production:\n  stage: deploy\n  script:\n    - echo \"Deploying to production\"\n  rules:\n    - if: $CI_COMMIT_BRANCH == \"main\"\n      when: manual\n```\n\nWith this setup, the pipeline behaves differently depending on context. A push to a feature branch with no open MR runs lint and unit tests only. Once an MR is opened, the workflow rules switch from a branch pipeline to an MR pipeline, and the full integration and E2E suite runs against the merged result. Merging to `main` queues a manual production deployment. A nightly schedule runs the comprehensive scan once, not on every commit.\n\n\nWhy it matters: Teams routinely cut CI costs significantly with this pattern, not by running fewer tests, but by running the right tests at the right time. Merged results pipelines catch the class of bugs that only appear after a merge, before they ever reach `main`.\n\n\n![Conditional pipelines (within a branch with no MR)](https://res.cloudinary.com/about-gitlab-com/image/upload/v1775738768/Blog/Imported/hackathon-fake-blog-post-s/image6_dnfcny.png \"Conditional pipelines (within a branch with no MR)\")\n\n\n\n![Conditional pipelines (within an MR)](https://res.cloudinary.com/about-gitlab-com/image/upload/v1775738772/Blog/Imported/hackathon-fake-blog-post-s/image1_wyiafu.png \"Conditional pipelines (within an MR)\")\n\n\n\n![Conditional pipelines (on the main branch)](https://res.cloudinary.com/about-gitlab-com/image/upload/v1775738774/Blog/Imported/hackathon-fake-blog-post-s/image5_r6lkfd.png \"Conditional pipelines (on the main branch)\")\n\n## 5. Governed pipelines: CI/CD Components\n\n\nThe problem: Your platform team has defined the right way to build, test, and deploy. But every team has their own `.gitlab-ci.yml` with subtle variations. Security scanning gets skipped. Deployment standards drift. Audits are painful.\n\n\nGitLab [CI/CD Components](https://docs.gitlab.com/ci/components/) let platform teams publish versioned, reusable pipeline building blocks. Application teams consume them with a single `include:` line and optional inputs — no copy-paste, no drift. Components are discoverable through the [CI/CD Catalog](https://docs.gitlab.com/ci/components/#cicd-catalog), which means teams can find and adopt approved building blocks without needing to go through the platform team directly.\n\n\nHere is a component definition from a shared library:\n\n```yaml\n# templates/deploy.yml\nspec:\n  inputs:\n    stage:\n      default: deploy\n    environment:\n      default: production\n---\ndeploy-job:\n  stage: $[[ inputs.stage ]]\n  script:\n    - echo \"Deploying $APP_NAME to $[[ inputs.environment ]]\"\n    - echo \"Deploy URL: $DEPLOY_URL\"\n  environment:\n    name: $[[ inputs.environment ]]\n```\nAnd here is how an application team consumes it:\n\n```yaml\n# Application repo: .gitlab-ci.yml\nvariables:\n  APP_NAME: \"my-awesome-app\"\n  DEPLOY_URL: \"https://api.example.com\"\n\ninclude:\n  - component: gitlab.com/my-org/component-library/build@v1.0.6\n  - component: gitlab.com/my-org/component-library/test@v1.0.6\n  - component: gitlab.com/my-org/component-library/deploy@v1.0.6\n    inputs:\n      environment: staging\n\nstages:\n  - build\n  - test\n  - deploy\n```\n\nThree lines of `include:` replace hundreds of lines of duplicated YAML. The platform team can push a security fix to `v1.0.7` and teams opt in on their own schedule — or the platform team can pin everyone to a minimum version. Either way, one change propagates everywhere instead of needing to be applied repo by repo.\n\n\nPair this with [resource groups](https://docs.gitlab.com/ci/resource_groups/) to prevent concurrent deployments to the same environment, and [protected environments](https://docs.gitlab.com/ci/environments/protected_environments/) to enforce approval gates - and you have a governed delivery platform where compliance is the default, not the exception.\n\n\nWhy it matters: This is the pattern that makes GitLab CI/CD scale across hundreds of teams. Platform engineering teams enforce compliance without becoming a bottleneck. Application teams get a fast path to a working pipeline without reinventing the wheel.\n\n\n![Component pipeline (imported jobs)](https://res.cloudinary.com/about-gitlab-com/image/upload/v1775738776/Blog/Imported/hackathon-fake-blog-post-s/image2_pizuxd.png \"Component pipeline (imported jobs)\")\n\n## Putting it all together\n\nNone of these features exist in isolation. The reason GitLab's pipeline model is worth understanding deeply is that these primitives compose:\n\n*   A monorepo uses parent-child pipelines, and each child uses DAG execution\n\n*   A microservices platform uses multi-project pipelines, and each project uses MR pipelines with merged results\n\n*   A governed platform uses CI/CD components to standardize the patterns above across every team\n\n\nMost teams discover one of these features when they hit a specific pain point. The ones who invest in understanding the full model end up with a delivery system that actually reflects how their engineering organization works, not a pipeline that fights it.\n\n## Other patterns worth exploring\n\n\nThe five patterns above cover the most common structural pain points, but GitLab's pipeline model goes further. A few others worth looking into as your needs grow:\n\n\n*   [Review apps with dynamic environments](https://docs.gitlab.com/ci/environments/) let you spin up a live preview for every feature branch and tear it down automatically when the MR closes. Useful for teams doing frontend work or API changes that need stakeholder sign-off before merging.\n\n*   [Caching and artifact strategies](https://docs.gitlab.com/ci/caching/) are often the fastest way to cut pipeline runtime after the structural work is done. Structuring `cache:` keys around dependency lockfiles and being deliberate about what gets passed between jobs with [artifacts:](https://docs.gitlab.com/ci/yaml/#artifacts) can make a significant difference without changing your pipeline shape at all.\n\n*   [Scheduled and API-triggered pipelines](https://docs.gitlab.com/ci/pipelines/schedules/) are worth knowing about because not everything should run on a code push. Nightly security scans, compliance reports, and release automation are better modeled as scheduled or [API-triggered](https://docs.gitlab.com/ci/triggers/) pipelines with `$CI_PIPELINE_SOURCE` routing the right jobs for each context.\n\n## How to get started\n\nModern software delivery is complex. Teams are managing monorepos with dozens of services, coordinating across multiple repositories, deploying to many environments at once, and trying to keep standards consistent as organizations grow. GitLab's pipeline model was built with all of that in mind.\n\nWhat makes it worth investing time in is how well the pieces fit together. Parent-child pipelines bring structure to large codebases. Multi-project pipelines make cross-team dependencies visible and testable. Dynamic pipelines turn environment management into something that scales gracefully. MR-first delivery with merged results ensures confidence at every step of the review process. And CI/CD Components give platform teams a way to share best practices across an entire organization without becoming a bottleneck.\n\nEach of these features is powerful on its own, and even more so when combined. GitLab gives you the building blocks to design a delivery system that fits how your team actually works, and grows with you as your needs evolve.\n\n> [Start a free trial of GitLab Ultimate](https://about.gitlab.com/free-trial/) to use pipeline logic today.\n\n## Read more\n\n*   [Variable and artifact sharing in GitLab parent-child pipelines](https://about.gitlab.com/blog/variable-and-artifact-sharing-in-gitlab-parent-child-pipelines/)\n*   [CI/CD inputs: Secure and preferred method to pass parameters to a pipeline](https://about.gitlab.com/blog/ci-cd-inputs-secure-and-preferred-method-to-pass-parameters-to-a-pipeline/)\n*   [Tutorial: How to set up your first GitLab CI/CD component](https://about.gitlab.com/blog/tutorial-how-to-set-up-your-first-gitlab-ci-cd-component/)\n*   [How to include file references in your CI/CD components](https://about.gitlab.com/blog/how-to-include-file-references-in-your-ci-cd-components/)\n*   [FAQ: GitLab CI/CD Catalog](https://about.gitlab.com/blog/faq-gitlab-ci-cd-catalog/)\n*   [Building a GitLab CI/CD pipeline for a monorepo the easy way](https://about.gitlab.com/blog/building-a-gitlab-ci-cd-pipeline-for-a-monorepo-the-easy-way/)\n*   [A CI/CD component builder's journey](https://about.gitlab.com/blog/a-ci-component-builders-journey/)\n*   [CI/CD Catalog goes GA: No more building pipelines from scratch](https://about.gitlab.com/blog/ci-cd-catalog-goes-ga-no-more-building-pipelines-from-scratch/)","5 ways GitLab pipeline logic solves real engineering problems","Learn how to scale CI/CD with composable patterns for monorepos, microservices, environments, and governance.",[717],"Omid Khan","https://res.cloudinary.com/about-gitlab-com/image/upload/v1772721753/frfsm1qfscwrmsyzj1qn.png","2026-04-09",[103,721,722,723],"DevOps platform","tutorial","features",{"featured":24,"template":13,"slug":725},"5-ways-gitlab-pipeline-logic-solves-real-engineering-problems",{"content":727,"config":737},{"title":728,"description":729,"authors":730,"heroImage":732,"date":733,"body":734,"category":9,"tags":735},"How to use GitLab Container Virtual Registry with Docker Hardened Images","Learn how to simplify container image management with this step-by-step guide.",[731],"Tim Rizzi","https://res.cloudinary.com/about-gitlab-com/image/upload/v1772111172/mwhgbjawn62kymfwrhle.png","2026-03-12","If you're a platform engineer, you've probably had this conversation:\n  \n*\"Security says we need to use hardened base images.\"*\n\n*\"Great, where do I configure credentials for yet another registry?\"*\n\n*\"Also, how do we make sure everyone actually uses them?\"*\n\nOr this one:\n\n*\"Why are our builds so slow?\"*\n\n*\"We're pulling the same 500MB image from Docker Hub in every single job.\"*\n\n*\"Can't we just cache these somewhere?\"*\n\nI've been working on [Container Virtual Registry](https://docs.gitlab.com/user/packages/virtual_registry/container/) at GitLab specifically to solve these problems. It's a pull-through cache that sits in front of your upstream registries — Docker Hub, dhi.io (Docker Hardened Images), MCR, and Quay — and gives your teams a single endpoint to pull from. Images get cached on the first pull. Subsequent pulls come from the cache. Your developers don't need to know or care which upstream a particular image came from.\n\nThis article shows you how to set up Container Virtual Registry, specifically with Docker Hardened Images in mind, since that's a combination that makes a lot of sense for teams concerned about security and not making their developers' lives harder.\n\n## What problem are we actually solving?\n\nThe Platform teams I usually talk to manage container images across three to five registries:\n\n* **Docker Hub** for most base images\n* **dhi.io** for Docker Hardened Images (security-conscious workloads)\n* **MCR** for .NET and Azure tooling\n* **Quay.io** for Red Hat ecosystem stuff\n* **Internal registries** for proprietary images\n\nEach one has its own:\n\n* Authentication mechanism\n* Network latency characteristics\n* Way of organizing image paths\n\nYour CI/CD configs end up littered with registry-specific logic. Credential management becomes a project unto itself. And every pipeline job pulls the same base images over the network, even though they haven't changed in weeks.\n\nContainer Virtual Registry consolidates this. One registry URL. One authentication flow (GitLab's). Cached images are served from GitLab's infrastructure rather than traversing the internet each time.\n\n## How it works\n\nThe model is straightforward:\n\n```text\nYour pipeline pulls:\n  gitlab.com/virtual_registries/container/1000016/python:3.13\n\nVirtual registry checks:\n  1. Do I have this cached? → Return it\n  2. No? → Fetch from upstream, cache it, return it\n\n```\n\nYou configure upstreams in priority order. When a pull request comes in, the virtual registry checks each upstream until it finds the image. The result gets cached for a configurable period (default 24 hours).\n\n```text\n┌─────────────────────────────────────────────────────────┐\n│                    CI/CD Pipeline                       │\n│                          │                              │\n│                          ▼                              │\n│   gitlab.com/virtual_registries/container/\u003Cid>/image   │\n└─────────────────────────────────────────────────────────┘\n                           │\n                           ▼\n┌─────────────────────────────────────────────────────────┐\n│            Container Virtual Registry                   │\n│                                                         │\n│  Upstream 1: Docker Hub ────────────────┐               │\n│  Upstream 2: dhi.io (Hardened) ────────┐│               │\n│  Upstream 3: MCR ─────────────────────┐││               │\n│  Upstream 4: Quay.io ────────────────┐│││               │\n│                                      ││││               │\n│                    ┌─────────────────┴┴┴┴──┐            │\n│                    │        Cache          │            │\n│                    │  (manifests + layers) │            │\n│                    └───────────────────────┘            │\n└─────────────────────────────────────────────────────────┘\n```\n\n## Why this matters for Docker Hardened Images\n\n[Docker Hardened Images](https://docs.docker.com/dhi/) are great because of the minimal attack surface, near-zero CVEs, proper software bills of materials (SBOMs), and SLSA provenance. If you're evaluating base images for security-sensitive workloads, they should be on your list.\n\nBut adopting them creates the same operational friction as any new registry:\n\n* **Credential distribution**: You need to get Docker credentials to every system that pulls images from dhi.io.\n* **CI/CD changes**: Every pipeline needs to be updated to authenticate with dhi.io.\n* **Developer friction**: People need to remember to use the hardened variants.\n* **Visibility gap**: It's difficult to tell if teams are actually using hardened images vs. regular ones.\n\nVirtual registry addresses each of these:\n\n**Single credential**: Teams authenticate to GitLab. The virtual registry handles upstream authentication. You configure Docker credentials once, at the registry level, and they apply to all pulls.\n\n**No CI/CD changes per-team**: Point pipelines at your virtual registry. Done. The upstream configuration is centralized.\n\n**Gradual adoption**: Since images get cached with their full path, you can see in the cache what's being pulled. If someone's pulling `library/python:3.11` instead of the hardened variant, you'll know.\n\n**Audit trail**: The cache shows you exactly which images are in active use. Useful for compliance, useful for understanding what your fleet actually depends on.\n\n## Setting it up\n\nHere's a real setup using the Python client from this demo project.\n\n### Create the virtual registry\n\n```python\nfrom virtual_registry_client import VirtualRegistryClient\n\nclient = VirtualRegistryClient()\n\nregistry = client.create_virtual_registry(\n    group_id=\"785414\",  # Your top-level group ID\n    name=\"platform-images\",\n    description=\"Cached container images for platform teams\"\n)\n\nprint(f\"Registry ID: {registry['id']}\")\n# You'll need this ID for the pull URL\n```\n\n### Add Docker Hub as an upstream\n\nFor official images like Alpine, Python, etc.:\n\n```python\ndocker_upstream = client.create_upstream(\n    registry_id=registry['id'],\n    url=\"https://registry-1.docker.io\",\n    name=\"Docker Hub\",\n    cache_validity_hours=24\n)\n```\n\n### Add Docker Hardened Images (dhi.io)\n\nDocker Hardened Images are hosted on `dhi.io`, a separate registry that requires authentication:\n\n```python\ndhi_upstream = client.create_upstream(\n    registry_id=registry['id'],\n    url=\"https://dhi.io\",\n    name=\"Docker Hardened Images\",\n    username=\"your-docker-username\",\n    password=\"your-docker-access-token\",\n    cache_validity_hours=24\n)\n```\n\n### Add other upstreams\n\n```python\n# MCR for .NET teams\nclient.create_upstream(\n    registry_id=registry['id'],\n    url=\"https://mcr.microsoft.com\",\n    name=\"Microsoft Container Registry\",\n    cache_validity_hours=48\n)\n\n# Quay for Red Hat stuff\nclient.create_upstream(\n    registry_id=registry['id'],\n    url=\"https://quay.io\",\n    name=\"Quay.io\",\n    cache_validity_hours=24\n)\n```\n\n### Update your CI/CD\n\nHere's a `.gitlab-ci.yml` that pulls through the virtual registry:\n\n```yaml\nvariables:\n  VIRTUAL_REGISTRY_ID: \u003Cyour_virtual_registry_ID>\n\n  \nbuild:\n  image: docker:24\n  services:\n    - docker:24-dind\n  before_script:\n    # Authenticate to GitLab (which handles upstream auth for you)\n    - echo \"${CI_JOB_TOKEN}\" | docker login -u gitlab-ci-token --password-stdin gitlab.com\n  script:\n    # All of these go through your single virtual registry\n    \n    # Official Docker Hub images (use library/ prefix)\n    - docker pull gitlab.com/virtual_registries/container/${VIRTUAL_REGISTRY_ID}/library/alpine:latest\n    \n    # Docker Hardened Images from dhi.io (no prefix needed)\n    - docker pull gitlab.com/virtual_registries/container/${VIRTUAL_REGISTRY_ID}/python:3.13\n    \n    # .NET from MCR\n    - docker pull gitlab.com/virtual_registries/container/${VIRTUAL_REGISTRY_ID}/dotnet/sdk:8.0\n```\n\n### Image path formats\n\nDifferent registries use different path conventions:\n\n| Registry | Pull URL Example |\n|----------|------------------|\n| Docker Hub (official) | `.../library/python:3.11-slim` |\n| Docker Hardened Images (dhi.io) | `.../python:3.13` |\n| MCR | `.../dotnet/sdk:8.0` |\n| Quay.io | `.../prometheus/prometheus:latest` |\n\n### Verify it's working\n\nAfter some pulls, check your cache:\n\n```python\nupstreams = client.list_registry_upstreams(registry['id'])\nfor upstream in upstreams:\n    entries = client.list_cache_entries(upstream['id'])\n    print(f\"{upstream['name']}: {len(entries)} cached entries\")\n\n```\n\n## What the numbers look like\n\nI ran tests pulling images through the virtual registry:\n\n| Metric | Without Cache | With Warm Cache |\n|--------|---------------|-----------------|\n| Pull time (Alpine) | 10.3s | 4.2s |\n| Pull time (Python 3.13 DHI) | 11.6s | ~4s |\n| Network roundtrips to upstream | Every pull | Cache misses only |\n\n\n\n\nThe first pull is the same speed (it has to fetch from upstream). Every pull after that, for the cache validity period, comes straight from GitLab's storage. No network hop to Docker Hub, dhi.io, MCR, or wherever the image lives.\n\nFor a team running hundreds of pipeline jobs per day, that's hours of cumulative build time saved.\n\n## Practical considerations\nHere are some considerations to keep in mind:\n\n### Cache validity\n\n24 hours is the default. For security-sensitive images where you want patches quickly, consider 12 hours or less:\n\n```python\nclient.create_upstream(\n    registry_id=registry['id'],\n    url=\"https://dhi.io\",\n    name=\"Docker Hardened Images\",\n    username=\"your-username\",\n    password=\"your-token\",\n    cache_validity_hours=12\n)\n```\n\nFor stable, infrequently-updated images (like specific version tags), longer validity is fine.\n\n### Upstream priority\n\nUpstreams are checked in order. If you have images with the same name on different registries, the first matching upstream wins.\n\n### Limits\n\n* Maximum of 20 virtual registries per group\n* Maximum of 20 upstreams per virtual registry\n\n## Configuration via UI\n\nYou can also configure virtual registries and upstreams directly from the GitLab UI—no API calls required. Navigate to your group's **Settings > Packages and registries > Virtual Registry** to:\n\n* Create and manage virtual registries\n* Add, edit, and reorder upstream registries\n* View and manage the cache\n* Monitor which images are being pulled\n\n## What's next\n\nWe're actively developing:\n\n* **Allow/deny lists**: Use regex to control which images can be pulled from specific upstreams.\n\nThis is beta software. It works, people are using it in production, but we're still iterating based on feedback.\n\n## Share your feedback\n\nIf you're a platform engineer dealing with container registry sprawl, I'd like to understand your setup:\n\n* How many upstream registries are you managing?\n* What's your biggest pain point with the current state?\n* Would something like this help, and if not, what's missing?\n\nPlease share your experiences in the [Container Virtual Registry feedback issue](https://gitlab.com/gitlab-org/gitlab/-/work_items/589630).\n## Related resources\n- [New GitLab metrics and registry features help reduce CI/CD bottlenecks](https://about.gitlab.com/blog/new-gitlab-metrics-and-registry-features-help-reduce-ci-cd-bottlenecks/#container-virtual-registry)\n- [Container Virtual Registry documentation](https://docs.gitlab.com/user/packages/virtual_registry/container/)\n- [Container Virtual Registry API](https://docs.gitlab.com/api/container_virtual_registries/)",[722,736,723],"product",{"featured":12,"template":13,"slug":738},"using-gitlab-container-virtual-registry-with-docker-hardened-images",{"content":740,"config":750},{"title":741,"description":742,"authors":743,"heroImage":745,"date":746,"category":9,"tags":747,"body":749},"How IIT Bombay students are coding the future with GitLab","At GitLab, we often talk about how software accelerates innovation. But sometimes, you have to step away from the Zoom calls and stand in a crowded university hall to remember why we do this.",[744],"Nick Veenhof","https://res.cloudinary.com/about-gitlab-com/image/upload/v1750099013/Blog/Hero%20Images/Blog/Hero%20Images/blog-image-template-1800x945%20%2814%29_6VTUA8mUhOZNDaRVNPeKwl_1750099012960.png","2026-01-08",[256,618,748],"open source","The GitLab team recently had the privilege of judging the **iHack Hackathon** at **IIT Bombay's E-Summit**. The energy was electric, the coffee was flowing, and the talent was undeniable. But what struck us most wasn't just the code — it was the sheer determination of students to solve real-world problems, often overcoming significant logistical and financial hurdles to simply be in the room.\n\n\nThrough our [GitLab for Education program](https://about.gitlab.com/solutions/education/), we aim to empower the next generation of developers with tools and opportunity. Here is a look at what the students built, and how they used GitLab to bridge the gap between idea and reality.\n\n## The challenge: Build faster, build securely\n\nThe premise for the GitLab track of the hackathon was simple: Don't just show us a product; show us how you built it. We wanted to see how students utilized GitLab's platform — from Issue Boards to CI/CD pipelines — to accelerate the development lifecycle.\n\nThe results were inspiring.\n\n## The winners\n\n### 1st place: Team Decode — Democratizing Scientific Research\n\n**Project:** FIRE (Fast Integrated Research Environment)\n\nTeam Decode took home the top prize with a solution that warms a developer's heart: a local-first, blazing-fast data processing tool built with [Rust](https://about.gitlab.com/blog/secure-rust-development-with-gitlab/) and Tauri. They identified a massive pain point for data science students: existing tools are fragmented, slow, and expensive.\n\nTheir solution, FIRE, allows researchers to visualize complex formats (like NetCDF) instantly. What impressed the judges most was their \"hacker\" ethos. They didn't just build a tool; they built it to be open and accessible.\n\n**How they used GitLab:** Since the team lived far apart, asynchronous communication was key. They utilized **GitLab Issue Boards** and **Milestones** to track progress and integrated their repo with Telegram to get real-time push notifications. As one team member noted, \"Coordinating all these technologies was really difficult, and what helped us was GitLab... the Issue Board really helped us track who was doing what.\"\n\n![Team Decode](https://res.cloudinary.com/about-gitlab-com/image/upload/v1767380253/epqazj1jc5c7zkgqun9h.jpg)\n\n### 2nd place: Team BichdeHueDost — Reuniting to Solve Payments\n\n**Project:** SemiPay (RFID Cashless Payment for Schools)\n\nThe team name, BichdeHueDost, translates to \"Friends who have been set apart.\" It's a fitting name for a group of friends who went to different colleges but reunited to build this project. They tackled a unique problem: handling cash in schools for young children. Their solution used RFID cards backed by a blockchain ledger to ensure secure, cashless transactions for students.\n\n**How they used GitLab:** They utilized [GitLab CI/CD](https://about.gitlab.com/topics/ci-cd/) to automate the build process for their Flutter application (APK), ensuring that every commit resulted in a testable artifact. This allowed them to iterate quickly despite the \"flaky\" nature of cross-platform mobile development.\n\n![Team BichdeHueDost](https://res.cloudinary.com/about-gitlab-com/image/upload/v1767380253/pkukrjgx2miukb6nrj5g.jpg)\n\n### 3rd place: Team ZenYukti — Agentic Repository Intelligence\n\n**Project:** RepoInsight AI (AI-powered, GitLab-native intelligence platform)\n\nTeam ZenYukti impressed us with a solution that tackles a universal developer pain point: understanding unfamiliar codebases. What stood out to the judges was the tool's practical approach to onboarding and code comprehension: RepoInsight-AI automatically generates documentation, visualizes repository structure, and even helps identify bugs, all while maintaining context about the entire codebase.\n\n**How they used GitLab:** The team built a comprehensive CI/CD pipeline that showcased GitLab's security and DevOps capabilities. They integrated [GitLab's Security Templates](https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/ci/templates/Security) (SAST, Dependency Scanning, and Secret Detection), and utilized [GitLab Container Registry](https://docs.gitlab.com/user/packages/container_registry/) to manage their Docker images for backend and frontend components. They created an AI auto-review bot that runs on merge requests, demonstrating an \"agentic workflow\" where AI assists in the development process itself.\n\n![Team ZenYukti](https://res.cloudinary.com/about-gitlab-com/image/upload/v1767380253/ymlzqoruv5al1secatba.jpg)\n\n## Beyond the code: A lesson in inclusion\n\nWhile the code was impressive, the most powerful moment of the event happened away from the keyboard.\n\nDuring the feedback session, we learned about the journey Team ZenYukti took to get to Mumbai. They traveled over 24 hours, covering nearly 1,800 kilometers. Because flights were too expensive and trains were booked, they traveled in the \"General Coach,\" a non-reserved, severely overcrowded carriage.\n\nAs one student described it:\n\n*\"You cannot even imagine something like this... there are no seats... people sit on the top of the train. This is what we have endured.\"*\n\nThis hit home. [Diversity, Inclusion, and Belonging](https://handbook.gitlab.com/handbook/company/culture/inclusion/) are core values at GitLab. We realized that for these students, the barrier to entry wasn't intellect or skill, it was access.\n\nIn that moment, we decided to break that barrier. We committed to reimbursing the travel expenses for the participants who struggled to get there. It's a small step, but it underlines a massive truth: **talent is distributed equally, but opportunity is not.**\n\n![hackathon class together](https://res.cloudinary.com/about-gitlab-com/image/upload/v1767380252/o5aqmboquz8ehusxvgom.jpg)\n\n### The future is bright (and automated)\n\nWe also saw incredible potential in teams like Prometheus, who attempted to build an autonomous patch remediation tool (DevGuardian), and Team Arrakis, who built a voice-first job portal for blue-collar workers using [GitLab Duo](https://about.gitlab.com/gitlab-duo-agent-platform/) to troubleshoot their pipelines.\n\nTo all the students who participated: You are the future. Through [GitLab for Education](https://about.gitlab.com/solutions/education/), we are committed to providing you with the top-tier tools (like GitLab Ultimate) you need to learn, collaborate, and change the world — whether you are coding from a dorm room, a lab, or a train carriage. **Keep shipping.**\n\n> :bulb: Learn more about the [GitLab for Education program](https://about.gitlab.com/solutions/education/).\n",{"slug":751,"featured":12,"template":13},"how-iit-bombay-students-code-future-with-gitlab",{"promotions":753},[754,768,779,791],{"id":755,"categories":756,"header":758,"text":759,"button":760,"image":765},"ai-modernization",[757],"ai-ml","Is AI achieving its promise at scale?","Quiz will take 5 minutes or less",{"text":761,"config":762},"Get your AI maturity score",{"href":763,"dataGaName":764,"dataGaLocation":238},"/assessments/ai-modernization-assessment/","modernization assessment",{"config":766},{"src":767},"https://res.cloudinary.com/about-gitlab-com/image/upload/v1772138786/qix0m7kwnd8x2fh1zq49.png",{"id":769,"categories":770,"header":771,"text":759,"button":772,"image":776},"devops-modernization",[736,564],"Are you just managing tools or shipping innovation?",{"text":773,"config":774},"Get your DevOps maturity score",{"href":775,"dataGaName":764,"dataGaLocation":238},"/assessments/devops-modernization-assessment/",{"config":777},{"src":778},"https://res.cloudinary.com/about-gitlab-com/image/upload/v1772138785/eg818fmakweyuznttgid.png",{"id":780,"categories":781,"header":783,"text":759,"button":784,"image":788},"security-modernization",[782],"security","Are you trading speed for security?",{"text":785,"config":786},"Get your security maturity score",{"href":787,"dataGaName":764,"dataGaLocation":238},"/assessments/security-modernization-assessment/",{"config":789},{"src":790},"https://res.cloudinary.com/about-gitlab-com/image/upload/v1772138786/p4pbqd9nnjejg5ds6mdk.png",{"id":792,"paths":793,"header":796,"text":797,"button":798,"image":803},"github-azure-migration",[794,795],"migration-from-azure-devops-to-gitlab","integrating-azure-devops-scm-and-gitlab","Is your team ready for GitHub's Azure move?","GitHub is already rebuilding around Azure. Find out what it means for you.",{"text":799,"config":800},"See how GitLab compares to GitHub",{"href":801,"dataGaName":802,"dataGaLocation":238},"/compare/gitlab-vs-github/github-azure-migration/","github azure migration",{"config":804},{"src":778},{"header":806,"blurb":807,"button":808,"secondaryButton":813},"Start building faster today","See what your team can do with the intelligent orchestration platform for DevSecOps.\n",{"text":809,"config":810},"Get your free trial",{"href":811,"dataGaName":45,"dataGaLocation":812},"https://gitlab.com/-/trial_registrations/new?glm_content=default-saas-trial&glm_source=about.gitlab.com/","feature",{"text":500,"config":814},{"href":49,"dataGaName":50,"dataGaLocation":812},1776675401845]