This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+163
-48
@@ -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.
|
||||
</h1>
|
||||
<p className="lede">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div className="plans">
|
||||
<div className="plan">
|
||||
<div>
|
||||
<div className="plan__n">Solo</div>
|
||||
<div className="plan__n">Free</div>
|
||||
<p className="plan__d">
|
||||
A homelab, a couple of VPSes, and the keys on your
|
||||
own laptop.
|
||||
</p>
|
||||
</div>
|
||||
<div className="plan__p">
|
||||
£0 <span>forever</span>
|
||||
£0 <span>3 servers</span>
|
||||
</div>
|
||||
<p className="plan__t">
|
||||
Cloud or self-hosted. Renewed once a year from the
|
||||
portal, one free instance per deployment.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Up to 3 servers</li>
|
||||
<li>Keys, workflows and monitors</li>
|
||||
<li>One member, one instance</li>
|
||||
<li>3 servers and 3 monitors</li>
|
||||
<li>Keys, workflows and the step library</li>
|
||||
<li>One secret group, one alert channel</li>
|
||||
<li>30 days of audit history</li>
|
||||
<li>Community support</li>
|
||||
</ul>
|
||||
<Link className="btn btn--line" href="/start">
|
||||
@@ -72,63 +84,107 @@ export default function PricingPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="plan__p">
|
||||
£4 <span>/ server / month</span>
|
||||
£19 <span>/ month, 3 servers</span>
|
||||
</div>
|
||||
<p className="plan__t">
|
||||
£5 per extra server. £190 a year saves two months.
|
||||
Self-hosted is £190 a year.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Unlimited servers and members</li>
|
||||
<li>Owner, admin and member roles</li>
|
||||
<li>OIDC single sign-on</li>
|
||||
<li>Browser console and secrets vault</li>
|
||||
<li>Full audit history</li>
|
||||
<li>Email support, next business day</li>
|
||||
<li>3 servers included, add as many as you like</li>
|
||||
<li>Unlimited monitors, secrets and channels</li>
|
||||
<li>365 days of audit history</li>
|
||||
<li>Browser console and single sign-on as add-ons</li>
|
||||
<li>Email support, 24/5</li>
|
||||
</ul>
|
||||
<Link className="btn btn--solid" href="/start">
|
||||
Start 14-day trial
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="plan">
|
||||
<div>
|
||||
<div className="plan__n">Self-hosted</div>
|
||||
<div className="plan__n">Enterprise</div>
|
||||
<p className="plan__d">
|
||||
The whole stack on your metal, behind your own
|
||||
boundary.
|
||||
A fleet, a compliance question, and someone who
|
||||
wants a phone number.
|
||||
</p>
|
||||
</div>
|
||||
<div className="plan__p">
|
||||
£290 <span>/ year, per install</span>
|
||||
£49 <span>/ month, 10 servers</span>
|
||||
</div>
|
||||
<p className="plan__t">
|
||||
£5 per extra server. £490 a year saves two months.
|
||||
Self-hosted is £490 a year.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Everything in Professional, no server cap</li>
|
||||
<li>Your MongoDB, Redis and certificates</li>
|
||||
<li>Mirror agent releases internally</li>
|
||||
<li>Priority support and upgrade notes</li>
|
||||
<li>10 servers included, add as many as you like</li>
|
||||
<li>Everything in Professional</li>
|
||||
<li>Audit history kept forever</li>
|
||||
<li>Email and phone support, 24/7</li>
|
||||
<li>Buy it yourself, no sales call</li>
|
||||
</ul>
|
||||
<Link className="btn btn--line" href="/contact">
|
||||
Talk to us
|
||||
<Link className="btn btn--line" href="/start">
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="scroll__hint">Scroll the table sideways for all three plans</p>
|
||||
<div className="scroll" tabIndex={0} role="region" aria-label="Plan comparison">
|
||||
<div
|
||||
className="specs"
|
||||
style={{ marginTop: "2.6rem", maxWidth: "66ch" }}
|
||||
>
|
||||
<span className="tag">
|
||||
Add-ons, on Professional and Enterprise
|
||||
</span>
|
||||
<div className="spec">
|
||||
<span className="spec__k">£5 / MO</span>
|
||||
<div>
|
||||
<h3>Browser console</h3>
|
||||
<p>
|
||||
SSH, RDP and VNC sessions in the browser, audited
|
||||
like everything else. £50 a year.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">£9 / MO</span>
|
||||
<div>
|
||||
<h3>Single sign-on</h3>
|
||||
<p>
|
||||
Connect your own OIDC provider to an instance. £90 a
|
||||
year.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="scroll__hint">
|
||||
Scroll the table sideways for all three tiers
|
||||
</p>
|
||||
<div
|
||||
className="scroll"
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label="Plan comparison"
|
||||
>
|
||||
<table className="cmp">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Capability</th>
|
||||
<th>Solo</th>
|
||||
<th>Free</th>
|
||||
<th>Professional</th>
|
||||
<th>Self-hosted</th>
|
||||
<th>Enterprise</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{COMPARISON.map(
|
||||
([capability, solo, professional, selfHosted]) => (
|
||||
([capability, free, professional, enterprise]) => (
|
||||
<tr key={capability}>
|
||||
<td>{capability}</td>
|
||||
<td>{solo}</td>
|
||||
<td>{free}</td>
|
||||
<td>{professional}</td>
|
||||
<td>{selfHosted}</td>
|
||||
<td>{enterprise}</td>
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
@@ -144,19 +200,69 @@ export default function PricingPage() {
|
||||
<div>
|
||||
<h3>What counts as a server</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">ADDING</span>
|
||||
<div>
|
||||
<h3>Going up mid-term</h3>
|
||||
<p>
|
||||
Raise the server count and the new cap applies
|
||||
straight away, prorated for the rest of the
|
||||
period. Your renewal date does not move.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">REDUCING</span>
|
||||
<div>
|
||||
<h3>Going down</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">LIMITS</span>
|
||||
<div>
|
||||
<h3>Going over on Solo</h3>
|
||||
<h3>Reaching the cap</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">CANCELLING</span>
|
||||
<div>
|
||||
<h3>What happens when you stop paying</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">SELF-HOSTED</span>
|
||||
<div>
|
||||
<h3>Why self-hosted is annual only</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,6 +280,15 @@ export default function PricingPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="prose" style={{ marginTop: "2.4rem" }}>
|
||||
Prices exclude VAT, which is calculated at checkout. Billing is
|
||||
handled by Paddle as merchant of record.{" "}
|
||||
<Link href="/contact" style={{ color: "var(--accent)" }}>
|
||||
Ask us anything
|
||||
</Link>{" "}
|
||||
before you buy.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user