EthicsComputerCourses
Join training +91 90532 10052

Build Optimisation, CI & Deployment

Angular · Lesson 37 of 41 Advanced 9 min read Updated

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):

Output
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 @defer block.
  • 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:

Output
CREATE src/environments/environment.ts (31 bytes)
CREATE src/environments/environment.development.ts (31 bytes)
UPDATE angular.json (3001 bytes)
TypeScript
// 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:

TypeScript
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.

  1. Commit your package-lock.json. CI uses npm ci, which installs exactly the versions in the lock file and fails if package.json and the lock file disagree.
  1. Create .github/workflows/ci.yml:
YAML
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/
  1. Read what each step guarantees. ng lint catches broken import rules and style problems (it needs ng add angular-eslint once). ng test --watch=false runs every test once and exits. ng build fails on type errors and budget errors. upload-artifact keeps the exact files that passed, so the deployment uses those files instead of rebuilding.
  1. Protect the main branch in your repository settings so pull requests can be merged only when this workflow passes.
  1. 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@v4 are 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.

  1. Build. If the app will live in a subfolder such as https://example.in/hr/, set the base URL: ng build --base-href /hr/.
  1. Upload the contents of dist/<project>/browser/ to the server's web folder.
  1. Add the fallback rule so deep links like /leave/7 return index.html (the Apache .htaccess version is in Routing). For Nginx:
Text
location / {
  try_files $uri $uri/ /index.html;
}
  1. Set caching headers. Files with a hash in the name (main-5MVKOGOY.js) never change, so browsers may keep them for a year. index.html must not be cached for long, or users keep loading the old version after a release:
Text
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";
}
  1. 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:

Terminal
NG_ALLOWED_HOSTS=leavedesk.in,www.leavedesk.in PORT=4000 node dist/leavedesk/server/server.mjs
  • The server listens on the PORT environment 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 Host header is not allowed get 400 Bad Request. List your domains in security.allowedHosts in angular.json or in the NG_ALLOWED_HOSTS environment variable.
  • Angular ignores X-Forwarded-* headers by default. Behind a proxy you control, trust them with NG_TRUST_PROXY_HEADERS (for example X-FORWARDED-HOST,X-FORWARDED-PROTO), and never on a server exposed directly to the internet.

Common mistakes

  • Uploading the dist folder itself instead of the contents of dist/<project>/browser/.
  • Caching index.html for 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 install in CI, which can silently change versions. Use npm ci.
  • Forgetting allowed hosts on an SSR server, which then answers every request with 400.

Key points

  • ng build produces hashed files, reports initial and lazy chunks, and fails on budget errors.
  • Use fileReplacements environments for build-time settings, or a config.json loaded by provideAppInitializer() to build once and deploy everywhere.
  • A CI pipeline runs npm ci, ng lint, ng test --watch=false and ng build on every pull request.
  • Static hosting needs the index.html fallback, long caching for hashed files and no caching for index.html.
  • SSR servers run server.mjs with PORT, 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
JSON
{ "apiUrl": "https://uat-api.leavedesk.in", "supportPhone": "1800-000-000" }
JSON
{ "apiUrl": "https://api.leavedesk.in", "supportPhone": "1800-000-000" }
TypeScript
@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?

2. Why does CI use npm ci instead of npm install?

3. Which file should not be cached for long on a static host?

4. An SSR server answers every request with 400 Bad Request. What is the most likely cause?

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