When you start working on a real Angular project, you quickly hear words like DEV, INT, PREPROD and PROD. Your app has to talk to different backends, use different login settings, and sometimes show different features depending on where it runs.

This article explains, in simple words, how Angular handles this, what isDevMode() actually does (it is not what most people think), and a few tips I learned along the way.

1. The goal: one app, many places

The same application usually runs in several places:

  • Local: on your laptop, for development
  • INT: the integration environment, where teams test together
  • PREPROD: a copy of production, for final checks
  • PROD: the real one, used by real users

Each place needs different settings, for example:

  • The URL of the backend API
  • The login configuration (client ID, scopes)
  • Feature flags (a new feature ON in INT but OFF in PROD)
  • The log level (very detailed locally, quiet in PROD)

The question is: how do we change these settings without rewriting the code each time?

2. First, an important truth

Angular does not know if it is running in DEV, INT or PROD. It does not know your Git branch either.

Angular only knows a few simple things: which build configuration was used, and whether the build is optimized or not. Everything else is up to you. You have to give your app the information about where it lives.

There are three main ways to do it.

3. Approach 1: File replacement (the classic way)

Angular can swap one file for another at build time. You write one environment file per environment:

src/environments/
  environment.ts          (default)
  environment.local.ts
  environment.int.ts
  environment.prod.ts

A simple example:

// environment.int.ts
export const environment = {
  production: false,
  stage: 'INT',
  apiUrl: 'https://api.int.mycompany.com'
};

Then you tell Angular what to swap in angular.json:

"configurations": {
  "int": {
    "fileReplacements": [
      {
        "replace": "src/environments/environment.ts",
        "with": "src/environments/environment.int.ts"
      }
    ]
  }
}

And you build with:

ng build --configuration int

Your code always imports environment.ts. At build time, Angular quietly replaces its content.

Good: simple, typed, no extra tool.

Not so good: you need one build per environment. The build you tested in INT is not the exact same file that goes to PROD. In a DevOps world, that is a weak point.

4. Approach 2: Build once, deploy many (runtime configuration)

The modern idea is simple: build your app one time, and give it its settings when it starts. The same artifact, tested in INT, is the one that goes to PROD. Only the settings change.

There are a few ways to do this in Angular.

Option A: a config.json file loaded at startup
// app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
    provideAppInitializer(() => inject(ConfigService).load())
  ]
};
// config.service.ts
@Injectable({ providedIn: 'root' })
export class ConfigService {
  private http = inject(HttpClient);
  config!: { apiUrl: string; stage: string };

  async load() {
    this.config = await firstValueFrom(
      this.http.get<{ apiUrl: string; stage: string }>('/assets/config.json')
    );
  }
}

The file config.json is placed next to your app when you deploy, not when you build.

Option B: angular-server-side-configuration (ngssc)

This is a small tool that reads environment variables from the server (or the container) and injects them into index.html when the app starts. In your code, you write:

import 'angular-server-side-configuration/process';

export const environment = {
  stage: process.env.STAGE!,
  backendUrl: process.env.BACKEND_URL!,
  devFeaturesEnabled: process.env.DEV_FEATURES_ENABLED === 'true'
};

Here is what happens step by step:

  1. At build time, a builder scans your code and finds every process.env.XXX. It creates a list of the variables your app expects.
  2. When the container starts, a command (ngssc insert) reads the real environment variables and writes them into index.html, inside a small JSON script tag.
  3. In the browser, the app reads those values and fills process.env.

The result in the served page looks like this:

<script id="ngssc" type="application/json">
{"environment":{"STAGE":"INT","BACKEND_URL":"https://api.int.mycompany.com"},"variant":"process"}
</script>

You can check it yourself: open the browser DevTools, look in the <head>, and search for ngssc.

5. What about isDevMode()?

This is where many people get confused. Let me clear it up.

isDevMode() does not tell you if you are in DEV, INT or PROD. It only tells you if Angular is running in development mode, which means: with extra checks and detailed error messages turned on.

Internally, it is very simple:

export function isDevMode(): boolean {
  return typeof ngDevMode === 'undefined' || !!ngDevMode;
}

ngDevMode is a global flag. When you build with optimization turned on, the build tool sets it to false and removes all the development-only code. This makes your bundle smaller and faster.

