·
6 min read

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<