Angular #

Angular is an opinionated enterprise frontend framework — it has opinions about modules, dependency injection, and reactive programming (RxJS). Since Angular 17, Angular uses standalone components and esbuild as the default build tool. Since Angular 18, zoneless mode is stable for more performant applications.

For local development, the Angular CLI + dev server provide automatic live reload. Docker ensures every developer has the same Node version, the same Angular CLI version, and isolated native dependencies. Fits large-scale applications with many developers.

This article covers an Angular + Docker Compose setup for local development, including standalone components, services, RxJS, and the production image with nginx.

Prerequisites #

Make sure you have installed:

  • Docker and Docker Compose (latest versions)
  • Node.js 20+ (optional, for tooling)

A standard Angular project (bootstrapped with ng new):

my-angular-app/
├── src/
│   ├── app/
│   │   ├── app.component.ts
│   │   ├── app.config.ts
│   │   ├── app.routes.ts
│   │   ├── core/
│   │   │   ├── services/
│   │   │   ├── guards/
│   │   │   └── interceptors/
│   │   ├── features/
│   │   │   ├── products/
│   │   │   └── auth/
│   │   └── shared/
│   │       ├── components/
│   │       └── pipes/
│   ├── main.ts
│   ├── index.html
│   └── styles.css
├── public/
├── angular.json
├── package.json
├── package-lock.json
├── tsconfig.json
├── Dockerfile
└── docker-compose.yml

Angular Architecture #

Angular organizes applications with a consistent structure:

flowchart TB
    Bootstrap[main.ts bootstrap]
    Config[app.config.ts providers]
    Routes[app.routes.ts]
    App[AppComponent]
    Feature1[Feature: Products]
    Feature2[Feature: Auth]
    Service[Service: ProductApi]
    Guard[AuthGuard]
    Interceptor[AuthInterceptor]
    
    Bootstrap --> Config
    Bootstrap --> App
    Config --> Routes
    App --> Feature1
    App --> Feature2
    Feature1 --> Service
    Feature2 --> Guard
    Config --> Interceptor
    Service -.HTTP.-> Interceptor
  • main.ts — the entry point, bootstraps with config
  • app.config.ts — root providers (HTTP, routing, etc.)
  • app.routes.ts — route definitions
  • standalone components — components without modules (default since Angular 17)
  • services — singletons with dependency injection
  • guards — route protection
  • interceptors — HTTP middleware

Multi-Stage Dockerfile #

The Angular build produces static output. Production only needs nginx.

# syntax=docker/dockerfile:1.6

# ---- Stage 1: Install dependencies ----
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm ci

# ---- Stage 2: Build Angular ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

ENV NODE_ENV=production
RUN npm run build -- --configuration production

# ---- Stage 3: Production with nginx ----
FROM nginx:1.27-alpine
COPY --from=builder /app/dist/my-angular-app/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Since Angular 17, the build output lives in dist/[app-name]/browser/ (not just dist/). This path changes depending on the Angular version — check architect.build.options.outputPath in angular.json for the correct path.

nginx.conf for an Angular SPA:

server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # Cache Angular hashed assets
    location ~* \.(js|css|woff2?|ttf|svg|png|jpg|jpeg|gif|ico)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # SPA fallback
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Proxy the API
    location /api/ {
        proxy_pass http://api:3001/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

docker-compose.yml for Development #

# docker-compose.yml
services:
  web:
    build:
      context: .
      dockerfile: Dockerfile.dev
    image: my-angular-app:dev
    container_name: angular-dev
    command: npm run start -- --host 0.0.0.0 --port 4200
    ports:
      - "4200:4200"
    volumes:
      - ./:/app
      - /app/node_modules
      - /app/.angular
    environment:
      - NODE_ENV=development
    depends_on:
      - api

  api:
    build: ./api
    image: my-api:dev
    container_name: angular-api
    ports:
      - "3001:3001"
    environment:
      - DATABASE_URL=postgresql://app:pass@db:5432/myapp
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=dev
      - POSTGRES_DB=myapp
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  db-data:

Dockerfile.dev:

FROM node:20-alpine
RUN apk add --no-cache libc6-compat
WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm install

# Install the Angular CLI globally in the container
RUN npm install -g @angular/cli@18

COPY . .

EXPOSE 4200

CMD ["ng", "serve", "--host", "0.0.0.0", "--port", "4200"]

Service Explanations #

web — the Angular dev server. Source code is bind-mounted, with anonymous volumes for node_modules and .angular (build cache). Port 4200 (Angular’s default).

api and db — the backend services.

Install the Angular CLI in the container, not on the host. This ensures all developers use the same Angular CLI version. If the host uses CLI 18 and the image uses 17, subtle errors can appear in schematics or builders.

Standalone Components #

Since Angular 17, standalone components are the default. No NgModule needed.

// src/app/features/products/product-list/product-list.component.ts
import { Component, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ProductService } from '../../../core/services/product.service';
import { ProductCardComponent } from '../product-card/product-card.component';

@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [CommonModule, ProductCardComponent],
  template: `
    <h1>Products</h1>
    <p>Filter: <input (input)="onFilterChange($event)" /></p>
    
    <div class="grid">
      @for (product of filteredProducts(); track product.id) {
        <app-product-card [product]="product" />
      } @empty {
        <p>No products.</p>
      }
    </div>
  `,
  styleUrl: './product-list.component.css',
})
export class ProductListComponent {
  private productService = inject(ProductService);
  