The possible results

  • ng serve with the development configuration: isDevMode() returns true. Optimization is off, so you get all the helpful checks and warnings.
  • ng build with the production configuration: isDevMode() returns false. Optimization is on.
  • A build with optimization: false deployed on a server: isDevMode() returns true, even though it is not on your laptop. This surprises people.
  • An INT or PREPROD build using the production configuration: isDevMode() returns false, even though it is not PROD. If you use the same build everywhere (like with ngssc), it is false in every deployed environment.
  • Unit tests (Karma or Jest): usually true, because tests run in development mode.
  • Calling enableProdMode() in older Angular versions: forces it to false. You rarely need this today, because the build does it for you.

A quick summary

  • Local with ng serve: isDevMode() is true, and stage comes from your local file.
  • Deployed INT: isDevMode() is false, and stage is INT.
  • Deployed PREPROD: isDevMode() is false, and stage is PREPROD.
  • Deployed PROD: isDevMode() is false, and stage is PROD.

The rule to remember

  • Use isDevMode() for technical things: extra debug logs, development-only checks.
  • Use your own stage variable for business things: showing an “INT” banner, enabling a feature, choosing a backend.

Example:

import { isDevMode } from '@angular/core';

if (isDevMode()) {
  console.log('Angular is in development mode');
}

if (environment.stage !== 'PROD') {
  // show a small banner "You are on INT"
}

Do not use isDevMode() to decide which environment you are in. It will let you down.

6. And the local environment file?

On your machine, you probably do not want to hit INT or PROD. A local file solves that:

"local": {
  "optimization": false,
  "fileReplacements": [
    {
      "replace": "src/environments/environment.ts",
      "with": "src/environments/environment.local.ts"
    }
  ]
}
ng serve --configuration local

If this file contains personal settings, add it to .gitignore so it never goes to the repository.

7. How this connects to a DevOps stack

This topic is a perfect bridge between development and operations. Here is how it connects to common DevOps tools.

  • OpenShift / Kubernetes: the environment variables (or a ConfigMap) are defined in the Deployment. Each environment has its own values, but the Docker image is exactly the same.
  • ArgoCD: your Git repository describes each environment (for example overlays/int, overlays/preprod, overlays/prod). ArgoCD makes sure the cluster matches Git. Changing a setting becomes a simple merge request.
  • Tekton: one pipeline builds the image once. Then the same image is promoted from INT to PREPROD to PROD. No rebuild, no surprise.
  • Vault: never put secrets in an Angular environment file or in config.json. Everything in a browser app is visible to the user. Secrets belong in the backend, loaded from Vault.
  • Splunk: add the stage and the app version to your frontend logs. Then you can filter by environment in one click.
  • Git: keep your environment files and your GitOps configuration in version control, so every change is traceable. Link your merge requests to Jira tickets.
  • Confluence: document your variables (name, meaning, default value, which environments use it). Your future self and your team will thank you.

A tiny example of an environment definition in OpenShift:

env:
  - name: STAGE
    value: INT
  - name: BACKEND_URL
    value: https://api.int.mycompany.com
  - name: DEV_FEATURES_ENABLED
    value: "true"

Change the values, keep the image. That is the whole idea.

8. Tips and tricks

  1. Prefer one build for all environments. What you test is what you ship.
  2. Never put secrets in the frontend. Anything the browser can read, a user can read.
  3. Use a clear stage variable. Do not guess the environment from the URL or from isDevMode().
  4. Choose your boolean defaults carefully. For example, value !== 'false' means “ON by default”, while value === 'true' means “OFF by default”. Both are fine, but write it down.
  5. Watch the variable names. They are case sensitive. API_KEY and api_key are two different variables, and the wrong one gives you undefined without any error.
  6. Do not hardcode the log level. Make it a setting: detailed in INT, quiet in PROD.
  7. Show a small banner outside PROD. It avoids testing on the wrong environment by mistake.
  8. Register every redirect URL in your identity provider. If you use location.origin for login, each environment needs to be allowed (INT, PREPROD, PROD).
  9. Keep your local file out of Git if it contains personal values.
  10. Check the result in the browser. Look at the ngssc script or the config.json call in the Network tab. It takes ten seconds and saves hours.

9. Which approach should you choose?

  • Small project, few environments: file replacement is fine.
  • Real DevOps pipeline, containers, several environments: use runtime configuration (config.json or ngssc). It is the cleanest option.
  • Always: keep secrets out of the browser.

Conclusion

Managing environments is not really about Angular. It is about a good habit: build once, configure outside, and keep everything traceable in Git. Angular gives you the tools, and your DevOps stack gives you the discipline.

And remember: isDevMode() tells you how Angular was built, not where your app is running. For that, you need your own variable.

By Shabazz

Software Engineer, MCSD, Web developer & Angular specialist , BizDevOps

Leave a Reply

Your email address will not be published. Required fields are marked *