Building Environment-Friendly React Apps

December 14, 2020

A core tenant of application promotion is that you are using the same version of your application between environments. If you have a version of your code running on staging, or in a Preview environment, and you want to promote that code to production, you simply deploy same image to production, with the production set of environment variables. With a populate-on-build system like Reacts', you can not do this. The staging environment variables are baked in to the image, so it can not be deployed to production.

I use dynamic, ephemeral environments for a lot of my development and review work. These are dynamic environments that spin up with a unique environment, so being able to provide run time configuration was extremely important.

This post focuses on serving a React App from a container image. The same issues apply to other deployment mechanisms.

I found 3 solutions:

  1. Refactor my React application to fetch the current environment’s configuration on start up.
  2. Serve the React app non-statically, and template the file on each request.
  3. Template the environment into the application on container start up.

Option 3 is the closest to the default behaviour, and I was lucky enough to find this post, where the author tool a similiar approach, and had open-sourced a tool for injecting the environment variables. All I needed to do was run this tool before serving my files, and update my process.env calls to window._jsenv with a fallback (There is a more detailed explanation of this here).

Running runtime-js-env

I serve my applications via a populated Nginx image deployed in Kubernetes, and was already using a multi-stage build for this purpose:

# Build the React app with Yarn
FROM node:13.12.0-alpine as build
WORKDIR /app
ENV PATH /app/node_modules/.bin:$PATH
COPY package.json ./
RUN npm install yarn
RUN yarn install
COPY . ./
RUN yarn run build

# Copy the built application into Nginx for serving
FROM nginx:stable-alpine
COPY --from=build /app/build /usr/share/nginx/html
COPY --from=go-downloader /go/bin/runtime-js-env /

COPY docker-nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

I need to extend this to do the following:

  • Download runtime-js-env
  • Execute runtime-js-env every time the container starts

Getting the binary to execute is straight forward:

FROM golang:1.15.6-alpine3.12 as go-downloader
RUN apk update && apk upgrade && \
    apk add --no-cache bash git openssh
RUN go get github.com/nrmitchi/runtime-js-env

Note that github.com/nrmitchi/runtime-js-env is forked from the original version at github.com/lithictech/runtime-js-env. This was due to a bug that I need to fix. More on that below.

I can add this in to my existing multi-stage build, and copy the built file in to my final image.

Regarding executing the tool on start up, the nginx image has an entrypoint which will execute any scripts under /docker-entrypoint.d/. This is perfect for our use case.

We can drop the following script in to that directory, and it will execute any time the container starts:

/runtime-js-env -i /usr/share/nginx/html/index.html && \
  chmod 644 /usr/share/nginx/html/index.html

The chmod command is necessary due to a bug with runtime-js-env mangling file permissions. This can be removed when this bug is fixed.

We’ll also need to ensure the script is executable:

chmod a+x /docker-entrypoint.d/docker-nginx-startup-runtime-env.sh

Final Dockerfile

# Build the React app with Yarn
FROM node:13.12.0-alpine as build
WORKDIR /app
ENV PATH /app/node_modules/.bin:$PATH
COPY package.json ./
RUN npm install yarn
RUN yarn install
COPY . ./
RUN yarn run build

# Download and build our environment injector
FROM golang:1.15.6-alpine3.12 as go-downloader
RUN apk update && apk upgrade && \
    apk add --no-cache bash git openssh
RUN go get github.com/nrmitchi/runtime-js-env

# Copy the built application into Nginx for serving
FROM nginx:stable-alpine
COPY --from=build /app/build /usr/share/nginx/html

# Copy the runtime-js-env binary
COPY --from=go-downloader /go/bin/runtime-js-env /

COPY docker-nginx.conf /etc/nginx/conf.d/default.conf

# Add our startup script
RUN echo "/runtime-js-env -i /usr/share/nginx/html/index.html && chmod 644 /usr/share/nginx/html/index.html" > /docker-entrypoint.d/docker-nginx-startup-runtime-env.sh
RUN chmod a+x /docker-entrypoint.d/docker-nginx-startup-runtime-env.sh

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Now we have an image that we can deploy into multiple environments, and configure with environment variables at run time. The exact set of environment variables which will be injected into the React app is configurable, but defaults to anything matching REACT_APP_*.


Hopefully this works out for you, or was at least helpful. If you have any questions, don’t hesitate to shoot me an email, or follow me on twitter @nrmitchi.