  filterTerm = signal('');
  products = signal<Product[]>([]);
  
  filteredProducts = computed(() => {
    const term = this.filterTerm().toLowerCase();
    return this.products().filter(p => p.name.toLowerCase().includes(term));
  });
  
  constructor() {
    this.productService.getAll().subscribe(products => {
      this.products.set(products);
    });
  }
  
  onFilterChange(event: Event) {
    const input = event.target as HTMLInputElement;
    this.filterTerm.set(input.value);
  }
}
@for and @if are the new control flow (since Angular 17) — more performant than *ngFor and *ngIf. Use them for all new components.

Services with Dependency Injection #

Services are classes with @Injectable() injected into components. Angular has a hierarchical injector — services provided at the root are singletons.

// src/app/core/services/product.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, map, shareReplay } from 'rxjs';
import { Product } from '../../models/product.model';

@Injectable({
  providedIn: 'root', // Singleton across the whole app
})
export class ProductService {
  private http = inject(HttpClient);
  private apiUrl = '/api/products';
  
  // Cache products with shareReplay
  private products$ = this.http.get<Product[]>(this.apiUrl).pipe(
    shareReplay({ bufferSize: 1, refCount: true })
  );
  
  getAll(): Observable<Product[]> {
    return this.products$;
  }
  
  getById(id: number): Observable<Product> {
    return this.http.get<Product>(`${this.apiUrl}/${id}`);
  }
  
  create(product: Omit<Product, 'id'>): Observable<Product> {
    return this.http.post<Product>(this.apiUrl, product);
  }
  
  update(id: number, product: Partial<Product>): Observable<Product> {
    return this.http.put<Product>(`${this.apiUrl}/${id}`, product);
  }
  
  delete(id: number): Observable<void> {
    return this.http.delete<void>(`${this.apiUrl}/${id}`);
  }
}

Use it in a component:

import { Component, inject, OnInit } from '@angular/core';
import { ProductService } from '../core/services/product.service';
import { Product } from '../models/product.model';

@Component({
  selector: 'app-product-list',
  standalone: true,
  template: `
    @for (product of products; track product.id) {
      <p>{{ product.name }} — ${{ product.price.toLocaleString('en-US') }}</p>
    }
  `,
})
export class ProductListComponent implements OnInit {
  private productService = inject(ProductService);
  products: Product[] = [];
  
  ngOnInit() {
    this.productService.getAll().subscribe(products => {
      this.products = products;
    });
  }
}
Use the inject() function rather than constructor injection for more concise code. inject() can be called in property initializers, while constructor injection only works in constructors.

RxJS for Reactive Programming #

RxJS is Angular’s core. All HTTP clients return Observables.

Stream transformation:

