Skip to content

fix(deploy): serve the custom domain, and make it the canonical origin - #6

Merged
theprogrammersingh merged 2 commits into
feat/fx-rate-lockfrom
fix/custom-domain
Sep 3, 2026
Merged

fix(deploy): serve the custom domain, and make it the canonical origin#6
theprogrammersingh merged 2 commits into
feat/fx-rate-lockfrom
fix/custom-domain

Conversation

@theprogrammersingh

Copy link
Copy Markdown
Owner

Stacked on #5. Base is feat/fx-rate-lock; GitHub retargets this to main
once #5 merges. Review only the two commits listed below.

Why

actuo.programmersingh.dev is attached to the Render service — DNS and the
certificate work, /api/health answers 200 — but every page returns 400:

Header "host" with value "actuo.programmersingh.dev" is not allowed.

NG_ALLOWED_HOSTS was "*.onrender.com", which the new host does not match.

Two things the live probe showed that are worth recording:

  • /sitemap.xml and /robots.txt answered 200 on a hostname where every SSR
    route 400'd, because express.static runs ahead of Angular's host check. That
    is what a half-broken deploy looks like.
  • Fixing the allowlist alone was not enough. PUBLIC_ORIGIN is baked at
    build time, so the custom domain was already serving a sitemap, canonical
    and og:image all naming actuo.onrender.com — a working site telling
    crawlers it lives somewhere else. No check caught it.

What

NG_ALLOWED_HOSTS lists both hostnames (comma-separated; the custom domain
exactly rather than *.programmersingh.dev, which would admit every other
subdomain of that zone). PUBLIC_ORIGIN moves to the custom domain, and
server.mjs reads it a second time at runtime to 308 page requests on any other
hostname to it — one variable rather than a CANONICAL_ORIGIN that would have
to stay in step, the same reasoning that keeps CONVERTER_URL a single value.

The redirect lives in backend/src/common/canonical-redirect.ts because that is
the only workspace with a test runner that can reach it; server.mjs imports it
from dist exactly as it already imports createNestApp. Its placement does two
jobs without a check: after Nest, so setGlobalPrefix('/api') means /api
can never be redirected (health probe included), and before the Angular
handler
, so it also covers the static files that skip Angular's host check.

The guard, and why it is not optional

PUBLIC_ORIGIN and NG_ALLOWED_HOSTS must move together. With the first on a
host the second does not cover, the alias 308s to a host Angular answers 400 for
every page dead, while /api/health still returns 200, so Render reports a
healthy deploy and never rolls back.

So the redirect refuses to run in that state and logs both values. The failure
degrades to "both hosts keep serving". parseAllowedHosts/isHostAllowed
duplicate Angular's matcher, which is safe in the only direction that matters:
the check can only disable a redirect, so drift cannot make anything worse
than not having it.

Deploying — the ordering matters

PUBLIC_ORIGIN is consumed at build time, and merging triggers a rebuild.

  • Blueprint sync on: merging is enough — render.yaml carries both variables.
  • Blueprint sync off: set PUBLIC_ORIGIN in the dashboard first, then merge.

Either way the guard makes a mistake here non-fatal.

Verify both origins afterwards:

pnpm run verify:deploy https://actuo.programmersingh.dev   # every check
pnpm run verify:deploy https://actuo.onrender.com          # reports the 308

Also

verify:deploy gains the two checks that would have caught this: the stamped
origin must match the URL being verified (a missing sentinel only proves
something was substituted, not that the right thing was), and an alias origin is
recognised and reported rather than silently followed.

Progress.md records cross-origin as proven from the deployed Actuo, closing the
last rough edge there.

Checks

  • typecheck, pnpm test, pnpm run test:e2e, pnpm run build
  • 12 shared · 129 backend unit · 34 backend e2e · 803 frontend
  • the redirect exercised against the composed server both ways: misconfigured,
    the alias returns 200 and the log explains why; configured, the 308 fires and
    /api, loopback and non-GET are all untouched
  • a test caught a real bug in it — a blanket /:\d+$/ port-strip turns ::1
    into ":", which matches no loopback entry and would have redirected

