Skip to content

feat: add Netlify deployment config for Console frontend#2362

Open
malinskibeniamin wants to merge 7 commits intomasterfrom
bm/console-netlify-deploy
Open

feat: add Netlify deployment config for Console frontend#2362
malinskibeniamin wants to merge 7 commits intomasterfrom
bm/console-netlify-deploy

Conversation

@malinskibeniamin
Copy link
Copy Markdown
Contributor

@malinskibeniamin malinskibeniamin commented Apr 2, 2026

Summary

Add Netlify deployment configuration for Console frontend, enabling it to be deployed as a static site alongside the existing Go HTTP server deployment.

What's included

  • frontend/netlify.toml — Build config, CORS headers for cross-origin MF loading, cache control
  • frontend/public/_redirects — SPA fallback for standalone mode

How it works

Console frontend assets can now be deployed to a Netlify site (e.g., redpanda-console-ui). Cloud UI loads the assets from Netlify when REACT_APP_CONSOLE_FRONTEND_URL is set, with automatic fallback to the cluster's Go HTTP server when Netlify is unreachable.

Deployment modes

Mode Source When
Netlify (new) REACT_APP_CONSOLE_FRONTEND_URL/mf-manifest.json Env var set + MF v2 enabled
Cluster pod (existing) cluster.redpandaConsole.url/embedded.js Fallback or no env var
Local dev REACT_APP_CONSOLE_FRONTEND_URL_OVERRIDE/embedded.js Dev override

Fallback chain (implemented in Cloud UI)

  1. Try Netlify URL (MF v2 manifest)
  2. If unreachable → try cluster URL (MF v2 embedded.js)
  3. If MF v2 unavailable → load legacy Injector (MF v1)
  4. Private-only clusters skip Netlify entirely

Prerequisites

Test plan

  • bun run build produces correct output in build/ (embedded.js, mf-manifest.json, static assets)
  • Netlify deploy preview builds successfully
  • CORS headers present on embedded.js and mf-manifest.json
  • _redirects SPA fallback works for standalone navigation

🤖 Generated with Claude Code

Add netlify.toml with:
- Build config (bun install + rsbuild build)
- CORS headers for cross-origin Module Federation loading
- Cache-busting for embedded.js and mf-manifest.json
- Immutable caching for hashed static assets
- SPA fallback redirect for standalone mode

This enables Console frontend to be deployed as a static Netlify site,
serving the same assets to all clusters. The per-cluster API URL is
provided by Cloud UI via the gatewayApiUrl prop at runtime.

Companion PRs:
- redpanda-data/cloudv2#25386 (Console Netlify support in Cloud UI)
- #2361 (MF v2 search params fix)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@malinskibeniamin malinskibeniamin self-assigned this Apr 3, 2026
@malinskibeniamin malinskibeniamin requested review from a team, c-julin, datamali, jvorcak and yougotashovel and removed request for a team April 3, 2026 09:47
@malinskibeniamin
Copy link
Copy Markdown
Contributor Author

@claude review

@malinskibeniamin malinskibeniamin enabled auto-merge (squash) April 3, 2026 09:48
@claude
Copy link
Copy Markdown

claude bot commented Apr 3, 2026

Claude finished @malinskibeniamin's task in 1m 9s —— View job


Code Review

  • Gather context and read changed files
  • Review frontend/netlify.toml
  • Review frontend/public/_redirects
  • Post review feedback

Overall this is a clean, minimal config. A few things worth considering:

frontend/netlify.toml

CORS scope is broader than necessary (minor)

