Repository navigation
A Domain proxies everything to one upstream: no path routing for UI and API on one origin #90
Description
Problem
Found while deploying Abecra's staging on 2026-10-19. Abecra serves its web app at / and its API at /api on one hostname, which keeps one TLS certificate and no CORS. domain.schema.json has a single proxy.upstream (address, port), and domain-site.conf.j2 renders exactly one location / that proxies to it.
So a static web build and an API cannot share a Domain unless the API serves the static files itself. That is the workaround: the API mounts the web build from an ABECRA_WEB_DIR directory with an SPA fallback, which puts static file serving, caching headers and index.html fallback in application code that nginx already does better. Two components behind one hostname (an API and a separate frontend server, or an API and a websocket service) are not expressible at all.
A related wrinkle seen at the same time: proxy.upstream is optional in the schema but the cloudfall_nginx_site role asserts it is defined, so a Domain without it validates and then fails on the server.
Proposal
Ordered routes on a Domain, each either a proxy or static files from a component's release:
spec:
routes:
- path: /api/
upstream: { address: 127.0.0.1, port: 8310 }
- path: /
static:
component: abecra-staging-api
directory: web/dist # inside releases/<release>/, via current
spaFallback: index.htmlproxy.upstreamstays as shorthand for a singlepath: /proxy route, so existing Domains validate unchanged- Paths are validated: unique, start with
/, most specific rendered first;/.well-known/acme-challenge/stays reserved - A
staticroute serves<installRoot>/current/<directory>, so it switches and rolls back with the component's release (this pairs with the build-step issue: the frontend build lives in the artifact) healthCheckstays per Domain; per-route checks can follow- Make
proxy.upstream(orroutes) required in the schema, so the role's assertion can never fail on a valid config
Alternatives
- Two hostnames (
app.andapi.): works today, but adds CORS, a second certificate and cookie-domain handling for every product that has a UI - A raw
extraConfignginx snippet: flexible, but unvalidated config is what the managed vhost exists to avoid
Acceptance
- A Domain with
/api/→ upstream and/→ static renders a vhost that passesnginx -t, servesindex.htmlfor an unknown path under/, and proxies/api/healthto the API - After
cloudfall rollbackof the component,/serves the previous release's files - An existing Domain with only
proxy.upstreamrenders a byte-identical vhost - A Domain with two routes for the same path fails
config validate
Activity
shipmill-romamo commentedon Oct 20, 2026
Triage: feature, with one bug fix split out to ship now.
Confirmed on main (348ed73):
domain.schema.jsonhas a singleproxy.upstreamanddomain-site.conf.j2renders onelocation /to it. Ordered routes with static files served from a component's release are new behaviour across the schema, the vhost template and rollback, so they go through a spec with acceptance criteria before any code. They pair with #86 (the frontend build lives in the artifact), so both specs are written together.The bug fix, in its own PR now:
proxy.upstreamis optional in the schema but thecloudfall_nginx_siterole asserts it, so a Domain without it passesconfig validateand fails on the server. The schema will require it, so the failure moves to validation; no config that deploys today changes. When the spec addsroutes, the rule becomes "upstreamorroutes". That PR says "Part of #90".The spec PR is being written now (
docs/specs/003-domain-routes.md); I'll put its link and the hold line here. Merging it is the approval.shipmill-romamo commentedon Oct 20, 2026
The routes spec is in #99 (
docs/specs/003-domain-routes.md). Notes from writing it: nginx matches prefix locations by the longest match, so routes render most specific first and the declared order doesn't matter. Static paths must end in/, and the nginx worker gets traverse-only access to the release directories (they are0750today). Its rule "exactly one ofproxy.upstreamorroutes" runs into the question below.On hold: the build waits on #99, the spec
Waits on your decision below: the bug-fix PR I planned isn't open, because a Domain without
upstreamturned out to be in use.shipmill-romamo commentedon Oct 20, 2026
@romamo Decision needed: should a Domain without
proxy.upstreamstay valid?My plan here was to make it required, so the failure in
cloudfall_nginx_siteshows up atconfig validate. But atlas-fleet'sconfig/production/domains/old-civic-club.yamlleavesupstreamout on purpose (docs/proxy-operations.md: it stays outside the GOST pool), and the fleet's ownproxy.ymlbuilds that route, not the Cloudfall role. The SDK model also treatsupstreamas optional. Requiring it would reject that Domain at the fleet's next pin bump.upstreamstays optional in the schema, anddomains.yml(orinventory render) fails before any change, naming each Domain bound forcloudfall_nginx_sitethat has noupstream. atlas-fleet keeps validating. The routes spec (Spec domain routes for #90 #99) then reads "at most one ofproxy.upstreamorroutes" instead of "exactly one"config validatecatches the mistake earliest, butold-civic-clubneeds anupstream, which changes how the fleet's proxy builds that routeoriginwhenupstreamis missing: the Cloudfall role would serve that shape too. This overlaps with the routes spec, so it would be written into Spec domain routes for #90 #99 rather than shipped as a fixReply here with a number or your own answer; shipmill takes this up on the tick after your reply.