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.
| Approach | Image size | Runtime needs | Attack surface |
|---|---|---|---|
| Single-stage Node | ~400–900 MB | Node + CLI | Large |
| Multi-stage + Nginx | ~25–50 MB | Nginx only | Minimal |
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 usedist/<project-name>directly. Runng buildlocally once and check before hardcoding theCOPY --from=buildpath.
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 floatinglatesttags. - Copy
package*.jsonand runnpm cibefore copying source so the dependency-install layer stays cached across code changes. - Provide a
try_files ... /index.htmlfallback in Nginx so client-side routes survive page refreshes and deep links. - Add a
.dockerignorethat excludesnode_modules,dist, and.angularto 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.