diff --git a/deploy/docker-compose.site.yml b/deploy/docker-compose.site.yml
index 0cd0772..a3d2977 100644
--- a/deploy/docker-compose.site.yml
+++ b/deploy/docker-compose.site.yml
@@ -1,9 +1,4 @@
services:
- # Adds to the base `server` service defined in docker-compose.yml —
- # only the cloud deployment reaps abandoned Free instances.
- server:
- environment:
- FREE_INSTANCE_REAP_AFTER: "336h"
site:
image: gitea.hostxtra.co.uk/mrhid6/vantage/site:latest
restart: unless-stopped
@@ -18,7 +13,6 @@ services:
- 8082:8082
environment:
PORT: "8082"
- MONGO_URI: ${MONGO_URI:-}
PUBLIC_URL: ${PUBLIC_URL:-}
SITE_ORIGIN: ${SITE_ORIGIN:-}
TRUST_PROXY: ${SITE_TRUST_PROXY:-false}
@@ -42,12 +36,6 @@ services:
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
LICENSE_SIGNING_KEY: ${LICENSE_SIGNING_KEY:-}
PUBLIC_URL: ${ADMIN_PUBLIC_URL:-}
- # Comma-separated, and it needs BOTH browser origins that call admin:
- # vantage-hq (the console) and the marketing site, whose /start form
- # posts account signups here. An origin missing from this list is not
- # a 403 — admin omits the CORS header entirely and the browser blocks
- # the preflight, so the form fails with nothing in admin's logs.
- # ADMIN_ORIGIN="https://vantage-hq.hostxtra.co.uk,https://vantage.hostxtra.co.uk"
ADMIN_ORIGIN: ${ADMIN_ORIGIN:-}
TRUST_PROXY: ${TRUST_PROXY:-true}
SMTP_HOST: ${SMTP_HOST:-}
@@ -57,23 +45,13 @@ services:
SMTP_FROM: ${SMTP_FROM:-}
APP_LOGIN_URL: ${APP_LOGIN_URL:-}
FREE_INSTANCE_REAP_AFTER: "336h"
- # Paddle billing. PADDLE_API_KEY and PADDLE_WEBHOOK_SECRET are
- # boot-REQUIRED — an unverified webhook endpoint is one anyone can
- # issue licences through. PADDLE_ENV selects which catalogue price
- # IDs are served (sandbox|production).
PADDLE_ENV: ${PADDLE_ENV:-sandbox}
PADDLE_API_KEY: ${PADDLE_API_KEY:-}
PADDLE_WEBHOOK_SECRET: ${PADDLE_WEBHOOK_SECRET:-}
-
- # The staff and customer console, served at vantage-hq.hostxtra.co.uk.
- # ADMIN_API_URL is baked into the image at build time, not read here, so
- # changing it needs a rebuild rather than a restart — and it must appear in
- # admin's ADMIN_ORIGIN above or the browser blocks every request.
adminsite:
image: gitea.hostxtra.co.uk/mrhid6/vantage/adminsite:latest
restart: unless-stopped
ports:
- # 3000 is web, 3003 is the marketing site; this takes 3004.
- 3004:3000
depends_on:
- admin
diff --git a/site/app/globals.css b/site/app/globals.css
index 58af6be..5392c59 100644
--- a/site/app/globals.css
+++ b/site/app/globals.css
@@ -931,6 +931,15 @@ code {
min-height: 3.2em;
}
+/* The term line under the headline price. A metered plan has a second and third
+ number — the annual equivalent and the per-server rate — and they are
+ qualifications of the headline rather than features, so they sit with it. */
+.plan__t {
+ font-size: 0.82rem;
+ color: var(--ink-3);
+ margin-top: -0.5rem;
+}
+
.plan ul {
list-style: none;
margin: 0;
diff --git a/site/app/pricing/page.tsx b/site/app/pricing/page.tsx
index 93cf767..0a23583 100644
--- a/site/app/pricing/page.tsx
+++ b/site/app/pricing/page.tsx
@@ -4,21 +4,27 @@ import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Pricing",
description:
- "Priced per managed server. People, keys, workflows and secrets are free. Free for 3 servers, £4 per server per month, or £290 a year self-hosted.",
+ "Three tiers, cloud or self-hosted, priced per managed server. Free for 3 servers. Professional from £19 a month, Enterprise from £49 a month, £5 per extra server.",
};
+/* Prices and allowances are the shipped catalogue, not marketing rounding:
+ * allowances come from shared/license/plans.go and the amounts from the Paddle
+ * catalogue. Annual is ten times monthly everywhere, which is the "two months
+ * free" the portal's own term picker states. */
const COMPARISON: [string, string, string, string][] = [
- ["Managed servers", "3", "Unlimited", "Unlimited"],
- ["Members", "1", "Unlimited", "Unlimited"],
+ ["Managed servers", "3", "3, then £5 each", "10, then £5 each"],
+ ["People", "Unlimited", "Unlimited", "Unlimited"],
["SSH key assignment", "Yes", "Yes", "Yes"],
["Workflows and step library", "Yes", "Yes", "Yes"],
["Monitors", "3", "Unlimited", "Unlimited"],
- ["Secrets vault", "No", "Yes", "Yes"],
- ["Browser console", "No", "Yes", "Yes"],
- ["OIDC single sign-on", "No", "Yes", "Yes"],
- ["Audit history", "30 days", "Forever", "Forever"],
- ["Runs on your hardware", "No", "No", "Yes"],
- ["Support", "Community", "Next business day", "Priority"],
+ ["Secret groups", "1", "Unlimited", "Unlimited"],
+ ["Notification channels", "1", "Unlimited", "Unlimited"],
+ ["Audit history", "30 days", "365 days", "Forever"],
+ ["Browser console", "Not Available", "Add-on", "Add-on"],
+ ["Single sign-on", "Not Available", "Add-on", "Add-on"],
+ ["Support", "Community", "Email, 24/5", "Email and phone, 24/7"],
+ ["Cloud term", "Annual, £0", "Monthly or annual", "Monthly or annual"],
+ ["Self-hosted term", "Annual, £0", "Annual", "Annual"],
];
export default function PricingPage() {
@@ -32,30 +38,36 @@ export default function PricingPage() {
maxWidth: "17ch",
}}
>
- Per managed server. Nothing else counts.
+ Pick a tier, then pay per server.
People are free. Keys, workflows, monitors and secrets are free.
- You pay for servers running an agent, because that is the only
- number that grows with you.
+ You pay for a tier and for the servers running an agent, because
+ that is the only number that grows with you. Every tier runs
+ either in our cloud or on your own hardware, for the same money.
-
Solo
+
Free
A homelab, a couple of VPSes, and the keys on your
own laptop.
- £0 forever
+ £0 3 servers
+
+ Cloud or self-hosted. Renewed once a year from the
+ portal, one free instance per deployment.
+
-
Up to 3 servers
-
Keys, workflows and monitors
-
One member, one instance
+
3 servers and 3 monitors
+
Keys, workflows and the step library
+
One secret group, one alert channel
+
30 days of audit history
Community support
@@ -72,63 +84,107 @@ export default function PricingPage() {
- £4 / server / month
+ £19 / month, 3 servers
+
+ £5 per extra server. £190 a year saves two months.
+ Self-hosted is £190 a year.
+
-
Unlimited servers and members
-
Owner, admin and member roles
-
OIDC single sign-on
-
Browser console and secrets vault
-
Full audit history
-
Email support, next business day
+
3 servers included, add as many as you like
+
Unlimited monitors, secrets and channels
+
365 days of audit history
+
Browser console and single sign-on as add-ons
+
Email support, 24/5
- Start 14-day trial
+ Get started
-
Self-hosted
+
Enterprise
- The whole stack on your metal, behind your own
- boundary.
+ A fleet, a compliance question, and someone who
+ wants a phone number.
- One running agent, one server. Remove a box and
- it stops billing that day.
+ One running agent, one server. You choose the
+ cap rather than being metered on what you ran,
+ so the invoice is the same number you set.
+
+
+
+
+ ADDING
+
+
Going up mid-term
+
+ Raise the server count and the new cap applies
+ straight away, prorated for the rest of the
+ period. Your renewal date does not move.
+
+
+
+
+ REDUCING
+
+
Going down
+
+ A reduction takes effect at your next renewal.
+ You keep what you have already paid for until
+ then, and we show you the date it changes.
LIMITS
-
Going over on Solo
+
Reaching the cap
- Nothing is deleted. A fourth agent registers and
- heartbeats, but stops syncing keys until you
- upgrade or remove a server.
+ Nothing is deleted. An agent beyond the cap
+ still registers and heartbeats, but stops
+ syncing keys until you raise the cap or remove a
+ server.
+
+
+
+
+ CANCELLING
+
+
What happens when you stop paying
+
+ Your licence runs to the end of the period you
+ paid for, then the instance goes read-only.
+ Monitors keep checking, alerts keep firing and
+ agents keep their keys — you just cannot change
+ anything until you renew.
+
+
+
+
+ SELF-HOSTED
+
+
Why self-hosted is annual only
+
+ A self-hosted licence is a signed file your
+ install checks offline, with no phone home. That
+ means we cannot withdraw one early, so the term
+ is the limit — and we would rather bound it at a
+ year than sell it twelve times over.
+ Prices exclude VAT, which is calculated at checkout. Billing is
+ handled by Paddle as merchant of record.{" "}
+
+ Ask us anything
+ {" "}
+ before you buy.
+
);
}
diff --git a/sitesvc/cmd/main.go b/sitesvc/cmd/main.go
index 6f2a492..63e352f 100644
--- a/sitesvc/cmd/main.go
+++ b/sitesvc/cmd/main.go
@@ -13,32 +13,14 @@ import (
"github.com/joho/godotenv"
"github.com/mrhid6/vantage/sitesvc/internal/api"
"github.com/mrhid6/vantage/sitesvc/internal/mail"
- "github.com/mrhid6/vantage/sitesvc/internal/store"
)
func main() {
godotenv.Load()
- mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017/vantage")
addr := ":" + getEnv("PORT", "8082")
- if err := store.Connect(mongoURI); err != nil {
- log.Fatalf("failed to connect to MongoDB: %v", err)
- }
- log.Printf("connected to MongoDB (database %q)", store.DatabaseName())
-
- guardCtx, guardCancel := context.WithTimeout(context.Background(), 10*time.Second)
- guardErr := store.RequireMigratedDatabase(guardCtx)
- guardCancel()
- if guardErr != nil {
- log.Fatalf("database check failed: %v", guardErr)
- }
-
- if err := store.EnsureIndexes(); err != nil {
- log.Fatalf("failed to ensure indexes: %v", err)
- }
-
mailCfg := mail.FromEnv()
if mailCfg.Enabled() {
log.Printf("smtp enabled (%s) contact form delivers to %s", mailCfg.Host, mailCfg.To)
diff --git a/sitesvc/internal/store/store.go b/sitesvc/internal/store/store.go
deleted file mode 100644
index 555f787..0000000
--- a/sitesvc/internal/store/store.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package store
-
-import (
- "context"
- "errors"
- "fmt"
- "time"
-
- "github.com/mrhid6/vantage/shared/indexes"
- "go.mongodb.org/mongo-driver/v2/bson"
- "go.mongodb.org/mongo-driver/v2/mongo"
- "go.mongodb.org/mongo-driver/v2/mongo/options"
- "go.mongodb.org/mongo-driver/v2/x/mongo/driver/connstring"
-)
-
-var database *mongo.Database
-
-func Connect(uri string) error {
- cs, err := connstring.ParseAndValidate(uri)
- if err != nil {
- return fmt.Errorf("parse MONGO_URI: %w", err)
- }
- if cs.Database == "" {
- return errors.New("MONGO_URI must name a database, e.g. mongodb://host:27017/vantage")
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
-
- client, err := mongo.Connect(options.Client().ApplyURI(uri))
- if err != nil {
- return err
- }
- if err := client.Ping(ctx, nil); err != nil {
- return err
- }
- database = client.Database(cs.Database)
- return nil
-}
-
-func DatabaseName() string {
- if database == nil {
- return ""
- }
- return database.Name()
-}
-
-func col(name string) *mongo.Collection { return database.Collection(name) }
-
-func EnsureIndexes() error {
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
-
- // users.email and instances.slug are declared in the shared module so both
- // services agree. Re-declaring at boot means sitesvc does not depend on the
- // control plane having started first.
- return indexes.EnsureCoreIndexes(ctx, database)
-}
-
-// RequireMigratedDatabase refuses to start against a control-plane database
-// that has not run migration 0004.
-//
-// Provisioning into `orgs` while the control plane reads `instances` would
-// create tenants nobody can see — the exact skew failure the shared module was
-// built to prevent. Failing to start is strictly better.
-func RequireMigratedDatabase(ctx context.Context) error {
- names, err := database.ListCollectionNames(ctx, bson.M{})
- if err != nil {
- return fmt.Errorf("list collections: %w", err)
- }
-
- var hasInstances, hasOrgs bool
- for _, n := range names {
- switch n {
- case "instances":
- hasInstances = true
- case "orgs":
- hasOrgs = true
- }
- }
-
- // A brand-new database has neither. That is fine — whichever service starts
- // first creates `instances`.
- if !hasInstances && !hasOrgs {
- return nil
- }
- if !hasInstances {
- return errors.New("instances collection not found; deploy the control plane first")
- }
- return nil
-}
-