DEV Community

Ultra Wizard
Ultra Wizard

Posted on

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

Most of my work is on healthcare provider apps and stores, 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 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"]
Enter fullscreen mode Exit fullscreen mode

docker-compose.yml

services:
  angular-ssr-app:
    build:
      context: .
    ports:
      - "3000:4000"
    env_file:
      - .env
Enter fullscreen mode Exit fullscreen mode

.env

API_URL=https://jsonplaceholder.typicode.com
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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": "" },
    })
  );
}
Enter fullscreen mode Exit fullscreen mode

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]],
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

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, platformId: object) {
  const serverReq = routeRelativeRequestsToInternalSsrServer(req, platformId);
  return normalizeBrowserApiUrlForTransferCache(serverReq, platformId);
}

function routeRelativeRequestsToInternalSsrServer(req: HttpRequest, 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, platformId: object) {
  if (!isPlatformBrowser(platformId) || !req.url.startsWith("/api")) {
    return req;
  }

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

Register it:

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

provideHttpClient(withFetch(), withInterceptors([apiUrlAlignmentInterceptor]));
Enter fullscreen mode Exit fullscreen mode

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(() => "/api/todos");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And:

API_URL=https://jsonplaceholder.typicode.com npm run serve:ssr
Enter fullscreen mode Exit fullscreen mode

You now have an Angular SSR setup that:

  • Runs from a single container
  • Keeps everything on one origin (via /api proxy)
  • 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: GitHub Repository

Top comments (0)