Skip to content
Angular ng deployment 4 min read

Docker & Containers

An Angular application is just static files once it is built, but building it requires Node.js, the Angular CLI, and your full dependency tree — none of which belong in a production image. The standard solution is a multi-stage Docker build: one stage compiles the app with all the build tooling, and a second, tiny stage serves the compiled browser/ output through Nginx. The result is a lightweight, reproducible container that runs identically on a laptop, in CI, and in production.

Why multi-stage builds

A naive Dockerfile that copies your source, runs npm install, and ships the whole thing produces an image of several hundred megabytes packed with node_modules, source files, and a Node runtime you never use at runtime. Multi-stage builds let you discard all of that. Only the artifacts you explicitly copy into the final stage survive, so the shipped image contains nothing but Nginx and your hashed static assets.

ApproachImage sizeRuntime needsAttack surface
Single-stage Node~400–900 MBNode + CLILarge
Multi-stage + Nginx~25–50 MBNginx onlyMinimal

The Dockerfile

The first stage uses a Node image to install dependencies and run ng build. The second stage starts from a slim Nginx image and copies in only the browser/ directory the build produced.

# ---- Stage 1: build ----
FROM node:22-alpine AS build
WORKDIR /app

# Install dependencies first so this layer is cached when only source changes
COPY package*.json ./
RUN npm ci

# Copy the rest and build the production bundle
COPY . .
RUN npm run build

# ---- Stage 2: serve ----
FROM nginx:1.27-alpine AS serve

# Remove the default site and add our SPA-aware config
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Copy ONLY the compiled browser output from the build stage
COPY --from=build /app/dist/my-app/browser /usr/share/nginx/html

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Copying package*.json and running npm ci before copying the source is deliberate: Docker caches that layer, so reinstalling dependencies only happens when your lockfile actually changes, not on every code edit.

Confirm the exact output path. Modern Angular writes to dist/<project-name>/browser, but older or customized projects may use dist/<project-name> directly. Run ng build locally once and check before hardcoding the COPY --from=build path.

The Nginx configuration

A single-page application needs Nginx to fall back to index.html for any unknown route, otherwise a refresh on /users/42 returns a 404 because no such file exists on disk. The try_files directive handles this. The same config sets aggressive caching for hashed assets while keeping index.html uncached so clients always pick up new deployments.

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

    # SPA fallback: serve index.html for client-side routes
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Hashed bundles are immutable — cache them for a year
    location ~* \.(?:js|css|woff2?|png|jpg|svg|ico)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # Never cache the entry point
    location = /index.html {
        add_header Cache-Control "no-cache";
    }

    gzip on;
    gzip_types text/css application/javascript image/svg+xml;
}

Excluding files with .dockerignore

Without a .dockerignore, the COPY . . step ships your local node_modules and dist into the build context, slowing the build and risking stale artifacts. Exclude them explicitly.

node_modules
dist
.git
.angular
*.log
Dockerfile
.dockerignore

Building and running

Build the image and run it, mapping a host port to the container’s port 80.

docker build -t my-angular-app .
docker run --rm -p 8080:80 my-angular-app

Output:

[+] Building 28.4s (15/15) FINISHED
 => [build 4/5] RUN npm ci                                   12.1s
 => [build 5/5] RUN npm run build                            9.8s
 => [serve 3/3] COPY --from=build /app/dist/my-app/browser   0.1s
 => exporting to image                                        0.3s
 => => naming to docker.io/library/my-angular-app:latest

The app is now available at http://localhost:8080. Because the final image is built on nginx:alpine, it weighs in around 30 MB rather than the hundreds of megabytes a Node-based image would.

Runtime configuration

Static builds bake environment values in at build time, so a container cannot be reconfigured by passing -e API_URL=... the way a backend can. If you need the same image to run against different environments, generate a small config.json (or an env.js that sets window.__env) at container start with an entrypoint script, and have the app fetch it on bootstrap rather than reading environment.ts. This keeps one image promotable across staging and production.

Best Practices

  • Always use a multi-stage build so the final image contains Nginx and static files only — never the Node runtime or node_modules.
  • Pin base images to specific versions (node:22-alpine, nginx:1.27-alpine) for reproducible builds instead of floating latest tags.
  • Copy package*.json and run npm ci before copying source so the dependency-install layer stays cached across code changes.
  • Provide a try_files ... /index.html fallback in Nginx so client-side routes survive page refreshes and deep links.
  • Add a .dockerignore that excludes node_modules, dist, and .angular to shrink the build context and avoid shipping stale artifacts.
  • Run the container as a non-root user and serve over HTTPS at your ingress or load balancer for production hardening.
Last updated June 14, 2026
Was this helpful?