{"community_link": "https://github.com/indepth-dev/community/discussions/134"}

Craft a complete GitLab pipeline for Angular. Part 2

Learn Gitlab to build a CI/CD pipeline for Angular apps and libraries. This second article focuses on deployment using two methods involving GitLab Registries and Pages. You'll also find docker jobs optimization tips using custom images.

Craft a complete GitLab pipeline for Angular. Part 2

This article is part of a series of two. In the first article, you learned GitLab pipelines basics. Also, you crafted an Angular pipeline including build, tests, coverage and lint. This second part focuses on deployment: app docker image, GitLab pages and NPM packages.

You don't need advanced experience in GitLab to follow this article. This is a step by step guide with many links to the well-written documentation. If you use other CI/CD tools, you'll find similar concepts and commands.

Pipeline overview

During the first part of the series, you built jobs for install, build and tests jobs. Let's turn the basic pipeline we had into a complete pipeline with a deployment stage.

Complete pipeline for Angular app

The pipeline suits both Angular applications and libraries, only the last part on publishing and deployment changes.

You'll learn how to deploy your app in two different ways. The first method is to publish a Docker image containing your app. With the second method, you'll host your app artifact on an HTTP server (GitLab Pages).

For the Angular library, the pipeline will publish it on the GitLab Package registry. The last part is a bonus to optimize the pipeline using custom docker images.

Build and publish Docker image

Releasing an Angular app means providing an artifact that's ready to deploy and run in production. The intuitive way is to provide the bundled app and let the DevOps folks upload it on a machine running a web server.  

Instead, imagine you can provide a machine with your app ready to run. You have full control over the machine setup and can fine-tune the web server configuration as needed. This is where Docker comes in!

Quick Docker intro

Docker allows you to describe the machine setup with code. You can start from a clean Alpine or Ubuntu install and then run a set of script instructions to set up your application.

FROM nginx:alpine

COPY ./my-app /usr/share/nginx/html
COPY ./my-app.conf /etc/nginx/conf.d/default.conf
Dockerfile example for a web application

The file that contains your instructions as code is a Dockerfile. After running this Dockerfile, you end up with a Docker image which is a snapshot of the machine with your app installed.

Docker journey for an Angular app

It's not possible to run a Docker image. You need to create a Docker container which uses the image as a blueprint. After starting this container, you have an isolated environment where your Angular app can run.

To learn more about the differences between Docker file, image and container, check out this article.

Design the GitLab job

This job purpose is to produce and make available a Docker image containing our Angular app. In a container-based infrastructure such as Kubernetes cluster, you need to provide a Docker image. The project artifact isn't the app bundle itself.

The pipeline will publish the image to the Container Registry. It enables softwares such as Docker or Kubernetes to pull and run it.

During a Kubernetes deployment, Kubernetes pulls the image from GitLab Container Registry before creating and running a Docker container out of it. The bundled app runs into this container with the runtime environment. Basically running the container starts the app.

Prepare a docker image

Do you remember the build_app job from the first article? Its purpose was to generate the bundled app and place it the artifacts/app folder along with the Dockerfile and Nginx configuration.

  APP_OUTPUT_PATH: "$CI_PROJECT_DIR/artifacts/app"

    - yarn ng build --prod
    - cp $PROJECT_PATH/nginx.conf $APP_OUTPUT_PATH
    - cp $PROJECT_PATH/Dockerfile $APP_OUTPUT_PATH
Extract of build_app job

Take a look at the bundled app, it's a single-page website (SPA) with a lot of scripts and a dash of styles.

Bundled app (artifacts/app folder)

You only need an HTTP server to host and serve your app. In our situation, we won't set up a server but rather create a docker image. Nginx looks like a reasonable choice for an HTTP server. Let’s use it in our Dockerfile.

FROM nginx:alpine

COPY . /usr/share/nginx/html
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
Angular app Dockerfile

COPY docker command moves the bundled app into the docker image. It replaces the default site but also the default configuration. The HTTP server will start with the container and expose port 80.  

Angular apps are simple websites but we need to replace the default configuration. Try to access a sub route such as https://localhost:4200/test. You'll get a 404 page because Nginx is looking for a test folder that doesn't exist.

