Build Optimisation, CI & Deployment
Code that works on your laptop is only half the job. A professional team needs every change to be built, tested and checked automatically, the bundle size kept under control, and a repeatable way to put the app on a server. This lesson connects the pieces: production builds and budgets, configuration for different environments, a continuous integration (CI) pipeline, and deployment to static hosting and to a Node.js server for SSR.
Reading a production build
ng build uses the production configuration by default. It removes development checks, minifies code, adds content hashes to file names and checks your budgets. Here is the output for a small app whose team set a strict budget (warning above 250 kB, error above 350 kB of initial JavaScript and CSS):
Initial chunk files | Names | Raw size | Estimated transfer size
chunk-NUKCBFQF.js | - | 286.69 kB | 79.42 kB
main-5MVKOGOY.js | main | 70.59 kB | 18.46 kB
styles-5INURTSO.css | styles | 0 bytes | 0 bytes
| Initial total | 357.28 kB | 97.89 kB
Lazy chunk files | Names | Raw size | Estimated transfer size
chunk-BEKKCOWW.js | order-confirmation | 1.21 kB | 638 bytes
Application bundle generation failed. [15.660 seconds]
▲ [WARNING] bundle initial exceeded maximum budget. Budget 250.00 kB was not met by 107.28 kB with a total of 357.28 kB.
X [ERROR] bundle initial exceeded maximum budget. Budget 350.00 kB was not met by 7.28 kB with a total of 357.28 kB.- Initial chunk files download before the first screen appears. Keep this small.
- Lazy chunk files download later, when the user opens that route or
@deferblock. - Raw size is the file on disk; estimated transfer size is roughly what travels over the network after compression.
- A budget error makes the build fail with a non-zero exit code, which stops a CI pipeline. That is the point: nobody can add a large library to the first screen by accident.
When a budget fails, find the cause with ng build --stats-json and a bundle analyser, as described in Performance Best Practices. Typical fixes are lazy-loading a route, moving a heavy widget behind @defer, or replacing a large library with a smaller one or a browser built-in.
Environments and runtime configuration
Apps need different settings for development, testing (often called UAT, user acceptance testing) and production: API addresses, feature flags, analytics IDs. Angular offers two approaches.
Build-time environments. ng generate environments creates two files and tells the build to swap them:
CREATE src/environments/environment.ts (31 bytes) CREATE src/environments/environment.development.ts (31 bytes) UPDATE angular.json (3001 bytes)
// environment.ts (production)
export const environment = { apiUrl: 'https://api.leavedesk.in', showDebugPanel: false };
// environment.development.ts (used by ng serve)
export const environment = { apiUrl: 'http://localhost:8080', showDebugPanel: true };The development configuration in angular.json gets a fileReplacements entry, so code that imports ./environments/environment receives the right file. The catch: you need a separate build per environment.
Runtime configuration. Many teams prefer "build once, deploy everywhere": the same tested files go to UAT and production, and only a small config.json next to index.html differs. Load it before the app starts with an initializer:
import { HttpClient } from '@angular/common/http';
import { InjectionToken, Service, inject, provideAppInitializer, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
export interface RuntimeConfig {
apiUrl: string;
supportPhone: string;
}
@Service()
export class ConfigStore {
readonly config = signal<RuntimeConfig | null>(null);
}
export function provideRuntimeConfig() {
return provideAppInitializer(async () => {
const config = await firstValueFrom(inject(HttpClient).get<RuntimeConfig>('/config.json'));
inject(ConfigStore).config.set(config);
});
}Remember from the Security lesson that both approaches are public: anything the browser downloads can be read. Never put secrets in either.
Step by step: a CI pipeline
Continuous integration means every push and pull request is automatically installed, linted, tested and built on a clean machine. If any step fails, the change cannot be merged. Here is a pipeline for GitHub Actions; GitLab CI, Azure Pipelines and Jenkins use the same steps with different syntax.
- Commit your
package-lock.json. CI usesnpm ci, which installs exactly the versions in the lock file and fails ifpackage.jsonand the lock file disagree.
- Create
.github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npx ng lint
- run: npx ng test --watch=false --coverage
- run: npx ng build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/- Read what each step guarantees.
ng lintcatches broken import rules and style problems (it needsng add angular-eslintonce).ng test --watch=falseruns every test once and exits.ng buildfails on type errors and budget errors.upload-artifactkeeps the exact files that passed, so the deployment uses those files instead of rebuilding.
- Protect the main branch in your repository settings so pull requests can be merged only when this workflow passes.
- Use a Node.js version that your Angular version supports; check the version compatibility table on angular.dev when you upgrade. Version tags such as
actions/checkout@v4are examples: use the current major versions shown in each action's documentation.
Step by step: deploy to static hosting
An app without SSR is a folder of static files: dist/<project>/browser/. Any web server can host it, including shared hosting with cPanel, which many small businesses in India use, Nginx on a VPS, or services such as Firebase Hosting, Netlify and Cloudflare Pages.
- Build. If the app will live in a subfolder such as
https://example.in/hr/, set the base URL:ng build --base-href /hr/.
- Upload the contents of
dist/<project>/browser/to the server's web folder.
- Add the fallback rule so deep links like
/leave/7returnindex.html(the Apache.htaccessversion is in Routing). For Nginx:
location / {
try_files $uri $uri/ /index.html;
}- Set caching headers. Files with a hash in the name (
main-5MVKOGOY.js) never change, so browsers may keep them for a year.index.htmlmust not be cached for long, or users keep loading the old version after a release:
location ~* \.(js|css|woff2|svg|png|webp)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location = /index.html {
add_header Cache-Control "no-cache";
}- Serve over HTTPS with compression (gzip or Brotli) turned on, and test a deep link and a refresh on the live site.
The CLI also has an ng deploy command. It works after you add a deployment package for your host (several hosting providers publish one), which then builds and uploads in one step.
Deploying an SSR app
With SSR & Hydration, ng build produces dist/<project>/browser/ (static files) and dist/<project>/server/server.mjs (a Node.js server). By default the build bundles Express and your app code into the server files, so the server machine needs Node.js and the copied dist/<project>/ folder, not your whole project. On a Linux VPS:
NG_ALLOWED_HOSTS=leavedesk.in,www.leavedesk.in PORT=4000 node dist/leavedesk/server/server.mjs- The server listens on the
PORTenvironment variable (default 4000). Put Nginx in front of it for HTTPS, and use a process manager such as PM2 or systemd, or a Docker container, so it restarts after a crash or reboot. - Requests whose
Hostheader is not allowed get 400 Bad Request. List your domains insecurity.allowedHostsinangular.jsonor in theNG_ALLOWED_HOSTSenvironment variable. - Angular ignores
X-Forwarded-*headers by default. Behind a proxy you control, trust them withNG_TRUST_PROXY_HEADERS(for exampleX-FORWARDED-HOST,X-FORWARDED-PROTO), and never on a server exposed directly to the internet.
Common mistakes
- Uploading the
distfolder itself instead of the contents ofdist/<project>/browser/. - Caching
index.htmlfor a long time, so users never see new releases. - Raising budgets every time they fail instead of finding the cause.
- Rebuilding for each environment and deploying files that were never tested. Prefer one build and runtime configuration.
- Using
npm installin CI, which can silently change versions. Usenpm ci. - Forgetting allowed hosts on an SSR server, which then answers every request with 400.
Key points
ng buildproduces hashed files, reports initial and lazy chunks, and fails on budget errors.- Use
fileReplacementsenvironments for build-time settings, or aconfig.jsonloaded byprovideAppInitializer()to build once and deploy everywhere. - A CI pipeline runs
npm ci,ng lint,ng test --watch=falseandng buildon every pull request. - Static hosting needs the
index.htmlfallback, long caching for hashed files and no caching forindex.html. - SSR servers run
server.mjswithPORT, need allowed hosts, and should trust proxy headers only behind a proxy you control.
Exercise
Your team deploys the same build to uat.leavedesk.in and leavedesk.in. Write the config.json for each environment, the initializer that loads it, and a service method that builds API URLs from apiUrl. Then list the CI steps that must pass before the files are copied to UAT.
Show solution
{ "apiUrl": "https://uat-api.leavedesk.in", "supportPhone": "1800-000-000" }{ "apiUrl": "https://api.leavedesk.in", "supportPhone": "1800-000-000" }@Service()
export class ApiUrls {
private store = inject(ConfigStore);
leave(path = '') {
return `${this.store.config()!.apiUrl}/leave${path}`;
}
}
// app.config.ts
providers: [provideHttpClient(), provideRuntimeConfig(), provideRouter(routes)],The initializer from the lesson runs before the first screen, so config() is filled when components call ApiUrls. CI must pass npm ci, ng lint, ng test --watch=false and ng build, then the uploaded artifact is copied to UAT together with the UAT config.json. After testing, the same artifact goes to production with the production config.json.
Quick Quiz
1. What happens when the initial bundle exceeds a budget's maximumError?
Budget errors fail the build, which stops the CI pipeline.
2. Why does CI use npm ci instead of npm install?
npm ci gives reproducible installs.
3. Which file should not be cached for long on a static host?
Hashed files never change; index.html points to the newest hashed files.
4. An SSR server answers every request with 400 Bad Request. What is the most likely cause?
Requests from hosts that are not allowed are rejected.
Learn Angular with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