The wildcard CORS block applies to /*, meaning every response — including the HTML page — gets Access-Control-Allow-Origin: * and Access-Control-Allow-Headers: *. For the microfrontend use case, only embedded.js, mf-manifest.json, and the hashed JS/CSS chunks actually need cross-origin headers (they're what Cloud UI fetches). The HTML page served standalone doesn't benefit from CORS headers and there's no harm, but it's worth narrowing scope to avoid surprising future consumers.

Consider scoping CORS headers to the files actually fetched cross-origin:

[[headers]]
  for = "/embedded.js"
  [headers.values]
    Access-Control-Allow-Origin = "*"
    Access-Control-Allow-Methods = "GET, OPTIONS"
    Access-Control-Allow-Headers = "*"
    X-Content-Type-Options = "nosniff"

[[headers]]
  for = "/mf-manifest.json"
  [headers.values]
    Access-Control-Allow-Origin = "*"
    ...

[[headers]]
  for = "/static/*"
  [headers.values]
    Access-Control-Allow-Origin = "*"
    ...

Fix this →


NODE_OPTIONS=--max_old_space_size=16384 — worth a comment

16384 MB is 16 GB, which far exceeds Netlify's build VM memory. Setting this doesn't cause the process to allocate that much — it just raises Node's V8 heap ceiling — but it's an unusual value that will confuse people reading this later. A brief inline comment explaining this is a guard against OOM during the Rsbuild/rspack bundling step would help:

  # Raise V8 heap limit to prevent OOM during rspack bundling
  command = "bun install && NODE_OPTIONS=--max_old_space_size=16384 bun run build"

Missing Access-Control-Max-Age (nit)

Without Access-Control-Max-Age, browsers send a preflight OPTIONS request before every CORS fetch. For assets loaded on every page load (especially mf-manifest.json), adding Access-Control-Max-Age = "86400" would reduce round-trips. Low priority since this is a CDN with low latency, but easy win.


No environment-specific contexts

There's no [context.deploy-preview] or [context.branch-deploy] section. Netlify will use the same build command for production, deploy previews, and branch deploys, which is probably fine here, but worth confirming the Netlify site is configured to only publish the master branch to the production URL rather than relying on TOML context separation.


frontend/public/_redirects

This is correct. The SPA fallback /* /index.html 200 is standard for React SPAs on Netlify and won't conflict with the header rules in netlify.toml. One minor note: _redirects rules and netlify.toml redirect rules are both processed, with netlify.toml taking priority — no conflict here since netlify.toml has no [[redirects]] blocks.


Summary

The config is functionally correct and well-structured. The main actionable item is narrowing the CORS scope from /* to the specific paths that need it. The other points are nits or informational. No blockers.

- Move CORS headers from /* to specific MF entry points only
  (embedded.js, mf-manifest.json, static/*)
- Add Access-Control-Max-Age for preflight caching
- Add comment explaining NODE_OPTIONS heap ceiling

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@malinskibeniamin
Copy link
Copy Markdown
Contributor Author

@claude review

@claude
Copy link
Copy Markdown

claude bot commented Apr 3, 2026

Claude finished @malinskibeniamin's task in 1m 17s —— View job


Code Review

  • Gather context and read changed files
  • Review frontend/netlify.toml
  • Review frontend/public/_redirects
  • Post review feedback

This is in good shape. The previous review items (CORS scope, Access-Control-Max-Age, NODE_OPTIONS comment) have all been addressed. A few remaining observations:

frontend/netlify.toml

Bun version not pinned (moderate)

NODE_VERSION = "22" is set, but BUN_VERSION is not. Netlify will use whatever Bun version is available on the build image, which can change and cause non-deterministic builds. Since the build command explicitly uses bun install && bun run build, pinning Bun is worth doing:

[build.environment]
  NODE_VERSION = "22"
  BUN_VERSION = "1.2.x"  # pin to current working version

Fix this →


Access-Control-Allow-Headers: * behavior note (informational)

Access-Control-Allow-Headers: * is only valid for non-credentialed requests (which is correct here since Access-Control-Allow-Origin: * precludes credentials). Just confirming this is intentional and correct for the MF use case — it is.


No environment-specific build contexts (low priority)

Without a [context.deploy-preview] or [context.production] block, the same build config applies to all deploy contexts. This is fine as long as the Netlify site settings restrict which branches publish to the production URL. If deploy previews should build with a different REACT_APP_CONSOLE_FRONTEND_URL (e.g., pointing to the preview domain vs the stable production Netlify URL), you'd need context-specific env vars. This is likely handled in Netlify's UI rather than TOML, just confirming it's been considered.


no-cache, no-store, must-revalidate redundancy (nit)

For /embedded.js and /mf-manifest.json, using all three directives together is a well-known belt-and-suspenders pattern for proxy/CDN compatibility. Intentional and fine.


frontend/public/_redirects

Correct. /* /index.html 200 is the standard SPA fallback for Netlify. The comment accurately distinguishes standalone vs embedded (MF) mode. No issues.


Summary

Functionally correct and well-structured. The one actionable item is pinning BUN_VERSION to ensure reproducible builds. Everything else is informational or already addressed from the prior review.

@malinskibeniamin
Copy link
Copy Markdown
Contributor Author

@claude review

@claude
Copy link
Copy Markdown

claude bot commented Apr 3, 2026

Claude finished @malinskibeniamin's task in 57s —— View job


Code Review

  • Gather context
  • Read changed files
  • Post review feedback

All previous feedback has been addressed. This is in good shape. A few remaining observations:

frontend/netlify.toml

BUN_VERSION = "1.3" is not a fully deterministic pin (minor)

Netlify resolves "1.3" to the latest available 1.3.x patch on the build image, which can vary as Netlify updates its image. The previous review suggested pinning to a specific version. To make builds fully reproducible, use an exact semver:

BUN_VERSION = "1.3.9"  # or whichever patch is currently in use

You can check the current version with bun --version locally. Low priority since 1.3.x patch releases are generally non-breaking, but worth doing for true determinism.

Fix this →


Access-Control-Max-Age on /static/* is harmless but unnecessary (nit)

/static/* assets have Cache-Control: public, max-age=31536000, immutable — they're content-hashed and cached for a year. The browser will never re-request the same URL, so caching the CORS preflight for 86400s has no practical effect. Not wrong, just slightly noisy. Fine to leave as-is for consistency.


Everything else looks good

  • CORS scoped correctly to /embedded.js, /mf-manifest.json, /static/*
  • X-Content-Type-Options: nosniff on /* without CORS bleed ✓
  • no-cache, no-store, must-revalidate on MF entry points ✓
  • Access-Control-Max-Age: 86400 present on CORS routes ✓
  • NODE_OPTIONS comment explaining the 16 GB ceiling guard ✓
  • BUN_VERSION pinned (to minor) ✓

frontend/public/_redirects

Correct. Standard SPA fallback, comment accurately describes embedded vs standalone distinction. No issues.


Summary

The config is well-structured and all substantive prior feedback has been addressed. The one remaining actionable item is pinning BUN_VERSION to a full patch version (1.3.x) for fully reproducible builds. Everything else is a nit or informational. This is ready to merge pending that fix and the prerequisite PRs noted in the description.

malinskibeniamin and others added 2 commits April 3, 2026 12:23
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…igate

TanStack Router expects search params via the search option, not
embedded in the to string. Split on ? and pass separately for
forward compatibility with future TanStack Router versions.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@malinskibeniamin
Copy link
Copy Markdown
Contributor Author

@claude review

@claude
Copy link
Copy Markdown

claude bot commented Apr 3, 2026

Claude finished @malinskibeniamin's task in 1m 9s —— View job


Code Review

  • Gather context and read changed files
  • Review frontend/netlify.toml
  • Review frontend/public/_redirects
  • Review frontend/src/federation/console-app.tsx

All previous feedback has been fully addressed. This is in good shape. One observation on the new console-app.tsx change:

frontend/src/federation/console-app.tsx

navigateTo.split('?') is slightly fragile (nit)

The fix to split navigateTo into pathname + search before calling router.navigate is the right approach — passing a full URL string to to is not idiomatic TanStack Router usage. The logic itself is correct for the common case.

The minor fragility: String.prototype.split('?') with no limit splits on all ? characters. For a malformed path like /topics?a=1?b=2, this produces ['/topics', 'a=1', 'b=2'], and only 'a=1' is passed to URLSearchParams, silently discarding 'b=2'. In practice, navigation paths won't have multiple ?, so this is a non-issue — but indexOf would be more precise:

const qIdx = navigateTo.indexOf('?');
const toPath = qIdx >= 0 ? navigateTo.slice(0, qIdx) : navigateTo;
const toSearch = qIdx >= 0 ? navigateTo.slice(qIdx + 1) : undefined;
router.navigate({
  to: toPath,
  search: toSearch ? Object.fromEntries(new URLSearchParams(toSearch)) : undefined,
});

Fix this →


navigateTo vs currentPath comparison includes search (informational)

The existing check if (navigateTo !== currentPath) compares navigateTo (may include ?search=...) against router.state.location.pathname (pathname only). This means a query-string-only change (same pathname, different search) always triggers navigation — which is actually correct behavior. Just confirming this is intentional and working as expected.


frontend/netlify.toml

All previous items resolved:

  • CORS scoped to /embedded.js, /mf-manifest.json, /static/*
  • BUN_VERSION = "1.3.11" exact pin ✓
  • NODE_OPTIONS comment explaining the 16 GB ceiling guard ✓
  • Access-Control-Max-Age: 86400 on CORS routes ✓
  • X-Content-Type-Options: nosniff on /* without CORS bleed ✓

frontend/public/_redirects

Correct. Standard SPA fallback, no issues.


Summary

The config files are solid and all prior feedback has been addressed. The console-app.tsx change is a correct fix — the indexOf nit is very low priority. This is ready to merge pending the prerequisite PRs noted in the description.

split('?') would silently discard params after a second '?' in
malformed paths. indexOf correctly handles the first '?' only.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
for = "/static/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
Access-Control-Allow-Origin = "*"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this how we manage it normally, could we enable CORS only for the specific domains ?

Copy link
Copy Markdown
Contributor Author

@malinskibeniamin malinskibeniamin Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. For Module Federation, the host app (Cloud UI on cloud.redpanda.com) loads remoteEntry.js and chunk files from this Netlify site via <script> tags and fetch(). Using Access-Control-Allow-Origin: * is standard for public static assets (same pattern as any CDN-served JS library).

We could restrict to specific domains (e.g., *.redpanda.com, *.netlify.app), but:

  • These are public, non-sensitive static JS/CSS files with no auth
  • Host app Netlify site uses the same Access-Control-Allow-Origin: * pattern for external scripts

The wildcard is scoped to only the MF entry points (/embedded.js, /mf-manifest.json, /static/*), not all responses. The catch-all /* block only has X-Content-Type-Options: nosniff without CORS. So the exposure is limited to files that are designed to be publicly fetchable.

base = "frontend"
# NODE_OPTIONS: 16 GB is a V8 heap ceiling guard, not actual memory usage.
# It prevents OOM kills during large builds; real RSS stays well below this.
command = "bun install && NODE_OPTIONS=--max_old_space_size=16384 bun run build"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we charged by memory consumption in builds? having a high node old space size could be hiding some bugs.

Copy link
Copy Markdown
Contributor Author

@malinskibeniamin malinskibeniamin Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. The --max_old_space_size=16384 is a V8 heap ceiling, not actual memory allocation — it just raises the limit before V8 triggers an OOM. The actual RSS during a Console build is typically 2-4 GB.

Netlify doesn't charge by memory consumption — builds run on shared VMs with a fixed time limit. The risk of a high ceiling is exactly what you said: it could mask a memory leak in the bundler that would otherwise surface as an OOM.

That said, other app's Netlify build uses NODE_OPTIONS=--max_old_space_size=4096 (see netlify.toml). We could lower this to 4096 or 8192 to be more conservative while still avoiding legitimate OOM during large builds.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lowered to 4096 in 286d804 to match Cloud UI's pattern.

Align with Cloud UI's netlify.toml. 16 GB was unnecessarily high and
could mask bundler memory leaks.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants