Field Notes
DevOps
9 min· 18 August 2026

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.

ValueWhen it's actually knownWhere it belongs
Production mode flagBuild timeKeep in environment.ts
Source map generationBuild timeKeep in angular.json
API base URLDeploy timeRuntime config
Auth tenant / client IDDeploy timeRuntime config
Feature flagsRuntime, often after deployRuntime config or flag service
Analytics keysDeploy timeRuntime 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:

json
{
  "apiBaseUrl": "https://api.example.com",
  "authClientId": "00000000-0000-0000-0000-000000000000",
  "features": { "newBillingFlow": false }
}

Load it before bootstrap in main.ts:

typescript
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