Containerizing an Angular App: a Node Build Stage, Unprivileged Nginx and Runtime Configuration
An Angular app is compiled in a build stage with Node, and only the static files are copied into a non-root nginx image; the API address that changes per environment goes not into the build but into a config.json written from environment variables when the container starts. With real output we cover the Dockerfile for bookstore-web, SPA fallback and cache headers, runtime config with provideAppInitializer, CORS, when SSR is needed and the node_modules trap.
Version note: The examples in this post were run with Docker Engine 29.9.3, Angular 22.2 (Angular CLI 22.2.7, TypeScript 6.0), node:26-alpine (Node.js 26 LTS), nginxinc/nginx-unprivileged:1.30-alpine (nginx 1.30.6) and .NET 10.
Guess First
bookstore-web's API address is written in environment.prod.ts, and the image is built once in CI. You want to run the same image in staging and in production; the two environments have different API addresses. How many images do you need to build? Second question: if you run docker build without a .dockerignore in the folder where you ran npm install on your machine, how big is the build context: 1, 20 or 200 MB?
bookstore-web and a Two-Stage Dockerfile
We created bookstore-web, the Angular client of Bookstore, the online bookstore used as the shared example across this site's series, in the repository's web/ folder with Angular CLI 22.2.7 (ng new bookstore-web --directory web; standalone and signals are the default, and Angular 22 projects come with TypeScript 6.0). For now it has a single page: it fetches the book list from Bookstore.Api and shows it. Once built, an Angular app is nothing but static files (HTML, JavaScript, CSS); in production it is served by a web server, not by Node. That is why the Dockerfile has two stages:
# syntax=docker/dockerfile:1
# build: Node runs on the build machine's platform; the output is plain JavaScript
FROM --platform=$BUILDPLATFORM node:26-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,id=npm,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
# final: unprivileged nginx (uid 101, port 8080) serving static files
FROM nginxinc/nginx-unprivileged:1.30-alpine AS final
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY nginx/config.template.json /etc/bookstore/config.template.json
COPY --chmod=755 nginx/40-runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh
COPY --from=build /app/dist/bookstore-web/browser /usr/share/nginx/html
# config.json is rewritten at startup, so the nginx user must own it
COPY --from=build --chown=101:101 /app/dist/bookstore-web/browser/config.json /usr/share/nginx/html/config.json
EXPOSE 8080
The rules from posts 6 and 7 apply as they are. First package.json and package-lock.json, then npm ci, the source code last: changing a component does not reinstall the packages. Unlike npm install, npm ci installs only the versions in the lockfile and fails if the lockfile and package.json disagree; it is the right command for a build that must use the same packages every time. With FROM --platform=$BUILDPLATFORM the Node stage always runs on the build machine's architecture; since the output is architecture-independent JavaScript, there is not even a need for a setting like -a $TARGETARCH from post 7, and for a multi-architecture web image only the nginx of the final stage changes.
Since Angular 17 the application builder writes the browser files under dist/<project>/browser; the dist/<project> path in older Dockerfile examples now points to a parent folder that contains the browser folder, not index.html. The build log states where the output goes:
docker build -t dockerseries-bookstore-web:1.0.0 web
#14 1.257 Initial chunk files | Names | Raw size | Estimated transfer size
#14 1.257 main-BCJ2DRL6.js | main | 143.48 kB | 42.78 kB
#14 1.257 styles-5INURTSO.css | styles | 0 bytes | 0 bytes
#14 1.257 | Initial total | 143.48 kB | 42.78 kB
#14 1.257 Application bundle generation complete. [0.867 seconds] - 2026-11-14T11:47:40.547Z
#14 1.257 Output location: /app/dist/bookstore-web
The build context is the web/ folder. We repeated the cache mount measurement from post 6 for npm: --no-cache twice, with and without the cache mount. npm ci for 241 packages took 3.4 and 5.0 seconds without the cache mount and 3.7 and 4.9 seconds with it. On this connection there is no difference; when the npm registry answers quickly, download time is not what decides. We keep the cache mount anyway: the gain appears on a slow or metered connection, behind a company npm proxy and as the number of packages grows, and it costs nothing.
We added a web target to the docker-bake.hcl that has been growing since post 6 and put it into the default and ci groups and the lint matrix; docker buildx bake ci now runs the checks of three applications, the Tools tests and three images in one session:
target "web" {
context = "web"
target = "final"
tags = ["dockerseries-bookstore-web:${TAG}"]
}
Unprivileged nginx and Image Size
The official nginx image starts the master process as root and listens on 80. nginxinc/nginx-unprivileged is the variant published by the nginx team itself: every process runs as the nginx user with uid 101, and the default port is 8080. The same choice we saw in the ASP.NET Core images in post 8. Looking from inside the container:
docker exec dockerseries-web id
docker exec dockerseries-web ls -ln /usr/share/nginx/html
uid=101(nginx) gid=101(nginx) groups=101(nginx)
total 172
-rw-r--r-- 1 0 0 497 Oct 2 17:14 50x.html
-rw-r--r-- 1 101 101 72 Nov 14 11:52 config.json
-rw-r--r-- 1 0 0 15086 Nov 14 11:46 favicon.ico
-rw-r--r-- 1 0 0 426 Nov 14 11:47 index.html
-rw-r--r-- 1 0 0 143482 Nov 14 11:47 main-BCJ2DRL6.js
The application files belong to root and the nginx user can only read them; a hijacked nginx process cannot change the JavaScript files. The one exception is config.json: we rewrite it when the container starts, so we copied it with --chown=101:101. The image itself is small: for linux/amd64 the base image is 90.1 MB / 25.6 MB, and with bookstore-web added 90.4 MB / 25.6 MB. Node, node_modules and the Angular CLI stayed in the build stage.
nginx.conf: SPA Fallback and Cache Headers
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
# SPA fallback: unknown paths go to index.html, the Angular router takes over
location / {
try_files $uri $uri/ /index.html;
}
# File names carry a content hash, so they can be cached for a year
location ~* \.(?:js|css|woff2?|ico|png|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Must never be cached: they change without a file name change
location = /index.html {
add_header Cache-Control "no-cache";
}
location = /config.json {
add_header Cache-Control "no-store";
}
}
Why these settings look like this is covered in the Web Server and Hosting series post on serving Angular with Nginx and in the Angular series post "Angular Build and Deploy: ng build, Environment Files, Budgets and SPA Routing"; here we check the result in the container. try_files returns index.html instead of 404 for a path with no file on disk (such as a book page the user bookmarked). Hashed file names are served with a one-year cache, index.html revalidated on every request, and config.json never cached:
curl -s -o /dev/null -D - localhost:8081/books/9780441172719 | grep -iE "^HTTP|cache-control"
curl -s -o /dev/null -D - localhost:8081/main-BCJ2DRL6.js | grep -iE "^HTTP|cache-control"
curl -s -o /dev/null -D - localhost:8081/config.json | grep -iE "^HTTP|cache-control"
HTTP/1.1 200 OK
Cache-Control: no-cache
HTTP/1.1 200 OK
Cache-Control: max-age=31536000
Cache-Control: public, immutable
HTTP/1.1 200 OK
Cache-Control: no-store
Because we ship our own default.conf, the image's startup script 10-listen-on-ipv6-by-default.sh logs that it cannot modify the file (can not modify /etc/nginx/conf.d/default.conf); the file belongs to root and is read-only on purpose, so this line is expected.
Build Once, Run Anywhere: a Runtime config.json
With environment files, the answer to the first question is "one image per environment". The API address in environment.prod.ts is baked into the JavaScript at build time; staging needs a separate build. That goes against the "Build Once, Deploy Many" principle of the CI/CD Fundamentals series: the image that goes to production is not the image tested in staging. The fix is to take the values that change per environment out of the build and move them to run time. On the Angular side, a small service reads config.json before the app starts:
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
export interface RuntimeConfig {
apiBaseUrl: string;
environment: string;
}
@Injectable({ providedIn: 'root' })
export class RuntimeConfigService {
private readonly http = inject(HttpClient);
private readonly config = signal<RuntimeConfig | null>(null);
get value(): RuntimeConfig {
const config = this.config();
if (!config) {
throw new Error('Runtime config is not loaded');
}
return config;
}
// config.json is written by the container at startup, not by the Angular build
async load(): Promise<void> {
this.config.set(await firstValueFrom(this.http.get<RuntimeConfig>('config.json')));
}
}
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideHttpClient(withFetch()),
// The app starts only after config.json is loaded
provideAppInitializer(() => inject(RuntimeConfigService).load()),
],
};
provideAppInitializer is the function-based counterpart of the old APP_INITIALIZER token; Angular does not start the app until the returned Promise completes, so the address is ready by the time the catalog service sends its first request. During development the default in public/config.json (http://localhost:5080, Bookstore.Api's dotnet run address) is used. In the container we rewrite this file with the nginx image's startup mechanism: before starting nginx, the image's entrypoint runs the executable scripts under /docker-entrypoint.d/ in order.
#!/bin/sh
# Writes config.json from environment variables when the container starts
set -eu
: "${API_BASE_URL:?API_BASE_URL must be set}"
export APP_ENVIRONMENT="${APP_ENVIRONMENT:-production}"
envsubst '${API_BASE_URL} ${APP_ENVIRONMENT}' \
< /etc/bookstore/config.template.json \
> /usr/share/nginx/html/config.json
echo "$0: config.json written for $APP_ENVIRONMENT, API at $API_BASE_URL"
{
"apiBaseUrl": "${API_BASE_URL}",
"environment": "${APP_ENVIRONMENT}"
}
We gave envsubst the list of variables to replace explicitly; without a list it replaces every $ expression in the template (turning the ones it cannot find into empty text). API_BASE_URL is mandatory; ${VAR:?message} stops the script with an error code if the variable is missing. We ran the same image with staging settings. We also started Bookstore.Api next to it; in the browser the request goes from one origin (8081) to the API (8080), so we added a CORS policy read from configuration to the Api:
string[] allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
builder.Services.AddCors(options => options.AddDefaultPolicy(policy =>
policy.WithOrigins(allowedOrigins).WithMethods("GET").AllowAnyHeader()));
docker run -d --name dockerseries-api -p 8080:8080 \
-e Cors__AllowedOrigins__0=http://localhost:8081 dockerseries-bookstore-api:1.0.0
docker run -d --name dockerseries-web -p 8081:8080 \
-e API_BASE_URL=http://localhost:8080 -e APP_ENVIRONMENT=staging dockerseries-bookstore-web:1.0.0
docker logs dockerseries-web 2>&1 | grep -E "40-runtime|Configuration complete"
curl -s localhost:8081/config.json
curl -s -o /dev/null -D - -H "Origin: http://localhost:8081" localhost:8080/books | grep -i access-control
/docker-entrypoint.sh: Launching /docker-entrypoint.d/40-runtime-config.sh
/docker-entrypoint.d/40-runtime-config.sh: config.json written for staging, API at http://localhost:8080
/docker-entrypoint.sh: Configuration complete; ready for start up
{
"apiBaseUrl": "http://localhost:8080",
"environment": "staging"
}
Access-Control-Allow-Origin: http://localhost:8081
We opened the page with headless Chromium from the deep link of a book page. The browser first requested index.html (through the SPA fallback), then config.json, and finally the API's /books endpoint, and the page showed this:
Bookstore
Environment: staging
Çalıkuşu
TRY240.00
Dune
TRY420.00
Emma
TRY180.00
İnce Memed
TRY310.00
(List shortened.) In production the same image starts with -e API_BASE_URL=https://api.bookstore.example.com -e APP_ENVIRONMENT=production; the answer to the first question is one image. If you forget the variable, the container does not start:
docker run --rm dockerseries-bookstore-web:1.0.0
echo "exit code: $?"
/docker-entrypoint.d/40-runtime-config.sh: line 5: API_BASE_URL: API_BASE_URL must be set
exit code: 2
It is better for a misconfigured container to die right away than to look "up" while serving an empty page or a wrong address; the orchestrator sees it as a failed deploy. Remember that everything you put into config.json goes to the browser: the API address, the environment name and public feature flags belong here; secrets do not. A detailed discussion of the same idea together with the .NET side is in the Configuration and Secret Management series post "One Artifact, Many Environments: Configuration Layering in .NET, Environment Variables and Runtime Config in Angular".
When Do You Need the SSR Variant?
So far bookstore-web is an SPA that runs entirely in the browser: nginx only serves files. With Angular SSR enabled, a server folder appears next to the browser folder in the build output, and a Node.js server that renders the pages runs. On the container side what changes is that the final stage is a Node runtime image (for example node:26-alpine, with USER node) instead of nginx, and the container runs a Node process; the build stage stays the same. When you need it is a decision independent of containers: SSR is valuable if search engines and social media previews must see filled-in HTML without running JavaScript (like Bookstore's catalog and book pages), or if first paint on slow devices matters. For pages behind a login, such as an order panel or an admin screen, a static SPA on nginx is simpler and cheaper: no Node process, steady memory and CPU, and scaling is as easy as serving files. Runtime config is different with SSR too: the Node server can read environment variables directly.
In Production
Commit the lockfile and use only npm ci in the build. An npm install without a lockfile can produce two different dependency trees from the same commit on two different days.
Never cache index.html and config.json for long. If, after a new release, the browser reads the old index.html from its cache, it requests hashed files that no longer exist and the page stays blank.
Do TLS and compression in the reverse proxy (or CDN) in front of this container, not in it; the same logic as for the API in post 8. If later you serve the web app and the API under the same origin (with an /api path), you no longer need CORS either; that needs a container network, so it is the topic of post 12.
Pin the base images and keep them up to date: node:26-alpine is used only in the build, but the security of build tools is part of the supply chain too, and nginx-unprivileged faces the internet directly. The digest pin and automatic updates from post 4 apply to both.
Common Pitfalls
Sending node_modules to the build context. The answer to the second question: when we temporarily removed web/.dockerignore and built, the context size was this:
#10 transferring context: 224.68MB 2.4s done
With the right .dockerignore the same step is 1.04kB. Worse than size is correctness: COPY . . writes the host's node_modules over the one npm ci installed in the container. A node_modules installed on Windows contains the Windows builds of packages that carry native binaries, such as esbuild, and the build breaks in the Linux container. web/.dockerignore:
node_modules/
dist/
.angular/
.vscode/
*.md
Dockerfile
Installing versions outside the lockfile with npm install. Writing npm install in the Dockerfile can install whatever version is newest that day within ranges like ^22.2.0 in package.json; builds stop being reproducible. npm ci fails when the lockfile does not match, which is the behavior we want.
Baking the API address into the image at build time. Changing environment.ts with --build-arg API_URL=... leads to the same result: one image per environment. A value that changes per environment must arrive at run time.
Copying the wrong dist path. With COPY --from=build /app/dist/bookstore-web /usr/share/nginx/html, the nginx root holds a browser/ folder instead of index.html. In our image this error shows up at startup, because the script does not find config.json where it expects it and cannot create a new file in a folder owned by root:
/docker-entrypoint.d/40-runtime-config.sh: line 8: can't create /usr/share/nginx/html/config.json: Permission denied
From Angular 17 on, the right path is dist/<project>/browser.
A Small Experiment
★☆☆ Start a second container from the same image with -p 8082:8080 -e APP_ENVIRONMENT=production -e API_BASE_URL=http://localhost:8080 and compare the config.json of the two containers. Are the image IDs the same?
★★☆ Change the try_files line in nginx/default.conf to try_files $uri =404;, rebuild the image and request /books/9780441172719 with curl. What came back? Would it make a difference if you clicked an in-app link from the home page in the browser?
★★★ Try starting the container with --read-only. Which files cannot be written, so that startup fails? Bring the container up by making only the needed folders writable with --tmpfs (preparation for post 13).
All three parts of Bookstore (Bookstore.Tools, Bookstore.Api and bookstore-web) are now ready as images, and docker buildx bake builds all three with one command. What is missing is persistent data: Bookstore.Api's catalog still lives in memory. In the next post we take on the SQL Server 2025 container and the volumes that keep data when the container is deleted.