The promotion should have been a copy. Staging had been green for two days, the release notes were written, and the plan was to take the exact artifact we had been testing and move it to production.
Then somebody asked how the shell would find the production remotes.
It would not. The remote URLs lived in an environment file, compiled in at build time, so the staging bundle knew only about staging remotes. Shipping meant rebuilding with the production values. So the thing going to production was not the thing we had tested. It was a sibling of it — same source, different constants baked in, and not one test had been run against it.
That is a small difference. It is also exactly the kind of small difference that produces an incident with no obvious cause.
Put the addresses in a file the build does not own
Native Federation can resolve remotes from a manifest read at runtime instead of from values compiled into the bundle. You have to ask for it: ng add @angular-architects/native-federation --type dynamic-host generates this arrangement, while the plain --type host inlines the remote map into main.ts — which is the thing we are trying to get away from. The manifest is a flat map of logical remote names to remote entry URLs:
// federation.manifest.json — the only file that differs between environments
{
"mfeReports": "https://reports.example.com/remoteEntry.json",
"mfeSettings": "https://settings.example.com/remoteEntry.json"
}
The shell reads it before Angular bootstraps, because the import map has to exist before the first module resolves:
// main.ts — fetch the manifest, build the import map, then start the app
import { initFederation } from '@angular-architects/native-federation';
initFederation('federation.manifest.json')
.then(() => import('./bootstrap'))
.catch((err) => console.error(err));
From then on, routes refer to remotes by name. The name is the contract; the URL is a runtime detail the shell looked up:
// app.routes.ts — no URL anywhere in the application code
{
path: 'reports',
loadComponent: () => loadRemoteModule('mfeReports', './Reports').then((m) => m.Reports),
}
What that produces, in the network tab of a running shell, is a lookup followed by a fetch of whatever the lookup returned:
GET /federation.manifest.json 200
GET /remoteEntry.json 200 ← the shell's own
GET https://reports.example.com/remoteEntry.json 200
GET https://settings.example.com/remoteEntry.json 200
GET https://reports.example.com/Reports-PX3XVL6J.js 200
Change the manifest, reload, and the same shell loads different remotes. Nothing was rebuilt.
The one deployment detail that decides whether this actually works: keep the manifest out of the hashed build output. It belongs with your static assets, at a stable path, so a deploy step can overwrite it per environment without touching a single hashed file. If your pipeline treats the whole dist folder as one immutable unit, you have reinvented the problem with extra steps. The manifest has to be the seam.
You can watch the arrangement work on the federated shell demo. The host serves a manifest at its own origin pointing at two remotes on entirely different hosts, fetches both remote entries at runtime, and renders reports and settings inside the same shell — with no compile-time reference to either address.
Three ways to tell a shell where its remotes live
Compile the URLs in. Environment files, resolved at build time. It is the simplest thing that works, it needs no extra infrastructure, and it is what most setups start with. The cost is the one I opened with: your artifact is environment-specific, so promotion is a rebuild, and a rebuild is a new artifact that nothing has tested. For a single-environment internal tool, that may never bite. For anything with a staging gate, it undercuts the reason the gate exists.
Fetch the config from a service. The shell calls a config endpoint before bootstrap and gets its remote addresses back. This is the most flexible option, and it is right if you already run config as a service and other things depend on it. It costs you a blocking request on the critical path before the app can start. It also makes that service's uptime your application's uptime. That is a real commitment for a payload that is four lines of JSON and changes twice a year.
Ship a static manifest and swap it per environment. This is what I would pick. There is no extra service, the file sits on the CDN next to everything else, and the artifact stays immutable — the same bundle you tested is the bundle you promote, with one small file swapped underneath it. It has less flexibility than a config service, and that is mostly a feature: a file is easy to diff, easy to review, and easy to roll back.
If you genuinely need remotes that appear after startup — a feature-flagged section, a plugin that is not always installed — there is a path for that too. Pass the entry URL at the call site: loadRemoteModule({ remoteEntry: url, remoteName: 'mfePlugin', exposedModule: './Plugin' }) fetches the remote, registers it, and installs its import map in one step. (fetchAndRegisterRemote does the first two and hands the import map back to you, but the function that installs one is not exported — so the one-call form is the path that actually works.) The dynamic case does not have to force the whole system into the service-lookup shape.
Design for the remote that does not answer
A manifest entry is a promise about a URL, and URLs break. A remote gets redeployed, a certificate expires, someone points a subdomain at the wrong bucket. This is the part of the design people skip, and the failure is much less graceful than it needs to be.
One behavior worth knowing. A remote that fails to load during startup does not reject by default. processRemoteInfos runs with throwIfRemoteNotFound: false, so it logs Error loading remote entry for <name> from file <url>, drops that remote from the import map, and lets the rest of the shell start. The failure resurfaces later, at the loadRemoteModule call that needed it.
I would keep that lenient and handle it at the route. A remote failing to load is a page-level problem, and it should not become an application-level one. The shell's navigation, its header, and every other remote have no reason to go down because one bundle is briefly unreachable. Catch the rejection where you load the component. Render something honest in that slot: this section is unavailable, here is what still works.
{
path: 'reports',
loadComponent: () =>
loadRemoteModule('mfeReports', './Reports')
.then((m) => m.Reports)
.catch(() => SectionUnavailable), // the rest of the shell keeps working
}
The version of this I have regretted is the one where the whole shell shows a spinner forever, because a single unreachable remote meant startup never finished. From the outside, that reads as "the product is down." It was one section.
Build Once, Deploy Anywhere — the Environment Is Not a Build Flag
Anything that differs between staging and production is configuration, and configuration that gets compiled into a bundle stops being configuration. It becomes a variant. The moment you have variants, the artifact you tested and the artifact you shipped are two different things that happen to share a commit, and every argument you make about test coverage has a hole in it. A federation manifest is a small file with a boring job: it holds the part that changes, so the part that was verified can move between environments untouched.


