Bjeaurn

Runtime Environments in Angular and why they matter

– 6 minutes read – AngularArchitectureFrontend Engineering

Angular is an opinionated framework that comes with the batteries included. It has strong opinions and best practices about almost everything you as a developer may run into. But one thing I keep running into in a corporate environment is that the default compilation assumes only compile time environment differences.

Compile time environments

In Angular, you can have multiple environment.ts files for different occasions. The default production build enables a bunch of optimizations and makes the production bundle ready for deployment. Although, since version 15, they do not come autogenerated anymore for new projects. (You can add it back using ng g environments, see Angular CLI)

You can add heaps of variables here, like baseUrls or API endpoints, configurations for authentication, and so on, that then can be used throughout the application.

These variables are determined at compile time, and the angular.json configuration allows you to swap in different files at compile time. The final result is then bundled as part of your application. For most use cases, this is great!

But what if you have some configuration required in your app, that you can’t predict or that changes depending on where you are deployed? What if you want a single “build artifact” to be promoted from your Dev environment to your Testing/Acceptation and finally Production environment?

Runtime environments

When you want to be able to supply your application with a configuration that is different per deployment, you need a runtime environment or configuration you can set.

This allows you to compile a single-build artifact, yet have the application behave differently depending on where you deploy it and with which configuration you boot it. For example, when you’re running in a containerized environment or on one of the bigger public cloud providers, the URLs to your backend might be different per environment.

Creating a runtime configuration

To begin creating a runtime configuration:

  1. create a new folder called config. Although I’ve used names like environment in the past too. Use whatever matches your situation best.

Config

In the newly created config folder:

  1. Create a config.ts for our type, so we can make our configuration usage more strongly typed throughout the application.
  2. In this same file, I also like to create a const APP_CONFIG InjectionToken, which we can provide upon starting the application.
src/app/config/config.ts
import { InjectionToken } from '@angular/core';

export type Config = {
  apiUrl: string;
  // Set this up in any way you like!
};

export const APP_CONFIG: InjectionToken<Config> = new InjectionToken<Config>(
  'Application Config'
);

InjectionToken is an Angular concept that allows you to manually define a string token that you can offer to the Dependency Injection system, to be used throughout your application.

Creating the assets

Next, we’ll need to create some assets that’ll contain our configuration.

  1. In the src/ folder, create a new folder called config or something similar to reflect your naming convention.
  2. Inside this new folder, I create a new file: config.json
  3. In the angular.json, add the path to src/config to the assets key:
angular.json
	...
	"assets": [
		"src/favicon.ico",
		"src/assets",
		"src/config" // <--- Add this line and make sure it points to your folder.
	],
	"styles": [
		"src/styles.css"
	],
	...

This will make sure that during the build stage, this folder is considered an asset and will be copied along into the final artifact.

Of course, you can create more or rename them to fit your situation better. I tend to keep them simple.

  1. In any config files you create, make sure you adhere to your Config type:
src/config/config.json
{
	"apiUrl": "localhost:3000"
}

Update the main.ts

Now we update the main.ts file, so it’ll load our configuration before it boots the app. By using the InjectionToken here, we can make our configuration available to the whole application.

src/main.ts
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
import { APP_CONFIG } from './app/config/config';

fetch('./config/config.json')
	.then((resp) => resp.json())
	.then((config) => {
		platformBrowserDynamic([{ provide: APP_CONFIG, useValue: config }])
		.bootstrapModule(AppModule)
		.catch((err) => console.error(err));
	});

We use fetch because this is initialized and run on the client before Angular has even fired. This means we cannot use Angular’s HttpClient or any of the tools that the Angular framework offers.

The platformBrowserDynamic can be provided with an array of providers, which will guarantee they exist upon starting of the application, unlike the APP_INITIALIZER. This way our config file is downloaded, converted to JSON, and provided as a token to the Angular framework before the framework is even booted.

This will guarantee that our configuration is available at start if the file is present.

Anywhere in the app we can now inject our APP_CONFIG to retrieve the current runtime configuration. To demonstrate this, we can load it up in our app.component and watch it download the configuration file and show the data on runtime.

src/app/app.component.ts
export class AppComponent {
	constructor(@Inject(APP_CONFIG) readonly config: Config) { 
		console.log(this.config);

		// Or you can use the Angular native inject() function 
		const config = inject(APP_CONFIG);
		console.log(config);
	}
}

A note on security

You might go and say, but now all my configuration is out in the open! And you’d be right! But the compile time configuration is also out in the open, very much in the same way. It’s maybe a little obfuscated, but if you’re trying to get information it’s really not that deeply hidden within your code.

In any case, for any frontend application, the rules are very simple: Any information you share with the client is considered public. If you have information and data that should be kept secret, don’t share it with your frontend client. No secrets, no private URLs, no username/password combinations. This should be obvious if you’re thinking about the security of your application.

Overwriting the config file for runtime purposes

Now when you build the app with ng build, you’ll see in the dist/ folder (by default) a new config/ folder, containing your configuration file(s). Wherever you want to deploy your app, you can make sure that the config files in that folder are correct and will override the original. You could even generate this in stacks that have an orchestrator like Kubernetes!

This means you can plug it into any CI/CD pipeline you may be using, update the file with some secrets contained in those pipelines and deploy. Or you could manually manage a file that is automatically copied. The possibilities to achieve this are endless! (And therefore, a bit out of scope for this article).

In conclusion & notes

Having runtime environment variables allows you to build once and deploy everywhere, versus having to rebuild different artifacts for different purposes. This is more in line with what I expect from a more conventional enterprise approach to deployments. As such I have made an example repository with a freshly generated Angular app that has been modified to use runtime configurations, versus only having compile time environment variables.

Because I’ve run into more complex situations in enterprise environments with these similar issues, I wrapped the configuration in a ConfigService in the example repository. This is to abstract the link between the application’s services that consume the configuration and the API of the configuration.

In part 2 of this article, I will dive deeper into this more advanced topic and cover things like testing and multiple environments for development and testing purposes. Stay tuned!

If you have any questions, suggestions or would like to discuss; you can reply on Twitter