July 23, 2021

NOTE! This article is still just a draft version.

Dockerizing React frontend app using Nginx container

We will be using a TypeScript frontend app which was created by react-create-app.

  1. First we’ll setup a Dockerfile for building a static image for the frontend. We’ll do this using the node:14-slim image, since Nginx image doesn’t contain a build system.

  2. Then we’ll use that image in the Nginx container’s Dockerfile and configure our Nginx docker image so that it can support runtime environment variables.

  3. Finally we’ll use docker-compose to spin up our complete setup.

Building the static frontend image

This frontend image is used only for building the compiled and minified frontend image. It will never be started as a container.

First we’ll define build argument NODE_IMAGE. It enables to customize the base image used for building the image at build time.

We’ll default it to node:14-slim:

ARG NODE_IMAGE=node:14-slim

FROM $NODE_IMAGE as node-image

FROM node-image

Then we’ll define default values as build arguments so that it’s possible to change defaults while building the image.

ARG DEFAULT_NODE_ENV=production
ARG DEFAULT_GENERATE_SOURCEMAP=false
ARG DEFAULT_API_URL=/api

Any frontend app created using create-react-app will recognize and pass on NODE_ENV and REACT_APP_* variables to the generated bundle.

The GENERATE_SOURCEMAP is also supported. It controls if source maps are generated or not.

ENV NODE_ENV=$DEFAULT_NODE_ENV
ENV GENERATE_SOURCEMAP=$DEFAULT_GENERATE_SOURCEMAP
ENV REACT_APP_API_URL=$DEFAULT_API_URL

You can use these variables in your bundle using process.env.NODE_ENV, etc. I usually create a file like frontend/src/environment.ts where I parse and export these to other parts of the frontend.

We’ll set the work directory as /app and copy package*.json files there first before installing dependencies.

WORKDIR /app
COPY ./package*.json ./
RUN npm ci --silent

This style enables Docker to use layers correctly.

Then we’ll copy our TypeScript configuration and rest of the source code:

COPY tsconfig.json ./tsconfig.json
COPY public ./public
COPY src ./src

Finally we’ll build the source code:

RUN npm run build

Here’s our full finished frontend/Dockerfile:

ARG NODE_IMAGE=node:14-slim

FROM $NODE_IMAGE as node-image

FROM node-image

ARG DEFAULT_NODE_ENV=production
ARG DEFAULT_GENERATE_SOURCEMAP=false
ARG DEFAULT_API_URL=/api

ENV NODE_ENV=$DEFAULT_NODE_ENV
ENV GENERATE_SOURCEMAP=$DEFAULT_GENERATE_SOURCEMAP
ENV REACT_APP_API_URL=$DEFAULT_API_URL

WORKDIR /app
COPY ./package*.json ./
RUN npm ci --silent
COPY tsconfig.json ./tsconfig.json
COPY public ./public
COPY src ./src
RUN npm run build

Customizing the Nginx container

We’ll use Nginx to host our frontend and route traffic to our backend.

Just like we did for the frontend image, we will define build-time options to dynamically change which images we use as a base.

ARG NGINX_IMAGE=nginx:alpine
ARG FRONTEND_IMAGE=my-frontend

FROM $FRONTEND_IMAGE as frontend-image
FROM $NGINX_IMAGE as nginx-image

FROM nginx-image

While debugging, you can change NGINX_IMAGE to nginx:latest if you want to debug Nginx inside the container using a shell executable. Debugging with the default nginx:alpine image is much harder since it does not include a shell.

With the option FRONTEND_IMAGE you can customize the name of the image from where to fetch the compiled static frontend code.

Just like with our NodeJS container, we can define some environment variables.

ARG DEFAULT_PORT=8080
ARG DEFAULT_API_URL=https://api.example.com

ENV NGINX_PORT=$DEFAULT_PORT
ENV NGINX_API_URL=$DEFAULT_API_URL

We’ll use NGINX_PORT to define the port where the Nginx listens for requests and NGINX_API_URL as the URL where to route traffic for our /api end point.

Now we’ll define rule to copy our templates folder (we’ll create it later):

COPY templates /etc/nginx/templates

Finally we copy our static frontend code from frontend-image’s /app/build path to Nginx’s public document root:

COPY --from=frontend-image /app/build /usr/share/nginx/html

Here’s the full Nginx Dockerfile:

ARG NGINX_IMAGE=nginx:alpine
ARG FRONTEND_IMAGE=my-frontend

FROM $FRONTEND_IMAGE as frontend-image
FROM $NGINX_IMAGE as nginx-image

