In this previous blog post, I have shown how to dockerize an Angular CLI application using NginX. However, the shown method has worked only for classical, client-side rendered Angular projects. In the current post, we will show an Angular Universal Docker example. We will show, how an Angular Universal CLI project with server-side rendering and transition to client-side rendering can be dockerized.

In the main blog post, we will use the node http-server static server in order to deliver the Angular application to the browser. However, you also will find NginX static and NodeJS dynamic variants of the same task. In the Summary section, we will compare the performance of the three variants (don’t expect too much: I just made a simple load time comparison in the Chrome browser).

We will apply the dockerization on my project I have created in the blog post Angular Universal CLI – Step-by-Step Example with REST Client that has been created based on the angular/universal-starter project.

Tools and Versions used

  • CentOS 7 image installed via Vagrant
  • Docker version 17.10.0-ce, build f4ffd25
  • git is installed (if not: sudo yum install -y git)

Step 1: Clone the Project from GitHub

(dockerhost)$ git clone https://github.com/oveits/universal-starter
(dockerhost)$ cd universal-starter/cli
(dockerhost)$ git checkout d6084ec

This is a project that has been forked from the /universal-starter project on GitHub.  I have added a REST interface towards WordPress API. With the git checkout command, you will start at the same step I have started this blog post.

You might prefer to perform your tests with the original project https://github.com/angular/universal-starter. This should work as well. Only the resulting page should look differently.

Step 2: Review the package.json

The Dockerfile will re-use scripts we find in the package.json. Therefore, we need to make sure that following scripts are defined in package.json:

...
  "scripts": {
    "ng": "ng",
    "start": "ng serve",
    "start:dynamic": "npm run build:dynamic && npm run serve:dynamic",
    "start:static": "npm run build:static && npm run serve:static",
    "build": "ng build",
    "build:client-and-server-bundles": "ng build --prod && ng build --prod --app 1 --output-hashing=false",
    "build:static": "npm run build:client-and-server-bundles && npm run webpack:server && npm run generate:static",
    "build:dynamic": "npm run build:client-and-server-bundles && npm run webpack:server",
    "generate:static": "cd dist && node prerender",
    "webpack:server": "webpack --config webpack.server.config.js --progress --colors",
    "serve:static": "cd dist/browser && http-server",
    "serve:dynamic": "node dist/server"
...

Step 3: Prepare the Dockerfile

The initial Dockerfile has been inspired by /angular4-docker-example on Github. I have forked and slightly changed the file, and we can download it via

(dockerhost)$ curl https://raw.githubusercontent.com/oveits/angular4-docker-example/master/Dockerfile > Dockerfile

For the static HTTP Server, we need to change the content as follows. Here, I have kept the original lines commented out in red, and the added lines in bold blue:

# Dockerfile
### STAGE 1: Build ###

# We label our stage as 'builder'
FROM node:8-alpine as builder

COPY package*.json ./

RUN npm set progress=false && npm config set depth 0 && npm cache clean --force

## Storing node modules on a separate layer will prevent unnecessary npm installs at each build
RUN npm i && mkdir /ng-app && cp -R ./node_modules ./ng-app

WORKDIR /ng-app

COPY . .

## Build the angular app in production mode and store the artifacts in dist folder
#RUN $(npm bin)/ng build --prod --build-optimizer
RUN npm run build:static


### STAGE 2: Setup ###

#FROM nginx:1.13.3-alpine
FROM node:8-alpine

## Install http-server
RUN npm install http-server -g

## Copy our default nginx config
#COPY nginx/default.conf /etc/nginx/conf.d/

## Remove default nginx website
#RUN rm -rf /usr/share/nginx/html/*

## From 'builder' stage copy over the artifacts in dist folder to default nginx public folder
#COPY --from=builder /ng-app/dist /usr/share/nginx/html
COPY --from=builder /ng-app/dist /dist

#CMD ["nginx", "-g", "daemon off;"]

WORKDIR /dist/browser
CMD ["http-server"]

In STAGE 1, we have made use of the script defined in the package.json, which will create all HTML files ahead of time (AOT) and put them in the dist/browser folder. In STAGE 2, we copy the dist folder, and run the http-server in the dist/browser folder, after we have installed the http-server globally.

Step 4: Build, Tag and Upload the Docker Image

The Docker Image can be built with the command similar to:

(dockerhost)$ docker build . --tag oveits/angular_universal-starter_with_cli:v0.1

Here, I have tagged it with a name and version, so I can upload it to Docker Hub on my oveits account. I also set the latest tag as follows:

docker tag oveits/angular_universal-starter_with_cli:v0.1 oveits/angular_universal-starter_with_cli:latest

Step 5: Test the image locally

We can test the image with

$ docker run -it --net=host oveits/angular_universal-starter_with_cli
Starting up http-server, serving ./
Available on:
 http://127.0.0.1:8080
 http://10.0.2.15:8080
 http://192.168.33.12:8080
Hit CTRL-C to stop the server

In this case, we can see that I am using a VirtualBox machine with a host port network interface with a static IP address. Since we have chosen the –net=host option, the port should be accessible on this IP address. If you are using Vagrant with the default Vagrant interface only, you might need to map the VM’s port to your Host port within VirtualBox.

Angular Universal Docker Example

The browser content looks as expected. But is server-side rendering working as well? Yes: when looking at the source of the page in the browser (e.g. press Ctrl-U in Chrome), the content of the page is shown: no javascript is required to show the content:

Angular Universal Docker Example -- Showing Source Code in a Browser

Step 6: Upload the Image to Docker Hub

Now we can upload the image to Docker Hub:

(dockerhost)$ docker login
Username: <-- answer with your user credentials
Password:
(dockerhost)$ docker push oveits/angular_universal-starter_with_cli:v0.1 
(dockerhost)$ docker push oveits/angular_universal-starter_with_cli:latest

Step 7: Use from any Docker Host

Now we can use the image from any Docker host that can access the Internet. In my case, this is a CentOS machine on AWS. If we want the application to be available on the standard port 80, we can run the application as follows:

(dockerhost)$ docker run --rm --name angular_universal-starter_with_cli -d -p 80:8080 oveits/angular_universal-starter_with_cli

Port 80 was occupied in my case already, so I have mapped port 8080 to 8080 instead:

Also here, the server-side rendering works fine: the innerHTML content is visible in the HTML source:

Summary

General

In this blog post, we have dockerized an Angular Universal CLI with server-side rendering by making use of the ahead-of-time (AOT) compilation scripts given in the /universal-starter seed project. We have covered the case, where the HTML pages are created ahead-of-time (AOT) and the npm http-server is used to serve the pages.

Dynamic vs Static

In Appendix A, you will see, what needs to be changed in the case we would like to use a (dynamic) NodeJS server instead of a (static) npm http-server. The NodeJS server case has the advantage that the user will never get out-of-date content from the server. I have not tested the performance yet, but I expect the latency of the dynamic case to exceed the one we can expect from the AOT compiled case.

In Appendix B, we can see how also NginX can be used to serve the pages that have been created ahead-of-time (AOT) with similar results.

This is no exact science, but in order to get a hint about the experienced latency, I have made a quick comparison of the loading times of the blog post page served by docker images running in detached mode on a CentOS VirtualBox VM as a Docker host on my local machine. The load times have been recorded on Chrome in debug mode (F12):

  • winner: static with http-server: load time average: 1.36 sec ± 0,05 sec
    measured load times [sec]: 1.38, 1.37, 1.30, 1.42, 1.33, 1.33, 1.32, 1.42
  • intermediate: static with NginX: load time average: 1.53 sec ± 0.19 sec
    measured load times [sec]: 1.85, 1.45, 1.22, 1.49, 1.56, 1.45, 1.70, 1.48
  • looser: dynamic with NodeJS: load time average: 2.37 sec ± 0.27 sec
    measured load times [sec]: 2.75, 1.86, 2.35, 2.24, 2.28, 2.40, 2.65, 2.50

A look at the measured times reveals that the http-server is marginally quicker than the NginX server. The NodeJS server has a higher latency than the other both. The same trend holds if the server Docker container is running on an AWS CentOS Docker host.

Appendix A: Dynamic NodeJS Server instead of static http-server

For the dynamic NodeJS server case, we need to change the following in the Dockerfile

### STAGE 1: Build ###

# We label our stage as 'builder'
FROM node:8-alpine as builder

COPY package*.json ./

RUN npm set progress=false && npm config set depth 0 && npm cache clean --force

## Storing node modules on a separate layer will prevent unnecessary npm installs at each build
RUN npm i && mkdir /ng-app && cp -R ./node_modules ./ng-app

WORKDIR /ng-app

COPY . .

## Build the angular app in production mode and store the artifacts in dist folder
#RUN npm run build:static
RUN npm run build:dynamic


### STAGE 2: Setup ###

FROM node:8-alpine

## Install http-server
#RUN npm install http-server -g

## From 'builder' stage copy over the artifacts in dist folder to default nginx public folder
COPY --from=builder /ng-app/dist /dist

#WORKDIR /dist/browser
#CMD ["http-server"]
CMD ["node", "/dist/server"]

When we run the service, we will notice that the server is accessing the WordPress API any time we click on the blog post link. In the static case, this is done only once at compile time (or more accurate: transpile time).

Appendix B: Static NginX Server instead of static http-server

For the (static) NginX server case, we need to change the following in the Dockerfile:

### STAGE 1: Build ###

# We label our stage as 'builder'
FROM node:8-alpine as builder

COPY package*.json ./

RUN npm set progress=false && npm config set depth 0 && npm cache clean --force

## Storing node modules on a separate layer will prevent unnecessary npm installs at each build
RUN npm i && mkdir /ng-app && cp -R ./node_modules ./ng-app

WORKDIR /ng-app

COPY . .

## Build the angular app in production mode and store the artifacts in dist folder
#RUN npm run build:static
RUN npm run build:dynamic


### STAGE 2: Setup ###

#FROM node:8-alpine
FROM nginx:1.13.3-alpine
## Install http-server
#RUN npm install http-server -g

## Copy our default nginx config
COPY nginx/default.conf /etc/nginx/conf.d/

## Remove default nginx website
RUN rm -rf /usr/share/nginx/html/*

## From 'builder' stage copy over the artifacts in dist folder to default nginx public folder
#COPY --from=builder /ng-app/dist /dist
COPY --from=builder /ng-app/dist/browser /usr/share/nginx/html

#WORKDIR /dist/browser
#CMD ["http-server"]
#CMD ["node", "/dist/server"]
CMD ["nginx", "-g", "daemon off;"]

As with the http-server, the WordPress API is contacted only once by the HTTP Server at compile time (or more accurate: transpile time). After the client (browser) has taken over, the client will contact the WordPress API, though.

 

7 comments

  1. Hi,

    In “Appendix B: Static NginX Server instead of static http-server” for building apllication you are using “RUN npm run build:dynamic”. Probably this is mistake and we have to use “RUN npm run build:static” ?

  2. $ docker run -it --net=host oveits/angular_universal-starter_with_cli
    Starting up http-server, serving ./
    Available on:
     http://127.0.0.1:8080
     http://10.0.2.15:8080
    http://192.168.65.3:8080
    Hit CTRL-C to stop the server
    

    When I run the above command its starts the server but when I try to open the app in my browser using http://192.168.65.3:8080 it is not opening my app. Instead, I get: ‘No site found – This site can’t be reached’

    1. Did you try to curl to the addresses on the machine the docker run command is running?
      In my case, I get:

      $ http://195.201.16.221:8080
      <!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>NgUniversalDemo</title><base href="/"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" type="image/x-icon" href="favicon.ico"><link href="styles.d41d8cd98f00b204e980.bundle.css" rel="stylesheet"></head><body><app-root ng-version="4.4.6">
        <h1>Universal Demo using Angular and Angular CLI</h1>
        <a routerLink="/" href="/">Home</a>
        <a routerLink="/lazy" href="/lazy">Lazy</a>
        <a routerLink="/lazy/nested" href="/lazy/nested">Lazy_Nested</a>
        <a routerLink="/blog/2017/06/13/angular-4-hello-world-with-quickstart" href="/blog/2017/06/13/angular-4-hello-world-with-quickstart">Angular 4 Hello World Quickstart</a>
        <router-outlet></router-outlet><home><h3>Hello</h3></home>
        </app-root><script type="text/javascript" src="inline.995ff0afeecaaa9b6a1a.bundle.js"></script><script type="text/javascript" src="polyfills.54dd1bb0dea7bab42697.bundle.js"></script><script type="text/javascript" src="vendor.6002006a8973795ef8a0.bundle.js"></script><script type="text/javascript" src="main.e327b31b9867bdc21cf1.bundle.js"></script></body></html>
      
      1. Why port 8081? My application oveits/angular_universal-starter_with_cli is running on port 8080. Have you created your own image? Is it available on Docker hub?
        BTW, I recommend performing the curl test on the same machine the docker run command runs on. This way, you can be sure there is no routing problem.
        In your case, where is docker running and where is your browser running?

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.