React environment variables in an nginx container
Your React app is ready to ship. Congratulations!
Packaging for production is (and should) be different from your development configuration.
In the case of Create React App the toolchain is rich, includes development productivity conveniences such as hot reloading, source maps and custom environment variables.
This toolchain is mind blowingly productive as you develop the app, npm start
and watch the magic unfold.
At this point, its possible to put the React app one big (~1.7GB) happy container:
FROM node:latest
WORKDIR /
COPY package*.json ./
RUN npm install --legacy-peer-deps
COPY . .
EXPOSE 3000
CMD [ "npm", "start" ]
Why ship the complete development toolchain (such as webpack, eslint, babeljs) and all the source code out to customers in a production build?
Its time to put the runtime container on a diet.
Create React App provides an npm task
for this very purpose called build
. It instructs node
and webpack
to prepare a production bundle.
The output of build
is a big ball of minified, tree shaken, optimised, transpiled JS, CSS and HTML. Not intended for human consumption, but perfect for serving up as static assets. Pick your favourite httpd
such as nginx:alpine
:
FROM node:latest as build
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --legacy-peer-deps
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /usr/src/app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
It turns out the nginx:alpine
container image is WAY WAY faster, and WAY WAY WAY (98.5%) smaller at 27MB.
However, one disappointing trade-off is that support for managing custom environment variables drops off, with the loss of the development toolchain.
The documentation highlights this:
The environment variables are embedded during the build time. Since Create React App produces a static HTML/CSS/JS bundle, it can’t possibly read them at runtime. To read them at runtime, you would need to load HTML into memory on the server and replace placeholders in runtime, as described here. Alternatively you can rebuild the app on the server anytime you change them.
In a nutshell this suggests using global window
variables in the base page, and replacing placeholders at runtime. For example:
<!DOCTYPE html>
<html lang="en">
<head>
<script>
window.API_URI = "$API_URI";
window.CONFLUENCE_URI = "$CONFLUENCE_URI";
window.INTRANET_URI = "$INTRANET_URI";
// for local development only - this wont affect production builds
if (window.API_URI.includes("API_URI")) {
window.API_URI = "http://localhost:5000/api";
}
</script>
</head>
</html>
Given this is running from a spartan alpine
base image, I opted to live off the land and use sed
to do this find and replace work. Using the -e
switch sed
can read env vars:
#!/bin/sh
# Substitute container environment into production packaged react app
# CRA does have some support for managing .env files, but not as an `npm build` output
# To test:
# docker run --rm -e API_URI=http://localhost:5000/api -e CONFLUENCE_URI=https://confluence.evilcorp.org -e INTRANET_URI=https://intranet.evilcorp.org -it -p 3000:80/tcp dam-frontend:latest
cp -f /usr/share/nginx/html/index.html /tmp
if [ -n "$API_URI" ]; then
sed -i -e "s|REPLACE_API_URI|$API_URI|g" /tmp/index.html
fi
if [ -n "$CONFLUENCE_URI" ]; then
sed -i -e "s|REPLACE_CONFLUENCE_URI|$CONFLUENCE_URI|g" /tmp/index.html
fi
if [ -n "$INTRANET_URI" ]; then
sed -i -e "s|REPLACE_INTRANET_URI|$INTRANET_URI|g" /tmp/index.html
fi
cat /tmp/index.html > /usr/share/nginx/html/index.html
Finally its simply a matter of invoking this shell script set-env.sh
as part of the CMD
directive in the Dockerfile
, like so:
FROM node:latest as build
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --legacy-peer-deps
COPY . .
RUN npm run build
FROM nginxinc:nginx-unprivileged:alpine
COPY --from=build /usr/src/app/build /usr/share/nginx/html
EXPOSE 8080
CMD ["sh", "-c", "cd /usr/share/nginx/html/ && ./set-env.sh && nginx -g 'daemon off;'"]
To get set-env.sh
in the container, I lazily put the set-env.sh
script into the public
folder within the React source tree. npm run build
automatically puts all assets in public
into the output build directory. You could of course run a second COPY
directive in the Dockerfile
. Your choice.