actuo.programmersingh.dev was attached to the Render service — DNS and the
certificate worked, /api/health answered 200 — but every page returned
400 text/plain: Header "host" with value "actuo.programmersingh.dev" is not
allowed. NG_ALLOWED_HOSTS was "*.onrender.com", which the new host does not
match. It is a comma list, so both hostnames are now listed; the custom domain
exactly rather than as *.programmersingh.dev, which would admit every other
subdomain of that zone.

Two things the live probe showed that are worth recording:

- /sitemap.xml and /robots.txt answered 200 on a hostname where every SSR route
  400'd, because express.static runs ahead of Angular's host check. That is what
  a half-broken deploy looks like.
- Fixing the allowlist alone was not enough. PUBLIC_ORIGIN is baked at build
  time, so the custom domain was already serving a sitemap, canonical and
  og:image all naming actuo.onrender.com — a working site telling crawlers it
  lives somewhere else. No check caught it.

So PUBLIC_ORIGIN moves to the custom domain and server.mjs now reads it a second
time at runtime, 308-redirecting page requests on any other hostname to it. One
variable rather than a CANONICAL_ORIGIN that would have to stay in step with it,
which is the same reasoning that keeps CONVERTER_URL a single value.

The redirect is in backend/src/common/canonical-redirect.ts rather than inline
in server.mjs because that is the only workspace with a test runner that can
reach it; server.mjs imports it from dist exactly as it already imports
createNestApp. Its placement does two jobs without a check: after Nest, so
setGlobalPrefix('/api') means /api can never be redirected (the health probe
included), and before the Angular handler, so it also covers the static files
that skip Angular's host check. GET/HEAD only, never loopback, and the target is
always built from PUBLIC_ORIGIN rather than from the request, so a hostile Host
header decides only whether to redirect, never where to.

A test caught a real bug in it: a blanket /:\d+$/ port-strip turns ::1 into ":",
which matches no loopback entry and would have redirected.

verify:deploy gains the two checks that would have caught this: the stamped
origin must MATCH the URL being verified — a missing sentinel only proves
something was substituted, not that the right thing was — and an alias origin is
recognised, reported, and its page checks skipped rather than silently following
the redirect and describing the canonical origin instead.

Progress.md also records cross-origin as proven from the deployed Actuo, which
closes the last rough edge there.
PUBLIC_ORIGIN and NG_ALLOWED_HOSTS have to move together, and getting that wrong
is the one way the canonical redirect can take the whole site down: with
PUBLIC_ORIGIN on a host the allowlist does not cover, the alias 308s to a host
Angular answers 400 for. Every page dead — and /api/health still returns 200, so
Render reports a healthy deploy and never rolls back.

canonicalRedirect now takes NG_ALLOWED_HOSTS and disables itself when the
canonical hostname is not in it, logging both values. The failure degrades to
"both hosts keep serving", which is wrong rather than down. An unset allowlist
lands here too, correctly: Angular then permits only angular.json's
localhost/127.0.0.1.

parseAllowedHosts and isHostAllowed mirror @angular/ssr's getArrayFromEnv and
isHostAllowed. Duplicating a matcher usually invites drift, but it is safe in
the only direction that matters here, because the check can *only* disable a
redirect: drift that wrongly says "allowed" leaves the behaviour it would have
had without the guard, and drift that wrongly says "not allowed" still serves
every host. Angular's list stays the authority on what is served; this decides
only whether to redirect.

Verified against the composed server both ways — misconfigured, the alias
returns 200 and the log explains why; configured, the 308 and every other
behaviour are unchanged.
@theprogrammersingh
theprogrammersingh merged commit c32665b into main Sep 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant