Javascript is required
·
6 min read
·
32011 views

How To Build An Angular App Once And Deploy It To Multiple Environments

How To Build An Angular App Once And Deploy It To Multiple Environments Image

In my last projects, we always had the same requirement: Build the application once and deploy the same build fragment to multiple environments. This leads to some technical challenges, as we need to be able to inject environment-specific information to our application during runtime.

In this article, I want to propose some solutions to solve this problem.

Build once — deploy everywhere

Most software projects are done in an agile way so we often use Continous Delivery. The idea is to deliver releases in short cycles by an automated software release process. This process is realized by building a corresponding pipeline that typically checks out the code, installs dependencies, runs tests and builds a production bundle.

This build artifact is then passed through multiple stages where it can be tested. Followed is an exemplary stage setup:

  • DEV: development environment which is mainly used by developers. A new deployment is automatically triggered by pushing a commit to develop branch.
  • TEST: test environment which is mostly used for automated tests and user tests. A new deployment is automatically triggered by pushing a commit to master branch
  • STAGING: this environment should be as similar as possible as the PROD environment. It is used for final acceptance tests before a PROD deployment of the build artifact is manually triggered.
  • PROD: the "final" environment which is used by the customers, deployment is triggered manually

The following image shows this process as graphical representation:

Angular build Once Process

Why build once?

Of course, we could just rebuild our application for every environment in our pipelines. But, then there could be a chance that the build artifact on TEST is not the same as the one used in PROD. Unfortunately, a build process is not deterministic even if it is done in an automated pipeline as it depends on other libraries, different environments, operating systems, and environment variables.

The Challenge

Building only one bundle is quite easy but it leads to one big challenge we need to consider: How can we pass environment-specific variables to our application?

Angular CLI provides environment files (like environment.ts) but these are only used at build time and cannot be modified at runtime. A typical use-case is to pass API URLs for each stage to the application so that the frontend can talk to the correct backend per environment. This information needs to be injected into our bundle per deployment on our environments.

Backend services can read environment variables but unfortunately, the frontend runs in a browser and there exists no solution to access environment variables. So we need to implement custom solutions that I want to present to you in the next chapters.

Solution 1: Quick & dirty

This is the quickest but "dirtiest" way to implement runtime environment variables.

The idea is to evaluate the browser URL and set the variables according to this information at the application initialization phase using Angular's APP_INITIALIZER:

app.module.ts
1providers: [{
2    provide: APP_INITIALIZER,
3    useFactory: (envService: EnvService) => () => envService.init(),
4    deps: [EnvService],
5    multi: true
6  }],
env.service.ts
1export enum Environment {
2  Prod = 'prod',
3  Staging = 'staging',
4  Test = 'test',
5  Dev = 'dev',
6  Local = 'local',
7}
8
9@Injectable({ providedIn: 'root' })
10export class EnvService {
11  private _env: Environment
12  private _apiUrl: string
13
14  get env(): Environment {
15    return this._env
16  }
17
18  get apiUrl(): string {
19    return this._apiUrl
20  }
21
22  constructor() {}
23
24  init(): Promise<void> {
25    return new Promise((resolve) => {
26      this.setEnvVariables()
27      resolve()
28    })
29  }
30
31  private setEnvVariables(): void {
32    const hostname = window && window.location && window.location.hostname
33
34    if (/^.*localhost.*/.test(hostname)) {
35      this._env = Environment.Local
36      this._apiUrl = '/api'
37    } else if (/^dev-app.mokkapps.de/.test(hostname)) {
38      this._env = Environment.Dev
39      this._apiUrl = 'https://dev-app.mokkapps.de/api'
40    } else if (/^test-app.mokkapps.de/.test(hostname)) {
41      this._env = Environment.Test
42      this._apiUrl = 'https://test-app.mokkapps.de/api'
43    } else if (/^staging-app.mokkapps.de/.test(hostname)) {
44      this._env = Environment.Staging
45      this._apiUrl = 'https://staging-app.mokkapps.de/api'
46    } else if (/^prod-app.mokkapps.de/.test(hostname)) {
47      this._env = Environment.Prod
48      this._apiUrl = 'https://prod-app.mokkapps.de.de/api'
49    } else {
50      console.warn(`Cannot find environment for host name ${hostname}`)
51    }
52  }
53}

Now we can inject the EnvService in our code to be able to access the values:

1@Injectable({ providedIn: 'root' })
2export class AnyService {
3  constructor(private envService: EnvService, private httpClient: HttpClient) {}
4
5  users(): User[] {
6    return this.httpClient.get<User[]>(`${this.envService.apiUrl}/users`)
7  }
8}
AdvantagesDisadvantages
Easy implementationSecrets would be included in source code
No change in build pipeline necessaryEach change of the environment variables would need a new build artifact
No backend implementation necessary

Solution 2: Provide environment configuration via REST endpoint

As already mentioned, a backend service can read environment variables so we can use this mechanism to fetch an environment-specific configuration from such an endpoint. Frontend applications (and SPAs in general) usually always communicate with one (or multiple) backend services to fetch data.

We assume that one of these backend services now provides an endpoint that delivers environment-specific variables (see interface Configuration below) and we take a look at a possible Angular implementation to read those configurations.

First we need a EnvConfigurationService which fetches the configuration from the backend:

1export enum Environment {
2  Prod = 'prod',
3  Staging = 'staging',
4  Test = 'test',
5  Dev = 'dev',
6  Local = 'local',
7}
8
9interface Configuration {
10  apiUrl: string
11  stage: Environment
12}
13
14@Injectable({ providedIn: 'root' })
15export class EnvConfigurationService {
16  private readonly apiUrl = 'http://localhost:4200'
17  private configuration$: Observable<Configuration>
18
19  constructor(private http: HttpClient) {}
20
21  public load(): Observable<Configuration> {
22    if (!this.configuration$) {
23      this.configuration$ = this.http.get<Configuration>(`${this.apiUrl}/config`).pipe(shareReplay(1))
24    }
25    return this.configuration$
26  }
27}

We want that each new subscriber gets the cached configuration without triggering a new HTTP request, therefore we use the shareReplay RxJS operator. This caching makes only sense if the configuration is not dynamic, otherwise, you might want to remove the shareReplay operator.

The configuration can then be loaded in our AppModule at application initialization:

1providers: [{
2    provide: APP_INITIALIZER,
3    useFactory: (envConfigService: EnvConfigurationService) => () => envConfigService.load().toPromise(),
4    deps: [EnvConfigurationService],
5    multi: true
6  }],
AdvantagesDisadvantages
Secrets are not part of frontend source codeBackend needs to be under control to be able to add such an endpoint
No changes in build pipeline necessary

Solution 3: Mount configuration files from environment

Sometimes we do not have control over our backend and therefore cannot add such a configuration endpoint. We can solve this problem by providing local configuration files in our assets folder. Loading such local JSON configurations can be done by using the same EnvConfigurationService demonstrated above, we just need to replace

private readonly apiUrl = 'http://localhost:4200';

by

private readonly configUrl = 'assets/config/config.json';

Now we need to replace this config.json file per environment with an environment-specific file. This is done by mounting a configuration to the assets/config folder if our pod is mounted.

The technical implementation depends on your CI tool, for example using Helm you can use a ConfigMap and mount a volume:

yaml
1volumeMounts:
2  - name: env-config
3    mountPath: /usr/share/nginx/html/assets/config
AdvantagesDisadvantages
Secrets are not part of frontend source code
No changes in build pipeline necessary
No backend necessary

Solution 4: Override environment file values

The idea is to use Angular's environment.ts (for local development) and environment.prod.ts (for all other stages) with placeholder values which are overwritten per deployment:

1export const environment = {
2  apiUrl: 'MY_APP_API_URL',
3  stage: 'MY_APP_STAGE',
4}

If our pod is started we can then run the following script, for example in a Dockerfile, that overrides these placeholder values:

bash
1#!/bin/sh
2# replace placeholder value in JS bundle with environment specific values
3sed -i "s#MY_APP_API_URL#$API_URL#g" /usr/share/nginx/html/main.*.js
AdvantagesDisadvantages
Secrets are not part of frontend source codeWe modify our bundle code by scrip which includes the risk to break it
No changes in build pipeline necessary
No backend necessary

Conclusion

In my opinion, it totally makes sense to build the application once and then deploy this artifact to all available stages. This way, we can at least ensure that we use the same artifact in each environment. But we then need to care about environment-specific variables which we need to pass to our build during runtime.

Angular's environment files are just used during build time so cannot help in such a setup, except we override placeholder values in the bundle which feels a bit "hacky".

The best solution is to load environment-specific configurations from a backend or from the local assets folder if you do not have control over the backend you are using in your Angular application. This way you do not have secrets in your frontend code and you are not modifying the source code of your build artifact.

I will never share any of your personal data. You can unsubscribe at any time.

If you found this article helpful.You will love these ones as well.
How To Generate Angular & Spring Code From OpenAPI Specification Image

How To Generate Angular & Spring Code From OpenAPI Specification

Manually Lazy Load Modules And Components In Angular Image

Manually Lazy Load Modules And Components In Angular

The Last Guide For Angular Change Detection You'll Ever Need Image

The Last Guide For Angular Change Detection You'll Ever Need

JHipster - The Fastest Way To Build A Production-Ready Angular & Spring Boot Application Image

JHipster - The Fastest Way To Build A Production-Ready Angular & Spring Boot Application