How to Deploy a React app to OpenShift

In this article, we’re going to look at how to build and deploy your React application on OpenShift, using your source code, and a Dockerfile.

What we’re going to build

I think it’s helpful to see what we’re going to create before we build it. So let’s look at what we’re aiming for:

  • Building a Docker image in the OpenShift cluster, with a Dockerfile. For this, we’ll create a BuildConfig with a Dockerfile.

  • Creating a trigger that will redeploy our app when a new image is built. For this, we’ll create an ImageStream.

  • Deploying our application. For this, we’ll create a DeploymentConfig which will manage some Pods for us.

  • We’ll also make the application available to the outside world with a Service and a Route.

  • Finally we’ll provide some runtime config to the application with a ConfigMap.

An example app to use

If you’ve already got a React app to use, then skip ahead.

If you haven’t got an app, then you can use mine here:

https://github.com/monodot/container-up/tree/master/react-hello-world

I’m going to use yarn in this tutorial because that’s the default when you create a new app with create-react-app.

Creating a Dockerfile

I’m going to use a Docker build here. Why? Because it gives a bit more flexibility than OpenShift’s Source-to-Image build – it means that you could actually build this image anywhere with Docker, not just inside OpenShift.

In my Docker build I’m going to use images available from public registries. But if you’ve got an OpenShift subscription, you can use the supported images from the Red Hat registry.

So here’s my Dockerfile, which I added into the root of my Git repository:

# Stage 1: Use yarn to build the app
FROM node:14 as builder
WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN yarn
COPY . ./
RUN yarn build

# Stage 2: Copy the JS React SPA into the Nginx HTML directory
FROM bitnami/nginx:latest
COPY --from=builder /usr/src/app/build /app
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]

Note it’s a multi-stage Docker build. I’m doing this to keep my final image small. The final image only needs to contain my web server and minified code, instead of Node, npmm and the rest of the kitchen sink!

The first stage builds my production distribution (minified JavaScript and all that stuff). The second stage copies the production distribution into an nginx container.

The COPY --from=builder /usr/src/app/build /app is a strange line which describes copying the output from Stage 1 into the /app dir in the Stage 2 container.

Finally we expose port 8080 and set the startup command for Nginx. I’m using the free Nginx image from Bitnami here.

Configuring the app with a ConfigMap

This is an interesting conundrum. How do you provide configuration to a static web app? There is no “runtime” to receive your env vars.

For back-end apps, we can usually use environment variables.

But this is static code (HTML and JavaScript) that will be hosted on a dumb web server. So how do we make the configuration dynamic?

It took me a while to get my head around this:

Instead of giving environment variables to our “app”, we give the environment variables to the client (the web browser). We serve an additional JS file to the browser, which contains these environment-specific variables.

When the browser loads the HTML, it will fetch the minified JS for the React app plus an extra JavaScript source file which contains environment config.

I learned this trick from this awesome blog post on React environment variables by Javaad Patel. Thanks, Javaad! :)

And here’s the light bulb moment:

To add any kind of dynamic or environment-specific file into a container, we can use a ConfigMap.

This means we don’t need to build any configuration files into our image at all.

This is ideal, because we don’t want to have to build a new image for each environment.

Giving runtime config to your React app

Define environment variables in a file static/environment.js. These will be our default variables, which we’ll override later:

// set some defaults here
window.FOO = "Some value goes here"
window.BAR = "Another value goes there"

Add another script, ./src/config.js:

// Fetch config variables defined in window.*
const envSettings = window;

export const configuration = {
  foo: envSettings.FOO,
  bar: envSettings.BAR
}

Finally you can refer to them inside your App.js, or anywhere else you like. Just import the configuration then use braces { } to refer to the values:

import { configuration } from './config.js';

//...
function App() {
    return (
        Value of foo = { configuration.foo }
    );
}

Finally, we need to give the configuration variables to the client’s web browser. So add a script tag to include your environment variable overrides. So now my HTML looks like this:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <script src="%PUBLIC_URL%/environment.js"></script>
    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
...

An OpenShift template to deploy it all

And now here’s an OpenShift template to bring it all together:

  • A BuildConfig which refers to a Dockerfile

  • An ImageStream to track our application’s Docker image

  • A DeploymentConfig to deploy the application and inject configuration into the container, plus a Service and a Route.

  • A ConfigMap containing some environment overrides

Here it is:

apiVersion: template.openshift.io/v1
kind: Template
objects:
- apiVersion: v1
  kind: ConfigMap
  metadata:
    name: ${NAME}
  data:
    environment.js: |
      // set some defaults here
      window.FAVOURITE_SONG = "The Locomotion"
      window.FAVOURITE_ERA = "Mid-90s Nick Cave Kylie"
- apiVersion: build.openshift.io/v1
  kind: BuildConfig
  metadata:
    labels:
      app: ${NAME}
      app.kubernetes.io/component: react-hello-world
      app.kubernetes.io/instance: ${NAME}
    name: ${NAME}
  spec:
    output:
      to:
        kind: ImageStreamTag
        name: ${NAME}:latest
    source:
      contextDir: ${CONTEXT_DIR}
      git:
        uri: ${SOURCE_REPOSITORY_URL}
        ref: ${SOURCE_REPOSITORY_REF}
      type: Git
    strategy:
      dockerStrategy:
        from:
          kind: ImageStreamTag
          name: nginx:latest
      type: Docker
    triggers:
    - type: ConfigChange
    - type: ImageChange
- apiVersion: apps.openshift.io/v1
  kind: DeploymentConfig
  metadata:
    labels:
      app: ${NAME}
      app.kubernetes.io/component: react-hello-world
      app.kubernetes.io/instance: ${NAME}
    name: ${NAME}
  spec:
    replicas: 1
    selector:
      deploymentconfig: ${NAME}
    template:
      metadata:
        labels:
          deploymentconfig: ${NAME}
      spec:
        containers:
        - image: ${NAME}
          imagePullPolicy: Always
          name: react-hello-world
          ports:
          - containerPort: 8080
            protocol: TCP
          volumeMounts:
            - name: app-config-volume
              mountPath: /app/environment.js
              subPath: environment.js
        volumes:
          - name: app-config-volume
            configMap:
              name: ${NAME}
    triggers:
    - type: ConfigChange
    - type: ImageChange
      imageChangeParams:
        automatic: true
        containerNames:
        - react-hello-world
        from:
          kind: ImageStreamTag
          name: ${NAME}:latest
- apiVersion: v1
  kind: Service
  metadata:
    labels:
      app: ${NAME}
      app.kubernetes.io/component: react-hello-world
      app.kubernetes.io/instance: ${NAME}
    name: ${NAME}
  spec:
    ports:
    - name: 8080-tcp
      port: 8080
      protocol: TCP
      targetPort: 8080
    selector:
      deploymentconfig: ${NAME}
    type: ClusterIP
- apiVersion: image.openshift.io/v1
  kind: ImageStream
  metadata:
    labels:
      app: ${NAME}
      app.kubernetes.io/component: react-hello-world
      app.kubernetes.io/instance: ${NAME}
    name: nginx
  spec:
    lookupPolicy:
      local: false
    tags:
    - name: latest
      from:
        kind: DockerImage
        name: bitnami/nginx:latest
      annotations:
        openshift.io/imported-from: bitnami/nginx:latest
      referencePolicy:
        type: Source
- apiVersion: image.openshift.io/v1
  kind: ImageStream
  metadata:
    labels:
      app: ${NAME}
      app.kubernetes.io/component: react-hello-world
      app.kubernetes.io/instance: ${NAME}
    name: ${NAME}
  spec:
    lookupPolicy:
      local: false
- apiVersion: route.openshift.io/v1
  kind: Route
  metadata:
    labels:
      app: ${NAME}
      app.kubernetes.io/component: react-hello-world
      app.kubernetes.io/instance: ${NAME}
    name: ${NAME}
  spec:
    port:
      targetPort: 8080-tcp
    to:
      kind: Service
      name: ${NAME}
      weight: 100
    wildcardPolicy: None
    tls:
      termination: Edge
parameters:
- description: The name assigned to all of the frontend objects defined in this template.
  displayName: Name
  name: NAME
  required: true
  value: react-hello-world
- description: The URL of the repository with your application source code.
  displayName: Git Repository URL
  name: SOURCE_REPOSITORY_URL
  required: true
  value: https://github.com/monodot/container-up.git
- description: Set this to a branch name, tag or other ref of your repository if you
    are not using the default branch.
  displayName: Git Reference
  name: SOURCE_REPOSITORY_REF
  value: master
- description: Set this to the relative path to your project if it is not in the root
    of your repository.
  displayName: Context Directory
  name: CONTEXT_DIR
  value: react-hello-world

Now you can apply this using:

oc process -f openshift-template | oc apply -f -

To see and run a completed example, check out the GitHub repo.

Thanks for reading!