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:
- At build time, a builder scans your code and finds every
process.env.XXX. It creates a list of the variables your app expects. - When the container starts, a command (
ngssc insert) reads the real environment variables and writes them intoindex.html, inside a small JSON script tag. - 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 servewith the development configuration:isDevMode()returnstrue. Optimization is off, so you get all the helpful checks and warnings.ng buildwith the production configuration:isDevMode()returnsfalse. Optimization is on.- A build with
optimization: falsedeployed on a server:isDevMode()returnstrue, even though it is not on your laptop. This surprises people. - An INT or PREPROD build using the production configuration:
isDevMode()returnsfalse, even though it is not PROD. If you use the same build everywhere (like with ngssc), it isfalsein every deployed environment. - Unit tests (Karma or Jest): usually
true, because tests run in development mode. - Calling
enableProdMode()in older Angular versions: forces it tofalse. You rarely need this today, because the build does it for you.
A quick summary
- Local with
ng serve:isDevMode()is true, andstagecomes from your local file. - Deployed INT:
isDevMode()is false, andstageisINT. - Deployed PREPROD:
isDevMode()is false, andstageisPREPROD. - Deployed PROD:
isDevMode()is false, andstageisPROD.
The rule to remember
- Use
isDevMode()for technical things: extra debug logs, development-only checks. - Use your own
stagevariable 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
stageand 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
- Prefer one build for all environments. What you test is what you ship.
- Never put secrets in the frontend. Anything the browser can read, a user can read.
- Use a clear
stagevariable. Do not guess the environment from the URL or fromisDevMode(). - Choose your boolean defaults carefully. For example,
value !== 'false'means “ON by default”, whilevalue === 'true'means “OFF by default”. Both are fine, but write it down. - Watch the variable names. They are case sensitive.
API_KEYandapi_keyare two different variables, and the wrong one gives youundefinedwithout any error. - Do not hardcode the log level. Make it a setting: detailed in INT, quiet in PROD.
- Show a small banner outside PROD. It avoids testing on the wrong environment by mistake.
- Register every redirect URL in your identity provider. If you use
location.originfor login, each environment needs to be allowed (INT, PREPROD, PROD). - Keep your local file out of Git if it contains personal values.
- Check the result in the browser. Look at the
ngsscscript or theconfig.jsoncall 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.jsonor 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.
