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
- Containerize the SSR server
- Add a runtime /api reverse proxy in the SSR server
- Map internal origin to client origin for transfer cache
- Add one interceptor to keep SSR and browser URLs aligned
- 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
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
/apiproxy. - 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 });
}
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(() => "/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
/apiproxy) - 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)