Build Once, Configure at Runtime: Fixing the Angular Deployment Mistake Almost Everyone Makes
By Ganesh
Almost every Angular pipeline I inherit has the same shape. There is a build step for staging, a build step for production, and each one runs ng build --configuration=whatever against its own environment.ts. It looks tidy. It is also the reason nobody can quite explain why the thing that worked in staging behaved differently in production.
The short version
- →Building per environment means the artifact you tested is not the artifact you shipped.
- →Angular's environment.ts is a build-time mechanism being used for a deploy-time problem.
- →The fix is a small JSON file fetched before bootstrap — no framework change, no rewrite.
- →Once configuration is external, promoting a build between environments becomes a copy, not a rebuild.
What actually goes wrong
The failure is rarely dramatic. It looks like this: staging passes QA on Thursday, production deploys on Friday, and something is subtly off — a feature flag reading differently, a bundle that is 40KB larger, a transitive dependency that resolved to a newer patch version because the production build ran on a fresh agent and the lockfile was not honoured as strictly as everyone assumed.
Every one of those is individually preventable. The point is that you should not have to prevent them individually. Two builds means two opportunities for the build to differ, and the only way to know they did not is to compare the outputs — which nobody does.
The uncomfortable version
If your pipeline rebuilds per environment, your staging sign-off is an opinion about a different binary. It is usually a good opinion. It is not evidence.
Why Angular pushes you into this
This is not really a mistake anyone makes out of carelessness. Angular's scaffolding hands you environment.ts and environment.prod.ts on day one, and the file replacement mechanism in angular.json exists specifically to swap them at build time. The framework's own defaults teach the pattern.
And for some things, build-time replacement is correct. enableProdMode(), stripping development-only providers, toggling source maps — those genuinely belong to the build. The problem is that environment.ts becomes the default home for everything, including values that are not build-time facts at all.
| Value | When it's actually known | Where it belongs |
|---|---|---|
| Production mode flag | Build time | Keep in environment.ts |
| Source map generation | Build time | Keep in angular.json |
| API base URL | Deploy time | Runtime config |
| Auth tenant / client ID | Deploy time | Runtime config |
| Feature flags | Runtime, often after deploy | Runtime config or flag service |
| Analytics keys | Deploy time | Runtime config |
The fix
Serve a small JSON file alongside the app, fetch it before Angular bootstraps, and expose it through a service. The bundle becomes environment-agnostic, and promoting a tested build to production is a copy operation rather than a rebuild.
Put the file in src/assets/config.json so it ships with the build output, then overwrite it during deployment:
{
"apiBaseUrl": "https://api.example.com",
"authClientId": "00000000-0000-0000-0000-000000000000",
"features": { "newBillingFlow": false }
}Load it before bootstrap in main.ts:
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
import { APP_RUNTIME_CONFIG, RuntimeConfig } from './app/core/runtime-config';
fetch('/assets/config.json', { cache: 'no-store' })
.then((res) => {
if (!res.ok) throw new Error(`Config load failed: ${res.status}`);
return res.json() as Promise<RuntimeConfig>;
})
.then((config) =>
bootstrapApplication(AppComponent, {
...appConfig,
providers: [
...appConfig.providers,
{ provide: APP_RUNTIME_CONFIG, useValue: config },
],
}),
)
.catch((err) => {
// Fail loudly. A misconfigured app that boots is worse than one that doesn't.
console.error('Fatal: runtime configuration unavailable', err);
document.body.innerHTML =
'<p style="font-family:sans-serif;padding:2rem">Configuration unavailable. Please contact support.</p>';
});On cache-control
cache: 'no-store' on the fetch matters, and so does a Cache-Control: no-cache header on the file itself. A config file cached by a CDN for a year is a genuinely miserable afternoon — the app updates, the config does not, and the symptoms look like a backend problem.
Deploying it
The pipeline builds once, publishes the artifact, and each environment stage writes its own config.json over the one in the package before deploying. In Azure DevOps that is a file transform or a short script task; in GitHub Actions it is equally boring. The boringness is the point.
Keep the values in the environment's own secret store rather than in the pipeline definition — Key Vault referenced from a variable group works well, and it means rotating a client ID does not require a commit.
What you gain beyond correctness
Rollback becomes trivial: you already have the exact artifact that was running before, and it needs no rebuild to redeploy. Teams that build per environment often discover during an incident that rolling back means rebuilding an old commit, on a build agent whose toolchain has since moved on.
What this does not solve
Runtime config is not a secrets mechanism. Anything in that JSON file is readable by anyone who opens devtools, exactly like anything else in a browser bundle. API keys that must stay private belong behind your own backend, and if they are currently in environment.prod.ts, they are already public — moving them to runtime config changes nothing about that, and it is worth checking before you assume you have improved your security posture.
It also does not fix per-environment differences in the backend, which is where a fair number of "it worked in staging" reports actually originate. What it does is remove the front end from the list of suspects, which is worth more than it sounds like when something is broken at four in the afternoon.
Common questions
Why not just build Angular separately for each environment?+
Because the bundle you tested in staging is then not the bundle you ship to production. Any difference in dependency resolution, build machine state, or source drift between the two builds is untested. Build-once removes that class of problem entirely.
Does runtime configuration hurt performance?+
Marginally. You add one small blocking request before bootstrap, typically a few hundred bytes. On any realistic connection this is a few milliseconds against a bundle measured in hundreds of kilobytes.
Can I keep using environment.ts?+
Yes, for things that genuinely differ at build time, like enabling production mode or stripping dev-only code. Move anything environment-specific and deploy-time-known — API URLs, tenant IDs, feature flags — out to runtime config.
Related engagement
This problem comes up often enough that it has its own entry on the engagements page, with the wider context of how the work is scoped and what typically goes wrong.
See the engagement