Skip to content

A Domain proxies everything to one upstream: no path routing for UI and API on one origin #90

Open

Description

@shipmill-romamo

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.html
  • proxy.upstream stays as shorthand for a single path: / proxy route, so existing Domains validate unchanged
  • Paths are validated: unique, start with /, most specific rendered first; /.well-known/acme-challenge/ stays reserved
  • A static route 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)
  • healthCheck stays per Domain; per-route checks can follow
  • Make proxy.upstream (or routes) required in the schema, so the role's assertion can never fail on a valid config

Alternatives

  • Two hostnames (app. and api.): works today, but adds CORS, a second certificate and cookie-domain handling for every product that has a UI
  • A raw extraConfig nginx 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 passes nginx -t, serves index.html for an unknown path under /, and proxies /api/health to the API
  • After cloudfall rollback of the component, / serves the previous release's files
  • An existing Domain with only proxy.upstream renders a byte-identical vhost
  • A Domain with two routes for the same path fails config validate

Activity

  1. shipmill-romamo commented on Oct 20, 2026

    @shipmill-romamo
    ContributorAuthor

    Triage: feature, with one bug fix split out to ship now.

    Confirmed on main (348ed73): domain.schema.json has a single proxy.upstream and domain-site.conf.j2 renders one location / 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.upstream is optional in the schema but the cloudfall_nginx_site role asserts it, so a Domain without it passes config validate and fails on the server. The schema will require it, so the failure moves to validation; no config that deploys today changes. When the spec adds routes, the rule becomes "upstream or routes". 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.

  2. shipmill-romamo commented on Oct 20, 2026

    @shipmill-romamo
    ContributorAuthor

    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 are 0750 today). Its rule "exactly one of proxy.upstream or routes" 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 upstream turned out to be in use.

  3. shipmill-romamo commented on Oct 20, 2026

    @shipmill-romamo
    ContributorAuthor

    @romamo Decision needed: should a Domain without proxy.upstream stay valid?

    My plan here was to make it required, so the failure in cloudfall_nginx_site shows up at config validate. But atlas-fleet's config/production/domains/old-civic-club.yaml leaves upstream out on purpose (docs/proxy-operations.md: it stays outside the GOST pool), and the fleet's own proxy.yml builds that route, not the Cloudfall role. The SDK model also treats upstream as optional. Requiring it would reject that Domain at the fleet's next pin bump.

    1. Keep it optional and fail early instead (recommended): upstream stays optional in the schema, and domains.yml (or inventory render) fails before any change, naming each Domain bound for cloudfall_nginx_site that has no upstream. atlas-fleet keeps validating. The routes spec (Spec domain routes for #90 #99) then reads "at most one of proxy.upstream or routes" instead of "exactly one"
    2. Require it, as planned: config validate catches the mistake earliest, but old-civic-club needs an upstream, which changes how the fleet's proxy builds that route
    3. Proxy to origin when upstream is 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 fix

    Reply here with a number or your own answer; shipmill takes this up on the tick after your reply.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestneeds-decisionA shipmill session asked a question here; waits for a reply

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions