You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
An app that is down returns OpenResty's stock 502 Bad Gateway page from the box. For a free *.opsh.io host, what the visitor actually ends up seeing is a page carrying oblien.com
branding and links — a third party's, from the point of view of the operator whose own domain
was requested.
Reported on Discord (11 Aug 2026):
502 contains link to oblien.com — Instead of the configured opsh.io domain
This is the direct sibling of #431. That issue fixed unrouted hostnames (raw TLS errors and a
stock openresty 404) with a truthful, branded page; the upstream-down case was left on the
stock page and is the one users hit far more often, because it fires every time a container is
restarting, crashed, or still booting.
What the box serves today
git grep error_page over the repo at v0.6.5 returns zero hits. Openship defines no page for
any 5xx on any install path, so every 502/504 from a generated vhost is whatever OpenResty emits
by default. The only HTML the edge serves is the 404 page in packages/adapters/src/infra/edge-not-found.ts.
Why oblien.com appears
A free *.opsh.io host is terminated by Openship Cloud's shared edge, which forwards to the box by IP with the Host: header — so, per apps/api/src/lib/edge-target.ts:
The edge is addressed by IP so the box's own OpenResty decides what to serve, matching on the Host: header the edge forwards
That cuts both ways, and it's what makes most of this fixable here: when the box answers, the box's
body is what reaches the visitor. When the box cannot be reached at all, the Cloud edge answers
on its own and that response is not ours to shape.
Scope boundary
Case
Who generates the 502
Fixable in this repo
App container down / not answering, box up
the box's OpenResty
Yes — this issue
Custom domain served directly by the box
the box's OpenResty
Yes — this issue
Box itself unreachable from the Cloud edge
Openship Cloud edge
No — needs a Cloud-side change
Stating the third row plainly: it is a known limitation of the fix proposed here, not something
this change quietly covers. A visitor whose box is entirely unreachable will still get the Cloud
edge's page. If that page's branding should change for self-hosted operators, it needs a separate
change outside this repository.
Proposed fix
Serve an Openship-branded page for upstream-down errors from the box's own edge, mirroring what #431 did for 404.
New packages/adapters/src/infra/edge-upstream-down.ts, built exactly like edge-not-found.ts: the body is inline in the config, for the reason that file's header
already documents — it has to arrive on every install path at once, and the bare/legacy edge is
driven over SSH into sites-enabled with no docroot we own. It reuses the 404 page's neutral
light/dark CSS and repeats its five tokenizer invariants (no ', no $, no newline, no #,
balanced braces), each of which is a nginx -t failure or a silently-truncated config read.
An include of one shared snippet file would avoid repeating ~2 KB per vhost, but a missing
include is a hard nginx -t failure, which freezes every later reload on the box. Happy to go
that way instead if you'd prefer the smaller config — flagging it as a call for you to make.
Content: no links, no reflected values. This page is served to strangers, so it carries no
dashboard URL, no project name, and no $host (which would be a reflected-XSS sink — the same
reasoning as in edge-not-found.ts). Removing the third-party link is the fix; pointing it at
the operator's own domain instead would just link to the site that is currently down.
Wired into proxy vhosts only in nginx.ts. locationBody already branches three ways; a hostRedirect or staticRoot route has no upstream and cannot 502. error_page goes at server
scope so the extra locations from renderProxyLocations are covered, and the named location is
repeated per server block exactly like renderSlashFallback does.
Codes 502 and 504 only — deliberately not 503.blockStatus (default 403) and rateLimit.status (default 429) are operator-overridable, and limit_req_status is set to 429
in nginx.ts. If an operator points either at 503, branding it "application unavailable" would
report a deliberate block as an outage.
VHOST_GENERATION 1 → 2. Without the bump the fix reaches no existing box: registerRoute
is the sole writer, and reapplyStoredRoutes replays only vhosts stamped below the current
generation.
edge-baked-conf.ts / apps/edge/nginx.conf are untouched — they generate the catch-all, which
serves the 404 and proxies nothing.
nginx semantics, verified rather than assumed
error_page … @named preserves or clobbers the status code depending on =, so I probed it in the
real image (openresty/openresty:1.27.1.1-alpine) with dead and hanging upstreams before proposing
the above:
Case
Syntax
Trigger
Status seen
Body seen
A
error_page 502 504 @loc;
nginx-generated 502
502 preserved
our handler
B
error_page 502 504 = @loc;
nginx-generated 502
503 — handler's code clobbers it
our handler
C
no =
real 504 read-timeout
504 preserved
our handler
D
no =
upstream's own 502
502
the app's own body
So: use the form without =, and one shared body covers both codes without collapsing 504 into
502. Case D is the one I most wanted to confirm — with proxy_intercept_errors off (the default,
and absent from this repo), an app that answers 502 itself keeps its own body, so this page never
hijacks a real response from a running app.
nginx.test.ts — handler present in both blocks of a proxy vhost, absent from static and
host-redirect vhosts, 503 never in the error_page line, generation marker reads 2.
openresty -t against a generated vhost in the real image, plus the probe above re-run as the
end-to-end check.
Summary
An app that is down returns OpenResty's stock
502 Bad Gatewaypage from the box. For a free*.opsh.iohost, what the visitor actually ends up seeing is a page carryingoblien.combranding and links — a third party's, from the point of view of the operator whose own domain
was requested.
Reported on Discord (11 Aug 2026):
This is the direct sibling of #431. That issue fixed unrouted hostnames (raw TLS errors and a
stock
openresty404) with a truthful, branded page; the upstream-down case was left on thestock page and is the one users hit far more often, because it fires every time a container is
restarting, crashed, or still booting.
What the box serves today
git grep error_pageover the repo at v0.6.5 returns zero hits. Openship defines no page forany 5xx on any install path, so every 502/504 from a generated vhost is whatever OpenResty emits
by default. The only HTML the edge serves is the 404 page in
packages/adapters/src/infra/edge-not-found.ts.Why
oblien.comappearsA free
*.opsh.iohost is terminated by Openship Cloud's shared edge, which forwards to the boxby IP with the
Host:header — so, perapps/api/src/lib/edge-target.ts:That cuts both ways, and it's what makes most of this fixable here: when the box answers, the box's
body is what reaches the visitor. When the box cannot be reached at all, the Cloud edge answers
on its own and that response is not ours to shape.
Scope boundary
Stating the third row plainly: it is a known limitation of the fix proposed here, not something
this change quietly covers. A visitor whose box is entirely unreachable will still get the Cloud
edge's page. If that page's branding should change for self-hosted operators, it needs a separate
change outside this repository.
Proposed fix
Serve an Openship-branded page for upstream-down errors from the box's own edge, mirroring what
#431 did for 404.
New
packages/adapters/src/infra/edge-upstream-down.ts, built exactly likeedge-not-found.ts: the body is inline in the config, for the reason that file's headeralready documents — it has to arrive on every install path at once, and the bare/legacy edge is
driven over SSH into
sites-enabledwith no docroot we own. It reuses the 404 page's neutrallight/dark CSS and repeats its five tokenizer invariants (no
', no$, no newline, no#,balanced braces), each of which is a
nginx -tfailure or a silently-truncated config read.An
includeof one shared snippet file would avoid repeating ~2 KB per vhost, but a missinginclude is a hard
nginx -tfailure, which freezes every later reload on the box. Happy to gothat way instead if you'd prefer the smaller config — flagging it as a call for you to make.
Content: no links, no reflected values. This page is served to strangers, so it carries no
dashboard URL, no project name, and no
$host(which would be a reflected-XSS sink — the samereasoning as in
edge-not-found.ts). Removing the third-party link is the fix; pointing it atthe operator's own domain instead would just link to the site that is currently down.
Wired into proxy vhosts only in
nginx.ts.locationBodyalready branches three ways; ahostRedirectorstaticRootroute has no upstream and cannot 502.error_pagegoes at serverscope so the extra locations from
renderProxyLocationsare covered, and the named location isrepeated per server block exactly like
renderSlashFallbackdoes.Codes
502and504only — deliberately not503.blockStatus(default 403) andrateLimit.status(default 429) are operator-overridable, andlimit_req_statusis set to 429in
nginx.ts. If an operator points either at 503, branding it "application unavailable" wouldreport a deliberate block as an outage.
VHOST_GENERATION1 → 2. Without the bump the fix reaches no existing box:registerRouteis the sole writer, and
reapplyStoredRoutesreplays only vhosts stamped below the currentgeneration.
edge-baked-conf.ts/apps/edge/nginx.confare untouched — they generate the catch-all, whichserves the 404 and proxies nothing.
nginx semantics, verified rather than assumed
error_page … @namedpreserves or clobbers the status code depending on=, so I probed it in thereal image (
openresty/openresty:1.27.1.1-alpine) with dead and hanging upstreams before proposingthe above:
error_page 502 504 @loc;error_page 502 504 = @loc;==So: use the form without
=, and one shared body covers both codes without collapsing 504 into502. Case D is the one I most wanted to confirm — with
proxy_intercept_errorsoff (the default,and absent from this repo), an app that answers 502 itself keeps its own body, so this page never
hijacks a real response from a running app.
Tests
edge-upstream-down.test.ts— the five invariants plus thestripComments+extractBlocksround trip that Unrecognized hostnames return raw TLS / OpenResty errors instead of a friendly “Service Not Found” page #431's fix originally broke.
nginx.test.ts— handler present in both blocks of a proxy vhost, absent from static andhost-redirect vhosts,
503never in theerror_pageline, generation marker reads 2.openresty -tagainst a generated vhost in the real image, plus the probe above re-run as theend-to-end check.
Questions before I write it
includesnippet with the missing-filerisk? I've assumed inline, following Unrecognized hostnames return raw TLS / OpenResty errors instead of a friendly “Service Not Found” page #431.
Happy to open the PR once the approach is agreed.