feat: documentation site

Docusaurus 3 docs-only site at docsite/, served statically by nginx under
/docs on the marketing host. Covers getting started (self-hosted install
through first server and first licence), the control plane, Vantage HQ,
a reference section and operations.

Wired into docker-compose.site.yml as docsite (3005:80) and into the
image build workflow, rebuilding on its own directory only. Never added
to the self-hosted compose file.
This commit is contained in:
2026-07-28 15:46:33 +01:00
parent d9945882e5
commit f46fb7fc0e
46 changed files with 23697 additions and 2 deletions
+19 -2
View File
@@ -81,11 +81,13 @@ jobs:
flag sitesvc '^(sitesvc/|shared/|go\.work)'
flag admin '^(admin/|shared/|go\.work)'
# The three Next images use their own directory as the build
# context, so nothing outside it can affect them.
# The three Next images and the docs site use their own
# directory as the build context, so nothing outside it can
# affect them.
flag web '^web/'
flag site '^site/'
flag adminsite '^adminsite/'
flag docsite '^docsite/'
- name: Log in to registry
run: |
@@ -152,3 +154,18 @@ jobs:
-t "$IMAGE" \
-f adminsite/Dockerfile adminsite/
docker push "$IMAGE"
- name: Build and push docsite image
if: steps.changed.outputs.docsite == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/docsite:latest"
# DOCS_BASE_URL must match the proxy location that routes to
# this container and the directory the image serves from.
docker build \
--build-arg DOCS_URL="${{ vars.DOCS_URL }}" \
--build-arg DOCS_BASE_URL="${{ vars.DOCS_BASE_URL }}" \
--build-arg APP_URL="${{ vars.APP_URL }}" \
--build-arg HQ_URL="${{ vars.HQ_URL }}" \
-t "$IMAGE" \
-f docsite/Dockerfile docsite/
docker push "$IMAGE"
+9
View File
@@ -55,4 +55,13 @@ services:
- 3004:3000
depends_on:
- admin
# Static docs, served by nginx at vantage.hostxtra.co.uk/docs through its own
# proxy location. That location must sort ABOVE the catch-all forwarding to
# site:3003, or Next serves its own 404 for /docs. The container serves from
# /usr/share/nginx/html/docs because the proxy forwards the full path.
docsite:
image: gitea.hostxtra.co.uk/mrhid6/vantage/docsite:latest
restart: unless-stopped
ports:
- 3005:80
networks: {}
+5
View File
@@ -0,0 +1,5 @@
node_modules
build
.docusaurus
.git
.gitignore
+4
View File
@@ -0,0 +1,4 @@
node_modules
build
.docusaurus
.cache-loader
+40
View File
@@ -0,0 +1,40 @@
# Build stage
FROM node:26-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
# Baked in at build time. DOCS_BASE_URL must agree with three things at once:
# the Nginx Proxy Manager location that routes to this container, the directory
# the runtime stage serves from below, and this value. When they disagree the
# HTML loads and every stylesheet and script 404s.
ARG DOCS_URL="https://vantage.hostxtra.co.uk"
ARG DOCS_BASE_URL="/docs/"
ARG APP_URL="https://vantage.hostxtra.co.uk"
ARG HQ_URL="https://vantage-hq.hostxtra.co.uk"
ENV DOCS_URL=$DOCS_URL
ENV DOCS_BASE_URL=$DOCS_BASE_URL
ENV APP_URL=$APP_URL
ENV HQ_URL=$HQ_URL
RUN npm run build
# Runtime stage
#
# Docusaurus emits a fully static site, so unlike web/, site/ and adminsite/
# there is no Node server at runtime. alpine-slim is roughly a quarter the size
# of caddy:alpine, and nothing here needs automatic TLS — the host proxy
# terminates it.
FROM nginx:alpine-slim AS runner
# NPM forwards the FULL request path upstream; it does not strip the /docs
# prefix. Serving from a matching subdirectory means prefix, asset URLs and
# upstream paths agree with no rewrite rule to keep in step.
COPY --from=builder /app/build /usr/share/nginx/html/docs
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
@@ -0,0 +1,80 @@
---
id: claim-free-licence
title: Claim a Free licence
sidebar_label: Claim a Free licence
---
A self-hosted install runs unlicensed until you give it a licence. Free is a
real tier in both deployments, and you can claim one for your install from the
HQ portal.
## What a licence is
A signed file. It carries the instance UUID it belongs to, the tier, the server
allowance, feature toggles and an expiry. The control plane verifies the
signature locally — checking a licence never contacts HQ, and a running instance
does not need HQ to be reachable.
Signing happens in exactly one place, in HQ. The control plane can only verify.
## 1. Find your instance UUID
In the control plane, go to **Settings → Licence**. The instance UUID is shown
there. It is the identity your licence binds to.
## 2. Link the install to your HQ account
1. Sign in at [Vantage HQ](https://vantage-hq.hostxtra.co.uk). If you have no
account, see [Accounts and signup](../hq/accounts-and-signup.md).
2. Choose **Link an instance**.
3. Paste the instance UUID and give it a name you will recognise.
Linking claims the UUID for your account. A UUID already linked elsewhere is
refused with a conflict rather than silently moved.
## 3. Claim Free
With the instance linked, choose **Claim Free** on it. HQ issues a Free licence
bound to that UUID and hands it back.
:::info One Free per account, per deployment
The limit is enforced per account **and** deployment, so a Free cloud instance
does not stop you claiming Free on a self-hosted install. Both the friendly
pre-check and the issuer apply the same rule — deliberately, because a
pre-check stricter than the issuer would refuse something that would actually
have worked.
:::
## 4. Install the licence
Download the licence from HQ and paste it in the control plane at
**Settings → Licence**.
The instance validates the signature, checks the UUID matches its own, and
starts reporting the tier, allowance and expiry.
:::warning Cloud instances cannot paste a licence
On a cloud instance `POST /license` answers `409 cloud_managed`, and the UI
hides the form entirely. A cloud licence is written directly by HQ. This is not
a restriction the injection path has to work around — it writes to the database,
not through the endpoint.
:::
## Renewing
Free licences are renewable from HQ within a renewal window near expiry;
outside that window the renew call refuses. See [Free tier](../hq/free-tier.md).
Pasting a licence keeps working while the current one is expired — that endpoint
is exempt from the licence check, because it is the way out of degraded mode.
## Moving the install to new hardware
Rebuilding produces a new instance UUID, and a licence binds to a UUID. Use
**Relink** in HQ to move the licence across. The number of relinks per term is
capped; the portal shows how many you have left.
## Next
- [Licensing and entitlements](../hq/licensing-and-entitlements.md)
- [Buying a paid self-hosted licence](../hq/self-hosted-instances.md)
@@ -0,0 +1,73 @@
---
id: cloud-vs-self-hosted
title: Cloud or self-hosted
sidebar_label: Cloud or self-hosted
---
Vantage runs in two deployments. They are the same software; what differs is
who operates it and how licensing, users and data lifecycle work.
## At a glance
| | Cloud | Self-hosted |
| --- | --- | --- |
| Who runs it | We do | You do |
| Where you sign in | `<your-slug>.vantage.hostxtra.co.uk` | Your own hostname |
| Database and backups | Ours | Yours |
| Licence | Written for you when you buy or create the instance | Pasted in, or claimed from HQ |
| Team members | Granted from HQ; the instance holds a projection | Created in the instance itself |
| Free tier | Yes, one per account | Yes, one per account |
| Expired Free instance | Eventually deleted, after warning | Never deleted |
## Cloud
You create an instance from the HQ portal and it exists a few seconds later,
already licensed. People you grant access to get a real user inside that
instance — see [People and roles](../hq/people-and-roles.md) — but HQ owns their
password, role and existence.
:::info The instance does not phone home
A grant writes a user row into the control plane once. After that the instance
authenticates that person entirely on its own. HQ being down does not stop
anyone signing in to a running instance.
:::
Cloud instances on the Free tier are reaped after their licence expires, with
warning emails first. See [Free tier](../hq/free-tier.md).
## Self-hosted
You run the Docker Compose stack on your own infrastructure. Nothing about the
control plane requires an internet connection to HQ at runtime — a licence is a
signed file, verified locally.
Two ways to get one:
1. **Free** — link the install to an HQ account and claim it
([Claim a Free licence](./claim-free-licence.md)).
2. **Paid** — buy from HQ, which creates a placeholder, then paste the install's
real instance UUID to bind and issue
([Self-hosted instances](../hq/self-hosted-instances.md)).
Self-hosted users are local (or OIDC). There is no projection from HQ, and the
three member endpoints in HQ refuse to touch a self-hosted instance at all.
:::warning Self-hosted instances are never deleted by us
The reaper that removes expired Free cloud instances is disabled by default and
must stay that way on a self-hosted install. See `FREE_INSTANCE_REAP_AFTER` in
[Environment variables](../reference/environment-variables.md).
:::
## Which should you pick
Pick cloud if you want the thing running now and do not want to own a MongoDB.
Pick self-hosted if your policy requires the control plane inside your own
network, or the servers you manage cannot reach the public internet.
Moving between them is a migration, not a switch — instances are bound to a
deployment at creation, and a licence binds to an instance UUID.
## Next
- [Self-hosted install](./self-hosted-install.md)
- [Accounts and signup](../hq/accounts-and-signup.md) if you are going cloud
@@ -0,0 +1,75 @@
---
id: first-login
title: First login
sidebar_label: First login
---
A fresh install has no users and no organisation. The first visit creates both.
## 1. Bootstrap
Open the control plane in a browser. Because no user exists, you land on
`/setup`.
Fill in:
| Field | Notes |
| --- | --- |
| Organisation name | Display name. Shown throughout the UI |
| Slug | Lowercase, used in the hostname on cloud. Some names are reserved |
| Your name | |
| Email | Becomes your sign-in identity |
| Password | Stored bcrypt-hashed |
Submitting creates the organisation and its **owner** — you.
:::warning Bootstrap works exactly once
The endpoint is open only while the database has no users. As soon as the first
one exists, `/setup` redirects to the login page and the bootstrap endpoint
refuses. There is no second chance to create the first owner, so record the
credentials before you close the tab.
:::
## 2. Sign in
You are taken to `/login`. Sign in with the email and password you just set.
Sessions are an opaque 32-byte token in the `km_session` cookie, with the body
held in Redis for 24 hours. Restarting Redis signs everyone out and loses
nothing else.
## 3. Look around
You land on the fleet dashboard, which is empty. The sidebar is the whole
product:
| Section | What it does |
| --- | --- |
| Servers | The fleet — enrol, inspect, console, update |
| Keys | SSH public keys and their assignments |
| Workflows | Compose and run scripted work |
| Steps | The reusable step library |
| Monitors | HTTP, TCP, ICMP and TLS checks |
| Secrets | The encrypted vault |
| Audit | Every mutating action |
| Settings | Members, SSO, alerts, retention, licence |
## 4. Add the rest of your team
Go to **Settings → Access**. Add members with a role:
| Role | Can |
| --- | --- |
| `owner` | Everything, including billing-adjacent settings |
| `admin` | Everything except owner-only settings |
| `member` | Day-to-day work — servers, keys, workflows, monitors |
Settings and organisation management require `owner` or `admin`.
If you would rather not manage passwords, configure OIDC instead — see
[Settings](../vantage/settings.md#single-sign-on-oidc). OIDC is configured per
organisation, and the client secret is stored encrypted.
## Next
[Add your first server](./first-server.md).
@@ -0,0 +1,111 @@
---
id: first-server
title: Add your first server
sidebar_label: Add your first server
---
Enrolling a machine means running one command on it. The control plane issues a
short-lived token, the install script fetches the agent and writes a config, and
the machine registers itself.
## 1. Create the enrolment
In the UI, go to **Servers → Add server**. That calls `POST /api/servers/new`,
which generates a server ID and a pre-registration token and hands back a ready
one-liner.
:::warning The token is single-use and lives one hour
It is the only credential in the flow, and it is spent the moment the agent
calls `Register`. If you paste it somewhere and come back tomorrow, create a new
enrolment instead — nothing is lost by doing so.
:::
## 2. Run the one-liner
### Linux
```bash
curl -fsSL "https://vantage.example.com/install?server_id=<id>&token=<token>" | bash
```
Run it as root. The script:
1. Detects architecture — `x86_64` and `aarch64` only; anything else exits.
2. Asks the Gitea API for the newest `agent/v*` release.
3. Downloads the binary and `checksums.txt`, and **verifies the SHA-256**,
aborting on a mismatch.
4. Installs to `/usr/local/bin/vantage-agent`, mode `0755`.
5. Writes `/etc/vantage/config.yaml` (directory `0700`, file `0600`) containing
the server ID, the pre-registration token and the gRPC host.
6. Writes `/etc/systemd/system/vantage-agent.service` with `Restart=always`, and
runs `systemctl enable --now vantage-agent`.
### Windows
```powershell
irm "https://vantage.example.com/install.ps1?server_id=<id>&token=<token>" | iex
```
Run from an elevated PowerShell. The agent is registered as a service through
NSSM, with the config at `%ProgramData%\vantage\config.yaml`. There is also an
MSI built by CI if you would rather deploy that.
:::info Windows agents are second-class on purpose
They register, heartbeat, run workflow steps and report inventory. They do
**not** manage `authorized_keys` — the key subsystem is Linux-only, and a
Windows agent stops after the heartbeat portion of the poll.
:::
## 3. Watch it come up
The server appears immediately as `pending`. Within one poll interval — 30
seconds — it flips to `active`.
On the machine:
```bash
systemctl status vantage-agent
journalctl -u vantage-agent -f
```
What happens on that first run:
```
1. Load /etc/vantage/config.yaml
2. pre_reg_token present → Register() → save agent_token, clear pre_reg_token
3. Reconnect with the permanent token
4. Start: command stream · hourly update check · inventory · monitors
5. Enter the SyncKeys poll loop
```
After registration the config no longer contains the pre-registration token; it
contains a permanent agent token instead. The control plane stores only the
SHA-256 of that token, never the token itself.
## 4. Confirm it works
Open the server's detail page. Within a minute or two you should see:
- Status `active`, with a recent last-seen timestamp.
- Inventory — CPU, memory, swap, partitions, kernel. Metrics refresh every 30
seconds; the full static snapshot every 15 minutes.
- Pending OS updates, checked hourly.
## If it does not appear
| Symptom | Cause |
| --- | --- |
| Script exits at "Unsupported architecture" | Not amd64 or arm64 |
| "Checksum mismatch!" | Interrupted download, or a proxy rewriting the body. Re-run |
| "Could not determine latest agent version" | The host cannot reach `gitea.hostxtra.co.uk`, or no `agent/v*` release exists |
| Service runs, server stays `pending` | The machine cannot reach `GRPC_HOST`. Test it from that machine |
| Registers once then goes `offline` | Reachable for `Register` but not for the poll — usually a firewall that permits the initial connection but drops the long-lived one |
A server is marked `offline` when its last-seen time passes the threshold; that
sweep runs every two minutes, so allow for it before concluding anything.
## Next
- [Assign an SSH key](../vantage/ssh-keys.md)
- [Run a workflow](../vantage/workflows.md)
- [Claim a Free licence](./claim-free-licence.md)
@@ -0,0 +1,137 @@
---
id: self-hosted-install
title: Install Vantage (self-hosted)
sidebar_label: Self-hosted install
---
This installs the control plane on a host you own. Budget about fifteen minutes.
## Before you start
You need:
- A Linux host with **Docker** and the **Compose plugin**.
- A DNS name pointing at it. You will use it for both the web UI and, with a
port, for agents.
- A reverse proxy terminating TLS in front of the web UI. gRPC on `:9090` is
reached directly by agents.
- Two ports reachable from every machine you intend to manage: the web port for
people, and **9090** for agents.
- Outbound access from the control plane, and from every managed machine, to
`gitea.hostxtra.co.uk`, which serves the agent releases.
The stack itself brings MongoDB, Redis and guacd with it. You do not need to
provide a database.
## 1. Get the compose file
Put `deploy/docker-compose.yml` from the repository in a working directory, for
example `/opt/vantage`.
```bash
mkdir -p /opt/vantage/data && cd /opt/vantage
# copy docker-compose.yml here
```
The `server` service bind-mounts `./data`, which is where workflow run logs are
written. Create it before first boot so it is not owned by root-in-container in
a way you did not intend.
## 2. Write the environment file
Create `/opt/vantage/.env`:
```bash
# The host:port agents dial. NOT the web URL — this port speaks gRPC.
GRPC_HOST=vantage.example.com:9090
# 32 bytes as 64 hex characters. Generate with the command below.
KEY_ENCRYPTION_KEY=
# Optional: where workflow run logs are written inside the container.
VANTAGE_WORKFLOW_LOG_DIR=/data/workflow-logs
```
Generate the encryption key:
```bash
openssl rand -hex 32
```
:::danger Keep the encryption key
`KEY_ENCRYPTION_KEY` encrypts SSH private keys, vault secrets, OIDC client
secrets and console credentials with AES-256-GCM. Lose it and every one of those
becomes unreadable — there is no recovery path. Back it up somewhere other than
the server it protects, and never rotate it without a planned re-encryption.
:::
:::warning `GRPC_HOST` has no default
The server refuses to boot without it. There is deliberately no fallback to the
web host: that would hand every agent a port that does not speak gRPC, and the
failure would only surface later, on each agent, as a connection error.
:::
## 3. Start the stack
```bash
docker compose up -d
docker compose ps
```
Five services come up: `mongo`, `redis`, `guacd`, `server` and `web`.
Check the server got through boot:
```bash
docker compose logs -f server
```
Boot runs database migrations, builds indexes and seeds the default workflow
step library. Index builders for auth and settings are **fatal on failure**
they enforce tenant isolation, so the server would rather not start than start
without them.
## 4. Put a proxy in front
Point your reverse proxy at `web` on port `3000` and terminate TLS there. The
web app calls the REST API through a Next rewrite, so you do not need to expose
`8080` publicly.
Do **not** proxy `9090`. Agents connect to it directly over TLS.
## 5. First sign-in
Open your hostname in a browser. With no users in the database, you are sent to
`/setup`.
Continue with [First login](./first-login.md).
## Verifying the install
| Check | Expected |
| --- | --- |
| `docker compose ps` | five services `running` |
| `curl -s localhost:8080/auth/bootstrap-status` | JSON saying bootstrap is needed |
| `nc -z your-host 9090` | open |
| `docker compose logs server \| grep -i fatal` | nothing |
## Common install problems
**Server exits immediately.** Almost always a missing `GRPC_HOST`. The log line
names it.
**Agents register but never go active.** They reached `:9090` for `Register` but
cannot sustain the poll, or `GRPC_HOST` names a host they resolve differently.
Check from the managed machine, not from the control plane host.
**Secrets pages error.** `KEY_ENCRYPTION_KEY` is empty or not 64 hex characters.
More in [Troubleshooting](../reference/troubleshooting.md).
## What this install does not include
The marketing site, the public form service, the HQ portal and this
documentation site are separate services in `deploy/docker-compose.site.yml`.
A self-hosted install deliberately runs none of them, and in particular never
holds the licence signing key.
@@ -0,0 +1,63 @@
---
id: what-is-vantage
title: What is Vantage
sidebar_label: What is Vantage
---
Vantage manages a fleet of servers from one place. It began as SSH key
management and grew outwards: key assignment, scripted workflow execution,
service monitoring, a secrets vault, a browser-based console and OS update
management.
## The pieces
```mermaid
flowchart TD
W["Web UI<br/>servers · keys · workflows · monitors<br/>secrets · audit · console · settings"]
S["Server<br/>REST :8080 · gRPC :9090<br/>MongoDB · Redis · guacd"]
A["Agent<br/>one per managed server<br/>Linux and Windows"]
W -->|REST, cookie session| S
S -->|gRPC over TLS| A
A -.->|outbound only| S
```
**The server** holds all state and does all decision-making. It exposes a REST
API on `:8080` for the web UI and a gRPC API on `:9090` for agents. MongoDB
stores everything durable; Redis stores sessions and nothing else.
**The agent** is a single Go binary running as root on each managed server. It
polls the control plane every 30 seconds for desired key state, and holds a
bidirectional command stream so the server can push work — run a workflow step,
generate a key, apply updates — without waiting for the next poll.
**The web UI** is the operator interface. Everything it does goes through the
REST API, which is the actual security boundary; the UI only ever makes things
convenient.
## How agents connect
The agent dials **out** to the control plane. There is no inbound listener on a
managed server, no port to open and no NAT traversal to arrange. If the machine
can reach your Vantage host on the gRPC port, it can be managed.
That direction is why `GRPC_HOST` exists as an explicit setting: the agent has
to be told a `host:port` it can reach, and there is no safe default the server
could guess on its behalf.
## Two request patterns
| Pattern | Used for | Why |
| --- | --- | --- |
| Poll (`SyncKeys`, every 30s) | desired SSH key state | Key changes are not urgent, and polling survives a dropped connection with no reconnection logic |
| Push (`CommandStream`) | workflow steps, key generation, updates, agent self-update | Clicking Run should not wait up to 30 seconds |
## Multi-tenancy
Every document in the database carries an instance ID, and every query is scoped
by it. One deployment can therefore host many independent tenants. On a
self-hosted install that mechanism is still there — you simply have one tenant.
## Next
- [Cloud or self-hosted](./cloud-vs-self-hosted.md) — which one you want
- [Self-hosted install](./self-hosted-install.md) — stand it up
+78
View File
@@ -0,0 +1,78 @@
---
id: accounts-and-signup
title: Accounts and signup
sidebar_label: Accounts and signup
---
Vantage HQ, at `vantage-hq.hostxtra.co.uk`, is where you manage the **account**
behind your instances: your team, your instances, their licences and billing.
## An account is a team, not a person
One account holds many people and many instances. Everyone in it has an account
role:
| Role | Can |
| --- | --- |
| `owner` | Everything, including billing |
| `admin` | Invite people, create instances, grant instance access |
| `member` | Read what the account holds |
Reading is open to any signed-in member. Every mutation except changing your own
password requires `owner` or `admin`. Billing is owner-only.
The three words are the same as the control plane's roles, on purpose — but they
are separate things. Your account role governs the portal; your role *inside* an
instance governs that instance.
## Signing up
Signup is **account-first**. Creating an account creates the account and you;
it does not create a Vantage instance. Nothing exists in any control plane until
you later create or link one.
1. Go to the signup form.
2. Enter your name, email and a password.
3. Check your email and click the verification link.
:::info Verify before you can sign in
An unverified account gets a distinct "check your email" message rather than a
generic authentication failure — the address is already known to be yours, so
there is nothing to protect by being vague.
:::
Verification links are valid for **24 hours**. The token is 32 random bytes and
only its SHA-256 hash is stored, so a leaked database yields no working links.
If the verification email cannot be sent, the signup is rolled back rather than
left stranded — retry rather than assuming a half-created account is in the way.
## Signing in
Email and password. The session is a cookie, separate from the control plane's:
signing in to HQ does not sign you in to an instance, and vice versa.
## What comes next
| You want | Go to |
| --- | --- |
| A Vantage instance we run | [Cloud instances](./cloud-instances.md) |
| To license an install you run | [Self-hosted instances](./self-hosted-instances.md) |
| To add colleagues | [People and roles](./people-and-roles.md) |
| To understand tiers and limits | [Licensing and entitlements](./licensing-and-entitlements.md) |
## The portal layout
Three destinations: **Overview**, **People**, **Billing**.
Settings lives in the account menu rather than the nav, because it is your
password rather than a place. The appearance toggle is there too.
Overview lists your instances. Each is one record, closed to a row and open to
its licence contents, members and actions. It opens by default when it is your
only instance or when it needs attention, and your manual choice is remembered.
There is deliberately no "your plan" card in the sidebar: tier, limits and
expiry belong to a **licence**, and a licence belongs to one instance. An
account with a Free cloud instance and a Professional self-hosted one has no
single plan to show.
+83
View File
@@ -0,0 +1,83 @@
---
id: billing
title: Billing
sidebar_label: Billing
---
Paid plans are billed through **Paddle**, which is the merchant of record. Your
invoice, your card details and your tax handling are all Paddle's; HQ holds a
customer reference and nothing sensitive.
Billing is **owner-only**.
## Buying
### Cloud
Open the instance, change its configuration to what you want, and check out.
Checkout runs in the browser.
### Self-hosted
**Buy self-hosted**, then bind the purchase to your install's UUID. See
[Self-hosted instances](./self-hosted-instances.md).
## What you are buying
A subscription's line items are the configuration: the plan base, the metered
server count above the base, and any per-instance features. Changing the
configuration changes the line items.
## Changing configuration
**Instance → Configuration**, adjust servers or features, and save.
- **Increases** take effect when the payment confirms.
- **Reductions** are scheduled for the end of the term. The portal shows the
date and the new value.
## The customer portal
**Billing → Manage** mints a Paddle customer-portal session where you can
update your payment method, see invoices and cancel.
## How a licence follows a payment
```mermaid
flowchart LR
C["Checkout / change"] --> P["Paddle"]
P -->|signed webhook| H["HQ"]
H --> G["Entitlement: desired → granted"]
G --> L["Licence signed from granted"]
```
The webhook is the **only** issuing path for paid plans. It is signature
verified, processed exactly once, and resolved from the subscription's *current*
line items — so a webhook that arrives out of order still produces the right
answer rather than replaying a stale state.
A licence is signed from **granted** only. A checkout you abandon changes
nothing.
## Cancelling and failed payments
Cancelling, or a payment going past due, takes **no immediate licence action**.
Your licence runs to its grace-padded expiry and then lapses normally. There is
no mid-term cut-off.
For a cloud Free instance, lapsing eventually leads to deletion — see
[Free tier](./free-tier.md). Paid instances are not reaped.
## Renewals
At renewal the subscription bills again and the licence is reissued for the new
term. It is also the only moment a scheduled **reduction** takes effect.
Self-hosted customers: download and paste the reissued licence. Cloud customers:
nothing to do.
## Free is not in Paddle at all
Free has no subscription, no £0 line item and no Paddle record. It has its own
renewal, in the portal. An account only acquires a Paddle customer reference
with its first paid purchase.
+78
View File
@@ -0,0 +1,78 @@
---
id: cloud-instances
title: Cloud instances
sidebar_label: Cloud instances
---
A cloud instance is a Vantage control plane we run for you, reachable at
`<your-slug>.vantage.hostxtra.co.uk`.
## Creating one
1. **Overview → New instance**.
2. Choose a name and a slug.
3. Create.
The instance is provisioned with you as its owner, and a Free licence is issued
immediately. The owner user inside it gets your HQ password hash **copied**, not
shared — see [People and roles](./people-and-roles.md).
### Slugs
The slug becomes your hostname label, so it is lowercase, and some names are
reserved. Pick something you can say on a phone call.
:::warning One Free instance per account, per deployment
Creating a second Free cloud instance is refused. If you want another, it needs
a paid plan — or free up the first.
:::
## Using it
Sign in at your instance's hostname with the email and password you use for HQ.
It is a normal Vantage install from that point: see
[Getting started](../getting-started/first-login.md) and the
[Vantage](../vantage/servers.md) section.
The instance does not depend on HQ at runtime. HQ being unreachable does not
affect anyone signing in or any agent syncing.
## The instance record
Each instance on Overview is one record. Closed, it is a row. Open, it shows:
- **Licence contents** — tier, server allowance, features, expiry.
- **Members** — who has access and with what instance role.
- **Actions** — grant access, change configuration, renew.
## Members
Granting access writes a real user into the instance. Covered fully in
[People and roles](./people-and-roles.md).
## Changing what it can do
Server allowance and per-instance features (browser console, single sign-on) are
part of the instance's **entitlement**. Changing it goes through billing — see
[Licensing and entitlements](./licensing-and-entitlements.md) and
[Billing](./billing.md).
## Renaming
The display name is free to change. The slug is the hostname and is not
casually changed — ask support if you need it.
## What happens if the licence lapses
A cloud instance whose Free licence expires enters degraded mode, then, after a
grace period, the instance and all its data are deleted. Warning emails go out
first. See [Free tier](./free-tier.md).
Paid instances do not get reaped by that mechanism. A cancelled subscription
runs to its grace-padded expiry and then lapses.
## Deleting
Ask support. Deletion is performed by the control plane, not by HQ — the control
plane is the only service that knows which collections carry the instance ID,
and duplicating that list into HQ would be a list that drifts.
+79
View File
@@ -0,0 +1,79 @@
---
id: free-tier
title: Free tier
sidebar_label: Free tier
---
Free is a real tier in both deployments — not a trial that turns into nothing.
## What you get
| | Free |
| --- | --- |
| Servers | 3 |
| Monitors | 3 |
| Secret groups | 1 |
| Notification channels | 1 |
| Audit retention | 30 days |
| Support | Community |
Browser console and single sign-on are not included; they are per-instance
features on a paid plan.
## One per account, per deployment
The limit is enforced per account **and** deployment. A Free cloud instance does
not prevent a Free self-hosted one — they are separate slots.
## Free is outside Paddle
There is no subscription, no £0 line item and no invoice. Your account acquires
a Paddle customer reference only with its first paid purchase.
## Renewing
Free licences have a term and must be renewed from the portal.
- The renew button appears **7 days before expiry**.
- It stays available **after** expiry, right up until the instance is reaped —
so the same button rescues a lapsed instance rather than needing a second
mechanism.
- Renewing outside that window is refused, and the message names the date it
opens.
:::tip Put the expiry in a calendar
Warning emails go to the account address. If nobody watches that inbox, a Free
cloud instance can lapse and eventually be deleted without anyone noticing.
:::
## What happens when it lapses
**Self-hosted:** the instance goes into degraded mode after the grace period and
stays that way. Nothing is deleted, ever — the reaper is disabled by default on
a self-hosted install and must stay that way.
**Cloud:** the instance goes into degraded mode, and after a further period the
instance **and all its data are deleted**. Warning emails are sent first, naming
the date.
:::danger Deletion is permanent
There is no restore. If a cloud Free instance is approaching that date and you
want to keep it, renew it, or move it to a paid plan.
:::
Deletion is carried out by the control plane rather than by HQ. HQ sends the
warnings because it knows the billing address; the control plane performs the
delete because it is the only service that knows which collections carry the
instance ID.
## Moving off Free
Change the instance's configuration to a paid tier and check out. Your data
stays where it is — a tier change reissues a licence, it does not rebuild
anything.
## Relinks
Free instances get the same relink allowance as paid ones: three per term. That
cap exists to put a human in front of a fourth attempt, not to obstruct a
genuine rebuild.
@@ -0,0 +1,95 @@
---
id: licensing-and-entitlements
title: Licensing and entitlements
sidebar_label: Licensing and entitlements
---
A **licence** is a signed statement of what one instance may do. An
**entitlement** is the configuration a licence is cut from.
## Tiers
Three tiers, in both deployments. The allowances are identical across cloud and
self-hosted — what differs is the term on offer, not what you get.
| | Free | Professional | Enterprise |
| --- | --- | --- | --- |
| Servers (base) | 3 | 3 | 10 |
| Monitors | 3 | unlimited | unlimited |
| Secret groups | 1 | unlimited | unlimited |
| Notification channels | 1 | unlimited | unlimited |
| Audit retention | 30 days | 365 days | unlimited |
| Support | Community | Email, 24×5 | Email and phone, 24×7 |
The server count is **metered**: the base allowance comes with the tier, and you
buy additional servers on top. That is why Professional shows a real number
rather than "unlimited" — the number you actually have is the one in your
entitlement.
## Features
Two are per-instance toggles rather than tier bundles:
| Feature | What it enables |
| --- | --- |
| `console` | The [browser console](../vantage/browser-console.md) |
| `oidc` | Per-instance [single sign-on](../vantage/settings.md#single-sign-on-oidc) |
No tier includes them by default; you enable them on the instances that need
them.
## Entitlements: desired and granted
Each instance has one entitlement row holding two configurations:
| | Meaning |
| --- | --- |
| **Desired** | What you last asked for |
| **Granted** | What a payment has confirmed |
Checkout is built from **desired**. A licence is only ever signed from
**granted**. An abandoned checkout therefore leaves a desired that reached
nothing and changed nothing.
### Increases and reductions
An increase takes effect when payment confirms, and the entitlement is promoted
desired → granted.
A **reduction** is scheduled rather than immediate: you keep what you paid for
until the end of the term, and the portal shows the date it drops. The collapse
happens at renewal.
## What a licence carries
Instance UUID, deployment, tier, resolved limits, features, term and expiry —
all signed.
Two properties follow from that:
- **A licence is bound to one instance UUID.** Moving it takes a
[relink](./self-hosted-instances.md#relinking).
- **A licence is a snapshot.** Editing a plan later never rewrites an issued
licence, the same way editing a workflow step never rewrites a past run.
Verification is local. Your instance does not call HQ to check a licence, and
signing happens only in HQ.
## Expiry and grace
Expiry is padded with a grace period. Past that, the instance goes into degraded
mode: it keeps running and keeps your data, but stops letting you do everything.
The way out is a current licence — renew or purchase, then paste it (self-hosted)
or let it be written for you (cloud).
## Server limits in practice
When you exceed your server allowance, enrolling another one is refused. The
existing fleet is unaffected. Raise the allowance in the portal, or remove a
server you are not using.
## Legacy tiers
An older `self_hosted` tier is mapped forward to self-hosted Professional
wherever it appears. Nothing needs doing about it.
+98
View File
@@ -0,0 +1,98 @@
---
id: people-and-roles
title: People and roles
sidebar_label: People and roles
---
Two separate things live here: who is in your **account**, and who has access to
each **instance**.
## Account members
**People** lists everyone in the account.
| Role | Can |
| --- | --- |
| `owner` | Everything, including billing |
| `admin` | Invite, create instances, grant instance access |
| `member` | Read |
Owners and admins invite; billing is owner-only.
### Inviting someone
1. **People → Invite**.
2. Enter their email and pick a role.
3. They receive a link and set their own password at `/accept-invite`.
:::info Why you cannot set their password
An invitation creates a person with an **empty password hash**, which cannot
authenticate at all until they set one. If the inviter chose it, that password
would be a shared credential to every instance the person is later granted
access to.
The verification endpoint knows the difference: a token belonging to a
passwordless person reports that a password is needed and is left unspent, so
the link still works when they get to it.
:::
### Removing someone
Removing them from the account removes their portal access. See below for what
happens to their instance access.
## Instance access
Granting access to a **cloud** instance creates a real user inside that
instance's control plane, with `auth_source: "hq"`.
```mermaid
flowchart LR
P["HQ account member"] -->|grant| U["Control-plane user<br/>auth_source: hq"]
U --> I["The instance authenticates<br/>this user like any other"]
```
The instance authenticates that user exactly as it authenticates anyone else,
with **no runtime dependency on HQ**. Revoking deletes the row — the control
plane has no disabled state, and a row that exists is a row that can sign in.
### Granting
On an instance record, **Members → Add**, choose an account member and an
instance role (`owner`, `admin`, `member`).
One person holds at most one user per instance, so granting twice is refused
rather than quietly creating a second user.
### Roles inside an instance
Independent of the account role. Someone can be an account `member` and an
instance `owner`, or the reverse.
### Revoking
Removes the user from the instance immediately. Any live session ends with the
session, since the user row backing it is gone.
:::warning Self-hosted instances cannot be granted from HQ
All three member endpoints refuse when the instance is self-hosted. Manage those
users in the instance itself, at **Settings → Access**.
:::
## Passwords
Your HQ password is the single source of truth for every user projected from it.
Changing it in the portal rehashes it and copies the hash to every instance you
have been granted.
Propagation is best-effort and immediate; a background pass compares and repairs
every 15 minutes, so a temporarily unreachable instance catches up on its own.
There is no local password-change endpoint for those users in the control plane,
so there is never a second writer for the hash.
:::warning HQ-managed users are read-only in the instance
Changing the role of, or deleting, an `hq`-sourced user inside the control plane
is refused with `409`. Do it from the portal. The UI shows those rows read-only
with a link back here, but the API is the boundary; the UI is the courtesy.
:::
+74
View File
@@ -0,0 +1,74 @@
---
id: self-hosted-instances
title: Self-hosted instances
sidebar_label: Self-hosted instances
---
A self-hosted instance is your install, licensed through HQ. HQ never touches
it: it issues a signed file that your install verifies locally.
## Free
Install first, then link and claim. Step by step in
[Claim a Free licence](../getting-started/claim-free-licence.md).
## Paid
Buying happens **before** the install has to exist, because you may well be
buying in order to build it.
```mermaid
flowchart LR
A["Buy in HQ"] --> B["Placeholder instance<br/>awaiting_link, no licence"]
B --> C["Install Vantage<br/>get its instance UUID"]
C --> D["Paste the UUID<br/>claim-link"]
D --> E["Licence issued<br/>bound to that UUID"]
```
1. **Overview → Buy self-hosted**, choose tier, term and configuration.
2. Complete checkout. HQ creates a **placeholder** instance in state
`awaiting_link` with no licence attached.
3. [Install Vantage](../getting-started/self-hosted-install.md) if you have not
already, and find its instance UUID at **Settings → Licence**.
4. Back in HQ, open the placeholder and paste the UUID.
5. The licence is issued, bound to that UUID. Download it and paste it into your
install.
:::info Why there is a placeholder at all
A licence binds to an instance UUID, and at the moment of payment that UUID may
not exist yet. Issuing early would mean issuing to nothing; refusing to sell
until you had installed would be the wrong order. The placeholder holds the
purchase until there is something to bind to.
:::
## Linking an existing install
If the install already exists, **Link an instance** takes the UUID directly. A
UUID already claimed by another account is refused with a conflict.
## Relinking
Rebuilding the host produces a new instance UUID, and the old licence no longer
matches. **Relink** moves the licence to the new UUID and reissues.
The number of relinks per term is capped, and the portal shows how many you have
left. This is not meant to obstruct disaster recovery — if you have exhausted
them for a real reason, ask support.
## Installing the licence
Paste it at **Settings → Licence** in your install. The instance verifies the
signature and checks that the UUID matches its own.
Pasting works even while the current licence is expired — that endpoint is
exempt from the licence check, because it is the route out of degraded mode.
## Keeping it current
Your install does not fetch licences. When a licence is reissued — renewal,
configuration change, relink — download the new one from HQ and paste it in.
:::warning Nothing reminds your install
The control plane knows only what its licence says. Expiry emails come from HQ,
to the account's address. Make sure someone reads them.
:::
+37
View File
@@ -0,0 +1,37 @@
---
id: index
title: Vantage documentation
sidebar_label: Overview
slug: /
---
# Vantage documentation
Vantage is a self-hosted, multi-tenant infrastructure control plane. It manages
SSH keys, runs scripted workflows, watches services, stores secrets, opens
browser consoles and applies OS updates across a fleet of servers.
A central server drives a lightweight agent installed on each managed machine.
The agent connects **outbound only**, so managed servers need no inbound
firewall holes.
## Where to start
| If you want to | Read |
| --- | --- |
| Understand what the pieces are | [What is Vantage](./getting-started/what-is-vantage.md) |
| Run it on your own hardware | [Self-hosted install](./getting-started/self-hosted-install.md) |
| Enrol your first machine | [Add your first server](./getting-started/first-server.md) |
| Manage your account, licence or billing | [Vantage HQ](./hq/accounts-and-signup.md) |
| Look something up | [Reference](./reference/environment-variables.md) |
## The two products
**Vantage** is the control plane — the thing you sign in to in order to manage
servers. It runs either on your own infrastructure or as a cloud instance we
run for you.
**Vantage HQ** is the portal at `vantage-hq.hostxtra.co.uk` where you manage the
account behind those instances: who is on your team, which instances exist, what
licence each one holds and how it is billed. HQ never manages your servers, and
a Vantage instance never depends on HQ being reachable in order to run.
+77
View File
@@ -0,0 +1,77 @@
---
id: agent-updates
title: Agent updates
sidebar_label: Agent updates
---
Agents are versioned and released independently of the control plane, and update
themselves on command.
## Checking the current version
Each server's detail page shows the version it reported at its last sync.
`GET /api/agent/latest-version` reports the newest release available.
## Updating from the UI
**Servers → *a server* → Update agent** pushes `UpdateAgentCmd` with a target
version. The agent then:
1. Downloads the binary for its platform from the release.
2. Verifies the SHA-256 against `checksums.txt`.
3. Stops itself, replaces the binary in place, and starts again.
`Restart=always` on the systemd unit is what makes the last step work.
The server briefly goes `offline` and comes back within a poll interval or two.
## Updating from the machine
There is a dynamic update script, the counterpart to the install one:
```bash
curl -fsSL https://vantage.example.com/update | bash
```
```powershell
irm https://vantage.example.com/update.ps1 | iex
```
It does the same download, checksum and replace, then restarts the service. Use
this when the control plane cannot push — for example, when the machine is
reachable but its command stream is not.
## Rolling out across a fleet
There is no built-in bulk update. Two reasonable approaches:
- Update from each server's page, a few at a time.
- Build a [workflow](../vantage/workflows.md) whose step runs the update script,
and target it at the machines you want. That gives you ordering, failure
handling and a log.
:::tip Update a canary first
An agent that fails to start after replacing itself needs hands on that machine.
Do one, confirm it returns to `active`, then do the rest.
:::
## Version compatibility
The gRPC API is versioned to tolerate an agent older than the control plane. The
reverse — an agent newer than the control plane — is not a case anyone tests.
Upgrade the control plane first.
Agents report their version on every `SyncKeys`, so a fleet running mixed
versions is visible in the server list rather than something you have to go
looking for.
## If an update fails
| Symptom | Cause |
| --- | --- |
| "Checksum mismatch" | Interrupted download, or a proxy rewriting the body. Retry |
| Downloads nothing | The machine cannot reach `gitea.hostxtra.co.uk` |
| Service will not start afterwards | Wrong architecture binary, or the file was replaced while a different service manager held it. Reinstall with the install one-liner |
Reinstalling is always safe: the config file is left alone, so the agent comes
back with the same identity and token.
+89
View File
@@ -0,0 +1,89 @@
---
id: backups
title: Backups
sidebar_label: Backups
---
Two things matter: **MongoDB** and **`KEY_ENCRYPTION_KEY`**. A backup missing
either one restores to something unusable.
## What holds what
| Store | Contents | Back up |
| --- | --- | --- |
| MongoDB | Everything durable — servers, keys, assignments, workflows, runs, monitors, incidents, secrets, settings, audit | **Yes** |
| Redis | Sessions only | No. Losing it signs everyone out and nothing else |
| `./data` bind mount | Workflow run logs | Optional |
| `KEY_ENCRYPTION_KEY` | Not stored anywhere by the app | **Yes, separately** |
:::danger The database alone is not a backup
Private keys, vault secrets, OIDC client secrets and console credentials are
encrypted with `KEY_ENCRYPTION_KEY`, which lives in your environment file and
nowhere in the database. Restore the database without it and every one of those
values is permanently unreadable.
Store the key somewhere other than the server it protects.
:::
## Backing up MongoDB
With the bundled Mongo container:
```bash
docker compose exec -T mongo mongodump --archive --gzip --db vantage \
> /backups/vantage-$(date +%F).archive.gz
```
Restoring:
```bash
docker compose exec -T mongo mongorestore --archive --gzip --drop \
< /backups/vantage-2026-07-28.archive.gz
```
`--drop` replaces existing collections. Stop the `server` container first, so
nothing writes during the restore.
## Backing up the environment file
```bash
cp /opt/vantage/.env /secure-location/vantage.env
```
Treat it as a credential in its own right — it holds the encryption key.
## Run logs
Workflow run logs live in the `./data` bind mount, not in the database. They are
swept on the retention schedule anyway, so most people do not back them up. If
you keep them for compliance, set retention to `0` (forever) and include the
directory.
## What a restore gives you
Everything: fleet, keys, assignments, workflows and their history, monitors and
incidents, secrets, settings and the audit log.
What it does **not** do is reconcile the world. After a restore:
- Agents reconnect with their existing tokens, since the token hashes are in the
database.
- If the restore is older than an enrolment, that server's token hash is missing
and the agent will fail to authenticate — re-enrol it.
- The next agent poll rewrites `authorized_keys` to match the restored desired
state, which may remove keys added since the backup.
## A workable schedule
| What | When |
| --- | --- |
| MongoDB dump | Nightly, retained per your policy |
| Environment file | On change, held in a password manager or secret store |
| Restore rehearsal | Occasionally, into a throwaway host |
The rehearsal is the part that gets skipped and the part that finds the
problems.
## Cloud instances
We back these up. You do not need to.
+103
View File
@@ -0,0 +1,103 @@
---
id: ci-cd
title: CI/CD
sidebar_label: CI/CD
---
Two Gitea Actions workflows: one releases agents, one builds images.
## Agent releases
Triggered by an `agent/v*` tag.
```bash
git tag agent/v1.0.0 && git push origin agent/v1.0.0
```
Builds `linux/amd64`, `linux/arm64` and `windows/amd64`, writes `checksums.txt`
and creates a Gitea release. A second job on Windows packages the WiX MSI.
The install and update scripts read the newest `agent/v*` release from the Gitea
API, so tagging is what makes a new agent available to every install.
## Image builds
Triggered on every push to `main`. Builds and pushes seven images: `server`,
`web`, `site`, `sitesvc`, `admin`, `adminsite` and `docsite`.
:::warning Despite the name, this workflow does not deploy
There is no SSH step. Rolling images out is a 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
```
:::
### Each image rebuilds only when its own inputs change
A `git diff` against the previous head decides. That is why the checkout uses
`fetch-depth: 0` — a shallow clone has one commit and nothing to diff against.
| Image | Rebuilds when |
| --- | --- |
| `server` | `server/`, `shared/`, `proto/`, `go.work` |
| `admin` | `admin/`, `shared/`, `go.work` |
| `sitesvc` | `sitesvc/`, `shared/`, `go.work` |
| `web` · `site` · `adminsite` · `docsite` | their own directory only |
`shared/` fans out to all three Go images because each of their Dockerfiles
copies it from a root context. **If a fourth service ever imports `shared/`, it
must be added to that list or it will ship stale.**
Everything rebuilds when there is no trustworthy base commit to diff against: a
manual `workflow_dispatch`, a new branch, or a force-push whose old head is
gone. Changing the workflow file itself also rebuilds everything, since a build
argument is baked into each image.
### The gap: repository variables
:::danger Editing a repository variable pushes no commit, so nothing rebuilds
Values like `API_URL`, `ADMIN_API_URL`, `HQ_URL`, `ADMIN_ENV`, `PADDLE_ENV`,
`PADDLE_CLIENT_TOKEN`, `DOCS_URL` and `DOCS_BASE_URL` are baked into images at
build time. After editing one, run the workflow manually — that is what
`workflow_dispatch` is for.
The symptom is a frontend that keeps using the old value with no error anywhere,
which is a long afternoon if you do not know about this.
:::
The same applies to base images: a service nobody touches stops being rebuilt on
newer base layers. A periodic manual run covers it.
## Secrets and variables
| Name | Type | Purpose |
| --- | --- | --- |
| `RELEASE_TOKEN` | Secret | Gitea API token, `write:release` |
| `REGISTRY_USER` / `REGISTRY_PASSWORD` | Secret | Registry push credentials |
| `PADDLE_API_KEY` | Secret | Read by admin at runtime |
| `PADDLE_WEBHOOK_SECRET` | Secret | Webhook signature verification |
| `GITEA_HOST` / `DOCKER_HOST` | Variable | Hosts used in tags and URLs |
| `API_URL` | Variable | Baked into `web` |
| `HQ_URL` | Variable | Baked into `web`; empty on self-hosted |
| `SITE_API_URL` / `SITE_CONTACT_EMAIL` | Variable | Baked into `site` |
| `ADMIN_API_URL` | Variable | Baked into `adminsite` **and** `site` |
| `ADMIN_ENV` | Variable | Environment badge in the portal |
| `PADDLE_ENV` | Variable | Baked into `adminsite`, read by `admin`. Must match on both sides |
| `PADDLE_CLIENT_TOKEN` | Variable | Browser Paddle token for checkout |
| `DOCS_URL` / `DOCS_BASE_URL` | Variable | Baked into `docsite` |
Anything marked "browser-reachable" must be an origin a browser can actually
resolve — not an internal service name. Get it wrong and every request fails at
runtime with a not-connected panel rather than at build time.
## The documentation site
`docsite/` builds to static files and is served by nginx under `/docs` on the
marketing host, routed by its own proxy location.
`DOCS_BASE_URL` has to agree with three things at once: that proxy location, the
directory the runtime image serves from, and the value baked into the build. When
they disagree the page loads and every stylesheet and script 404s.
+79
View File
@@ -0,0 +1,79 @@
---
id: upgrading
title: Upgrading
sidebar_label: Upgrading
---
Upgrading the control plane is a pull and a recreate. Agents are versioned and
upgraded separately — see [Agent updates](./agent-updates.md).
## Upgrade
```bash
cd /opt/vantage
docker compose pull
docker compose up -d --remove-orphans
```
`--remove-orphans` clears containers for services that no longer exist in the
Compose file, which is what leaves a stale container running after a service is
renamed or removed.
## What happens on boot
1. **Migrations** run, recording markers so each runs once.
2. **Indexes** are ensured. Auth and settings index builders are fatal on
failure; secret and workflow ones only warn.
3. **Default steps** are reseeded from the image, overwriting the `default`
library — which is why those steps are read-only.
Watch it:
```bash
docker compose logs -f server
```
## Before you upgrade
- **Back up MongoDB.** See [Backups](./backups.md). Migrations are one-way.
- **Read the release notes** for anything about migrations or environment
variables.
- **Check your `.env`** still supplies everything required. A newly required
variable stops the boot rather than defaulting to something unsafe.
## Downgrading
There is no automatic downgrade. Migrations do not roll back, so returning to an
older image means restoring the database backup taken before the upgrade. This
is the reason the backup is not optional.
## Zero-downtime
The stack is not designed for it. `docker compose up -d` recreates the server
container, which is a short interruption:
- Agents reconnect on their own — they retry, and the poll loop is idempotent.
- Workflow runs in progress lose their command stream. Steps already dispatched
finish on the agent, but their results have nowhere to go. **Do not upgrade
during a run.**
- Sessions survive, because they live in Redis rather than in the server.
## Upgrading the hosted deployment
Both Compose files, together:
```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
```
CI builds and pushes images but does **not** deploy them; rolling out is this
manual step. See [CI/CD](./ci-cd.md).
## After upgrading
- Confirm every service is `running`.
- Confirm servers return to `active` within a couple of poll intervals.
- Open a page that touches encryption — a secret group — to confirm
`KEY_ENCRYPTION_KEY` came through.
+111
View File
@@ -0,0 +1,111 @@
---
id: agent-config
title: Agent configuration
sidebar_label: Agent config
---
The agent reads no environment variables. Everything is in one YAML file.
## Location
| Platform | Path |
| --- | --- |
| Linux | `/etc/vantage/config.yaml` |
| Windows | `%ProgramData%\vantage\config.yaml` |
Directory `0700`, file `0600`. The install script sets both.
## Contents
```yaml
server_url: "vantage.yourdomain.com:9090"
server_id: "<uuid>"
pre_reg_token: "<token>" # removed after the first successful Register()
agent_token: "" # written by the agent after Register()
poll_interval: 30s
tls: true
```
| Field | Meaning |
| --- | --- |
| `server_url` | `host:port` of the gRPC endpoint. Comes from the server's `GRPC_HOST` |
| `server_id` | The identity issued when the enrolment was created |
| `pre_reg_token` | Single-use, one hour. Cleared once registration succeeds |
| `agent_token` | The permanent credential, written by the agent itself |
| `poll_interval` | How often `SyncKeys` runs. Default `30s` |
| `tls` | Whether to use TLS. Leave `true` |
:::danger This file is the credential
`agent_token` is plaintext here and nowhere else — the control plane holds only
its SHA-256. Anyone who can read this file can act as this agent. That is why
it is `0600` and the directory is `0700`.
:::
## Startup sequence
```
1. Load the config
2. pre_reg_token present → Register() → save agent_token,
clear pre_reg_token, reconnect
3. Start goroutines: command stream · hourly update check ·
inventory · monitors
4. Enter the SyncKeys poll loop
```
## The poll loop
```
1. SyncKeys(server_id, agent_token, agent_version)
2. Non-Linux hosts stop here — Windows agents register and heartbeat only
3. Diff the desired keys against /root/.ssh/authorized_keys;
unchanged → write nothing
4. Changed → write a temp file, os.Rename() over the real one, chmod 0600
```
## Service management
### Linux
Unit at `/etc/systemd/system/vantage-agent.service`, `Restart=always`, running
as root.
```bash
systemctl status vantage-agent
systemctl restart vantage-agent
journalctl -u vantage-agent -f
```
### Windows
A service registered through NSSM, or installed by the MSI that CI builds.
```powershell
Get-Service vantage-agent
Restart-Service vantage-agent
```
## Command-line flags
```
vantage-agent -generate-key
```
Generates a keypair locally. Normal operation takes no flags.
## Moving an agent to a new control plane
Change `server_url`, clear `agent_token`, set a fresh `pre_reg_token` from a new
enrolment, and restart. The old control plane still holds a server record that
will go `offline`; delete it there.
## Uninstalling
```bash
systemctl disable --now vantage-agent
rm -f /usr/local/bin/vantage-agent /etc/systemd/system/vantage-agent.service
rm -rf /etc/vantage
systemctl daemon-reload
```
Keys already written to `authorized_keys` remain on disk — the agent is no
longer running to remove them. Revoke first if that matters.
@@ -0,0 +1,107 @@
---
id: environment-variables
title: Environment variables
sidebar_label: Environment variables
---
Everything the control plane reads from the environment, and what happens when
it is absent.
## Server
| Name | Required | Default | Notes |
| --- | --- | --- | --- |
| `GRPC_HOST` | **yes** | — | The `host:port` agents dial. Boot fails without it. There is deliberately no fallback to the web host: that would hand every agent a port that does not speak gRPC |
| `MONGO_URI` | no | `mongodb://localhost:27017` | The database name is taken from the URI path, falling back to `vantage`. There is no separate `MONGO_DB` |
| `REDIS_ADDR` | no | `localhost:6379` | Sessions only |
| `KEY_ENCRYPTION_KEY` | yes in practice | — | 64 hex characters (32 bytes) for AES-256-GCM. Required for private keys, vault secrets, OIDC client secrets and console credentials |
| `GITEA_HOST` | yes | `gitea.example.com` | Used to build the install scripts and agent download URLs. The default is a placeholder that will not resolve |
| `GUACD_ADDR` | no | `guacd:4822` | The [browser console](../vantage/browser-console.md) daemon |
| `APP_ROOT_LABEL` | no | `vantage` | The app root label for the host and session organisation guard |
| `VANTAGE_WORKFLOW_LOG_DIR` | no | — | Where workflow run logs are written |
| `VANTAGE_DEFAULT_STEPS_DIR` | no | baked into the image | Where the seeded step library is read from |
| `VANTAGE_DEPLOYMENT` | no | self-hosted | Set to `cloud` on a cloud instance. Governs whether a licence may be pasted |
| `VANTAGE_LICENSE` | no | — | A licence blob, used **only** when the instance has no stored one |
| `FREE_INSTANCE_REAP_AFTER` | no | empty | How long past a Free licence's expiry before the instance and all its data are deleted |
:::danger `KEY_ENCRYPTION_KEY` has no recovery path
It encrypts SSH private keys, vault secrets, OIDC client secrets and console
credentials. Lose it and all of them are unreadable. Back it up separately from
the database it protects.
:::
:::warning `FREE_INSTANCE_REAP_AFTER` empty means disabled, and empty is the default
That is the correct value for a self-hosted install, which must never reap. It
is set only on the hosted deployment.
:::
:::info A wrong `APP_ROOT_LABEL` fails quietly
It does not error. It simply stops matching, and the host/session guard stops
protecting anything.
:::
### Not configurable
The HTTP port (`8080`) and the gRPC port (`9090`) are fixed in the server. The
`HTTP_PORT` and `GRPC_PORT` entries in the shipped Compose file are inert —
remap with Docker's port publishing instead.
## Agent
The agent reads no environment variables. Everything is in its
[config file](./agent-config.md).
## Hosted-only services
These run only on the hosted deployment, from
`deploy/docker-compose.site.yml`. A self-hosted install runs none of them.
### sitesvc — the public contact form
| Name | Required | Notes |
| --- | --- | --- |
| `MONGO_URI` | yes | Must point at the control plane's database. Refuses to start against a database that has not run the instances migration. The database name is read from the URI path; a URI without one is refused rather than defaulted |
| `SMTP_HOST`, `SMTP_FROM` | yes | Without them the contact form answers `503` rather than silently dropping messages |
| `SMTP_TO` | no | Defaults to `support@hostxtra.co.uk` |
| `SMTP_PORT` | no | Defaults to `587`; `465` uses implicit TLS |
| `SMTP_USERNAME`, `SMTP_PASSWORD` | no | Auth is skipped when the 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 |
### admin — the licensing authority
| Name | Required | Notes |
| --- | --- | --- |
| `ADMIN_MONGO_URI` | yes | Admin's own database |
| `CONTROL_MONGO_URI` | yes | The control plane's database, for licence injection and user projection |
| `LICENSE_SIGNING_KEY` | yes | **The only service that ever holds this.** Never add it to the server, and never add admin to the self-hosted Compose file |
| `REDIS_ADDR`, `REDIS_USERNAME`, `REDIS_PASSWORD` | yes | Admin uses an external Redis; the base Compose file hardcodes `redis:6379` for the server, so these reach admin only |
| `ADMIN_ORIGIN` | yes | Comma-separated browser origins that call admin. See the warning below |
| `PADDLE_API_KEY` | yes | Boot-required |
| `PADDLE_WEBHOOK_SECRET` | yes | Boot-required. An unverified webhook endpoint is one anyone can issue licences through |
| `PADDLE_ENV` | yes | `sandbox` or `production`. Selects which catalogue price IDs are served, and must match the value baked into the portal build |
| `SMTP_*` | yes in practice | Account, licence and billing email |
| `PUBLIC_URL`, `APP_LOGIN_URL` | yes in practice | Used in links inside emails |
| `FREE_INSTANCE_REAP_AFTER` | yes | Must match the control plane's value. Admin only uses it to name the date in warning emails; the control plane performs the delete |
:::warning A missing `ADMIN_ORIGIN` entry produces no error anywhere
The CORS layer simply omits the allow-origin header and still answers the
preflight with `204`. The browser blocks the request and **admin logs nothing at
all**. The symptom is a preflight failure on an endpoint that works perfectly
under `curl`.
:::
## Build-time variables
These are baked into frontend images at build time, not read at runtime.
Changing one requires rebuilding that image — and because editing a CI variable
pushes no commit, nothing rebuilds on its own. See [CI/CD](../operations/ci-cd.md).
| Name | Baked into |
| --- | --- |
| `API_URL` | `web` |
| `HQ_URL` | `web` |
| `SITE_API_URL`, `SITE_CONTACT_EMAIL` | `site` |
| `ADMIN_API_URL` | `adminsite` **and** `site` |
| `ADMIN_ENV`, `PADDLE_CLIENT_TOKEN`, `PADDLE_ENV` | `adminsite` |
| `DOCS_URL`, `DOCS_BASE_URL` | `docsite` |
+92
View File
@@ -0,0 +1,92 @@
---
id: grpc-api
title: gRPC API
sidebar_label: gRPC API
---
The agent-facing API, on port `9090`, over TLS. Agents dial **out** to it;
nothing dials an agent.
## Service
```protobuf
service Vantage {
rpc Register(RegisterRequest) returns (RegisterResponse);
rpc SyncKeys(SyncRequest) returns (SyncResponse);
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
rpc SyncMonitors(SyncMonitorsRequest) returns (SyncMonitorsResponse);
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
rpc CommandStream(stream AgentMessage) returns (stream ServerCommand);
}
```
The full message definitions live in `proto/vantage/v1/vantage.proto`.
## Authentication
`Register` presents the single-use, one-hour pre-registration token and receives
a permanent agent token. Every other call presents that agent token.
The control plane stores only the SHA-256 of the agent token. The plaintext
exists in the agent's `0600` config file and nowhere else, so a token cannot be
read back out of the control plane.
## Unary calls
| RPC | Direction | Frequency |
| --- | --- | --- |
| `Register` | once, at enrolment | once |
| `SyncKeys` | agent asks for desired key state | every 30s (`poll_interval`) |
| `UploadGeneratedKey` | agent returns a keypair it generated | on demand |
| `ReportUpdates` | pending OS package updates | hourly |
| `ReportInventory` | CPU, memory, disk, kernel | metrics 30s, static 15 min |
| `SyncMonitors` | agent asks which checks it should run | periodically |
| `ReportChecks` | agent returns check results | after each check cycle |
`SyncKeys` doubles as the heartbeat. A server that stops calling it is marked
`offline` by a sweep that runs every two minutes.
## The command stream
`CommandStream` is the only streaming RPC and the only push path.
```mermaid
sequenceDiagram
participant A as Agent
participant S as Server
A->>S: AgentReady (authenticate)
S-->>A: ServerCommand (RunStepCmd)
A-->>S: StepOutputChunk (repeated)
A-->>S: StepResult
S-->>A: ServerCommand (CleanupWorkspaceCmd)
A-->>S: CommandResult
```
The agent authenticates once with `AgentReady`, then the server pushes commands
and the agent replies with `CommandResult`, `StepResult` or `StepOutputChunk`.
### Commands
| Command | Effect |
| --- | --- |
| `GenerateKeyCmd` | Generate an SSH keypair on the machine |
| `DeleteKeyCmd` | Remove a generated key by label |
| `UpdateAgentCmd` | Download and replace the agent binary with a target version |
| `ApplyUpdatesCmd` | Apply pending OS package updates |
| `RunStepCmd` | Execute one workflow step |
| `CleanupWorkspaceCmd` | Recursively remove the run's working directory |
## Why poll for keys and push for commands
A 30-second delay on a key change is fine, and polling needs no reconnection
logic to survive a dropped link. Clicking **Run** on a workflow and waiting up
to 30 seconds is not fine. Hence one of each.
## Network requirements
Every managed machine needs outbound TCP to `GRPC_HOST`. Nothing needs to reach
the machine. Watch for middleboxes that permit the short `Register` call but
drop the long-lived command stream — that failure looks like a server that
registers and then goes offline.
@@ -0,0 +1,97 @@
---
id: ports-and-networking
title: Ports and networking
sidebar_label: Ports and networking
---
## Control plane ports
| Port | Service | Who connects | Expose publicly |
| --- | --- | --- | --- |
| `3000` | web | Browsers, via your reverse proxy | Yes, behind TLS |
| `8080` | server REST | The web app | No |
| `9090` | server gRPC | Agents | **Yes** |
| `4822` | guacd | The server | No |
| `27017` | MongoDB | The server | No |
| `6379` | Redis | The server | No |
## Hosted-only ports
Only on the hosted deployment, from `deploy/docker-compose.site.yml`.
| Port | Service |
| --- | --- |
| `3003` | marketing site |
| `3004` | HQ portal |
| `3005` | this documentation site |
| `8082` | sitesvc |
| `8083` | admin |
## Direction of travel
```mermaid
flowchart LR
B["Browser"] -->|HTTPS| P["Reverse proxy"]
P --> W["web :3000"]
W --> S["server :8080"]
A["Agent on a managed server"] -->|"gRPC/TLS :9090, outbound"| S
S --> G["guacd :4822"]
G -->|"SSH / RDP / VNC"| T["Target machine"]
```
Two things are worth reading off that diagram.
**Agents connect outbound.** No inbound rule is needed on a managed server, and
NAT is not an obstacle. The only requirement is that the machine can reach
`GRPC_HOST`.
**The console does not use the agent.** guacd connects directly to the target on
the protocol port. A machine reachable only by its agent — behind NAT, on a
private subnet — cannot be consoled, even though every other feature works.
## What to open
### On your firewall, inbound to the control plane
- Your web port, from wherever people are.
- `9090`, from every network holding managed machines.
### Outbound from the control plane
- `gitea.hostxtra.co.uk`, for agent releases and version checks.
- Anything a server-run [monitor](../vantage/monitors.md) checks.
- SMTP, if you use an SMTP notification channel.
- Protocol ports on machines you intend to console.
### Outbound from a managed machine
- `GRPC_HOST`.
- `gitea.hostxtra.co.uk`, for install and self-update.
- Its package mirrors, for OS updates.
## TLS
Terminate TLS for the web UI at your reverse proxy.
gRPC on `9090` is reached directly by agents with `tls: true`, so that port needs
a valid certificate for the name in `GRPC_HOST`. If you proxy it, the proxy must
speak HTTP/2 end to end — many do not by default, and the symptom is agents that
register and then fail to hold the command stream.
## Reverse proxy notes
- Point the proxy at `web:3000`. The web app reaches the REST API internally, so
`8080` does not need publishing.
- The console uses a **WebSocket** at `/api/console/tunnel`. A proxy that does
not forward upgrade headers breaks the console and nothing else.
- Workflow log streaming is a long-lived response. A short proxy read timeout
truncates live logs while the run itself continues.
## Air-gapped and restricted networks
The control plane needs outbound access to fetch agent releases. Managed
machines need it too, unless you distribute the agent binary yourself and write
the config by hand — the install script's only job is to do those two things.
Licence verification is entirely local, so a licensed install works with no
outbound access to HQ at all.
+149
View File
@@ -0,0 +1,149 @@
---
id: rest-api
title: REST API
sidebar_label: REST API
---
The control plane's HTTP API, on port `8080`. The web UI is a client of it and
has no privileges it does not.
## Authentication
Most endpoints take a session: an opaque 32-byte token in the `km_session`
cookie, with the body in Redis for 24 hours.
One endpoint takes a bearer token instead — the External Secrets Operator read
path.
## Unauthenticated
```
GET /install /install.ps1 # dynamic agent install scripts
GET /update /update.ps1
GET /auth/bootstrap-status
POST /auth/bootstrap /auth/login /auth/logout
GET /auth/me /auth/oidc/start /auth/oidc/callback
GET /api/secrets/:group/values # bearer token (ESO)
```
`/install` and `/install.ps1` take `server_id` and `token` as query parameters
and return a shell script with the newest agent version substituted in.
`POST /auth/bootstrap` works only while the database has no users.
## Session-authenticated, under `/api`
### Servers
```
GET,POST /servers
GET,POST /servers/new
GET,DELETE /servers/:id
POST /servers/:id/generate-key
POST /servers/:id/update-agent
POST /servers/:id/apply-updates
```
### Keys
```
GET,POST /keys
GET,DELETE /keys/:id
GET /keys/:id/private-key
POST /keys/:id/assign
DELETE /keys/:id/assign/:serverId
```
### Workflows and steps
```
GET,POST /steps
PUT,DELETE /steps/:id
GET /steps/:id/export
POST /steps/import · /steps/seed-defaults · /steps/parse
GET /steps/usage
GET,POST /workflows
GET,PUT,DELETE /workflows/:id
POST /workflows/:id/run
GET /workflows/:id/runs
GET /runs/:runId
POST /runs/:runId/cancel
GET /runs/:runId/servers/:serverId/logs
GET /runs/:runId/servers/:serverId/logs/stream
```
`PUT` and `DELETE` on a step whose source is `default` answer `409`. See
[Workflows](../vantage/workflows.md#default-steps).
### Monitors and channels
```
GET,POST /monitors
GET,PUT,DELETE /monitors/:id
GET /monitors/:id/incidents · /monitors/:id/uptime
GET,POST /channels
PUT,DELETE /channels/:id
POST /channels/:id/test
```
### Secrets
```
GET,POST /secrets
GET,PUT,DELETE /secrets/:group
POST /secrets/:group/reveal
DELETE /secrets/:group/:key
```
### Console
```
POST /console/connect
GET /console/tunnel # websocket
```
### Other
```
GET /audit
GET /agent/latest-version
GET,PUT /settings (owner|admin)
POST /settings/secrets-token (owner|admin)
GET /license
POST /license (self-hosted only)
GET,POST /org/users
PUT /org/users/:id/role
DELETE /org/users/:id
GET,PUT /org/oidc (owner|admin)
```
## Notable refusals
| Endpoint | Condition | Status |
| --- | --- | --- |
| `POST /license` | deployment is `cloud` | `409 cloud_managed` |
| `PUT,DELETE /steps/:id` | the step's source is `default` | `409` |
| `PUT /org/users/:id/role`, `DELETE /org/users/:id` | the user's auth source is `hq` | `409` |
`POST /license` is exempt from the licence check, so pasting a valid licence
works while the current one is expired — that is the way out of degraded mode.
## Multi-tenancy
Every request is scoped to the instance resolved from the session. On a
multi-tenant deployment, a request arriving at `<slug>.vantage.<tld>` also has
its host checked against the session's instance, and a mismatch is rejected.
## Errors
Errors are JSON with an `error` field. Customer-facing endpoints in the HQ API
answer `404` rather than `403` for another account's resource, because a `403`
confirms the resource exists; the control plane's own API is single-tenant per
session and does not need that distinction.
## Admin API
The HQ service has its own API, its own database and its own session cookie
(`admin_session`) on port `8083`. It is documented in the
[Vantage HQ](../hq/accounts-and-signup.md) section rather than here; the two
services share no session and no authentication.
+137
View File
@@ -0,0 +1,137 @@
---
id: troubleshooting
title: Troubleshooting
sidebar_label: Troubleshooting
---
Symptoms, in the order people hit them.
## The server will not start
**Exits immediately on boot.** Almost always a missing `GRPC_HOST` — the server
refuses to start rather than guess a value that would break every agent later.
**Fails during index creation.** The auth and settings index builders are fatal
on failure by design: those unique indexes are what enforce tenant isolation,
so starting without them is worse than not starting. Check the MongoDB user's
permissions and whether a conflicting index already exists.
**Starts, but every secret operation errors.** `KEY_ENCRYPTION_KEY` is missing
or is not 64 hex characters.
## Nobody can sign in
**`/setup` appears when users already exist.** The server is pointed at a
different database than you think. Check the database name in `MONGO_URI`
it comes from the URI path, not a separate variable.
**Sessions do not stick.** Redis is unreachable, or the cookie is being dropped
because the site is served over plain HTTP.
**"Wrong organisation" style rejections.** The host and session guard is
comparing the request host's label against the session's organisation. Check
`APP_ROOT_LABEL`.
**OIDC redirects and then fails.** The callback URL registered with the provider
must match exactly. Keep one local owner account so a broken provider is not a
lockout.
## A server never becomes active
Work through it in this order:
1. Is the agent running? `systemctl status vantage-agent`.
2. What does it say? `journalctl -u vantage-agent -f`.
3. Can that machine reach the endpoint? Test `GRPC_HOST` **from the machine**,
not from the control plane host.
4. Was the token already used or expired? It is single-use and lives one hour —
create a fresh enrolment rather than reusing the old command.
| Symptom | Cause |
| --- | --- |
| Registers, then goes `offline` within minutes | Something permits the short `Register` call but drops the long-lived stream. Usually a proxy or idle-timeout middlebox |
| Stays `pending` forever | Registration never happened. Token spent, or the endpoint unreachable |
| Flaps between `active` and `offline` | Intermittent path, or a poll interval longer than the offline threshold |
Remember the offline sweep runs every two minutes, so status is never
instantaneous.
## Keys are not appearing on a machine
- **It is a Windows server.** Key management is Linux-only, by design.
- **The agent is not running.** Nothing polls, nothing writes.
- **The key is assigned but revoked.** Revocation is soft; check the assignment
state rather than the key.
- **Someone edited `authorized_keys` by hand.** The agent rewrites the file to
match the desired set; hand-added keys disappear on the next change.
## A workflow run fails or hangs
- **Hangs at dispatch.** The target's command stream is not connected — the
server may be `offline`.
- **Fails immediately with an interpreter error.** A bash step on a Windows
target, or PowerShell on Linux.
- **A value does not reach the next step.** Values pass through the file at
`$WORKFLOW_ENV`, one `KEY=value` per line. Declaring an output does not export
it.
- **A secret is empty.** The group is not in the step's `secret_refs`, or the
key name differs from the environment variable you are reading.
- **Logs stop mid-run.** A reverse proxy read timeout cut the stream. The run
itself continues; reload the page.
## The console will not connect
| Symptom | Cause |
| --- | --- |
| Connects, then closes at once | guacd unreachable. Check `GUACD_ADDR` and that the container is running |
| SSH rejects the key | The stored key has no private half, or is not on the target |
| RDP fails on retry | Credentials are single-use and consumed at tunnel open — enter them again |
| Hangs at "connecting" | The **control plane** cannot reach the target on the protocol port. The agent's reachability is irrelevant here |
| Fails only in production | The reverse proxy is not forwarding WebSocket upgrade headers |
## Monitors report down when the service is up
- The check is running from the control plane and the endpoint is only reachable
internally. Switch the runner to an agent on a machine that can see it.
- The keyword no longer appears in the response body.
- Retries are `0`, so a single dropped packet flips the state.
## Notifications are not arriving
Use the channel **Test** button — it goes through the real delivery path, so a
test that arrives proves credentials, network path and destination.
If the test fails: a webhook returning 300 or above counts as a failure, SMTP
needs `host`, `port`, `from` and `to`, and Telegram needs both `token` and
`chat_id`.
## Licence problems
| Symptom | Cause |
| --- | --- |
| `409 cloud_managed` when pasting | It is a cloud instance. Licences are written by HQ; there is nothing to paste |
| Licence rejected as not matching | It is bound to a different instance UUID. Relink in HQ |
| Instance degraded despite a valid-looking licence | It has expired past its grace period. Pasting still works — that endpoint stays available specifically so it can |
| Cannot enrol another server | The server allowance is reached. Raise it in HQ or remove one |
## HQ portal problems
**A request fails in the browser but works under `curl`.** The browser origin is
missing from `ADMIN_ORIGIN`. This produces no log line at all in admin — the
preflight is answered `204` without the allow-origin header, and the browser
blocks the real request.
**A price or plan looks wrong after an edit.** Repository variables are baked
into images at build time and editing one pushes no commit, so nothing rebuilds.
Trigger the build manually. See [CI/CD](../operations/ci-cd.md).
## Gathering information before asking for help
```bash
docker compose ps
docker compose logs --tail=200 server
journalctl -u vantage-agent --no-pager -n 200 # on the affected machine
```
Include your instance UUID from **Settings → Licence** — it is the reference
support works from.
+51
View File
@@ -0,0 +1,51 @@
---
id: audit-log
title: Audit log
sidebar_label: Audit log
---
Every mutating API path writes an audit event. The log is at **Audit**.
## What an event carries
| Field | Meaning |
| --- | --- |
| Action | A dotted name, e.g. `server.created`, `settings.updated` |
| Actor | Who did it |
| Target | The object acted on |
| Detail | A short human-readable note |
| Time | When |
## What is recorded
Creation, modification and deletion across the product: servers and enrolments,
keys and assignments, workflow and step changes, runs triggered, monitors and
channels, secret groups and reveals, console sessions opened, settings and
member changes, licence installs.
Reads are not recorded, with one deliberate exception: **revealing a secret**
writes an event, because reading that particular thing is an act rather than a
lookup.
## What is not recorded
- Sign-ins and sign-out.
- Anything inside a console session.
- Step output. That lives in the run log, kept under the workflow retention
setting rather than with the audit log.
## Retention
Audit events are not swept by the workflow log retention setting — that setting
governs run logs only. Audit history stays until the instance does.
:::warning It is a log, not a control
The audit log tells you what happened. It does not restrict what can happen, and
an admin can do anything an admin can do. Use roles for restriction and the log
for accountability.
:::
## Getting events out
`GET /api/audit` returns recent events as JSON and accepts a `limit`. There is
no streaming or push export; if you need events in a SIEM, poll that endpoint.
+70
View File
@@ -0,0 +1,70 @@
---
id: browser-console
title: Browser console
sidebar_label: Browser console
---
An SSH, RDP or VNC session in a browser tab, with no client software and no
inbound port on the target beyond the one the protocol already uses.
Protocol handling is Apache Guacamole's — the control plane proxies a WebSocket
to a **guacd** daemon and manages credentials around it.
## Requirements
- `guacd` running and reachable from the server. The bundled Compose stack
includes it; `GUACD_ADDR` defaults to `guacd:4822`.
- `KEY_ENCRYPTION_KEY` set, since every credential involved is stored encrypted.
- Network reachability **from the control plane to the target** on the protocol
port. This is the one part of Vantage that is not agent-mediated: guacd
connects directly, so a machine reachable only by its agent cannot be
consoled.
## Opening a session
From a server's page, choose **Console**. Then:
1. The UI calls `POST /api/console/connect`, which mints a **one-time** session
token.
2. The browser opens a WebSocket to `GET /api/console/tunnel` with that token.
3. The server marks the token consumed — atomically, so a second use cannot
race — and proxies the connection to guacd.
## Credentials
### SSH
Authenticates with a private key stored in the [key library](./ssh-keys.md). The
key must have its private half uploaded; a public-only key cannot open a
session.
### RDP and VNC
You supply credentials when connecting. They are encrypted, **single-use**, and
consumed when the tunnel opens. They are not retained for the next session.
:::info Why single-use
A stored console credential is a standing grant to that machine for anyone who
can reach the endpoint. Consuming it at tunnel-open means a leaked session token
is worth one connection at most, and only until it is used.
:::
## Session behaviour
Closing the tab ends the session. There is no reconnect and no session
persistence — reopening mints a new token and a new connection.
## Auditing
Opening a console is an audited action, with actor, server and time. What
happens *inside* the session is not recorded: there is no session capture or
keystroke log. If you need that, it has to come from the target machine.
## When it does not work
| Symptom | Cause |
| --- | --- |
| Connects then closes immediately | guacd unreachable — check `GUACD_ADDR` and that the container is up |
| SSH refuses the key | The stored key has no private half, or is not in the target's `authorized_keys` |
| RDP fails on a fresh credential | Credentials are consumed on open; a retry needs them entered again |
| Hangs at connecting | The control plane cannot reach the target on the protocol port |
+78
View File
@@ -0,0 +1,78 @@
---
id: monitors
title: Monitors
sidebar_label: Monitors
---
Monitors check that something is answering. Four types, two places they can run
from, and a notification path when they stop being satisfied.
## Types
| Type | Checks | Options |
| --- | --- | --- |
| `http` | An HTTP(S) URL | method, expected status, keyword that must appear in the body, allow insecure TLS |
| `tcp` | A host and port accept a connection | — |
| `icmp` | A host answers ping | — |
| `tls` | A certificate is valid and not expiring | warn N days before expiry |
An `http` monitor with a keyword is usually the one you want for an application:
a 200 that returns an error page still fails the keyword.
## Where a check runs
Every monitor has a **runner**:
| Runner | Meaning |
| --- | --- |
| `server` | The control plane's scheduler performs the check |
| a server ID | That server's agent performs it locally and reports the result |
Use `server` for anything reachable from the control plane — public endpoints,
your own front door. Use an agent for anything only reachable from inside the
target network: a database on a private subnet, a service bound to localhost, a
device on a management VLAN.
:::tip Agent-run monitors measure what your users can't
A check run from the control plane tells you the service is reachable from
there. A check run on the machine tells you the process is alive. Those are
different questions, and outages usually live in the gap.
:::
## Interval, retries and state
- **Interval** — how often to check.
- **Retries** — how many consecutive failures are tolerated before the state
flips.
A monitor sits in `pending` until its first result. Failures accumulate; once
they exceed `retries`, the monitor goes `down`, an **incident** opens and the
attached notification channels fire. A subsequent success closes the incident.
Retries are what keeps one dropped packet from paging you. Set them to at least
`1` for anything crossing the public internet.
## Notifications
Attach one or more [notification channels](./notification-channels.md) to a
monitor. Channels are shared, so one Slack destination can serve every monitor
you have.
Notification state is tracked per monitor, so a service that is down for six
hours does not send a message per interval.
## Uptime and incidents
The monitor detail page shows:
- **Uptime**, from hourly rollup records — checks performed, how many were up,
and mean latency per hour. Rollups are what make the graph cheap to draw over
long windows.
- **Incidents**, each with a start, a resolution and the cause recorded at the
moment it opened.
## Disabling versus deleting
Disabling stops the checks and keeps the history. Deleting removes the monitor.
Prefer disabling for anything seasonal — the uptime record is usually the part
you wanted.
@@ -0,0 +1,101 @@
---
id: notification-channels
title: Notification channels
sidebar_label: Notification channels
---
A channel is a destination for alerts. [Monitors](./monitors.md) reference
channels by ID, so one destination serves as many monitors as you like.
Manage them at **Settings → Notifications**.
## Types
### Webhook
Posts JSON to a URL you choose.
| Setting | |
| --- | --- |
| `url` | Required |
```json
{
"monitor": "API front door",
"type": "http",
"old_status": "up",
"new_status": "down",
"message": "HTTP 502",
"time": "2026-07-28T09:14:02Z"
}
```
Any response of 300 or above counts as a delivery failure. The request times out
after 10 seconds.
### Discord
| Setting | |
| --- | --- |
| `url` | Discord webhook URL |
Posts the alert as message content.
### Slack
| Setting | |
| --- | --- |
| `url` | Slack incoming webhook URL |
### Telegram
| Setting | |
| --- | --- |
| `token` | Bot token |
| `chat_id` | Target chat |
### SMTP
| Setting | |
| --- | --- |
| `host`, `port` | Required |
| `from`, `to` | Required |
| `username`, `password` | Optional; auth is skipped when the username is empty |
Port `465` uses implicit TLS; anything else uses STARTTLS.
Alert emails are rendered by the same email system that sends licence and
account mail, so a monitor alert and an account email look like the same
product.
## The message
Non-webhook channels all send the same one-line title:
```
[Vantage] API front door (http) is DOWN: HTTP 502
```
Recoveries read `recovered` in place of `is DOWN`. The webhook payload carries
the same information as fields, which is the one to use if you are routing into
something that needs to branch on status.
## Testing
Every channel has a **Test** button. It dispatches a fabricated down event for a
monitor called "Test monitor", through the real delivery path — so a test that
arrives proves the credentials, the network path and the destination, not just
the configuration form.
:::tip Test after every change
Channel settings are only exercised when something breaks, which is the worst
time to discover a stale webhook URL. Re-test after rotating a token.
:::
## Choosing destinations
- Use a **chat channel** for awareness, and make sure someone owns it.
- Use **SMTP** where a durable record matters.
- Use a **webhook** to reach an on-call system that does escalation properly.
Vantage does not do escalation, rotas or acknowledgement; a webhook into
something that does is the intended answer.
+74
View File
@@ -0,0 +1,74 @@
---
id: secrets
title: Secrets vault
sidebar_label: Secrets
---
Key/value pairs, grouped by name, encrypted at rest with AES-256-GCM under
`KEY_ENCRYPTION_KEY`. Two things consume them: workflow steps, and Kubernetes
External Secrets Operator.
## Groups and values
A **group** is a named bundle — `prod-db`, `registry`, `acme-api`. Inside it are
key/value pairs.
Group by consumer, not by type. A group is the unit a workflow step references
and the unit ESO reads, so a group that matches one consumer is one reference;
a group holding everything is over-sharing to every step that needs any of it.
## Managing them
**Secrets → New group**, then add keys.
Values are write-then-hidden. The list shows keys, never values. **Reveal** is a
separate action on a separate endpoint, and it writes an audit event — so
looking at a secret is a recorded act.
Deleting a single key and deleting the whole group are separate operations.
## Using secrets in workflows
Add the group name to a step's `secret_refs`. At execution the group's pairs are
injected into the step's environment:
```bash
# secret_refs: ["registry"]
echo "$REGISTRY_PASSWORD" | docker login registry.example.com -u "$REGISTRY_USER" --password-stdin
```
A workflow can also override `secret_refs` per step, without changing the
library entry.
:::warning A step can print its own secrets
Injection puts values in the environment. If your script echoes them, or runs
with `set -x`, they land in the run log — which is stored on disk and readable
in the UI. Vantage does not scrub step output.
:::
## Kubernetes External Secrets Operator
`GET /api/secrets/:group/values` returns a group's pairs for ESO, authenticated
with a **bearer token** rather than a session.
1. Generate the token at **Settings → Integrations**. It is shown once; only its
SHA-256 is stored.
2. Put it in a Kubernetes secret.
3. Point an ESO `SecretStore` at the endpoint with that bearer token.
The token is rotatable: generating a new one replaces the stored hash and
invalidates the old one immediately.
:::danger This token reads every group
It is instance-wide, not scoped to one group. Treat it as a credential to the
whole vault: store it as a secret in the cluster, never in a manifest in git,
and rotate it when anyone with access leaves.
:::
## What the vault is not
- **Not a password manager.** There is no sharing, expiry or per-user
visibility. Anyone who can sign in and reveal, can reveal.
- **Not versioned.** Overwriting a value loses the previous one.
- **Not recoverable without the key.** If `KEY_ENCRYPTION_KEY` is lost, so is
every value. Back it up separately from the database.
+98
View File
@@ -0,0 +1,98 @@
---
id: servers
title: Servers
sidebar_label: Servers
---
The fleet. Every managed machine runs an agent that connects outbound to the
control plane, and everything else in Vantage — keys, workflows, monitors,
consoles — targets these records.
## Enrolling a server
Covered step by step in [Add your first server](../getting-started/first-server.md).
In short: **Servers → Add server** issues a single-use, one-hour token and shows
a one-liner to run as root on the target machine.
## Lifecycle
| Status | Meaning |
| --- | --- |
| `pending` | Enrolment created; the agent has not registered yet |
| `active` | The agent registered and is syncing |
| `offline` | Last-seen passed the threshold |
The offline sweep runs every two minutes, so a machine that has just gone away
takes a little while to be marked as such. That delay is intentional — a single
missed poll is not an outage.
## The server detail page
### Keys
Which SSH keys are assigned to this machine, and their state. See
[SSH keys](./ssh-keys.md).
### Inventory
Agents report:
| Data | Refreshed |
| --- | --- |
| CPU, memory, swap, load | every 30 seconds |
| Partitions, kernel, full static snapshot | every 15 minutes |
The two carry separate timestamps, so a stale static snapshot beside fresh
metrics is normal rather than a fault.
### OS updates
Agents check for pending package updates hourly and report the count. From the
server page you can:
- **Apply updates** — pushes `ApplyUpdatesCmd` down the command stream. The
agent runs the platform's package manager and reports back.
- **Update agent** — pushes `UpdateAgentCmd` with a target version; the agent
downloads the release, verifies it and replaces itself. See
[Agent updates](../operations/agent-updates.md).
:::warning Applying updates is not scheduled or staged
It runs now, on that machine. If you need ordering, health gates or a canary,
build it as a [workflow](./workflows.md) instead — that is what workflows exist
for.
:::
### Console
Opens a browser SSH, RDP or VNC session. See [Browser console](./browser-console.md).
## Windows servers
Windows agents register, heartbeat, run workflow steps and report inventory.
They do not manage `authorized_keys` — the poll loop stops after the heartbeat
on any non-Linux host. This is a deliberate scope decision, not a gap being
worked on.
## Removing a server
Deleting the server record removes it from the fleet. It does **not** uninstall
the agent, which will keep trying to sync and failing. Uninstall it on the
machine too:
```bash
systemctl disable --now vantage-agent
rm -f /usr/local/bin/vantage-agent /etc/systemd/system/vantage-agent.service
rm -rf /etc/vantage
systemctl daemon-reload
```
Keys previously written to `authorized_keys` stay on disk, because the agent is
no longer running to remove them. Revoke and let the agent apply the change
**before** you delete the server if that matters to you.
## Agent tokens
Each server has its own token. The control plane stores only its SHA-256; the
plaintext exists in the agent's `0600` config and nowhere else. There is no way
to read a token back out of the control plane — if one is lost, re-enrol the
machine.
+113
View File
@@ -0,0 +1,113 @@
---
id: settings
title: Settings
sidebar_label: Settings
---
One page, three groups: **Access**, **Monitoring** and **Integrations**. Plus
the licence, which has its own page.
Settings require the `owner` or `admin` role.
:::info Where instance settings went
Members and single sign-on used to live at `/settings/instance`. They are now
the Access group at the top of this page — splitting "who can sign in" from "how
this instance behaves" produced two half-pages and a nav entry nobody could
distinguish from Settings. The old path still redirects.
:::
## Access
### Members
Add, remove and re-role the people who can sign in.
| Role | Can |
| --- | --- |
| `owner` | Everything |
| `admin` | Everything except owner-only settings |
| `member` | Servers, keys, workflows, monitors, secrets, console |
Local members authenticate with email and a bcrypt-hashed password.
#### Members managed by Vantage HQ
On a cloud instance, people granted access from the HQ portal appear here as
read-only rows with a link to the portal.
:::warning HQ-managed users cannot be edited locally
Changing the role of, or deleting, an `hq`-sourced user is refused with `409`.
HQ owns their role, their password and whether they exist at all — a local
change would be overwritten by the next sync and would leave two writers for one
password hash. Manage them from [People and roles](../hq/people-and-roles.md).
:::
### Single sign-on (OIDC)
Configured per organisation:
| Field | |
| --- | --- |
| Issuer | Your provider's issuer URL |
| Client ID | |
| Client secret | Stored AES-256-GCM encrypted |
Sign-in then goes `/auth/oidc/start` → your provider → `/auth/oidc/callback`.
Local and OIDC users coexist. Keep at least one local owner: if the provider is
misconfigured or unreachable, a local account is the way back in.
## Monitoring
- **Alert defaults** for monitors.
- **Notification channels** — their own page. See
[Notification channels](./notification-channels.md).
## Integrations
### Workflow log retention
How long run logs are kept.
| Value | Meaning |
| --- | --- |
| unset | 30 days |
| a number | that many days |
| `0` | forever |
### ESO read token
The bearer token External Secrets Operator uses to read secret groups. Shown
once, stored as a SHA-256 hash, rotatable. See
[Secrets](./secrets.md#kubernetes-external-secrets-operator).
## Licence
`/settings/license` shows the deployment, tier, server allowance, enabled
features and expiry.
On **self-hosted**, paste a licence here. This works even while the current
licence is expired — that is the way out of degraded mode.
On **cloud**, there is no paste form. The endpoint answers `409 cloud_managed`,
because a cloud licence is written by HQ directly. The page links to the portal
instead.
See [Licensing and entitlements](../hq/licensing-and-entitlements.md).
## Sessions
Sessions are an opaque token in the `km_session` cookie, held in Redis with a
24-hour TTL. There is no per-session management UI; restarting Redis signs
everyone out and affects nothing else.
## Host and organisation guard
On a multi-tenant deployment, a request to `<slug>.vantage.<tld>` resolves the
organisation from the slug and rejects a session belonging to a different one.
The label it looks for comes from `APP_ROOT_LABEL`.
:::warning A wrong `APP_ROOT_LABEL` disables the guard
It does not fail loudly — it simply stops matching, and the host check stops
protecting anything. If you serve the UI on a custom domain, set it to match.
:::
+81
View File
@@ -0,0 +1,81 @@
---
id: ssh-keys
title: SSH keys
sidebar_label: SSH keys
---
Vantage holds a library of public keys and decides, per server, which ones
belong in `/root/.ssh/authorized_keys`. The agent makes the file match.
:::info root only
Vantage manages `/root/.ssh/authorized_keys` and nothing else. There is no
per-user key management. The agent runs as root because writing that file
requires it.
:::
## Adding a key
### Upload one you already have
**Keys → Add key**, paste the public half. Vantage stores the public key and its
fingerprint, and never needs the private half for this path.
### Generate one on a server
Vantage can have an agent generate a keypair on a managed machine
(`GenerateKeyCmd` over the command stream). The public half comes back to the
library. You may optionally upload the private half too, in which case it is
stored **AES-256-GCM encrypted** under `KEY_ENCRYPTION_KEY`.
The JSON representation of a key exposes only `has_private_key` and
`has_passphrase` — never the material. Retrieving a stored private key is its
own endpoint and its own audit event.
:::tip Why store a private key at all
The [browser console](./browser-console.md) needs one to open an SSH session. If
you are not using the console, do not upload private halves.
:::
## Assigning
Assign a key to one or more servers. Within one poll interval — 30 seconds — the
agent picks up the change.
## Revoking
Revocation is **soft**: the assignment gets a `revoked_at` timestamp rather than
being deleted, so the history of who had access to what, and when, survives.
The agent treats a revoked assignment as "not desired" and removes the line from
`authorized_keys` on its next sync.
:::warning Revoking does not close open sessions
It removes the key from the file. An SSH session already established stays up
until it ends. Kill sessions on the machine if that matters.
:::
## What the agent actually does
Each poll:
1. `SyncKeys` returns the desired set of public keys for that server.
2. The agent reads `/root/.ssh/authorized_keys` and computes fingerprints.
3. **If the sets match, it writes nothing.** No disk churn on unchanged state,
which is most polls.
4. If they differ, it writes a temporary file, then `os.Rename()`s it over the
real one and sets mode `0600`.
The rename is atomic, so a machine that dies mid-write keeps the old file
intact. There is no window in which `authorized_keys` is truncated or partial.
:::danger Vantage owns the whole file
The agent rewrites `authorized_keys` to match the desired set. Keys added by
hand on the machine are removed on the next change. If a key must survive, put
it in Vantage.
:::
## Recovering from a lockout
If you have removed every key from a machine and cannot get in, you still have
the console — provided a private key is stored — or out-of-band access from your
hosting provider. Vantage has no backdoor and does not keep a break-glass key.
+142
View File
@@ -0,0 +1,142 @@
---
id: workflows
title: Workflows and steps
sidebar_label: Workflows
---
A **step** is a reusable script with declared inputs, outputs and secret
references. A **workflow** composes steps in order and targets a set of servers.
Running one dispatches the steps to each target's agent and streams the output
back live.
## Steps
A step has:
| Field | Meaning |
| --- | --- |
| `name`, `description` | Library identity |
| `interpreter` | `bash` or `powershell` |
| `script` | The body |
| `declared_inputs` | Named parameters with defaults and descriptions |
| `declared_outputs` | Names this step promises to export |
| `secret_refs` | Vault entries injected as environment variables |
### Passing values between steps
Each step runs with `WORKFLOW_ENV` set to a file path. Anything written there as
`KEY=value` becomes an environment variable for the **later steps of the same
run on the same server**.
```bash
HOSTNAME=$(hostname)
echo "$HOSTNAME"
echo "HOSTNAME=$HOSTNAME" >> $WORKFLOW_ENV
```
That is the whole mechanism. `declared_outputs` documents what a step exports so
the designer can show it; the file is what actually carries the value.
### Secrets
List a vault group in `secret_refs` and its key/value pairs are injected as
environment variables when the step runs. They are not written to the run log
unless your own script echoes them. See [Secrets](./secrets.md).
### The workspace
Every run gets a per-run working directory on each target. Steps share it, so
one step can leave a file for the next. The agent removes it at the end of the
run (`CleanupWorkspaceCmd`).
Do not use it for anything that must outlive the run.
## Default steps
A small library is seeded into every organisation at boot from the image, so a
new install is not staring at an empty page.
:::warning Default steps are read-only
Editing or deleting one is refused with `409`. Seeding rewrites them on every
boot, so an edit would silently revert and a delete would come back at the next
restart — refusing is the honest answer.
To customise one, use the per-step **script override** in the workflow designer,
which belongs to that workflow and is not touched by seeding. To add to the
shared library permanently, a file has to be committed to the repository and the
server image rebuilt.
:::
The UI mirrors this — the step modal opens read-only and Delete is hidden — but
the API is the boundary; the UI is the courtesy.
## Building a workflow
1. **Workflows → New**.
2. Add steps in order from the library.
3. Set inputs per step.
4. Set failure behaviour per step.
5. Choose target servers.
### Failure behaviour
| `on_failure` | Effect |
| --- | --- |
| `stop` | Abort this server's run. Other servers continue |
| `continue` | Record the failure, run the next step anyway |
| `retry` | Re-run the step up to `max_retries`, then treat it as a failure |
### Per-step overrides
A workflow can override a step's script or its secret references without
touching the library entry. This is how you adapt a default step, and it is
scoped to that workflow.
## Running
**Run** snapshots the resolved steps into the run record and dispatches
`RunStepCmd` to each target's agent over the command stream — no waiting for the
next poll.
:::info Runs freeze their steps
The snapshot is why editing a step tomorrow never rewrites what happened today.
A run shows the script that actually executed, not the current library version.
:::
Targets run **in parallel**; steps within one server run **in order**.
## Watching a run
Step stdout and stderr stream back as chunks, are appended to a log file on the
server, and the UI follows them live. Each step records status, attempts, exit
code and its exported environment.
**Cancel** stops a run in progress. Steps already running on an agent finish;
nothing further is dispatched.
## Log retention
Run logs are swept on a schedule set by `workflow_log_retention_days` in
Settings:
| Value | Meaning |
| --- | --- |
| unset | 30 days |
| a number | that many days |
| `0` | keep forever |
## Import and export
Steps export to a JSON file (`vantage.step/v1`) and import back, which is how
you move a step between instances or keep one in version control. There is also
a parse endpoint that turns a pasted script into a draft step by reading its
declared inputs and outputs.
## Practical notes
- A step is a script. It runs as root, on the target, with no sandbox. Review
what you import.
- Keep steps small and single-purpose; compose them in the workflow. That is
what makes the library reusable rather than a folder of near-duplicates.
- PowerShell steps only make sense on Windows targets and bash steps on Linux
ones. Nothing stops you targeting the wrong one; the step simply fails.
+123
View File
@@ -0,0 +1,123 @@
import type * as Preset from "@docusaurus/preset-classic";
import type { Config } from "@docusaurus/types";
import { themes as prismThemes } from "prism-react-renderer";
// Served as a path on the marketing host (vantage.hostxtra.co.uk/docs), routed
// by its own Nginx Proxy Manager location rather than by site/. A path and not
// a subdomain on purpose: *.vantage.hostxtra.co.uk is the per-tenant instance
// namespace, and APP_ROOT_LABEL resolves an org from the label before
// "vantage", so a docs. label there would be read as a tenant slug.
//
// DOCS_BASE_URL has to agree with three things at once: the NPM location, the
// directory the runtime image copies the build into, and this value. When they
// disagree the HTML still loads and every asset 404s.
const url = process.env.DOCS_URL || "https://vantage.hostxtra.co.uk";
const baseUrl = process.env.DOCS_BASE_URL || "/docs/";
const appUrl = process.env.APP_URL || "https://vantage.hostxtra.co.uk";
const hqUrl = process.env.HQ_URL || "https://vantage-hq.hostxtra.co.uk";
const config: Config = {
title: "Vantage Docs",
tagline: "Fleet management for servers you actually own",
favicon: "img/favicon.svg",
url,
baseUrl,
organizationName: "hostxtra",
projectName: "vantage",
onBrokenLinks: "throw",
onBrokenAnchors: "throw",
i18n: { defaultLocale: "en", locales: ["en"] },
markdown: {
mermaid: true,
hooks: { onBrokenMarkdownLinks: "throw" },
},
themes: [
"@docusaurus/theme-mermaid",
[
// Compile-time index served from this origin. No Algolia account,
// no external host, nothing to key or rotate.
"@easyops-cn/docusaurus-search-local",
{
hashed: true,
indexBlog: false,
docsRouteBasePath: "/",
highlightSearchTermsOnTargetPage: true,
},
],
],
presets: [
[
"classic",
{
docs: {
// Docs-only mode: the documentation is the site.
routeBasePath: "/",
sidebarPath: "./sidebars.ts",
},
blog: false,
theme: { customCss: "./src/css/custom.css" },
} satisfies Preset.Options,
],
],
themeConfig: {
colorMode: {
// Light default, matching site/ and adminsite/. web/ is the only
// app locked to dark, and that contrast is deliberate.
defaultMode: "light",
respectPrefersColorScheme: true,
},
navbar: {
// Wordmark only. The logo mark is drawn with currentColor in
// site/components/Logo.tsx, which an <img> src cannot inherit, and
// baking a fill into the file would mean a hex that stops tracking
// the theme.
title: "Vantage",
items: [
{
type: "docSidebar",
sidebarId: "docs",
position: "left",
label: "Documentation",
},
{ href: appUrl, label: "Control plane", position: "right" },
{ href: hqUrl, label: "Vantage HQ", position: "right" },
],
},
footer: {
style: "light",
links: [
{
title: "Documentation",
items: [
{ label: "Getting started", to: "/getting-started/what-is-vantage" },
{ label: "Vantage", to: "/vantage/servers" },
{ label: "Vantage HQ", to: "/hq/accounts-and-signup" },
{ label: "Reference", to: "/reference/environment-variables" },
],
},
{
title: "Product",
items: [
{ label: "Control plane", href: appUrl },
{ label: "Vantage HQ", href: hqUrl },
],
},
],
copyright: `© ${new Date().getFullYear()} HostXtra.`,
},
prism: {
theme: prismThemes.github,
darkTheme: prismThemes.dracula,
additionalLanguages: ["bash", "powershell", "yaml", "json", "protobuf", "nginx"],
},
} satisfies Preset.ThemeConfig,
};
export default config;
+32
View File
@@ -0,0 +1,32 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Hitting the container directly is a 403 on an empty document root
# otherwise, which reads as a broken deploy rather than "wrong path".
location = / {
return 302 /docs/;
}
# Hashed assets are immutable — the filename changes when the content does.
location /docs/assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location /docs/ {
# Docusaurus emits a real page per route, so a miss is a genuine 404
# rather than something a SPA fallback should paper over.
try_files $uri $uri/ $uri.html /docs/404.html;
}
error_page 404 /docs/404.html;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
}
+20082
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "vantage-docs",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "docusaurus start",
"build": "docusaurus build",
"serve": "docusaurus serve",
"clear": "docusaurus clear",
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "^3.10.2",
"@docusaurus/preset-classic": "^3.10.2",
"@docusaurus/theme-mermaid": "^3.10.2",
"@easyops-cn/docusaurus-search-local": "^0.52.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.1.1",
"prism-react-renderer": "^2.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.10.2",
"@docusaurus/tsconfig": "^3.10.2",
"@docusaurus/types": "^3.10.2",
"typescript": "~5.6.2"
},
"browserslist": {
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 3 chrome version",
"last 3 firefox version",
"last 5 safari version"
]
},
"engines": {
"node": ">=20.0"
}
}
+74
View File
@@ -0,0 +1,74 @@
import type { SidebarsConfig } from "@docusaurus/plugin-content-docs";
// Authored by hand rather than autogenerated, so ordering is a decision and
// not a filename accident.
const sidebars: SidebarsConfig = {
docs: [
"index",
{
type: "category",
label: "Getting started",
collapsed: false,
items: [
"getting-started/what-is-vantage",
"getting-started/cloud-vs-self-hosted",
"getting-started/self-hosted-install",
"getting-started/first-login",
"getting-started/first-server",
"getting-started/claim-free-licence",
],
},
{
type: "category",
label: "Vantage",
items: [
"vantage/servers",
"vantage/ssh-keys",
"vantage/workflows",
"vantage/monitors",
"vantage/notification-channels",
"vantage/secrets",
"vantage/browser-console",
"vantage/audit-log",
"vantage/settings",
],
},
{
type: "category",
label: "Vantage HQ",
items: [
"hq/accounts-and-signup",
"hq/people-and-roles",
"hq/cloud-instances",
"hq/self-hosted-instances",
"hq/licensing-and-entitlements",
"hq/billing",
"hq/free-tier",
],
},
{
type: "category",
label: "Reference",
items: [
"reference/environment-variables",
"reference/rest-api",
"reference/grpc-api",
"reference/agent-config",
"reference/ports-and-networking",
"reference/troubleshooting",
],
},
{
type: "category",
label: "Operations",
items: [
"operations/upgrading",
"operations/backups",
"operations/agent-updates",
"operations/ci-cd",
],
},
],
};
export default sidebars;
+236
View File
@@ -0,0 +1,236 @@
/* ==========================================================================
Vantage documentation design tokens
The token block below is COPIED VERBATIM from site/app/globals.css — same
names, same values. adminsite/ holds a copy too, web/ holds the dark half,
and shared/mail/templates/layout.html.tmpl holds it a fifth time as literal
hex because email clients support neither var() nor prefers-color-scheme.
Nothing enforces the match automatically: change a token in one file and you
change it in all of them, in the same commit.
Everything below the token block maps Docusaurus's --ifm-* variables onto
these. No rule in this file, and no component in this app, may carry a hex
value outside the two token blocks.
Docusaurus always stamps data-theme on <html>, so unlike site/ there is no
prefers-color-scheme branch to keep in step — the theme toggle is the only
writer.
========================================================================== */
:root,
:root[data-theme="light"] {
--ground: #eaedf3;
--panel: #ffffff;
--panel-2: #f4f6fa;
--ink: #0a1b33;
--ink-2: #41556f;
--ink-3: #6c7f96;
--rule: #cdd6e2;
--rule-soft: #e0e6ef;
--accent: #0b2a58;
--accent-ink: #ffffff;
--up: #2f8a60;
--down: #c6462f;
--pend: #b0801f;
--shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45);
--logo: #0b2a58;
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, "Cascadia Mono", "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
--s--1: clamp(0.76rem, 0.74rem + 0.1vw, 0.81rem);
--s-0: clamp(1rem, 0.97rem + 0.14vw, 1.05rem);
--s-1: clamp(1.16rem, 1.09rem + 0.32vw, 1.36rem);
--s-2: clamp(1.5rem, 1.34rem + 0.74vw, 2rem);
--s-3: clamp(2rem, 1.66rem + 1.6vw, 3.1rem);
--s-4: clamp(2.6rem, 1.9rem + 3.3vw, 4.9rem);
--rail: 1200px;
}
:root[data-theme="dark"] {
--ground: #071628;
--panel: #0d2138;
--panel-2: #102842;
--ink: #e4ecf6;
--ink-2: #9fb3ca;
--ink-3: #71879f;
--rule: #1e3855;
--rule-soft: #172c44;
--accent: #5b9be8;
--accent-ink: #04101f;
--up: #4fb484;
--down: #e2705a;
--pend: #d6a63f;
--shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85);
--logo: #7fb2f0;
}
/* ---------- Docusaurus mapping ---------- */
:root {
--ifm-color-primary: var(--accent);
--ifm-color-primary-dark: var(--accent);
--ifm-color-primary-darker: var(--accent);
--ifm-color-primary-darkest: var(--accent);
--ifm-color-primary-light: var(--accent);
--ifm-color-primary-lighter: var(--accent);
--ifm-color-primary-lightest: var(--accent);
--ifm-background-color: var(--ground);
--ifm-background-surface-color: var(--panel);
--ifm-font-family-base: var(--sans);
--ifm-font-family-monospace: var(--mono);
--ifm-font-size-base: var(--s-0);
--ifm-line-height-base: 1.65;
--ifm-heading-font-weight: 800;
--ifm-heading-line-height: 1.15;
--ifm-h1-font-size: var(--s-3);
--ifm-h2-font-size: var(--s-2);
--ifm-h3-font-size: var(--s-1);
--ifm-font-color-base: var(--ink);
--ifm-heading-color: var(--ink);
--ifm-link-color: var(--accent);
--ifm-link-hover-color: var(--accent);
--ifm-toc-link-color: var(--ink-2);
--ifm-navbar-background-color: var(--panel);
--ifm-navbar-shadow: none;
--ifm-navbar-link-color: var(--ink-2);
--ifm-navbar-link-hover-color: var(--ink);
--ifm-footer-background-color: var(--panel-2);
--ifm-footer-color: var(--ink-2);
--ifm-footer-link-color: var(--ink-2);
--ifm-footer-title-color: var(--ink);
--ifm-menu-color: var(--ink-2);
--ifm-menu-color-active: var(--accent);
--ifm-menu-color-background-active: var(--panel-2);
--ifm-menu-color-background-hover: var(--panel-2);
--ifm-toc-border-color: var(--rule-soft);
--ifm-hr-border-color: var(--rule-soft);
--ifm-table-border-color: var(--rule-soft);
--ifm-table-stripe-background: var(--panel-2);
--ifm-table-head-background: var(--panel-2);
--ifm-code-background: var(--panel-2);
--ifm-code-font-size: 0.88em;
--ifm-pre-background: var(--panel-2);
--ifm-blockquote-color: var(--ink-2);
--ifm-blockquote-border-color: var(--rule);
--ifm-global-radius: 4px;
--ifm-alert-border-radius: 4px;
--ifm-button-border-radius: 4px;
--ifm-global-shadow-lw: var(--shadow);
--ifm-global-shadow-md: var(--shadow);
--ifm-breadcrumb-color-active: var(--accent);
--ifm-breadcrumb-item-background-active: var(--panel-2);
--docusaurus-highlighted-code-line-bg: var(--rule-soft);
}
/* Admonitions carry the semantic three. They read by shape and label as well
as colour, which is the rule everywhere else in the product too. */
.theme-admonition-note,
.theme-admonition-info {
--ifm-alert-background-color: var(--panel-2);
--ifm-alert-border-color: var(--rule);
--ifm-alert-foreground-color: var(--ink);
}
.theme-admonition-tip {
--ifm-alert-background-color: var(--panel-2);
--ifm-alert-border-color: var(--up);
--ifm-alert-foreground-color: var(--ink);
}
.theme-admonition-warning {
--ifm-alert-background-color: var(--panel-2);
--ifm-alert-border-color: var(--pend);
--ifm-alert-foreground-color: var(--ink);
}
.theme-admonition-danger {
--ifm-alert-background-color: var(--panel-2);
--ifm-alert-border-color: var(--down);
--ifm-alert-foreground-color: var(--ink);
}
/* ---------- small corrections ---------- */
.navbar {
border-bottom: 1px solid var(--rule-soft);
}
.navbar__title {
font-weight: 800;
letter-spacing: -0.02em;
}
.footer {
border-top: 1px solid var(--rule-soft);
}
.markdown h1,
.markdown h2,
.markdown h3 {
letter-spacing: -0.03em;
text-wrap: balance;
}
.markdown h2 {
margin-top: 2.4rem;
padding-top: 1.6rem;
border-top: 1px solid var(--rule-soft);
}
.markdown > p,
.markdown li {
color: var(--ink-2);
}
.markdown strong {
color: var(--ink);
}
/* Machine output — install one-liners, key blobs, run logs — sits on a floor
beneath the panel, the same distinction web/ draws with --well. */
.theme-code-block {
border: 1px solid var(--rule-soft);
box-shadow: none !important;
}
table {
display: table;
width: 100%;
}
.menu {
font-size: var(--s--1);
padding: 1rem;
}
.menu__list-item-collapsible .menu__link--sane,
.menu__link {
border-radius: 4px;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
/* The reference tables in Reference/ are wide by nature; they scroll inside
their own container rather than pushing the page sideways. */
.markdown table {
display: block;
overflow-x: auto;
max-width: 100%;
}
+12
View File
@@ -0,0 +1,12 @@
<!--
The Vantage mark, traced from site/components/Logo.tsx. A favicon is an asset
rather than a component, and a browser tab has no access to the token block,
so the logo navy is a literal here — the same concession the email layout
makes. Keep it in step with --logo.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="246 207 533 610">
<g transform="translate(0,1024) scale(0.1,-0.1)" fill="#0b2a58" stroke="none">
<path d="M4940 7767 c-96 -57 -528 -312 -960 -567 -678 -400 -1064 -628 -1187 -702 l-33 -20 0 -1357 0 -1357 293 -174 c160 -96 425 -252 587 -348 946 -559 1352 -799 1407 -833 34 -22 67 -39 72 -39 5 0 188 106 408 236 219 130 459 272 533 315 74 44 425 252 780 462 l645 382 0 1355 0 1355 -135 81 c-140 84 -1118 662 -1812 1070 -218 129 -402 236 -410 239 -7 3 -92 -42 -188 -98z m297 -331 c309 -182 971 -572 1208 -712 149 -88 372 -220 498 -294 l227 -135 0 -1175 0 -1175 -578 -342 c-317 -188 -694 -411 -837 -495 -143 -85 -344 -204 -447 -265 l-186 -111 -314 185 c-987 584 -1710 1013 -1725 1025 -10 8 -13 256 -13 1178 0 1099 1 1168 18 1182 9 8 150 93 312 188 162 96 417 246 565 333 805 476 1149 677 1156 677 4 0 56 -29 116 -64z" />
<path d="M3760 6109 c0 -6 187 -396 417 -867 229 -471 483 -994 564 -1162 l148 -305 232 0 233 0 271 560 c150 308 403 830 564 1160 160 329 291 605 291 612 0 19 -553 19 -568 1 -9 -12 -229 -480 -652 -1390 -73 -158 -136 -285 -140 -283 -3 2 -100 206 -215 452 -115 246 -291 624 -390 838 l-182 390 -287 3 c-202 2 -286 -1 -286 -9z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+7
View File
@@ -0,0 +1,7 @@
{
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"baseUrl": "."
},
"exclude": [".docusaurus", "build"]
}