Millions of front-end applications are managing environment-specific builds. For each environment — whether it's development, staging, or production — a separate build of the frontend app must be created, and the right environment variables must be set up. The number of builds multiplies if multiple apps are involved, adding to the frustration. This has been a common problem for a long time, but there's a better way to handle environment variables. I’ve found a way to streamline this process, and in this article, I will guide you step-by-step to create an efficient process that will reduce build times and help you ensure consistency across environments in your projects.
Before we start, I think we should do a recap. Web applications almost always rely on variables known as "environment variables", which often include internal system endpoints, integration systems, payment system keys, release numbers, and so on. Naturally, the values of these variables differ depending on the environment in which the application is deployed.
For example, imagine an application that interacts with a payment gateway. In the development environment, the payment gateway URL might point to a sandbox for testing (https://sandbox.paymentgateway.com), while in the production environment, it points to the live service (https://live.paymentgateway.com). Similarly, different API keys or any other environment specific setting are used for each environment to ensure data security and avoid mixing up environments.
When building backend applications, this is not an issue. Declaring these variables in the application code is enough since the values of these variables are stored in the server environment where the backend is deployed. This way, the backend application accesses them at startup.
However, with frontend applications things get somewhat more complicated. Since they are run in the user's browser, they do not have access to specific environment variable values. To tackle this, the values of these variables are typically "baked into" the frontend application at build time. This way, when the application runs in the user's browser, all the necessary values are already embedded in the frontend application.
This approach, like many others, comes with a caveat: you need to create a separate build of the same frontend application for each environment so that each build contains its respective values.
For example, let's say we have three environments:
development for internal testing;
stage for integration testing;
and production for customers.
To submit your work for testing, you build the app and deploy it to the development environment. After internal testing is complete, you need to build the app again to deploy it to stage, and then build it yet again for deployment to production. If the project contains more than one front-end application, the number of such builds increases significantly. Plus, between these builds, the codebase is not changing — the second and third builds are based on the same source code.
All of this makes the release process bulky, slow, and costly, as well as poses a quality assurance risk. Maybe the build was well-tested in the development environment, but the stage build is technically new, meaning there's now fresh potential for error.
An example: You have two applications with build times of X and Y seconds. For those three environments, both apps would take 3X + 3Y in build time. However, if you could build each app just once and use that same build across all environments, the total time would be reduced to just X + Y seconds, cutting the build time threefold.
This can make a big difference in frontend pipelines, where resources are limited and build times can range from just a few minutes to well over an hour. The issue is present in almost every frontend application globally, and often there is no way to solve it. Yet, this is a serious problem, especially from a business perspective.
Wouldn’t it be great if instead of creating three separate builds, you could just make one and deploy it across all environments? Well, I’ve found a way to do exactly that.
Setting up environment variables
First, you need to create a file in your frontend project’s repository where the required environment variables will be listed. These will be used by the developer locally. Typically, this file is called .env.local
, which most modern frontend frameworks can read. Here’s an example of such a file:
CLIENT_ID='frontend-development'
API_URL=/api/v1'
PUBLIC_URL='/'
COMMIT_SHA=''
Note: different frameworks require different naming conventions for environment variables. For example, in React, you need to prepend REACT_APP_
to the variable names. This file doesn’t necessarily need to include variables that affect the application directly; it can also contain helpful debugging information. I added the COMMIT_SHA
variable, which we’ll later pull from the build job to track the commit this build was based on.
Next, create a file called environment.js
, where you can define which environment variables you need. The frontend framework will inject these for you. For React, for example, they are stored in the process.env
object:
const ORIGIN_ENVIRONMENTS = window.ORIGIN_ENVIRONMENTS = {
CLIENT_ID: process.env.CLIENT_ID,
API_URL: process.env.API_URL,
PUBLIC_URL: process.env.PUBLIC_URL,
COMMIT_SHA: process.env.COMMIT_SHA
};
export const ENVIRONMENT = {
clientId: ORIGIN_ENVIRONMENTS.CLIENT_ID,
apiUrl: ORIGIN_ENVIRONMENTS.API_URL,
publicUrl: ORIGIN_ENVIRONMENTS.PUBLIC_URL ?? "/",
commitSha: ORIGIN_ENVIRONMENTS.COMMIT_SHA,
};
Here, you retrieve all the initial values for the variables in the window.ORIGIN_ENVIRONMENTS
object, which allows you to view them in the browser’s console. Plus, you need to copy them into the ENVIRONMENT
object, where you can also set some defaults, for instance: we assume publicUrl
is / by default. Use the ENVIRONMENT
object wherever these variables are needed in the application.
At this stage, you’ve fulfilled all needs for local development. But the goal is to handle different environments.
To do this, create a .env
file with the following content:
CLIENT_ID='<client_id>'
API_URL='<api_url>'
PUBLIC_URL='<public_url>'
COMMIT_SHA=$COMMIT_SHA
In this file, you’ll want to specify placeholders for the variables that depend on the environment. They can be anything you want, as long as they are unique and do not overlap with your source code in any way. For extra assurance, you can even use
For those variables that don't change across environments (e.g., the commit hash), you can either write the actual values directly or use values that will be available during the build job (such as $COMMIT_SHA). The frontend framework will replace these placeholders with actual values during the build process:
File
Now you get the opportunity to put in real values instead of the placeholders. To do this, create a file, inject.py
(I chose Python, but you can use any tool for this purpose), which should first contain a mapping of placeholders to variable names:
replacement_map = {
"<client_id>": "CLIENT_ID",
"<api_url>": "API_URL",
"<public_url>": "PUBLIC_URL",
"%3Cpublic_url%3E": "PUBLIC_URL"
}
Note that public_url
is listed twice, and the second entry has escaped brackets. You need this for all variables that are used in CSS and HTML files.
base_path = 'usr/share/nginx/html'
target_files = [
f'{base_path}/static/js/main.*.js',
f'{base_path}/static/js/chunk.*.js',
f'{base_path}/static/css/main.*.css',
f'{base_path}/static/css/chunk.*.css',
f'{base_path}/index.html'
]
injector.py
file, where we will receive the mapping and list of build artifact files (such as JS, HTML, and CSS files) and replace the placeholders with the values of the variables from our current environment:import os
import glob
def inject_envs(filename, replacement_map):
with open(filename) as r:
lines = r.read()
for key, value in replacement_map.items():
lines = lines.replace(key, os.environ.get(value) or '')
with open(filename, "w") as w:
w.write(lines)
def inject(target_files, replacement_map, base_path):
for target_file in target_files:
for filename in glob.glob(target_file.glob):
inject_envs(filename, replacement_map)
And then, in the inject.py
file, add this line (do not forget to import injector.py
):
injector.inject(target_files, replacement_map, base_path)
inject.py
script runs only during deployment.You can add it to the Dockerfile
in the CMD
command after installing Python and copying all artifacts:RUN apk add python3
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/ci /ci
COPY --from=build /app/build /usr/share/nginx/html
CMD ["/bin/sh", "-c", "python3 ./ci/inject.py && nginx -g 'daemon off;'"]That's it! This way, during each deployment, the pre-built files will be used, with variables specific to the deployment environment injected into them.
That's it! This way, during each deployment, the pre-built files will be used, with variables specific to the deployment environment injected into them.
File:
One thing – if your build artifacts include a content hash in their filenames, this injection will not affect the filenames and this could cause problems with browser caching. To fix this, after modifying the files with injected variables, you’ll need to:
To implement this, add a hash library import (import hashlib
) and the following functions to the inject.py
file.
def sha256sum(filename):
h = hashlib.sha256()
b = bytearray(128 * 1024)
mv = memoryview(b)
with open(filename, 'rb', buffering=0) as f:
while n := f.readinto(mv):
h.update(mv[:n])
return h.hexdigest()
def replace_filename_imports(filename, new_filename, base_path):
allowed_extensions = ('.html', '.js', '.css')
for path, dirc, files in os.walk(base_path):
for name in files:
current_filename = os.path.join(path, name)
if current_filename.endswith(allowed_extensions):
with open(current_filename) as f:
s = f.read()
s = s.replace(filename, new_filename)
with open(current_filename, "w") as f:
f.write(s)
def rename_file(fullfilename):
dirname = os.path.dirname(fullfilename)
filename, ext = os.path.splitext(os.path.basename(fullfilename))
digest = sha256sum(fullfilename)
new_filename = f'{filename}.{digest[:8]}'
new_fullfilename = f'{dirname}/{new_filename}{ext}'
os.rename(fullfilename, new_fullfilename)
return filename, new_filename
Yet, not all files need to be renamed. For example, the index.html
filename must remain unchanged and to achieve this, create a TargetFile
class that will store a flag indicating if renaming is necessary:
class TargetFile:
def __init__(self, glob, should_be_renamed = True):
self.glob = glob
self.should_be_renamed = should_be_renamed
Now you just have to replace the array of file paths in inject.py
with an array of TargetFile
class objects:
target_files = [ injector.TargetFile(f'{base_path}/static/js/main.*.js'),
injector.TargetFile(f'{base_path}/static/js/chunk.*.js'),
injector.TargetFile(f'{base_path}/static/css/main.*.css'),
injector.TargetFile(f'{base_path}/static/css/chunk.*.css'),
injector.TargetFile(f'{base_path}/index.html', False)
]
And update the inject
function in injector.py
to include renaming the file if the flag is set:
def inject(target_files, replacement_map, base_path):
for target_file in target_files:
for filename in glob.glob(target_file.glob):
inject_envs(filename, replacement_map)
if target_file.should_be_renamed:
filename, new_filename = rename_file(filename)
replace_filename_imports(filename, new_filename, base_path)
As a result, the artifact files will follow this naming format: <origin-file-name>
.<injection-hash>
.<extension>
.
File name before injection:
File name after injection:
The same environment variables yield the same file name, allowing the user's browser to cache the file correctly. There’s now a guarantee that the correct values of these variables will be stored in the browser cache, as a result – better performance for the client.
The traditional approach of creating separate builds for each environment has led to a few critical inefficiencies, which can be an issue for teams with limited resources.
You now have a blueprint for a release process that can solve prolonged deployment times, excessive builds, and increased risks in quality assurance for frontend applications – all of it. All while introducing a new level of guaranteed consistency in all environments.
Instead of needing N builds, you’ll only need one. For the upcoming release, you can simply deploy the build that was already tested, which also helps resolve potential bug issues since the same build will be used across all environments. Plus, the execution speed of this script is incomparably faster than even the most optimized build. For example, local benchmarks on a MacBook 14 PRO, M1, 32GB are as follows:
My approach simplifies the release process, maintains application performance by allowing for effective caching strategies, and ensures that build-related bugs won’t make it into the environments. Plus, all the time and effort previously spent on tiresome build tasks can now be focused on creating an even better user experience. What’s not to love?
We ensure that build-related bugs don’t slip into the app for other environments. There can be phantom bugs that show up due to imperfections in the build systems. The chances are slim, but they do exist.