refactor: move the public host out to vantage-site and vantage-docs

site/ and sitesvc/ become web/ and server/ in vantage-site; docsite/
becomes the root of vantage-docs. Their images move with them, to
vantage/vantage-site/{web,server} and vantage/vantage-docs.

Nothing here imported any of them, and sitesvc turned out to read no
database at all, so both cuts are clean. docker-compose.site.yml is
deleted rather than emptied: every service it held now ships with the
repository that builds it, and deploy/docker/docker-compose.yml is once
again exactly a self-hosted install.

Corrects four comments that named sitesvc for work it no longer does.
This commit is contained in:
2026-09-08 08:41:11 +00:00
parent 872699c38c
commit f9dec9b230
98 changed files with 135 additions and 34383 deletions
+120 -105
View File
@@ -68,36 +68,26 @@ vantage/
│ ├── app/login, app/setup # unauthed routes
│ ├── components/ # ui/, workflows/, monitors/, Sidebar
│ └── lib/ # api client, guac console, query client
├── site/ # public marketing site
│ ├── app/ # one directory per route
│ ├── components/ # Nav, Footer, Logo, InstrumentPanel, forms
│ ├── assets/ # image sources, not served
│ └── Dockerfile # same shape as web/: standalone, node, 3000
├── sitesvc/ # public form: contact mail only
│ ├── cmd/main.go
│ └── internal/
│ ├── api/ # contact
│ └── store/ # Mongo connect helper
├── docsite/ # user documentation (Docusaurus, static)
│ ├── docs/ # getting-started, vantage, hq, reference, operations
│ ├── src/css/custom.css # site/'s tokens, copied, mapped onto --ifm-*
│ ├── sidebars.ts # authored by hand, not autogenerated
│ └── nginx.conf # serves the build under /docs
├── proto/vantage/v1/vantage.proto
├── installer/ # Windows: setup.ps1, nssm.exe, WiX .wxs
├── deploy/ # docker-compose.yml, agent.service
└── .gitea/workflows/ # agent-release.yml, server-deploy.yml
```
**Two repositories carry parts of Vantage that this one does not.**
**Three repositories carry parts of Vantage that this one does not.**
| Repository | What it holds |
| ---------------- | ------------------------------------------------------------------------------------------------- |
| `vantage-shared` | the private Go module below — `mail`, `license`, `models`, `provision`, `backup`, `grpc/pb`, … |
| `vantage-admin` | Vantage HQ: the licensing authority (`server/`, was `admin/`) and its console (`web/`, was `adminsite/`) |
| `vantage-site` | the marketing site (`web/`, was `site/`) and its contact-form service (`server/`, was `sitesvc/`) |
| `vantage-docs` | the user documentation, at the repository root (was `docsite/`) |
`vantage-admin` is **not** a build dependency of anything here — there is no
import in either direction, deliberately (see "Grants project, they do not
**None of the three is a build dependency of anything here**, and nothing here
is a dependency of them. `vantage-site` and `vantage-docs` are wholly
independent — the contact-form service stores nothing and reads no database, so
the split cost nothing. `vantage-admin` is the only one with a live coupling,
and there is still no import in either direction, deliberately (see "Grants project, they do not
federate"). It reaches this codebase two ways at runtime, both by writing
directly into the control plane's MongoDB: `inject` for three licence fields
and `cloudprov` for instances and their owners. The parts of that contract this
@@ -111,9 +101,9 @@ The rest lives in that repository's own CLAUDE.md.
email system: transport plus templates), `license/` (payload, sign, verify,
trusted keys, plans), `models/` (Instance, User, Settings), `provision/`,
`backup/`, `cryptobox/`, `indexes/`, `grpc/pb` + `grpc/codec`, and
`cmd/lkctl/`. Four modules here depend on it — `server`, `agent`, `sitesvc`,
`vantagectl` — each pinning a version in its own `go.mod`, as does
`vantage-admin`. It was a
`cmd/lkctl/`. Three modules here depend on it — `server`, `agent` and
`vantagectl` — each pinning a version in its own `go.mod`, as do
`vantage-admin` and `vantage-site`. It was a
directory in this repository until it was extracted with its history; the
`replace ../shared` directives and the `./shared` entry in `go.work` are gone
with it.
@@ -136,9 +126,9 @@ arg, because an arg survives in the builder layer's history and this one is a
Gitea token. Locally, either a netrc or
`git config --global url."git@gitea.hostxtra.co.uk:".insteadOf https://gitea.hostxtra.co.uk/`.
**Two build contexts shrank as a result.** `sitesvc` and `vantagectl` build from
their own directory now; only `server` still builds from the repository root,
and only because its runtime stage copies `default_steps/`.
**`vantagectl` builds from its own directory as a result.** `server` still
builds from the repository root, and only because its runtime stage copies
`default_steps/`.
---
@@ -611,7 +601,7 @@ inserted in front. The same setting also decides the address recorded in
`vantagectl` is a standalone Go module (`vantagectl/`), not a subcommand of
`server`. It needs its own module rather than living inside `server`'s for the
same reason `admin` and `sitesvc` already do: `server` imports the rest of
same reason `admin` and `sitesvc` did before they left: `server` imports the rest of
`server`'s dependency graph, and `spf13/cobra` has no business in a process
that also terminates gRPC streams and serves the REST API. More to the point,
`vantagectl` has to run when the control plane **does not** — a backup or
@@ -760,46 +750,47 @@ reference that lies. Scalar is vendored (`scalar.standalone.js`, served from
reference page has to work on an air-gapped install with no outbound access at
all — the same requirement licence verification already meets.
### Marketing site and sitesvc
### The public host
Every Paddle variable — `PADDLE_CLIENT_TOKEN`, `PADDLE_ENV`, `PADDLE_API_KEY`, `PADDLE_WEBHOOK_SECRET` — now belongs to `vantage-admin` and is set there. None is read by anything in this repository.
`site/` is a separate Next.js app built exactly like `web/``output: "standalone"`, run by Node in a `node:26-alpine` image, listening on `3000` and published as `3003`. The contact form posts to `sitesvc`; account signup posts to `admin` (`NEXT_PUBLIC_ADMIN_API_URL`), which creates an HQ account, not an org — the control plane is not touched until the customer later creates a cloud instance from the portal.
The HQ console is no longer built here at all — it is `web/` in
`vantage-admin`, published as `3004` and served at
`vantage-hq.hostxtra.co.uk`, deliberately _outside_
`*.vantage.hostxtra.co.uk` because that namespace is per-tenant instance
subdomains and `APP_ROOT_LABEL` resolves an org from the label before
`vantage`.
`sitesvc/` (port `8082`) now owns only the contact flow:
| Form | Endpoint | Effect |
| ------- | ------------------- | ----------------------------------------------------------------------- |
| Contact | `POST /api/contact` | Emails `support@hostxtra.co.uk`, `Reply-To` the sender. Nothing stored. |
Account signup lives in `admin` instead (`POST /auth/signup`, `GET /auth/verify?token=…`) — see Signup and verification below.
`site`, `sitesvc` and `docsite` are deliberately **excluded from the self-hosted deployment**: `deploy/docker-compose.yml` mentions none of them, and they live in `deploy/docker-compose.site.yml` instead. So is Vantage HQ, which is now excluded by construction — it is a different repository with its own compose fragment.
### Documentation site
`docsite/` is the user-facing documentation — Docusaurus 3 in docs-only mode (`routeBasePath: "/"`, no blog), one version tracking `main`, search indexed at build time by `@easyops-cn/docusaurus-search-local` so nothing external is keyed or called. It documents the **product**, not the codebase: this file remains the contributor's map, and the two are allowed to differ in altitude but not in fact. Five sections — Getting started, Vantage, Vantage HQ, Reference, Operations — with `sidebars.ts` authored by hand so ordering is a decision rather than a filename accident.
Unlike the three Next apps it builds to static files, so its runtime stage is `nginx:alpine-slim` rather than Node, and it listens on `80`. See the compose note below for the `/docs` prefix, which is the one thing about it that is easy to get wrong.
**vantage.hostxtra.co.uk is not served by this repository.** The marketing site
and its contact-form service are `vantage-site`; the documentation at `/docs` is
`vantage-docs`; the HQ console at `vantage-hq.hostxtra.co.uk` is
`vantage-admin`. Each carries its own compose fragment, and the host composes
them on top of this one:
```bash
# self-hosted install — no marketing site, no sitesvc
docker compose up -d
# self-hosted install — the control plane and nothing else
docker compose -f deploy/docker/docker-compose.yml up -d
# vantage.hostxtra.co.uk — control plane plus the public site
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d
# vantage.hostxtra.co.uk — every repository's fragment together
docker compose \
-f vantage/deploy/docker/docker-compose.yml \
-f vantage-site/deploy/docker-compose.yml \
-f vantage-docs/deploy/docker-compose.yml \
-f vantage-admin/deploy/docker-compose.yml \
up -d
```
`docker-compose.site.yml` is gone from this repository: every service it held
now lives with the repository that builds it. The self-hosted exclusion used to
be a rule about which file a service went in; it is the repository boundary now.
**The reverse proxy in front is shared and belongs to none of them.** On
vantage.hostxtra.co.uk that is an Nginx Proxy Manager, and its routing spans
repositories: `/docs` to `vantage-docs` — a location that must sort **above**
the catch-all — and everything else on that host to `vantage-site`. A
self-hosted install needs its own; see the compose note below for what it must
route.
One coupling survives the split and is easy to miss: the marketing site's
`/start` form posts account signups **straight to `vantage-admin`**, not to
anything here. The control plane is not touched until the customer later creates
a cloud instance from the portal.
### Signup and verification
Signup is **account-first**: it creates an HQ account and an unverified `customer_user` in admin's own database, nothing in the control plane. Only after a customer later creates a cloud instance from the portal (`POST /api/instances`, see Admin REST API) does an org, or rather an `instance`, come to exist — provisioned by `cloudprov`, with the owner's password hash copied from the HQ user rather than shared. `site_pending_signups` is gone; sitesvc no longer has a signup flow at all.
Signup is **account-first**: it creates an HQ account and an unverified `customer_user` in admin's own database, nothing in the control plane. Only after a customer later creates a cloud instance from the portal (`POST /api/instances`, see Admin REST API) does an org, or rather an `instance`, come to exist — provisioned by `cloudprov`, with the owner's password hash copied from the HQ user rather than shared. `site_pending_signups` is gone; the contact-form service has no signup flow at
all, and lives in another repository besides.
- The token is 32 random bytes; only its **SHA-256 hash** is stored, so a leaked database yields no working links.
- Links expire after 24 hours (`VerifyWindow`).
@@ -822,16 +813,17 @@ left unspent.
### Email
`shared/mail` is the only email system. It owns the SMTP conversation, the RFC
5322 envelope and the look of every message; `server`, `admin` and `sitesvc`
each import it and none of them builds a subject line, a MIME part or a colour.
5322 envelope and the look of every message; the control plane, `vantage-admin`
and `vantage-site` each import it and none of them builds a subject line, a MIME
part or a colour.
Before this existed the transport was copied three times, and the copies had
already diverged once — the 465-implicit-TLS fix landed in one of them while
the others silently delivered nothing.
`Sender` is a value, not a singleton: `server/internal/notify` builds one per
notification channel from the channel document in Mongo, while `sitesvc` builds
one at boot and `admin` holds one in `admin/internal/mail.Default`, alongside
its other boot-time singletons. Callers only ever see typed methods —
notification channel from the channel document in Mongo, while `vantage-site`'s
service builds one at boot and `vantage-admin` holds one in its own
`internal/mail.Default`, alongside its other boot-time singletons. Callers only ever see typed methods —
`SendVerification`, `SendExpiring`, `SendMonitorAlert`, `SendEnquiry` and the
rest, grouped by owner into `account.go`, `licence.go`, `billing.go`,
`monitor.go` and `contact.go`.
@@ -1169,19 +1161,7 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
| `VANTAGE_SKIP_MIGRATIONS` | no | serve without running schema setup, on the assumption a Job already did. Set by the chart's Deployment whenever `server.migrationJob.enabled`. Unset under Compose, where one process still migrates and then serves |
| `VANTAGE_TRIVY_DB_REF` | no | default `ghcr.io/aquasecurity/trivy-db:2`. Point at a mirror for an air-gapped install, or to avoid the anonymous ghcr rate limit |
| `VANTAGE_VULNDB_DISABLED` | no | `true` disables the vulnerability database puller and scan loop entirely. Findings already written are still served, and still shown as stale |
| `FREE_INSTANCE_REAP_AFTER` | no | duration past a Free licence's expiry before the instance and all its data are deleted. **Empty disables the reaper, and empty is the default.** Set to `336h` in `docker-compose.site.yml` only — a self-hosted deployment must never reap. Must match admin's value, which only names the date in warning emails |
**sitesvc** (`deploy/docker-compose.site.yml` only):
| Name | Required | Notes |
| --------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MONGO_URI` | yes | **must point at the control plane's database.** sitesvc no longer provisions orgs itself, but it still refuses to start (`RequireMigratedDatabase`) against a database that has not run migration `0004` (the `orgs` → `instances` rename), and it (re)declares the shared `users.email` / `instances.slug` indexes at boot. The database name is read from the URI path; a URI without one is refused rather than defaulted. Note this differs from the server, which takes `MONGO_DB` separately. |
| `SMTP_HOST` / `SMTP_FROM` | yes | without them the contact form refuses (503) rather than silently dropping |
| `SMTP_TO` | no | default `support@hostxtra.co.uk`; contact enquiries only |
| `SMTP_PORT` | no | default `587`; `465` uses implicit TLS |
| `SMTP_USERNAME` / `SMTP_PASSWORD` | no | auth skipped when username is empty |
| `SITE_ORIGIN` | yes in practice | comma-separated allowed origins; unset refuses every cross-origin browser request |
| `TRUST_PROXY` | no | only `true` behind a proxy that overwrites `X-Forwarded-For`, or clients spoof past the rate limiter |
| `FREE_INSTANCE_REAP_AFTER` | no | duration past a Free licence's expiry before the instance and all its data are deleted. **Empty disables the reaper, and empty is the default.** Set to `336h` on vantage.hostxtra.co.uk only — a self-hosted deployment must never reap. Must match admin's value, which only names the date in warning emails |
### Ingress (Helm, Traefik)
@@ -1193,7 +1173,7 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
| `ingress.api.paths` (when `api.enabled`) | `/api`, `/auth` → `<release>-server:8080`, bypassing the Next proxy |
| `ingress.grpc.host` | agents → a dedicated `<release>-server-grpc` Service on 9090, annotated `serversscheme: h2c` |
**`ingress.web.host` is normally a wildcard.** `*.vantage.example.com` is the per-tenant instance namespace — `APP_ROOT_LABEL` resolves the instance from the label. A Kubernetes wildcard host matches **exactly one** label, so it does not match the apex, and here that is correct rather than a gap: `vantage.hostxtra.co.uk` is the marketing site (`site/`, in `docker-compose.site.yml`), which this chart does not deploy. `extraHosts` is for a genuine second name; adding the apex to it would put the control plane on the marketing host. Every host in the list gets identical paths.
**`ingress.web.host` is normally a wildcard.** `*.vantage.example.com` is the per-tenant instance namespace — `APP_ROOT_LABEL` resolves the instance from the label. A Kubernetes wildcard host matches **exactly one** label, so it does not match the apex, and here that is correct rather than a gap: `vantage.hostxtra.co.uk` is the marketing site, which lives in `vantage-site` and which this chart does not deploy. `extraHosts` is for a genuine second name; adding the apex to it would put the control plane on the marketing host. Every host in the list gets identical paths.
**`ingress.api.enabled` routes `/api`, `/auth`, `/public`, `/install*` and `/update*` straight to the server, and it is not optional.** It defaults to **true** and the chart refuses to render with it off, because `web` proxies nothing: with those prefixes unrouted the UI loads and every request it makes 404s against Next. The value survives only for an installation whose own terminator sits in front of this ingress and routes them there instead. Traefik derives router priority from rule length, so `PathPrefix(/api)` outranks the catch-all `/` with no priority annotation needed.
@@ -1207,11 +1187,9 @@ TLS is `ingress.tls.secretName` / `grpcSecretName` (pre-existing certificates) *
**Neither compose file ships a reverse proxy, and both now need one.** `web:3000` serves the UI only; a request to `/api` there is a Next 404. Route `/api`, `/auth`, `/public`, `/install`, `/install.ps1`, `/update`, `/update.ps1` to `server:8080` and everything else to `web:3000` — on vantage.hostxtra.co.uk that is the Nginx Proxy Manager already in front, and it is what a self-hosted install has to configure before the UI works at all.
`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds three more — `site` (3003), `sitesvc` (8082) and `docsite` (3005) — and is only used on vantage.hostxtra.co.uk. **Vantage HQ is a third file**, `deploy/docker-compose.yml` in the `vantage-admin` repository, which adds `admin` (8083) and `adminsite` (3004); the host composes all three together.
`deploy/docker/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. **That is the whole of a self-hosted install**, and it is now the only compose file here. vantage.hostxtra.co.uk adds three fragments from three other repositories — `vantage-site` (`site` 3003, `sitesvc` 8082), `vantage-docs` (`docsite` 3005) and `vantage-admin` (`admin` 8083, `adminsite` 3004) — composed together as shown under "The public host".
`docsite` is the odd one: a **static** build served by `nginx:alpine-slim`, not a Node runtime, and it listens on `80` rather than `3000`. It is reached at **`vantage.hostxtra.co.uk/docs`** — a path on the marketing host, routed by its own Nginx Proxy Manager location, which must sort **above** the catch-all forwarding to `site:3003` or Next answers the 404. A path and not a subdomain because `*.vantage.hostxtra.co.uk` is the per-tenant instance namespace and `APP_ROOT_LABEL` would read a `docs.` label as a tenant slug. NPM forwards the **full** path upstream — it does not strip `/docs` — so `DOCS_BASE_URL`, the proxy location and the directory the image copies the build into (`/usr/share/nginx/html/docs`) must all agree. When they do not, the HTML loads and every asset 404s.
`LICENSE_SIGNING_KEY` appears in **no compose file in this repository**, and must never be added to one: admin is the only signer, and it now lives in `vantage-admin` along with its own compose fragment. Neither `docker-compose.yml` nor `docker-compose.site.yml` should ever mention `admin` or `adminsite` again — the separation used to be a rule someone had to remember, and is now the repository boundary. `server` reads `REDIS_ADDR`/`REDIS_USERNAME`/`REDIS_PASSWORD` so a Kubernetes install can point at a managed Redis; the base compose still hardcodes an unauthenticated `redis:6379` for it.
`LICENSE_SIGNING_KEY` appears in **no compose file in this repository**, and must never be added to one: admin is the only signer, and it now lives in `vantage-admin` along with its own compose fragment. `docker-compose.yml` should never mention `admin` or `adminsite` again — the separation used to be a rule someone had to remember, and is the repository boundary now. `server` reads `REDIS_ADDR`/`REDIS_USERNAME`/`REDIS_PASSWORD` so a Kubernetes install can point at a managed Redis; the base compose still hardcodes an unauthenticated `redis:6379` for it.
---
@@ -1235,28 +1213,60 @@ Next.js 16 (App Router) + React 18, Tailwind 3, TanStack Query. Guacamole client
All four apps are **one visual system**, anchored on the logo navy. What differs between them is which end of it they stand on:
| App | Ground | Accent | Themes |
| ------------ | -------------------------- | -------------------------------- | --------------------------- |
| `web/` | `--ground` dark, `#071628` | `#5b9be8` | dark only, locked |
| `site/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark |
| `adminsite/`\* | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
| `docsite/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
| App | Ground | Accent | Themes |
| --------------- | -------------------------- | -------------------------------- | --------------------------- |
| `web/` (here) | `--ground` dark, `#071628` | `#5b9be8` | dark only, locked |
| `vantage-site` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark |
| `vantage-admin` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
| `vantage-docs` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
\* `adminsite/` is `web/` in the **`vantage-admin` repository** now, and is listed here because the palette is one system across all four apps regardless of which repository they sit in.
Only the first row is in this repository. The other three are listed because
the palette is one system across all four front ends regardless of which
repository they sit in.
`docsite/src/css/custom.css` holds `site/app/globals.css`'s token blocks **copied verbatim** — same names, same values — and so does `vantage-admin/web/app/globals.css`. `web/app/globals.css` holds the same tokens too, but only the **dark** values, since it does not switch. **Change a token in all four files in the same commit; nothing enforces the match automatically** — and one of those four is now in another repository, so "the same commit" is no longer even possible. That makes this worse than it was, not better: the drift window is however long it takes to push twice.
`vantage-site`'s `web/app/globals.css` is the **origin**: it is the only one
carrying both light and dark values in full, and the other three copy its token
blocks **verbatim** — same names, same values. `web/` here holds the same tokens
but only the **dark** values, since it does not switch. **Nothing enforces the
match, and the four now sit in four repositories, so "change them in the same
commit" is not merely unenforced but impossible.** The drift window is however
long it takes to push four times. Treat a token change as an announcement
rather than a refactor.
`docsite/` is the one place the tokens are not consumed through Tailwind: everything below its token block maps Docusaurus's `--ifm-*` variables onto them. Docusaurus already stamps `data-theme` on `<html>`, which is the selector `site/`'s dark block keys on, so the built-in toggle needed no wiring. The rule holds all the same — no rule in that file outside the token blocks carries a hex. The one concession is `docsite/static/img/favicon.svg`, which must, for the same reason the email layout must: a browser tab cannot read a token.
`vantage-docs` is the one place the tokens are not consumed through Tailwind:
everything below its token block maps Docusaurus's `--ifm-*` variables onto
them. Docusaurus already stamps `data-theme` on `<html>`, which is the selector
the dark block keys on, so the built-in toggle needed no wiring. The rule holds
all the same — no rule in that file outside the token blocks carries a hex. Its
one concession is a favicon, which must, for the same reason the email layout
must: a browser tab cannot read a token.
There is a **fifth** copy, and it is the one people forget: `shared/mail/templates/layout.html.tmpl` carries web/'s dark values as literal hex. Email clients support neither `var()` nor a reliable `prefers-color-scheme`, so the token indirection is simply not available there — an email is read before the recipient clicks through to the control plane, and the two should not look like different products. Every colour in the email system is in that one file, in the same way no component in the four web apps carries a hex.
Tailwind in all of them maps `var(--…)` references only, so **no component in
any of them may carry a hex value**. The names differ per app on purpose,
because each has its own subject: `vantage-site` calls the semantic three
`--up`/`--pend`/`--down` for monitor state, `vantage-admin` aliases them to
`valid`/`warn`/`expired` for licence state, and `web/` here to
`success`/`warning`/`danger`. Same colours, honest names on each side.
Tailwind in all of them maps `var(--…)` references only, so **no component in any of them may carry a hex value**. The names differ per app on purpose, because each app has its own subject: `site/` calls the semantic three `--up`/`--pend`/`--down` for monitor state, `adminsite/` aliases them to `valid`/`warn`/`expired` for licence state, and `web/` to `success`/`warning`/`danger`. Same colours, honest names on each side.
`web/` is locked to dark and the HQ console defaults to **light**, and that
pairing is the point: an operator with both open should never mistake one for
the other before clicking Reissue. Now that both are drawn from the same palette
the distinction rests **entirely** on the ground, so do not make dark the HQ
console's default and do not give `web/` a light theme. State never reads by
colour alone in either: every pill carries a distinct shape and a text label.
`web/` stores its tokens as **RGB channel triplets** with the hex in a trailing comment, and derives `--token: rgb(var(--token-rgb))` from them. That is not a style preference: the console leans on Tailwind's opacity modifiers (`bg-danger/10`, `border-accent/50`, `ring-accent/30`) in a way the other two do not, and `<alpha-value>` only compiles against channels. Keep the hex comments — they are what lets the three token blocks still be diffed by eye. `web/` also adds three tokens site/ has no use for: `--accent-hover` and `--down-hover` (site/ brightens with a CSS `filter`, which a Tailwind colour token cannot do) and `--well`, the floor beneath the ground for install one-liners, key blobs and run logs — surfaces showing machine output rather than interface.
There is a **fifth** copy, and it is the one people forget:
`shared/mail/templates/layout.html.tmpl` in `vantage-shared` carries `web/`'s
dark values as literal hex. Email clients support neither `var()` nor a reliable
`prefers-color-scheme`, so the token indirection is simply not available there —
an email is read before the recipient clicks through to the control plane, and
the two should not look like different products.
`web/` stores its tokens as **RGB channel triplets** with the hex in a trailing comment, and derives `--token: rgb(var(--token-rgb))` from them. That is not a style preference: the console leans on Tailwind's opacity modifiers (`bg-danger/10`, `border-accent/50`, `ring-accent/30`) in a way the other two do not, and `<alpha-value>` only compiles against channels. Keep the hex comments — they are what lets the four token blocks still be diffed by eye, which matters more now that they cannot be diffed by `git`. `web/` also adds three tokens the marketing site has no use for: `--accent-hover` and `--down-hover` (it brightens with a CSS `filter`, which a Tailwind colour token cannot do) and `--well`, the floor beneath the ground for install one-liners, key blobs and run logs — surfaces showing machine output rather than interface.
`web/` is locked to dark and the HQ console defaults to **light**, and that pairing is the point: an operator with both open should never mistake one for the other before clicking Reissue. Now that both are drawn from the same palette the distinction rests **entirely** on the ground, so do not make dark the HQ console's default and do not give web/ a light theme. State never reads by colour alone in either: every pill carries a distinct shape and a text label. The same argument applies one level in: the **staff** masthead sits on `--panel-2` with a `STAFF` chip, so staff and customer screens are not identical either.
`web/` collapses Tailwind's radius scale — `md`, `lg` and `xl` all resolve to site/'s 4px — rather than rewriting the ~140 `rounded-lg` classes across its pages. Every one of them meant "a panel corner", and `tailwind.config.ts` is now where that decision lives. `rounded-full` is untouched: status dots and pills still need it.
`web/` collapses Tailwind's radius scale — `md`, `lg` and `xl` all resolve to the shared 4px — rather than rewriting the ~140 `rounded-lg` classes across its pages. Every one of them meant "a panel corner", and `tailwind.config.ts` is now where that decision lives. `rounded-full` is untouched: status dots and pills still need it.
The HQ console's own shell, its `/staff/pricing` page and the catalogue coverage
ledger are documented in `vantage-admin`. They are still built from these
@@ -1314,13 +1324,17 @@ GOOS=linux GOARCH=amd64 go build \
### `server-deploy.yml` — triggered on every push to `main`
Builds and pushes five images to the Gitea container registry: `server`, `web`, `site`, `sitesvc` and `docsite`. **`admin` and `adminsite` are no longer among them** — they are built by `vantage-admin`'s own workflow, as `vantage/vantage-admin/server` and `vantage/vantage-admin/web`. **`vantagectl` is deliberately not among them** — it is a released tool rather than a running service, and its image is version-tagged by `vantagectl-release.yml`.
Builds and pushes **two** images to the Gitea container registry: `server` and `web`. Everything else that used to be built here now belongs to the repository that owns it — `vantage-site`, `vantage-docs` and `vantage-admin` each publish their own. **`vantagectl` is also not among them** — it is a released tool rather than a running service, and its image is version-tagged by `vantagectl-release.yml`.
Note that despite the name, **this workflow does not deploy** — it only builds and pushes. There is no SSH step. Rolling images out is a separate manual step on the host:
```bash
cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.yml pull && \
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --remove-orphans
# self-hosted
cd /opt/vantage && docker compose -f deploy/docker/docker-compose.yml pull && \
docker compose -f deploy/docker/docker-compose.yml up -d --remove-orphans
# vantage.hostxtra.co.uk — all four repositories' fragments, see "The public host"
```
**Each image only rebuilds when its own inputs changed.** A `git diff` against `github.event.before` decides, which is why the checkout uses `fetch-depth: 0` — the default shallow clone has one commit and nothing to diff — and why `git` is installed in the `docker:dind` container. The mapping follows the build contexts exactly:
@@ -1328,8 +1342,7 @@ cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.y
| Image | Rebuilds when |
| ---------------------------- | -------------------------------- |
| `server` | `server/`, `proto/`, `go.work` |
| `sitesvc` | `sitesvc/`, `go.work` |
| `web` · `site` · `docsite` | their own directory only |
| `web` | `web/` only |
**No path in this table names `shared/` any more**, and no fan-out rule replaces
it: `vantage-shared` is an external module pinned per service, so a service
@@ -1384,14 +1397,16 @@ git push origin main # server + web deploy
| ~~`REGISTRY_PASSWORD`~~ | — | **Not used.** Named here historically; no workflow reads it. Referencing an unset secret yields an empty password and a `401 Failed to authenticate user` that looks like a token scope problem. Use `RELEASE_TOKEN` |
| `DOCKER_HOST` | Variable | registry host used for image tags |
| ~~`API_URL`~~ | — | **Gone.** `web` proxies nothing and holds no address for the control plane. `/api`, `/auth`, `/public`, `/install*` and `/update*` must be routed to `server:8080` by the reverse proxy in front of both; everything else goes to `web:3000`. One variable that could name the wrong host was one request path too many — pointed at the marketing site, `/public/status/…` answered a Next 404 indistinguishable from a status page that does not exist. |
| `SITE_URL` | Variable | browser URL of the marketing site. Also set in `vantage-admin`, where it is baked into the console so `/login` can point at `/start`. **Signup has no page in the console at all** — one signup form, on `site/`. |
| `SITE_API_URL` | Variable | **browser-reachable** sitesvc URL, baked into the `site` image. Required — if empty, both forms report "not connected" and submit nowhere. Must also be in sitesvc's `SITE_ORIGIN`. |
| `SITE_CONTACT_EMAIL` | Variable | optional; address shown when a form is misconfigured |
| `ADMIN_API_URL` | Variable | **browser-reachable** admin URL, baked into the `site` image — `site/start` posts account signups straight to admin. Same footgun as `SITE_API_URL`: wrong here and every request fails at runtime with the not-connected panel. `vantage-admin` sets a variable of the same name for its own console; they must agree. |
| `HQ_URL` | Variable | optional; browser URL of the HQ portal, baked into `web` so an `hq`-sourced member links to where they are managed. Empty on self-hosted, which renders a plain label instead. |
| `DOCS_URL` | Variable | site `url` baked into `docsite`; `https://vantage.hostxtra.co.uk`. Empty falls back to that default rather than breaking the build. |
| `DOCS_BASE_URL` | Variable | `/docs/`. Must match the NPM location and the directory the image serves from — all three, or the HTML loads and every asset 404s. |
| `APP_URL` | Variable | control-plane link in `docsite`'s navbar. |
`SITE_URL`, `SITE_API_URL`, `SITE_CONTACT_EMAIL`, `ADMIN_API_URL`, `ADMIN_ENV`,
`DOCS_URL`, `DOCS_BASE_URL`, `APP_URL` and every `PADDLE_*` name are set on the
repository that bakes them in — `vantage-site`, `vantage-docs` or
`vantage-admin` — and none of them is read by anything here. Two are set in
**two** repositories and must agree: `ADMIN_API_URL` (`vantage-site` bakes it
into the marketing site's signup form, `vantage-admin` into its own console) and
`SITE_URL`.
---