import { Component, inject, OnInit, OnDestroy } from '@angular/core';
import { Subject, takeUntil, debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { ProductService } from './product.service';

@Component({
  selector: 'app-product-search',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <input [formControl]="searchControl" placeholder="Search products..." />
    <ul>
      @for (product of results; track product.id) {
        <li>{{ product.name }}</li>
      }
    </ul>
  `,
})
export class ProductSearchComponent implements OnInit, OnDestroy {
  private productService = inject(ProductService);
  private destroy$ = new Subject<void>();
  
  searchControl = new FormControl('');
  results: any[] = [];
  
  ngOnInit() {
    this.searchControl.valueChanges.pipe(
      debounceTime(300),          // Wait 300ms after the user stops typing
      distinctUntilChanged(),     // Ignore identical values
      switchMap(term => this.productService.search(term ?? '')),
      takeUntil(this.destroy$),
    ).subscribe(results => {
      this.results = results;
    });
  }
  
  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Subject as an Event Bus:

import { Injectable } from '@angular/core';
import { Subject, Observable } from 'rxjs';

interface Notification {
  type: 'success' | 'error' | 'info';
  message: string;
}

@Injectable({ providedIn: 'root' })
export class NotificationService {
  private notifications$ = new Subject<Notification>();
  
  get notifications(): Observable<Notification> {
    return this.notifications$.asObservable();
  }
  
  success(message: string) {
    this.notifications$.next({ type: 'success', message });
  }
  
  error(message: string) {
    this.notifications$.next({ type: 'error', message });
  }
}
Always unsubscribe from Observables to prevent memory leaks. Use the takeUntil(destroy$) pattern or the async pipe in templates (auto-unsubscribe).

HTTP Interceptors #

Interceptors are HTTP middleware — suitable for attaching tokens, logging, or error handling.

// src/app/core/interceptors/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthService } from '../services/auth.service';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);
  const router = inject(Router);
  const token = authService.getToken();
  
  // Attach the token if present and the request goes to our API
  if (token && req.url.startsWith('/api')) {
    req = req.clone({
      setHeaders: { Authorization: `Bearer ${token}` },
    });
  }
  
  return next(req).pipe(
    catchError((error) => {
      if (error.status === 401) {
        authService.logout();
        router.navigate(['/login']);
      }
      return throwError(() => error);
    })
  );
};

Register it in app.config.ts:

// src/app/app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes),
    provideHttpClient(withInterceptors([authInterceptor])),
  ],
};

Routing with Guards #

// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from './core/guards/auth.guard';

export const routes: Routes = [
  {
    path: '',
    loadComponent: () => import('./features/home/home.component').then(m => m.HomeComponent),
  },
  {
    path: 'products',
    loadComponent: () => import('./features/products/product-list.component').then(m => m.ProductListComponent),
  },
  {
    path: 'products/:id',
    loadComponent: () => import('./features/products/product-detail.component').then(m => m.ProductDetailComponent),
  },
  {
    path: 'dashboard',
    canActivate: [authGuard],
    loadComponent: () => import('./features/dashboard/dashboard.component').then(m => m.DashboardComponent),
  },
  {
    path: '**',
    loadComponent: () => import('./features/not-found/not-found.component').then(m => m.NotFoundComponent),
  },
];

Functional guard (Angular 17+):

// src/app/core/guards/auth.guard.ts
import { CanActivateFn, Router } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';

export const authGuard: CanActivateFn = (route, state) => {
  const authService = inject(AuthService);
  const router = inject(Router);
  
  if (authService.isAuthenticated()) {
    return true;
  }
  
  return router.createUrlTree(['/login'], {
    queryParams: { returnUrl: state.url },
  });
};

Signals for Fine-Grained Reactivity #

Since Angular 16, signals are the modern way to manage local state. Simpler than RxJS for UI state.

import { Component, signal, computed, effect } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <p>Count: {{ count() }} (double: {{ double() }})</p>
    <button (click)="increment()">Add</button>
    <button (click)="reset()">Reset</button>
  `,
})
export class CounterComponent {
  count = signal(0);
  double = computed(() => this.count() * 2);
  
  constructor() {
    // Side effect runs automatically when count changes
    effect(() => {
      console.log('Count changed:', this.count());
    });
  }
  
  increment() {
    this.count.update(v => v + 1);
  }
  
  reset() {
    this.count.set(0);
  }
}
StateUse
UI state (count, toggle, form inputs)signal()
Computed valuescomputed()
Side effectseffect()
Async data (HTTP, events)Observable + RxJS
Global state across componentssignal() in a service

Build and Run #

# Development
docker compose up --build
# Access: http://localhost:4200

# Production build
docker build -t my-angular-app:prod -f Dockerfile .
docker run -p 8080:80 my-angular-app:prod

# View logs
docker compose logs -f web

# Stop
docker compose down

Best Practices #

1. Standalone Components for Everything #

Since Angular 17, standalone is the default. Avoid NgModule unless maintaining old projects.

2. Functional Guards and Interceptors #

CanActivateFn and HttpInterceptorFn are lighter than class-based ones. Use them for all new guards and interceptors.

3. The inject() Function over Constructor Injection #

// ✓ More concise
private http = inject(HttpClient);

// ✗ Verbose
constructor(private http: HttpClient) {}

4. Lazy-Load Routes #

loadComponent: () => import('./feature.component').then(m => m.FeatureComponent)

The Angular CLI automatically code-splits per lazy route.

5. OnPush Change Detection #

For components that rarely change, use OnPush to skip change detection:

import { ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-product',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `...`,
})
export class ProductComponent {}