FROM nginx-image

ARG DEFAULT_PORT=8080
ARG DEFAULT_API_URL=https://api.example.com

ENV NGINX_PORT=$DEFAULT_PORT
ENV NGINX_API_URL=$DEFAULT_API_URL

COPY templates /etc/nginx/templates
COPY --from=frontend-image /app/build /usr/share/nginx/html

Now, we’ll create a folder named templates and a file ./templates/10-default.conf.template.

The file will look like:

server {
        listen ${NGINX_PORT};
        listen [::]:${NGINX_PORT};
        root /usr/share/nginx/html;
        index index.html;
        server_name _;

	location /api/ {
		proxy_pass          ${NGINX_API_URL}/;
		proxy_set_header    Host            $proxy_host;
		proxy_set_header    X-Forwarded-For $proxy_add_x_forwarded_for;
		proxy_buffers       16 4k;
		proxy_buffer_size   2k;
		proxy_http_version  1.1;
		proxy_set_header    Upgrade     $http_upgrade;
		proxy_set_header    Connection  "Upgrade";		
	}

        location / {
                try_files $uri $uri/ =404;
        }
}

The Nginx container will read this file and change our environment variables automatically using envsubst when ever our container starts up.

See also my previous Hosting web apps article for meaning of rest of the syntax.

We also defined a proxy from the end point /api/* to our backend at configurable address NGINX_API_URL/*:

location /api/ {
	proxy_pass          ${NGINX_API_URL}/;
	proxy_set_header    Host            $proxy_host;
	proxy_set_header    X-Forwarded-For $proxy_add_x_forwarded_for;
	proxy_buffers       16 4k;
	proxy_buffer_size   2k;
	proxy_http_version  1.1;
	proxy_set_header    Upgrade     $http_upgrade;
	proxy_set_header    Connection  "Upgrade";
}

This setup enables support for websockets and also makes it possible to run it behind other load balancers by defining X-Forwarded-For using the $proxy_add_x_forwarded_for. This is a special variable which will be set as X-Forwarded-For header, if it exists, otherwise it will be $remote_addr.

Putting it together

Finally we can use docker-compose to spin our setup up for development.

Create a file named docker-compose.yml:

version: "3.9"
services:

  my-frontend-builder:
    container_name: my-frontend-builder
    image: "my-frontend-builder:latest"
    build:
      context: ./frontend
      dockerfile: Dockerfile
      args:
        DEFAULT_NODE_ENV: "development"
        DEFAULT_GENERATE_SOURCEMAP: "true"
        DEFAULT_API_URL: "/api"
    entrypoint: "sh -c"
    command: "exit 0"
    restart: "no"

  my-backend:
    container_name: my-backend
    image: "my-backend:latest"
    build:
      context: ./backend
      dockerfile: Dockerfile
    ports:
      - "8081:8081"
    environment:
      PORT: 8081

  my-nginx:
    container_name: my-nginx
    image: "my-nginx:latest"
    build:
      context: .
      dockerfile: Dockerfile
      args:
        FRONTEND_IMAGE: 'my-frontend-builder:latest'
    ports:
      - "8080:8080"
    depends_on:
      - my-frontend-builder
      - my-backend
    environment:
      NGINX_PORT: 8080
      NGINX_API_URL: "http://my-backend:8081"

This configuration also includes my-backend which we didn’t create. We assume you have a backend container already available in folder ./backend.

Now we can build our three containers using a command docker-compose build.

And finally start them using docker-compose up.

Configuring the development proxy

While developing the frontend, you don’t actually need Nginx, since react-create-app comes with a proxy setup for development purposes.

Just install package http-proxy-middleware:

npm install http-proxy-middleware --save

…and create a file frontend/src/setupProxy.js:

const { createProxyMiddleware } = require('http-proxy-middleware');
const REACT_APP_BACKEND_API_URL = process.env.REACT_APP_BACKEND_API_URL ? process.env.REACT_APP_BACKEND_API_URL : 'https://localhost:8081';

module.exports = function(app) {
  app.use(
    '/api',
    createProxyMiddleware({
      target: REACT_APP_BACKEND_API_URL,
      changeOrigin: true,
      autoRewrite: true,
      protocolRewrite: 'http',
      pathRewrite: {
          ['^/api'] : ''
      },
    })
  );
};

Then you can set the environment variable REACT_APP_BACKEND_API_URL to point to your backend’s URL, and start the frontend development server:

cd frontend
REACT_APP_BACKEND_API_URL='https://api.example.com' npm start

Now any request to http://localhost:3000/api will be redirected to https://api.example.com.