server {
  listen 80;

  location / {
    root /usr/share/nginx/html;
    index index.html;
    try_files $uri $uri/index.html /index.html =404;
Basic Nginx configuration for Angular apps

One solution is to provide a custom Nginx configuration. try_files instruction redirects unknown paths to index.html. In other words, it delegates routing to Angular router for unknown routes.

Create build and publish jobs

As described in container registry documentation, it takes three steps:

  • Log in into the project container registry
  • Build the image from a Dockerfile
  • Push the image to the project container registry

  stage: publish
    - shell
    - docker build --tag $DOCKER_IMAGE_NAME:$CI_COMMIT_SHORT_SHA .
    - build_app
    - master

Earlier you used environment variables to get the project directory. There are plenty of variables but only the pipeline credentials are important here. The remaining steps are pure docker commands build & push.

Note the image got $CI_COMMIT_SHORT_SHA tag. It would be convenient to use the latest tag but using it is a bad practice. Each eligible pipeline will publish a docker image with a different version and tag.

You don't want to run this job for each pipeline, it runs only when pushing on master with the only keyword. Make sure to set up an expiration policy to clean the registry regularly. For the job to only download this artifact, we specify the dependency to the build_app job.

GitLab Container Registry

Deploy and run the image

A complete pipeline would trigger a deployment using the image. For instance, it can be a Kubernetes deployment pulling the image and spinning up containers.

This part is off the limit for this article but you can take a look at Kubernetes integration in GitLab. It enables a bunch of features such as Deployments, Logs, Monitoring and Review App in your project interface. GitLab supports some Kubernetes cluster solutions including Google Cloud Platform (GCP) and Amazon Elastic Kubernetes Service (EKS).

Let’s try to run the image on your local machine. It requires you to login to the container registry and run docker commands.

$ docker login registry.gitlab.com
$ docker pull registry.gitlab.com/jbardon/angular-app-pipeline/app:YOUR_TAG
$ docker run --rm -i -p 4200:80 registry.gitlab.com/jbardon/angular-app-pipeline/app:YOUR_TAG

Check available tags in your container registry and replace YOUR_TAG in the command below. For more information about the URL to use, read the official documentation.

Setup Docker-in-Docker

Did you notice the tag for this job? It uses a shell executor instead of the usual docker executor. The reason is you can’t use docker to build a docker image inside a docker container out-of-the-box. The documentation describes several ways to build docker images in a pipeline:

GitLab shared runners do have privileged mode enabled. You can try any of the three solutions, they’re all working at the time. Let’s focus on the shell executor solution as in an enterprise environment you’ll have your own runners you can install Docker on.

For testing purposes, you can host a runner on your local machine. GitLab provides Shared runners with free tier but not with shell executor and Docker installed.

Project runners (Settings > CI/CD > Runners)

To host a runner on your machine, you need to install gitlab-runner and register your local runner in your project. Once it’s done, the pipeline will run the job on your machine. Don’t forget it’s for testing only, your machine must be turned on with docker service started. Otherwise, the pipeline will be stuck waiting for a runner with the shell tag.

Deploy to GitLab Pages

You learned how to build an Angular app docker image and make it available for deployment. Let's explore a simpler alternative: hosting the bundled app on an HTTP server. GitLab Pages allows hosting static websites for free.

Update build with subroute

Depending on your GitLab user, group and project names, you'll receive a default domain name. For instance, the example project from the first article of this series is named angular-app-pipeline and its owner is jbardon.

Example project GitLab Pages is accessible through:

The app isn't hosted on domain root but under /angular-app-pipeline path. This detail is important because it won't work out-of-the-box. You need to provide extra options to ng-cli for the build.

$ ng build --prod --base-href /angular-app-pipeline/ --deploy-url /angular-app-pipeline/

These options set the base href in index.html. Also, all scripts and styles generated during the build will include the given path.

<html lang="en">
  <base href="/angular-app-pipeline/">
  <link rel="stylesheet" href="/angular-app-pipeline/styles.css">
  <script src="/angular-app-pipeline/runtime.js" type="module"></script>
Extract of index.html from dist directory

In the docker image job, we used a custom Nginx configuration to delegate the routing to Angular Router. It's not possible to do it with GitLab pages so we need to use a trick explained in Angular documentation.

The idea is to copy index.html and name it 404.html. I don't recommend it for production but it works since the HTTP server fallbacks on this page by default.

Job implementation

Deploying on GitLab Pages is easy thanks to the pages keyword. The only rule is to output your website in the public directory as an artifact.

  APP_OUTPUT_PATH: "$CI_PROJECT_DIR/artifacts/app"

  stage: deploy
    - shell
      - public
    - build_app
    name: prod
    url: https://jbardon.gitlab.io/angular-app-pipeline
  when: manual
    - master
Deploy to GitLab Pages job

Our Angular app is now hosted on GitLab Pages. Let's leverage Environments to follow the project deployments in several environments.

Environment page (in operation menu)

First, create a prod environment in the Operations/Environment page. You can now use the environment keyword in the job which deploys the project. In our example, there is only a prod environment for GitLab Pages.

If you enabled Kubernetes integration, the job may try to deploy on your cluster. Make sure no cluster has a prod environment in its scope.
Pipeline for GitLab Pages deployment

Note using when:manual keyword is a good practice to avoid the job to deploy automatically. The pipeline stops when reaching the job and waits for a manual trigger. It gives the opportunity to watch the deployment job and checks everything works once it's done.

Deploy library

Deploying an Angular app means serving it from an HTTP server or a Docker container running an HTTP server. This process is different for an Angular library because deploying a library means pushing it on a package registry. Don’t confuse it with the container registry reserved for Docker images.

It exists many public and private registries besides npmjs such as Artifactory and Nexus. Yet, GitLab offers a package registry with each project. If you need help to create an Angular library step by step, check out my article.

  LIBRARY_OUTPUT_PATH: "$CI_PROJECT_DIR/dist/angular-library"

  stage: publish
    - docker
    REGISTRY_URI: "gitlab.com/api/v4/projects/$CI_PROJECT_ID/packages/npm/"
    - npm config set "@jbardon:registry" "https://$REGISTRY_URI"
    - npm config set "//$REGISTRY_URI:_authToken" "$CI_JOB_TOKEN"
    - npm publish
    - build_library
    - master
Deploy library job

npm publish is the command to publish a package. Yet, we need to override two configuration keys because we don't use the default registry (npmjs). These keys are set using the before_script keyword. They'll be taken into account by the job once running the main script.

NPM configuration

The two configuration keys to update are:

  • registry to target GitLab package registry
  • authToken to be allowed to publish

Here is the extract from the job script. Both use the config set command to set the value of a configuration key. Note the registry URI is slightly different for each config key.

$ REGISTRY_URI="gitlab.com/api/v4/projects/$CI_PROJECT_ID/packages/npm/"
$ npm config set "@jbardon:registry" "https://$REGISTRY_URI"
$ npm config set "//$REGISTRY_URI:_authToken" "$CI_JOB_TOKEN"
NPM configuration for publishing

There exists several ways to override the registry, let's see how it works with the config command. The example library full name is @jbardon/angular-lib-pipeline with @jbardon being the scope.

Your library must follow GitLab naming conventions. The package scope is your organisation or account name.

That's why the job overrides @jbardon:registry configuration key. It uses the project-level endpoint which allows publishing. Let's try another way to ensure manual publish targets GitLab project registry.

  "name": "@jbardon/angular-lib-pipeline",
  "version": "0.0.1",
  "publishConfig": {
    "@jbardon:registry": "https://gitlab.com/api/v4/projects/YOUR_PROJECT_ID/packages/npm/"
  "peerDependencies": {
    "@angular/common": "^10.1.0",
    "@angular/core": "^10.1.0"
Library package.json

Here the publishConfig entry is equivalent to the configuration key. It's optional but adds the extra check for manual publishing. You can find the Project ID on the project main page under its title.

The second configuration, authToken is exposed through the $CI_JOB_TOKEN environment variable. If you use another package registry the two configurations are roughly the same. Be careful while adding your registry authToken. It’s not the API key but the token saved into ~/npmrc when running npm login.

Fetch your library

Once the job is done, you can see the library in the project registry. The last published version is labeled with latest, it’s the version you download by default. Don’t worry if you forgot to update the version in package.json, it’s not possible to publish the same version twice.

Project package registry

While testing the job, I recommend using semver. You can publish pre-release versions such as 1.0.0-alpha.1. It allows you to make several tests without bothering people pulling the registry and suggests the final version will be 1.0.0.

Don’t be tempted to delete the last version and publish it again.

NPM won’t get the last version from the registry but install the last version from its local cache. The version is the same on both sides but the files are different.

 $ yarn config set @jbardon:registry https://gitlab.com/api/v4/packages/npm/
 $ yarn login
 $ yarn add @jbardon/angular-lib-pipeline
Install the library

In Package registry, click on the library. GitLab provides commands to install the library with NPM. You already know what the two first commands are for: set registry and authToken. Using the instance-level endpoint is enough for installing here. Plus, it’s the same for all the libraries you host on GitLab Package Registry.

Custom docker image for jobs

Most of the job runs on the docker executor using the image keyword. For the install_dependency job, the whole node environment is set up.

In some cases, like during the test_app job, it's necessary to use the before_script keyword to perform some extra setup before the job runs. The extra setup can take a while, for instance installing Chrome for unit tests can take up to 30 seconds each time it's running.

  image: node:12-alpine
    - docker
    - apk add chromium
    - export CHROME_BIN=/usr/bin/chromium-browser

Docker can help to solve this issue by creating a new image based on node:12-alpine which includes what the before_script does. The job will run this new image containing the required tools so it doesn't need to install them.

FROM node:12_alpine

RUN apk add chromium
ENV CHROME_BIN /usr/bin/chromium-browser

By default, the runner pulls docker images from the docker.io registry. Remember GitLab provides a Container Registry with each project? Let's leverage this feature and push our image to this private registry instead.

$ docker build --tag=ci-tests:latest .
$ docker login registry.gitlab.com
$ docker push registry.gitlab.com/jbardon/angular-app-pipeline/ci-tests:latest

The last step is to make sure the job pulls the image from the project registry. You only have to append the image name with the corresponding environment variable.

image: $CI_REGISTRY_IMAGE/ci-node:latest

Now the pipeline goes fast and doesn't lose time with environment setup. Yet, updating images used by CI and pushing them into the project registry isn't a robust workflow.

Automate custom image update

You can take this step forward and make the pipeline build and push the images itself when needed.

You already built and published a Docker image to the project container registry before. In this context, the job script is the same: login, build and push. Note the parallel:matrix keyword, which enables you to run the whole job multiple times with parameters.

  stage: .pre
    - shell
    - cd $PROJECT_PATH/.ci
    - docker build --tag $CI_REGISTRY_IMAGE/$STAGE_IMAGE:latest
                   --target $STAGE_IMAGE $PROJECT_PATH/.ci

    - docker push $CI_REGISTRY_IMAGE/$STAGE_IMAGE:latest
      - STAGE_IMAGE: [ci-node, ci-tests]
      - .ci/Dockerfile

The idea is to have the Dockerfile for each image used by the pipeline in the repository. All images are defined in a single Dockerfile under .ci/Dockerfile. It's placed into an empty directory so each image has its context.

Having the Dockerfile in the repository allows triggering the job for building the images only when it changes with only:changes keyword. This building images job always runs first in the pipeline thanks to the .pre keyword.

FROM node:12-alpine AS ci-node

FROM ci-node AS ci-tests

RUN apk add chromium
ENV CHROME_BIN /usr/bin/chromium-browser
Multi stage Dockerfile

This example leverages multi-stage builds so a single file can define many images. Depending on the specified target when running docker build, two images can be created from this Dockerfile: ci-node and ci-tests.

Wrapping up

That's it, you learned how to build a complete GitLab pipeline including deployment for Angular apps and Angular libraries.

Complete pipeline for Angular app

With this pipeline, you can deploy your Angular app on static sites free hosting platforms such as GitLab pages. For a more production-ready approach prefer to go for the docker image method. We also leveraged GitLab using both Container and package registries. It hosts the Angular app and pipeline docker image but also our Angular library.

Here are two GitLab projects using this pipeline
- https://gitlab.com/jbardon/angular-app-pipeline
- https://gitlab.com/jbardon/angular-lib-pipeline

A few last pieces of advice to develop your pipeline. Use CI Lint tool to debug it and read this documentation about Pipeline efficiency. I'm sure you'll find improvement for this pipeline, don't hesitate to drop a comment.

If you liked this article or if you are curious about how we innovate at Smart AdServer, take a look at our official Smart AdServer blog. See you there!

Thanks to the reviewers who helped me to make this article better : Gaurav Dasgupta and Max Koretskyi from InDepthDev community.