Denys Vuika's Blog

Denys Vuika

Your Angular apps as Docker containers

March 02, 2018

In this article, I am going to show you how to create a Docker image from your Angular application, publish and consume it from the Docker Hub.

Creating a new Angular application

Creating a Docker image from your Angular application is easy.

You can use images for internal purposes, for example, testing on different machines, operating systems, and browsers.

You can also use Docker images for external use. It becomes possible to try your application without installing node.js, running npm install to “download entire internet,” or avoid troubleshooting environment setup.

Before we start, let’s generate a new application using the following Angular CLI command:

ng new my-angular-app
cd my-angular-app

Try running the application to ensure it compiles correctly and displays in the browser:

ng serve --open

By default, the browser should open with the “http://localhost:4200” address. Note the port 4200; later in the article, we are going to use also port 3000 to access the application that is running from the Docker container and not the local folder.

Creating a Docker Image

Now as we got the base application up and running, let’s create a new Dockerfile to build an image.

We are going to use Nginx to serve the compiled application. Let’s take the stable alpine version to get the smallest output:

FROM nginx:stable-alpine
LABEL version="1.0"

COPY nginx.conf /etc/nginx/nginx.conf

WORKDIR /usr/share/nginx/html
COPY dist/my-angular-app/ .

As you can see from the example above, we are going to copy the dist folder inside the image, together with the nginx.conf configuration file.

A straightforward configuration implementation can look like in the next example:

worker_processes  1;

events {
    worker_connections  1024;
}

http {
    server {
        listen 80;
        server_name  localhost;

        root   /usr/share/nginx/html;
        index  index.html index.htm;
        include /etc/nginx/mime.types;

        gzip on;
        gzip_min_length 1000;
        gzip_proxied expired no-cache no-store private auth;
        gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;

        location / {
            try_files $uri $uri/ /index.html;
        }
    }
}

Next, we need to get the dist folder, containing the compiled application. It can be either a development or production build. Let’s create the optimized production one:

ng build --prod

Never try to run npm install or yarn install inside the production image on startup. When it comes to scaling and creating multiple containers, the last thing you want is hundreds of images downloading lots of data at startup. You should always aim to serve the final production builds inside your Docker images.

Now, let’s build the image and call it my-angular-app:

docker image build -t my-angular-app .

The Docker should produce an output similar to the following one but with different hash codes:

Sending build context to Docker daemon  346.5MB
Step 1/5 : FROM nginx:stable-alpine
 ---> 14d4a58e0d2e
Step 2/5 : LABEL version="1.0"
 ---> Running in c44ea1c70237
Removing intermediate container c44ea1c70237
 ---> 4be87ff53fdb
Step 3/5 : COPY nginx.conf /etc/nginx/nginx.conf
 ---> 2f77c43d1ee5
Step 4/5 : WORKDIR /usr/share/nginx/html
 ---> Running in bacd618bf2d9
Removing intermediate container bacd618bf2d9
 ---> fd79cc68b583
Step 5/5 : COPY dist/ .
 ---> 1ce2602f089f
Successfully built 1ce2602f089f
Successfully tagged my-angular-app:latest

The process should be relatively fast. Once it is complete, feel free to test the image is there, by running the next command:

docker image ls

You should see a list of available images, including the my-angular-app one with the tag latest and about 18 MB size.

It is now time to test the image. Run the command below to create a container and map it to the port 3000:

docker run -p 3000:80 --rm my-angular-app

If you now navigate to the http://localhost:3000, you should get your initial Angular application up and running there. Moreover, the Nginx logs are redirected to your console output, so that you can see what’s happening inside the container:

Angular Docker
Nginx log output in the console

Optimizing the build

Take a closer look at the console output when building the image. You are sending more than 300 MB each time as a build context for the Docker daemon:

Sending build context to Docker daemon  346.5MB

It is sending the whole project folder content, while all you need is the dist folder that contains the build output. Note, however, that the data does not end up in the resulting Docker image, but it significantly slows down the entire process.

You can improve the process by adding a .dockerignore file with the files and directories to exclude from the Docker build:

e2e
node_modules
src

Run the build command once again:

docker image build -t my-angular-app .

This time, the output should be as following:

Sending build context to Docker daemon  1.071MB

That is an excellent result. The build should be almost instant, and the size of the context gets reduced from 345 MB to 1 MB.

That is all you need to build an image on your local machine! Let’s now proceed to provide support for multi-container Docker setup.

Docker Compose

At some point, you will most probably end up with multiple containers each serving a particular need, for example, a separate backend server, a database server and so forth.

Here’s a simple docker-compose.yml file you can prepare as a template for your Angular application:

version: '3.1'

services:
  app:
    image: 'my-angular-app'
    build: '.'
    ports:
      - 3000:80

Note the build entry provided for development purposes. That means we are always going to build the local image if it is not available. You can remove this line later on once you start publishing images to Docker Hub.

Now use the next command to build the image and run it in a container:

docker-compose up

As before, navigate to http://localhost:3000 to ensure the application runs as expected.

Once you are done playing with the application, you can clean up everything using the next command:

docker-compose down

If you want to remove everything, including the image created earlier, use the following command:

docker-compose down --rmi all

Next time when you run the docker-compose up command, you may see the following message:

WARNING: Image for service app was built because it did not already exist.
To rebuild this image you must use `docker-compose build`
or `docker-compose up --build`.

That is a proof that Docker tool automatically builds a new image if it is not available. Now, let’s try to publish our image to Docker Hub so that other users can use it.

Publishing to Docker Hub

