I used to frequently run in to problems building CI / CD pipelines at both my previous and my current organization.
Often times, I would be building a website that would need to be deployed multiple times, either internally or externally. Years ago, when I was researching how to support rollouts like this online, the general guidance that I found was that I should be creating a .env file for each environment, and then letting my build process sub in those environment variables.
Years ago, the general advice I came across was to use a .env
file for each environment, and have the build process substitute the appropriate environment variables. While this worked in some cases, I quickly realized that managing separate builds for each environment was inefficient. Especially when deploying the same website to multiple locations. This approach was increasing the cost of our rented CI/CD agents and artifact storage, and adding unnecessary complexity to our workflows
One possible solution that we piloted was to try to load the configuration into an API that would be fetched at runtime. While this seemed like a viable solution, it felt problematic. The primary issue was that we'd need to run an additional fetch call in our app, which slowed down our load times. Plus, we had to modify every API request or introduce guards to ensure that the configuration was loaded beforehand. This added unnecessary complexity and potential failure points.
What I eventually settled on was creating a JavaScript file to inject these settings into globalThis. By including this file at the start of the document, we ensure that the configuration is available as soon as the app code begins executing. This method provides a few key benefits:
Single Build, Multiple Deployments: Since you only need to run one build, you can generate your deployment artifacts once, and then customize the settings for each environment by using unique
settings.js
files for each deployment.No Need for Extra Checks: With the configuration already loaded when the app starts, there's no need to worry about checking whether the configuration has been successfully loaded. The app can rely on it being available immediately, which simplifies the code and removes unnecessary validation steps."
<!-- index.html -->
<head>
<meta charset="UTF-8" />
<link rel="icon" href="./assets/favicon.svg" type="image/x-icon" />
<script src="/settings.js"></script>
<script src="/main.js"></script>
<!-- remaining site assets ... -->
</head>
Here, you can see that the settings.js
file is loaded in the same breath as the main.js
file, inside of that settings.js
file we have the following code:
// settings.js
const siteSettings = Object.freeze({
// sso
clientId: '<client-id>',
tenantId: '<tenant-id>',
audienceId: '<audience-id>',
// api
apiAddress: '<api-address>',
// auditing
metricsEndpoint: '<metrics-endpoint>'
});
globalThis.configuration = siteSettings;
When this script is loaded, it declares a new property against globalThis which holds all of our site specific configuration.
A few notes that I should add:
NEVER EVER store sensitive configuration information in your settings.js files - If you do this, it's essentially broadcasting your secrets to the world. This probably goes without saying, but its better that I spell this out before someone blames me for their secrets getting leaked.
Potential Namespace Pollution - I recommend that you choose a unique name (not configuration) when assigning to globalThis or window. There is no guarantee that one of your dependencies hasn't decided that they want to use globalThis.configuration
to hold some global state that it needs. You should use a globalThis
key that gives you reasonable assurance that no other code is writing to it. Think globalThis.<my-app-name>_settings
.
Keep it lean - don't put anything in your settings.js
file that you wouldn't put in a .env
file. This file should be lightweight, no massive base64 strings for your favicon, or other weird stuff like that. Keep it to keys and values so your users have no idea that there was a couple lines of extra JS loaded.
Conclusion
I've found this approach great for simplifying CI/CD pipelines. It has reduced the total executions of our build pipeline and made it much easier for us to manage our release builds.
It does come with trade-offs; there are some security risks (particularly with exposing sensitive configuration values) and potential conflicts in the global namespace. However, it is important to note that these are not risks introduced by the method, rather they are risks inherent to developing public applications.
By ensuring sensitive data is handled separately and by implementing best practices around naming and performance, these drawbacks can be mitigated.
As always, feel free to critique this in the comments. I am always happy to create revisions and offer corrections. I recognize that this is a rather niche option that most people wouldn't be able to take advantage of, but I still think it should be published for people with similar issues.
note - My organization uses a template settings.js
file and replaces the individual keys of the settings using environment variables. If there is interest in this approach I can post and link that approach as well.
Top comments (0)