Introduction
In this post I'm going to show how we can keep the configuration external to an Angular application. Ideally, you want to build your application once and just apply different configurations based on the environment the application is running in.
I will show a couple of different techniques, but you do not need to use all of them. I just thought they'd be good to mention. Not everything will be explained in detail, but there is a repository with a working example.
We will see an Angular application, running either in a Docker container or locally using ng serve
, loading its configuration from an external source and environment variables.
This was all inspired by this comment by Lars Gyrup Brink Nielsen on this blog post by Kevin Kreuzer.
The "Twelve-Factor App"
The Twelve-Factor App methodology is a set of best practices, designed to enable applications to be built with portabililty and resilience, when deployed to the web.
Factor number three is called "Config", and simply states: "Store config in the environment".
So what are the benefits of doing that? Well, it enables "Build once. Run everywhere", meaning that you can reuse the same artefacts produced by a single build in dev, test, staging, qa, pre-prod, prod, etc. This can make you confident the exact same version of the build you tested in pre-prod will be the one running in prod as well. But this can only be possible if none of your configuration is hard coded and can be loaded by the application from an external source. For example from environment variables.
The default Angular configuration
When you create a new angular application using ng new
, it will create environment.ts
and environment.prod.ts
. The idea is that you can have your development config in the first, and then overwrite the things you need in production using the latter.
The problem with this approach is that you decide at build time what configuration to use. So if you build using ng build --configuration=development
, then environment.ts
will be bundled inside your build. And likewise, you have to use ng build --configuration=production
to get a build where environment.prod.ts
is bundled into the build.
So you can't just build once and use the outputted artefacts for all your environments, since the configuration is environment dependent and bundled into the artefacts. This goes against the Twelve-Factor App methodology.
Load the configuration from an external source
So what can we do then? Well, the solution is to not depend on the bundled environment.ts
but to load the configuration from an external source at startup instead.
The first thing we can do is to delete environment.ts
and environment.prod.ts
, we don't need them anymore.
The next step is to define some kind of replacement for environment.ts
that we can use instead, let's call it configuration.ts
:.
import { InjectionToken } from "@angular/core";
export interface Configuration {
production: boolean
myCustomSetting: string
}
// We use a dependency injection token to access the configuration in our application.
export const configurationToken = new InjectionToken('Configuration');
It's basically an interface defining what our configuration looks like, and an InjectionToken
that we can use to access the configuration.
Then, we make some modifications to main.ts
:
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
import { configurationToken } from './configuration/configuration';
// Platform creation and bootstrapping of the application is delayed until we have loaded the configuration file.
// The contents of the configuration file will be replaced (in Dockerfile) based on environment
let configurationPath = '/configuration/configuration.json';
fetch(configurationPath)
.then(response => response.json())
.then(configuration => {
if (configuration.production) {
enableProdMode();
}
return platformBrowserDynamic([
{ provide: configurationToken, useValue: configuration },
]).bootstrapModule(AppModule);
})
.catch(error => console.error(error));
The changes we have made are to not bootstrap the application until we have fetched configuration.json
from a remote source. (We are basically bootstrapping the application in the callback of an HTTP request). When we call platformBrowserDynamic()
we also wire up the dependency injection to say that the configurationToken
should return the value of configuration.json
.
The next thing we need to do is make sure that we use this configuration instead of environment.ts
. Here is an example on how to access it in app.component.ts
:
import { Component, Inject } from '@angular/core';
import { Configuration, configurationToken } from 'src/configuration/configuration';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'angular-config';
myCustomSetting;
constructor(@Inject(configurationToken) private configuration: Configuration) {
this.myCustomSetting = configuration.myCustomSetting
}
}
Now we just need to make sure we can control the contents of that configuration.json
file. We'll get to that shortly. But first, here's an example of what that file will look like (it should match the format of configuration.ts
):
{
"production": false,
"myCustomSetting": "Hello from Development"
}
Dockerize an Angular application
Creating a Docker image for your Angular application is a great way to make sure you get the exact same experience no matter where you run it. I won't go into too much detail, but here is an example of a working Dockerfile that builds your application and then copies it to a base image with nginx running on Alpine Linux:
# This is the base of our final image, has nginx.
FROM nginx:1.19-alpine as nginx-base
COPY nginx.conf /etc/nginx/nginx.conf
# This is the image we use to build the angular application.
FROM node:12.18.2-alpine AS node-build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build
# Switch to the final runtime image.
FROM nginx-base as final
# Copy the outputs from the build.
COPY --from=node-build /app/dist/angular-config /usr/share/nginx/html
Change configuration based on environment
The whole point of this blog post was to be able to change configuration for an application based on the environment it runs in (without having to rebuild it). We can acheive this by making changes to configuration.json
before we start the Docker container.
I will show two different techniques. You can use whatever you like, or combine them both.
Bundle configs for all environments
The first option is one that I like, but that the "purists" might not. If at development time you know all the configuration for your different environments (and you have no secrets in there), then you can create separate config files for each environment: configuration.development.json
, configuration.production.json
, etc, and just bundle them all into your Docker image and choose which one to use at runtime. For example, if running the container like docker run -e ENVIRONMENT=Development my-angular-app
, then you'd want to copy the contents of configuration.development.json
into configuration.json
(inside the container) before starting the application. We can do this using a custom shell script as the entrypoint to our image.
Note: A varation of this would be to mount the config file into the container at runtime, instead of relying on a script to swap between bundled files. Depending on your CI/CD workflow this might be a better option.
Overwrite config values based on environment variables
The second option is one what might be more "pure". Basically the Docker image doesn't contain any configuration at all, and the contents of configuration.json
is only based on the environment variables passed in when starting the container. For example running the container like docker run -e SOME_ENV_VALUE_1=123 -e SOME_ENV_VALUE_2=Hello my-angular-app
should copy those env values into a configuration.json
file used by the application. This can also be solved by using a custom shell script as the entrypoint for our image.
The shell script
The example script shown here shows both thechniques described up there ☝️. So you can choose however you want to do.
#!/bin/sh
set -e
# Lowercase the environment name
ENV=$(echo $ENVIRONMENT | tr '[:upper:]' '[:lower:]')
# Copy the configuration file for the current environment
cp -f ./usr/share/nginx/html/configuration/configuration.${ENV}.json ./usr/share/nginx/html/configuration/configuration.json
# It is possible to overwrite configuration via environment variables.
# Input is of the form ANGULAR_CONFIG_APP_SETTING__VALUE=123
# It should replace { appSettings: { value: "123" }}
vars=$(set | awk -F '=' '
$1 ~ /^ANGULAR_CONFIG/ {
# Go from ANGULAR_CONFIG_APP_SETTING__VALUE to .app_setting.value
gsub("ANGULAR_CONFIG_", ".", $1);
gsub("__", ".", $1);
$1=tolower($1);
# Go from .app_setting.value to .appSetting.value (convert from snake case to camelcase)
# In our example the four matched groups will be: .ap, p, s, etting.value
# The third match should be uppsercased
while ( match($1, /(.*)(\w)_(\w)(.*)/, cap))
$1 = cap[1] cap[2] toupper(cap[3]) cap[4];
# Replace single quotes with double quotes
gsub("\x27", "\"", $2)
# Concatenate variable name with value.
# For example .appSetting.value=123
print "\x27"$1"="$2"\x27"
}')
for i in $vars
do
:
# Use jq to replace the values in the json file
# Need to use a tmp file since we can't replace in place
# https://stackoverflow.com/a/42718624/492067
tmp=$(mktemp)
echo "jq $i ./usr/share/nginx/html/configuration/configuration.json" | sh > "$tmp" && mv "$tmp" ./usr/share/nginx/html/configuration/configuration.json
done
# Set read permissions for everyone on the file
chmod +r ./usr/share/nginx/html/configuration/configuration.json
exit 0
There's quite a lot going on there, and I'm really no expert when it comes to shell scripts, so I've done what people do: Google and copy paste from Stack Overflow 😬.
The first part of the script demonstrates the first technique I mentioned. It will copy the correct config file based on the value of an environment variabled called ENVIRONMENT
. So for example running the container like this: docker run -e ENVIRONMENT=Development my-angular-app
would copy the contents of configuration.development.json
to configuration.json
. This is dependent upon you having bundled config files for all environments.
The second (longer) part of the script has a bit more stuff going on. The idea here is that cou can define multiple environment variables on the format ANGULAR_CONFIG_MY_APP_SETTING
that will be mapped into the configuration.json
file. It will even support nested objects by using double underscores (see the example in the script). We use jq
and gawk
for this, so we will have to install them in the Docker image as well. You don't need to understand the script in detail, just know that running the container like this: docker run -e ANGULAR_CONFIG_MY_APP_SETTING=123 my-angular-app
would be translated to a configuration.json
with { myAppSetting: 123 }
.
So how do we run this script when the container starts? The nginx image I'm using has a folder called docker-entrypoint.d
where there already are a couple of shell scripts that will be executed when the container starts. We can add our own custom script there.
To support this, we need to add some things to our Dockerfile. It should look something like this instead:
# This is the base of our final image, has nginx.
FROM nginx:1.19-alpine as nginx-base
COPY nginx.conf /etc/nginx/nginx.conf
# Install jq and gawk.
# We need this to be able to overwrite config based on environment variables.
RUN apk update && apk add jq=1.6-r1 gawk=5.1.0-r0
# This is the image we use to build the angular application.
FROM node:12.18.2-alpine AS node-build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build
# Switch to the final runtime image.
FROM nginx-base as final
# Copy and make the entrypoint script executable.
# This will be used to make sure we use the correct configuration (based on env variables).
COPY --from=node-build /app/entrypoint.sh docker-entrypoint.d/40-entrypoint.sh
RUN chmod +x docker-entrypoint.d/40-entrypoint.sh
# Copy the outputs from the build.
COPY --from=node-build /app/dist/angular-config /usr/share/nginx/html
With all of these things in place, we can now run our container like this
docker run -p 8080:80 -e ENVIRONMENT=Development -e ANGULAR_CONFIG_MY_CUSTOM_SETTING=123 my-angular-app
This will copy the contents of configuration.development.json
to configuration.json
and also add/overwrite myCustomSetting
in that file as well.
To me, this gives best of both worlds. We can have bundled default configuration for all environments, but we also have the option to overwrite a specific setting, if we'd like to do so.
Supporting running in development
What we have now works when running things in a Docker container, but if you run locally using ng serve
then you are depending on the extistance of a configuration.json
file with development config.
I prefer not having configuration.json
checked in into Git, and always auto generate it based on environment variables. So we need to find some solution for this to work in local development as well. Here's a snippet from my package.json
:
...
"scripts": {
...
"start": "npm run copy-config --environment=development && ng serve --open",
"copy-config": "cross-var cpy ./src/configuration/configuration.$npm_config_environment.json ./src/configuration --rename=configuration.json"
},
When I run npm run start
two things happen. First, we copy configuration.development.json
into configuration.json
. Then, we just run ng serve
like we normally would, now that there is a configuration.json
generated.
SSR
Just a quick note on Server Side Rendering. SSR is a whole other topic which I won't go into detail here, but if used, then the configuration has to be loaded "server side" and injected into the application when the application is bootstrapped in the main.server.ts
file as well.
When running server side, there is no fetch
defined, so we need to use something like node-fetch
to load the configuration instead, or fs
to load the file directly from disk.
Summary/Conclusion
What I've shown here is how to build an Angular application once, and how to apply different configuration based on the environment it runs in using environment variables. The things shown here should help you mix and match and find a solution that fits your needs.
Worth mentioning is that yet another alternative approach, not covered here, would be to generate the configuration, at startup, as a global object in index.html
so that it can be loaded immediately, synchronously and without an HTTP call in main.ts
.