In most apps you want a slightly different configuration per environment — for example, using a different API base URL in staging vs production.
The Angular team recommends using environment.ts with file replacements for environment-specific configuration (see the official docs on configuring application environments).
Technically, that pattern works. But it has one practical downside: you need a different build per environment.
I usually want the opposite: build once, deploy the same artifact everywhere (staging/production/etc.), and inject environment-specific values at runtime.
In this article, I’ll show you how I do it:
- Put a JSON config file in
public/(served from the same origin) - Load it before Angular bootstraps using
provideAppInitializer - Use the configuration values synchronously anywhere in your app (no race conditions)
Important: never put secrets in frontend config
This sounds obvious, but I keep seeing confusion about it online, so let’s be explicit:
- Never put secrets in your frontend — not in a public runtime JSON file, and not in
environment.tseither. - Your users can inspect, copy, and modify everything that ships to the browser.
So yes, put things like API base URLs, feature flags, and simple UI config (like a theme color) in your runtime config.
But don’t put things like private API keys, database passwords, or anything that should stay secret.
This is not comparable to a server-side .env file where you can store secrets because the client never gets to see it.
The config file
Put a public-environment-config.json file in your public/ folder (or wherever your host serves static assets from).
For example:
public/environment/public-environment-config.json→ served as/environment/public-environment-config.json
This plays really nicely with “build once, deploy everywhere” setups (for example when you Dockerize your app).
If you run your Angular app in Docker, you can ship a default config inside the image, and then override it per environment by bind-mounting a folder with your config:
- Put your runtime config on the host in something like
./runtime-config/ - Map that folder into the container path that serves your static files under
environment/
I prefer mapping a folder instead of a single file, because it makes it much easier to swap configs without fiddling with file-level mounts.
{
"name": "Production",
"themeColor": "green",
"apiUrl": "https://api-prod.example.com"
}
When using the example path above, this is available at /environment/public-environment-config.json.
Step 1 — Create a RuntimeConfigService
This service loads the JSON once and exposes it synchronously afterwards.
// runtime-config.service.ts
import { Injectable } from "@angular/core";
export type RuntimeConfig = {
name: string;
themeColor: string;
apiUrl: string;
};
@Injectable({ providedIn: "root" })
export class RuntimeConfigService {
private readonly configUrl = "/environment/public-environment-config.json";
config!: RuntimeConfig;
async load(): Promise<void> {
const response = await fetch(this.configUrl, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Config load failed: ${response.status} ${response.statusText} (${this.configUrl})`);
}
this.config = (await response.json()) as RuntimeConfig;
}
}
Step 2 — Load config before bootstrap with provideAppInitializer
If your project uses an ApplicationConfig (common in modern standalone apps), add an initializer provider that calls configService.load() in app.config.ts.
// app.config.ts
import { provideHttpClient, withFetch, withInterceptors } from "@angular/common/http";
import { ApplicationConfig, inject, provideAppInitializer } from "@angular/core";
import { RuntimeConfigService } from "./runtime-config.service";
import { apiBaseUrlInterceptor } from "./api-base-url.interceptor";
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withFetch(), withInterceptors([apiBaseUrlInterceptor])),
provideAppInitializer(async () => {
const configService = inject(RuntimeConfigService);
try {
await configService.load();
} catch (e) {
// config broken = app broken
const message = `Failed to start: could not load runtime config.\n\n${e instanceof Error ? e.message : String(e)}`;
alert(message);
throw e;
}
}),
],
};
Your main.ts stays simple:
import { bootstrapApplication } from "@angular/platform-browser";
import { App } from "./app/app";
import { appConfig } from "./app/app.config";
bootstrapApplication(App, appConfig);
At this point, the config is guaranteed to be available before any HTTP request happens.
That means you can read configService.config synchronously anywhere (including interceptors, guards, and services) without worrying about timing issues.
Note that the initializer function runs in Angular's injection context, so you can use inject() directly without needing separate deps or factory functions.
Step 3 — Example usage: prefix your API calls with an interceptor
Now that the config is loaded, you can use it anywhere in your application.
Let’s use it in an Angular HTTP interceptor, so you can simply call api/... throughout your app and the correct base URL gets applied automatically.
For example, you can apply the runtime base URL to any request that starts with api/.
That means your app code can do:
this.http.get("api/v1/users");
And the interceptor turns it into:
// https://api-prod.example.com/api/v1/users
Here’s the interceptor:
// api-base-url.interceptor.ts
import { HttpInterceptorFn } from "@angular/common/http";
import { inject } from "@angular/core";
import { RuntimeConfigService } from "./runtime-config.service";
export const apiBaseUrlInterceptor: HttpInterceptorFn = (req, next) => {
const configService = inject(RuntimeConfigService);
if (!req.url.startsWith("api/")) {
return next(req);
}
const baseUrl = configService.config.apiUrl.replace(/\/+$/, "");
const apiPath = req.url.replace(/^\/+/, "");
return next(req.clone({ url: `${baseUrl}/${apiPath}` }));
};
That’s it.
This interceptor was just one example — the key point is that after APP_INITIALIZER runs, you can access configService.config synchronously anywhere in your app.