Skip to content

Upstream-down hostnames return a raw OpenResty 502 instead of a friendly “Application unavailable” page #556

Description

@AbdullahM07

Summary

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

Tests

Questions before I write it

  1. Inline body per vhost (~2 KB × 2 blocks), or a shared include snippet with the missing-file
    risk? I've assumed inline, following Unrecognized hostnames return raw TLS / OpenResty errors instead of a friendly “Service Not Found” page #431.
  2. Any objection to excluding 503 for the reason in (4)?

Happy to open the PR once the approach is agreed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions