purple abstract decorative image

React Applications: Build Once, Deploy Anywhere in React

As a DevOps Engineer at adjoe, I’ve recently had to deal with the deployments of multiple React applications from one monorepo. These React applications get deployed to multiple environments – for example, development, sandbox, staging, and production.

These React apps then run on 

  • dev.example.com/react-app-1
  • dev.example.com/react-app-2
  • prod.example.com/react-app-1

And so on.

What Was the Problem?

All of these React applications were created following the create-react-app (CRA) approach, which, at the time, was the recommended way to start developing these applications.

The problem was that the builds for these React applications were environment-specific. This meant that when deploying to dev, we had to build the application for dev and could not deploy this same build to staging or production.

Each deployment of our application required a new build, which costs our developers time and unnecessarily increases the load on our pipeline infrastructure.

Diagram showing the initial pipeline with tightly coupled build and deploy stage

In this process, we are also rejecting the principle of strictly separating build and run stages from the Twelve-Factor-App


This has led to more clutter in our container registry, as there has needed to be a version of the application for each environment. A deployment to sandbox would then have to grab the app-sandbox version from the container registry. Otherwise – for example – the app-staging image would not work when deployed to sandbox.

Diagram showing the initial state of our ECR container registry

Why Were the Builds Environment-Specific?

Have you ever wondered how environment variables work for client-side (JavaScript) applications? Well, they don’t. There are simply no environment variables. Environment variables are defined in the environment that an application runs in – most of the time, in some linux server – but for client-side applications, it’s the browser, and there are no environment variables we can set here.

But, that might leave you wondering: “What about REACT_APP_ prefixed environment variables?” which the create-react-app official documentation mentions. Even inside the source code, these are used as process.env. This tells us that these are clearly environment variables, right? Wrong!

In reality, the object “process” does not exist inside the browser environment; it’s node-specific.  create-react-app (CRA) by default doesn’t do server-side rendering. It can’t inject environment variables during content serving. During the build process, Webpack replaces all occurrences of process.env with a given string value. This means it can only be configured during build time.

The build and deploy in this pipeline are tightly coupled because we include npm run build in our Dockerfile, and Webpack hard-codes the environment variables of this moment into the JavaScript code bundle. Thus when building one of these applications for sandbox, we would hard-code sandbox-specific environment variables into the JavaScript bundle. This means you cannot reuse the same image for different environments, even if it actually contains the same code.

How Can We Work Around That?

Instead of incorporating these variables into the build process, we define them at the very last possible moment, at the start of the container.

To do this, we change the entry point of the docker container to a bash script. This will read these variables from the environment that the container runs in. In our case, that’s AWS ECS, and it creates a JavaScript file.

#!/bin/bash

echo "window.env = {" >> ./react-app/env-config.js

while read -r line || [[ -n "$line" ]];
do
  if printf '%s\n' "$line" | grep -q -e '='; then
    varname=$(printf '%s\n' "$line" | sed -e 's/=.*//')
    varvalue=$(printf '%s\n' "$line" | sed -e 's/^[^=]*=//')
  fi
  value=$(printf '%s\n' "${!varname}")
  [[ -z $value ]] && value=${varvalue}
  echo "  $varname: \"$value\"," >> ./react-app/env-config.js
done < .env

echo "}" >> ./react-app/env-config.js

Running this script with the environment variables REACT_APP_ENV_URL and REACT_APP_ENV present will result in the file env-config.js being created with content such as the following, depending on the values of the variables:

// env-config.js
window.env = {
  REACT_APP_ENV_URL: "https://sandbox.example.com",
  REACT_APP_ENV: "sandbox",
}

We then load these variables in our index.html file as the very first thing, which makes them available to the rest of the application.

<script src="%PUBLIC_URL%/env-config.js"></script>


Throughout the rest of the code, we can access these variables now with window.env.REACT_APP_ENV. This way, the variables are present at the start of the application, but we don’t need to set them during the build process anymore.

What About Local Development?

During the development process, our developers run the application locally using Docker Compose. So, here we have to find an alternative way to set these variables, as we can’t utilize the ECS task definition here. 

The following code snippet shows a part of our docker-compose.yml file, where you can see that we also set the environment variables here.

react-app:
    build:
      context: .
      dockerfile: ./react-app/docker/Dockerfile
    command: yarn run start:dev
    ports:
      - 5040:3001
    environment:
      REACT_APP_ENV_URL: 'https://local.dsp-dashboard.adjoe.zone'
      REACT_APP_ENV: 'development'

We then make sure that our env.sh script from before is the first thing that runs once the container starts by adjusting the start:dev command in our package.json file.

Without this change, start:dev would otherwise execute the following:

"start:dev": "yarn install; yarn start"

Now we’ve modified the command to first execute the new env.sh script and to copy the result into the public directory.

"start:dev": "chmod +x ./env.sh && ./env.sh && cp env-config.js ./public/ && yarn install; yarn start"

What Is the Result?

This change subsequently benefits us in two ways:

  • We have automatic builds on push
  • We can deploy to multiple environments

Automatic builds on push: Since builds were previously part of deployment, we wouldn’t trigger them automatically. With only the tests running automatically on new commits coming in, it was possible for a change to be included in our main branch that made our builds fail. Now, after this change, our builds are run automatically as well. A change that fails to be built would be caught long before it reaches the main branch.

Deploying to multiple environments: When a build passes, it can now be deployed to multiple environments seamlessly. This saves precious time for our developers.

Diagram showing the final architecture of the deployment pipeline

And, of course, the container registry is now also cleaner, as there is only one image of the React application. This is still with different tags based on the branch.

Diagram showing the state of our container registry after the clean-up

Lessons Learned

To sum it all up, we learned that there are generally no environment variables in the browser. This makes it complicated to build client-side applications that can be deployed to multiple environments, such as development, staging, and production. We also found a way around this limitation by reading the environment variables at the start of the container, creating a JavaScript file containing these variables, and adding it to our bundle.

Our team’s key takeaways were as follows:

  • Always separate build and deploy stages
  • Environment variables don’t exist in the browser
  • Define environment-specific variables at (container) startup time

Cloud Engineering

Senior DevOps Engineer (f/m/d)

  • Full-time,
  • Hamburg

Conquer cloud technologies at adjoe

See vacancies