You should have a Docker Hub account to publish images. The account is free, and you can create it here: https://hub.docker.com/.

Once you have an account, run the login command in the terminal app and fill the username and password to be able publishing images:

docker login

Publishing your image is easy. Just run the following command in the root folder of your project, and replace the account value with your Docker Hub username.

docker image build -t account/my-angular-app:1.0 .

That creates a new image, ready to be deployed for your account. Use the next command to publish it, and don’t forget to use your Docker Hub username instead of the account value.

docker push account/my-angular-app:1.0

The process should be high-speed, and in a few seconds, your image is going to be public and available for use.

Running from Docker Hub

Now, as you project is published, you or any other person can run it locally by utilizing the following command:

docker run -p 3000:80 account/my-angular-app:1.0

To quickly test the container and automatically clean everything up once it got stopped, you can add the --rm switch:

docker run -p 3000:80 --rm account/my-angular-app:1.0

Finally, to use the docker-compose.yml file with your published image, just remove the build option and point the image value to the published version:

version: '3.1'

services:
  app:
    image: 'account/my-angular-app'
    ports:
      - 3000:80

Staged Builds

You can also dockerize the application build process by utilizing the “staged builds” feature. The flow can be as follows:

  • Install dependencies and build production output in Image A
  • Copy the production output to Image B
  • Discard Image A and publish Image B to the repository

The primary benefit of such an approach is that you don’t have to install NPM and other tooling dependencies on your local machine or CI/CD server. Also, that raises a security level for your build process as third-party libraries with custom “postinstall” scripts won’t be able accessing the data outside the image boundaries.

First, you need to pass the source code to the Docker context, so remove the src rule from the .dockeringnore file and leave it like the following:

e2e
node_modules

Now, update the Dockerfile according to the next listing:

# Step 1: Build the app in image 'builder'
FROM node:12.8-alpine AS builder

WORKDIR /usr/src/app
COPY . .
RUN yarn && yarn build

# Step 2: Use build output from 'builder'
FROM nginx:stable-alpine
LABEL version="1.0"

COPY nginx.conf /etc/nginx/nginx.conf

WORKDIR /usr/share/nginx/html
COPY --from=builder /usr/src/app/dist/my-angular-app/ .

Run the build command once again:

docker image build -t my-angular-app .

At this point, you are now able to setup dependencies and build the Angular application without having NPM or Angular CLI installed. Typically, you may want to have such setup for CI/CD integration too.

To test the flow, update the application title but don’t build the application locally. For the sake of simplicity, edit the app.component.html file and replace the <h1> element with the following markup:

<h1>
  Welcome to Dockerized {{ title }}!
</h1>

Rebuild and start the application using the following commands:

docker image build -t my-angular-app .
docker run -p 3000:80 --rm my-angular-app

Check the ”http://localhost:3000”, and you should see the updated application up and running:

Multi-stage build
Multi-stage build

Package Scripts

To reduce the time for typing the commands, you can put the “build” and “start” commands to the “package.json” file:

{
  "scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build --prod",
    "build:docker": "docker image build -t my-angular-app .",
    "start:docker": "docker run -p 3000:80 --rm my-angular-app",
    "test": "ng test",
    "lint": "ng lint",
    "e2e": "ng e2e"
  }
}

Now, every time you need to build a new image, run npm run build:docker. To test the application from within the container, use npm run start:docker command.

Fixing index.html Cache Problems

When preparing the production builds, the Angular CLI always addresses the browser caching by appending the hash sums to the file name.

This technique is often called “cache busting” and helps browsers to re-download the files only if the content has changed. The hash code in the name is directly related to the content of the file. As soon as you make a modification to the source code, the resulting hash changes and Angular CLI updates the index.html file. That instructs the user browser to download a fresh copy of the changed files only.

As an example, run the “npm run build” command and check the dist/app/index.html contents. It should end up with the set of scripts similar to the following listing:

<body>
  <app-root></app-root>
  <script type="text/javascript" src="runtime.26209474bfa8dc87a77c.js"></script>

  <script
    type="text/javascript"
    src="es2015-polyfills.c5dd28b362270c767b34.js"
    nomodule
  ></script>

  <script
    type="text/javascript"
    src="polyfills.dfd9644fffcf7f355e2e.js"
  ></script>

  <script type="text/javascript" src="main.665a5dcb77766956827f.js"></script>
</body>

The most common problem that occurs with such approach is that index.html does not change and users may always get the cached version, while the JavaScript files may get replaced with the new builds and deployments. In this case, users get a blank page as soon as a new version of your Angular application gets deployed.

The solution I suggest for the Docker image is to bust the cache for the index.html file for all page requests. You can do that in the nginx.conf configuration file:

location ~ \.html$ {
  add_header Cache-Control "private, no-cache, no-store, must-revalidate";
  add_header Expires "Sat, 01 Jan 2000 00:00:00 GMT";
  add_header Pragma no-cache;
}

With Angular, you usually get a single-page web application with a single HTML file pointing to JavaScript bundles. So busting the cache for this file won’t hurt the performance, but ensures your users are always using the correct versions of the compiled bundles.

Summary

Running your Angular applications with Docker containers can significantly reduce your development and testing efforts, especially if you intend frequently sharing your application either internally or with external users.

One of the most excellent benefits is that people who test or use your application do not need to use development tools to compile your project from the source code or configure a web server to run the compiled output. Everything comes with a container and runs on all operating systems without extra overhead.

You can find the source code for the article in the following GitHub repository: https://github.com/DenysVuika/medium-angular-docker.