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. +

@@ -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. +

- 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.

- £290 / year, per install + £49 / month, 10 servers
+

+ £5 per extra server. £490 a year saves two months. + Self-hosted is £490 a year. +

- - Talk to us + + Get started
-

Scroll the table sideways for all three plans

-
+
+ + Add-ons, on Professional and Enterprise + +
+ £5 / MO +
+

Browser console

+

+ SSH, RDP and VNC sessions in the browser, audited + like everything else. £50 a year. +

+
+
+
+ £9 / MO +
+

Single sign-on

+

+ Connect your own OIDC provider to an instance. £90 a + year. +

+
+
+
+ +

+ Scroll the table sideways for all three tiers +

+
- + - + {COMPARISON.map( - ([capability, solo, professional, selfHosted]) => ( + ([capability, free, professional, enterprise]) => ( - + - + ), )} @@ -144,19 +200,69 @@ export default function PricingPage() {

What counts as a server

- 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.

@@ -174,6 +280,15 @@ export default function PricingPage() { + +

+ 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 -} -
CapabilitySoloFree ProfessionalSelf-hostedEnterprise
{capability}{solo}{free} {professional}{selfHosted}{enterprise}