by Georg Ledermann

Dockerize and configure a JavaScript single-page application

A way of accessing environment variables

Photo by Markus Spiske on Unsplash

Photo by Markus Spiske on Unsplash

Building a lean Docker image for delivering a single-page JavaScript application is simple. But it is not that easy when it comes to configuring with environment variables.

Let’s say we have a Vue.js application which is compiled with yarn build for production. The files to be delivered are placed in the /dist folder with a simple index.html to load the JS code. It doesn’t matter if your JavaScript application is based on React, Angular or something else, the following steps would be similar.

Building the smallest possible Docker image

After compilation with yarn build, we don’t need the large node_modules/ folder with its hundreds of MB. We even don’t need Node.js or Yarn. In production, we just need a web server to deliver the compiled files.

So, to build the smallest possible Docker image for production, a multi-stage Dockerfile is recommended: First, the build process is performed by Node.js and Yarn. The resulting artifacts are then copied to a new image based on the official nginx image:

1
2
3
4
5
6
7
8
9
10
11
12
# First step: Build with Node.js
FROM node:alpine AS Builder
WORKDIR /app
COPY package.json yarn.lock /app/
RUN yarn install
COPY . /app
RUN yarn build

# Use plain nginx to deliver the dist folder only
FROM nginx:stable-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=Builder /app/dist /usr/share/nginx/html

We also need an nginx simple configuration file named nginx.conf:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
server {
  listen 80 default_server;
  listen [::]:80 default_server;

  root /usr/share/nginx/html;

  index index.html;

  location / {
    # Support the HTML5 History mode of the vue-router.
    # https://router.vuejs.org/en/essentials/history-mode.html
    try_files $uri $uri/ /index.html;
  }
}

Okay, that’s all. The result is a very small image (~ 8MB download size for my example application) and can be used in production.

Configure the container with environment variables

With Docker, containers are configured by environment variables. For example, this is used to define backend URLs, API access tokens etc. Assume we want to use the following docker-compose.yml:

1
2
3
4
5
6
7
8
9
10
version: '3.4'
services:
  app:
    image: 'ledermann/docker-vue'
    ports:
      - '80'
    environment:
      - VUE_APP_BACKEND_HOST=backend.example.com
      - VUE_APP_MATOMO_HOST=matomo.example.com
      - VUE_APP_MATOMO_ID=42

Now we have a problem: We deliver compiled JavaScript with nginx. There is no Node.js, so we have no access to Docker’s environment variables.

However, the following approach allows configuration via environment variables:

  1. In your JS code, use strings like '$VUE_APP_BACKEND_HOST' and assume they contain the configuration value
  2. On container startup, modify the existing JS files with search & replace to set values
  3. In development, just use process.env

Step 1: Add Configuration class to the JS code

In development, we use Node.js, so process.env has access to the local environment. There is a package called dotenv to load env vars from a file. Add this to your project:

$ yarn add dotenv

Remember: We need this in development only. In production, there is no Node.js at runtime, so process.env doesn’t include any environment variable!

To encapsulate application configuration, I have created a simple class named Configuration to access environment variables both in development and production. It includes config strings named $VUE_APP_XXX to be replaced later at container startup (see step 2):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import dotenv from 'dotenv'
dotenv.config()

export default class Configuration {
  static get CONFIG () {
    return {
      backendHost: '$VUE_APP_BACKEND_HOST',
      matomoHost: '$VUE_APP_MATOMO_HOST',
      matomoId: '$VUE_APP_MATOMO_ID'
    }
  }

  static value (name) {
    if (!(name in this.CONFIG)) {
      console.log(`Configuration: There is no key named "${name}"`)
      return
    }

    const value = this.CONFIG[name]

    if (!value) {
      console.log(`Configuration: Value for "${name}" is not defined`)
      return
    }

    if (value.startsWith('$VUE_APP_')) {
      // value was not replaced, it seems we are in development.
      // Remove $ and get current value from process.env
      const envName = value.substr(1)
      const envValue = process.env[envName]
      if (envValue) {
        return envValue
      } else {
        console.log(`Configuration: Environment variable "${envName}" is not defined`)
      }
    } else {
      // value was already replaced, it seems we are in production.
      return value
    }
  }
}

Usage example:

1
2
3
import Configuration from 'configuration'
var backendHost = Configuration.value('backendHost')
console.log(backendHost)

Step 2: Replace vars on container startup

First, we need a bash script named entrypoint.sh to run on every container startup:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/bin/sh

# Replace env vars in JavaScript files
echo "Replacing env vars in JS"
for file in /usr/share/nginx/html/js/app.*.js;
do
  echo "Processing $file ...";

  # Use the existing JS file as template
  if [ ! -f $file.tmpl.js ]; then
    cp $file $file.tmpl.js
  fi

  envsubst '$VUE_APP_BACKEND_HOST,$VUE_APP_MATOMO_HOST,$VUE_APP_MATOMO_ID' < $file.tmpl.js > $file
done

echo "Starting nginx"
nginx -g 'daemon off;'

What this script does:

  • The first time the container is started, the existing app.*.js files are copied and used as a template for the following search & replace (line 9-12).
  • The main part (line 14) is to use envsubst, which is included in the nginx Docker image. It replaces strings in a file with the values of the given environment variables.

To use this script as entrypoint, add this lines to the Dockerfile described above:

1
2
COPY entrypoint.sh /
ENTRYPOINT ["/entrypoint.sh"]

Step 3: Make use of dotenv in development

It is more simple to use environment variables in development. Because we have included the dotenv package, we can place a file called env.local with this content:

1
2
3
VUE_APP_BACKEND_HOST="backend.my-site.dev"
VUE_APP_MATOMO_HOST="matomo.my-site.com"
VUE_APP_MATOMO_ID="42"

Result

You find the complete code in my DockerVue example application on GitHub.