Dockerizing an Angular SSR App for Production (Single Origin + /api Proxy + Working Transfer Cache)

January 6, 2026 Updated: January 6, 2026 Rens Jaspers Angular Docker SSR Node.js Reverse Proxy

Most of my work is on healthcare provider apps, where SEO and initial load time usually do not matter much.

Lately I have been working on apps where these metrics do matter, so I started using Angular SSR and wanted to containerize my apps with a single origin and a build-once, deploy-anywhere setup.

In my previous post, I showed how to containerize a client-side rendered (CSR) Angular app for production.

In this post, I do the same for an SSR app.

What we will build

A single container that:

  • Runs the Angular SSR server (Node)
  • Keeps the browser on a single origin (no CORS)
  • Keeps HttpTransferCache working, so hydration does not repeat SSR HTTP calls

Steps

  1. Containerize the SSR server
  2. Add a runtime /api reverse proxy in the SSR server
  3. Map internal origin to client origin for transfer cache
  4. Add one interceptor to keep SSR and browser URLs aligned
  5. Keep app code using only /api/...

Step 1: Containerize the SSR server

Dockerfile

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

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# Stage 2: runtime
FROM node:22-alpine
WORKDIR /app

COPY --from=build /build/dist/containerized-angular-ssr-app-example ./dist

ENV PORT=4000
EXPOSE 4000

CMD ["node", "dist/server/server.mjs"]

docker-compose.yml

services:
  angular-ssr-app:
    build:
      context: .
    ports:
      - "3000:4000"
    env_file:
      - .env

.env

# Backend base URL for the /api reverse proxy:
API_URL=https://jsonplaceholder.typicode.com

Step 2: Add a runtime /api reverse proxy (Express)

We want to proxy all API requests on /api to process.env.API_URL. This avoids CORS issues, prevents hardcoded API URLs in the codebase, and makes it easy to change the API url by updating .env.

Let's set this up in the Angular SSR server inside the container.

Install:

npm i http-proxy-middleware

Then in your server.ts file:

import { createProxyMiddleware } from "http-proxy-middleware";

const apiUrl = process.env["API_URL"];

if (apiUrl) {
  app.use(
    "/api",
    createProxyMiddleware({
      target: apiUrl,
      changeOrigin: true,
      pathRewrite: { "^/api": "" },
    }),
  );
}

Step 3: Fix HttpTransferCache origin mismatch (server config)

In Docker, the SSR server often uses an internal origin like:

  • http://localhost:4000 (inside the container)

But the browser uses:

  • http://localhost:3000 (port mapping in dev), or
  • https://myapp.example.com (real production host)

If those origins do not match, Angular can miss the transfer cache during hydration.

In app.config.server.ts:

import { HTTP_TRANSFER_CACHE_ORIGIN_MAP } from "@angular/common/http";
import { ApplicationConfig, REQUEST } from "@angular/core";

const port = process.env["PORT"] || 4000;
const internalOrigin = `http://localhost:${port}`;

export const appConfigServer: ApplicationConfig = {
  providers: [
    // ...your SSR providers...

    {
      provide: HTTP_TRANSFER_CACHE_ORIGIN_MAP,
      useFactory: (request: Request | null) => {
        if (request?.url) {
          const clientOrigin = new URL(request.url).origin;
          return { [internalOrigin]: clientOrigin };
        }
        return {};
      },
      deps: [[REQUEST]],
    },
  ],
};

This maps the internal SSR origin to the real client origin for the current request.


Step 4: Add one interceptor to keep SSR and browser requests aligned

We want two things at the same time:

  • On the server, API calls must hit the internal SSR server instance, so they go through the Express /api proxy.
  • In the browser, the request URL must end up with the same cache key shape, so hydration can reuse the SSR response.

Here is the interceptor:

import { isPlatformBrowser, isPlatformServer } from "@angular/common";
import { HttpInterceptorFn, HttpRequest } from "@angular/common/http";
import { inject, PLATFORM_ID } from "@angular/core";

/**
 * Ensures API requests hit the same server instance and share cache keys between SSR and browser.
 */
export const apiUrlAlignmentInterceptor: HttpInterceptorFn = (req, next) => {
  const platformId = inject(PLATFORM_ID);
  const normalizedReq = normalizeUrlForSsrAndTransferCache(req, platformId);
  return next(normalizedReq);
};

function normalizeUrlForSsrAndTransferCache(req: HttpRequest<unknown>, platformId: object) {
  const serverReq = routeRelativeRequestsToInternalSsrServer(req, platformId);
  return normalizeBrowserApiUrlForTransferCache(serverReq, platformId);
}

function routeRelativeRequestsToInternalSsrServer(req: HttpRequest<unknown>, platformId: object) {
  if (!isPlatformServer(platformId) || !req.url.startsWith("/")) {
    return req;
  }

  const port = process.env["PORT"] || 4000;
  const internalUrl = `http://localhost:${port}${req.url}`;
  return req.clone({ url: internalUrl });
}

function normalizeBrowserApiUrlForTransferCache(req: HttpRequest<unknown>, platformId: object) {
  if (!isPlatformBrowser(platformId) || !req.url.startsWith("/api")) {
    return req;
  }

  const absoluteUrl = `${window.location.origin}${req.url}`;
  return req.clone({ url: absoluteUrl });
}

Register it:

import { provideHttpClient, withFetch, withInterceptors } from "@angular/common/http";

provideHttpClient(withFetch(), withInterceptors([apiUrlAlignmentInterceptor]));

What the interceptor does

  • SSR path

    • If a request is relative (starts with /), we rewrite it to the internal SSR origin (http://localhost:${PORT}).
    • This makes sure SSR calls go through the same Express server instance that hosts the /api proxy.
  • Browser path

    • If a request starts with /api, we rewrite it to an absolute URL using window.location.origin.
    • This makes the browser request URL format line up with what Angular expects for transfer cache matching, together with HTTP_TRANSFER_CACHE_ORIGIN_MAP.

Step 5: Keep app code simple (only /api)

Example with httpResource:

import { httpResource } from "@angular/common/http";

todosResource = httpResource<Todo[]>(() => "/api/todos");

Development note: do not forget API_URL

In production (Docker), API_URL comes from your .env (or your platform config).

During local development, it is easy to forget this and then wonder why SSR cannot load data.

Use:

API_URL=https://jsonplaceholder.typicode.com npm run start

and:

API_URL=https://jsonplaceholder.typicode.com npm run serve:ssr

You now have an Angular SSR setup that:

  • runs from a single container,
  • keeps everything on one origin (via /api proxy),
  • and preserves HTTP state transfer during hydration.

That means you can build once and deploy the same image to dev, staging, and production, only changing environment variables.

Working example: https://github.com/rensjaspers/containerized-angular-ssr-app-example