6. Track Functions in @for #

@for (product of products; track product.id) {
  ...
}

track prevents wrong DOM reuse when the list changes.

7. Unsubscribe from Observables #

Use the async pipe in templates (auto-unsubscribe) or takeUntil(destroy$) in classes.

@Component({
  template: `
    <ul>
      @for (item of items$ | async; track item.id) {
        <li>{{ item.name }}</li>
      }
    </ul>
  `,
})
export class MyComponent {
  items$ = this.service.getItems();
}

8. Use Signals for UI State, RxJS for Async Streams #

The ideal combination:

  • signal for local state (form inputs, toggles, counters)
  • Observable for HTTP, WebSocket, event buses
  • toSignal() to convert Observables to signals when entering templates

9. Vite host: '0.0.0.0' #

Mandatory in ng serve or serve.options.host in angular.json. Without it, the dev server is unreachable from the host.

10. The Angular CLI in the Container, Not the Host #

Make sure npm install -g @angular/cli is in Dockerfile.dev. All developers use the same version.


Troubleshooting #

HMR Not Updating #

Make sure ng serve --host 0.0.0.0 (or the angular.json configuration). Check docker compose logs -f web for errors.

“Cannot find module” in AOT Builds #

Usually a circular dependency or module resolution issue. Check the tsconfig.json paths and restart the container.

“NullInjectorError: No provider for X” #

The service isn’t provided. For root singletons, add providedIn: 'root'. For scoped, add it to the component’s providers array.

Build Fails with Budget Exceeded #

Angular has default bundle-size budgets. Raise them in angular.json:

{
  "budgets": [
    {
      "type": "initial",
      "maximumWarning": "500kb",
      "maximumError": "1mb"
    }
  ]
}

Port 4200 Conflict #

lsof -i :4200

Change the Compose port mapping or stop the process.

“NG0” Error (Missing Provider) #

A service or directive wasn’t imported. For standalone components, add it to the imports array.


Summary #

  • Angular is ideal for enterprise SPAs with TypeScript, RxJS, and dependency injection.
  • Multi-stage Dockerfiles with static nginx serving. The deps stage installs, builder builds with ng build, runner serves with nginx.
  • Standalone components are the default since Angular 17. No more NgModule. Simpler and tree-shakeable.
  • Services with providedIn: 'root' are app-wide singletons. Use the inject() function for concise DI.
  • RxJS for HTTP, events, and async streams. Always unsubscribe with takeUntil(destroy$) or the async pipe.
  • HTTP interceptors for attaching tokens, logging, and error handling. Functional interceptors (Angular 17+) are lighter than class-based ones.
  • Functional guards for route protection. CanActivateFn is simpler than class-based guards.
  • Signals for local UI state (counters, toggles, forms). RxJS for async streams. The combination is the modern pattern.
  • Lazy-loaded routes with loadComponent: () => import(...) for automatic code splitting.
  • OnPush change detection for rarely-changing components. Skips unnecessary checks, better performance.
  • Track functions in @for prevent wrong DOM reuse. Always include them for dynamic lists.
  • The Angular CLI in the container, not the host. npm install -g @angular/cli@18 in Dockerfile.dev.
  • Anonymous volumes for node_modules and .angular prevent host conflicts.
  • Troubleshooting: dead HMR (check host), NullInjector (check providers), budget exceeded (raise limits), NG0 errors (check imports).
  • Build output lives in dist/[app-name]/browser/ since Angular 17. This path matters for the Dockerfile.
  • Vite is still experimental for Angular. The default is still webpack/esbuild.
  • TypeScript is mandatory in Angular. Type safety in services, components, and templates.
  • Hot reload via ng serve HMR. State is preserved for OnPush components with signals.
  • Production images are static with nginx. Small bundles after tree-shaking, lazy loading, and minification.
  • Best practices: standalone, functional guards/interceptors, inject(), lazy loading, OnPush, signals + RxJS hybrids, unsubscribing.

← Previous: Svelte   Next: Best Practice →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact