Skip to content

Try the paid flow on this machine

The whole product runs in five containers on one machine: the relay, the control plane, Postgres, Caddy, and the docs site. This page walks the parts that need a human (signing in, the operator panel, and paying with a Stripe test card) against that stack rather than against a deployment.

The sandbox keys are the only thing this needs from outside. Everything else is in the repository.

1. Set the three variables that make it a local stack

Section titled “1. Set the three variables that make it a local stack”

.env is read by docker compose. Three values decide whether a local run works:

Terminal window
SUITE_DOMAIN=localhost # Caddy's internal CA, not Let's Encrypt
SUITE_ADMIN_EMAILS=you@example.com # who may use the operator surface
STRIPE_SECRET_KEY=sk_test_... # the sandbox key
STRIPE_WEBHOOK_SECRET=whsec_... # printed by `pnpm stripe --listen`, step 4
STRIPE_PRICE_TIERS='{"price_…":{"tier":"personal",…}}' # printed by `pnpm stripe`
SUITE_BILLING_ENABLED=1

SUITE_MAIL_TRANSPORT stays unset: the console transport writes every message to the control plane’s log, which is where the magic links and the verification links are read from in step 2.

Terminal window
docker compose --profile docs up -d --build
docker compose ps # five services, four healthy and one running

Caddy serves https://app.localhost, https://docs.localhost and https://suite.localhost with its own certificate authority. A browser warns once and can accept it. curl needs the address pinned and the warning ignored:

Terminal window
curl -sk --resolve app.localhost:443:127.0.0.1 https://app.localhost/health

Accepting the warning by hand is enough for a look. A headless browser or a tool that refuses an unknown authority needs the root itself, which Caddy keeps in its volume:

Terminal window
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt

SUITE_DOMAIN=canvas.example.com in a local run makes Caddy ask Let’s Encrypt for a domain this machine does not own; every host then answers nothing while it retries.

An operator is an address, not a token, so the account has to exist before its address can be trusted:

  1. Open https://app.localhost and create the account with the address in SUITE_ADMIN_EMAILS.

  2. Read the confirmation link out of the log:

    Terminal window
    docker compose logs control-plane | grep "auth mail" | tail -1

    The line is JSON and the URL is in line. Open it.

  3. Sign in. Then open https://app.localhost/admin.html; the panel says whether this account is an operator. If it says it is not, the variable did not reach the process — it is read through a whitelist (services/auth/src/http/env.ts): an empty value there mints operator: false rather than refusing to boot.

The panel is where accounts are admitted (SUITE_ADMISSION_REQUIRED=1) and where starter templates are written. https://app.localhost/metrics is a scrape, and needs either an operator session or a request from inside the container while SUITE_METRICS_LOCAL=1.

4. Forward Stripe’s webhooks through Caddy

Section titled “4. Forward Stripe’s webhooks through Caddy”

The control plane publishes no port: in a deployment, Caddy is the only way in, so the local stack is the same shape.

Terminal window
pnpm stripe # creates or reuses the two prices, prints STRIPE_PRICE_TIERS
pnpm stripe --listen https://app.localhost/api/auth/stripe/webhook

--listen adds --skip-verify for a localhost target, because the certificate is Caddy’s own and the CLI has no way to trust it. The CLI also resolves names itself and does not treat *.localhost as loopback, so add one line to /etc/hosts first, or every delivery answers no such host:

Terminal window
echo '127.0.0.1 app.localhost docs.localhost suite.localhost' | sudo tee -a /etc/hosts

Copy the whsec_… it prints into STRIPE_WEBHOOK_SECRET and restart the control plane:

Terminal window
docker compose up -d control-plane

Leave the listener running. A delivery that arrives while it is down is retried by Stripe, but the sandbox forgets quickly, so a missed one is easiest to reproduce with a fresh purchase.

Sign in as any account, open the workspace menu, and the Plan tab: the two plans come from STRIPE_PRICE_TIERS, so a plan that is missing there is missing from the page.

Field Value
Card number 4242 4242 4242 4242
Expiry any future date
CVC any three digits
Name and postcode anything

The Checkout page is Stripe’s own, so no card detail reaches this origin. It also carries a promotion code field: a percentage or 100% coupon made in the sandbox dashboard is how a comp is given, and a 100%-off code is the fastest way to test the whole flow without money moving.

Three places, in increasing order of directness:

Terminal window
# 1. the webhook arrived and was verified
docker compose logs control-plane | grep stripe/webhook
# 2. the subscription row exists
docker compose exec postgres psql -U suite -d suite -c 'select plan, status, "referenceId" from subscription'
# 3. the entitlement the relay will enforce
curl -sk --resolve app.localhost:443:127.0.0.1 \
-X POST https://app.localhost/api/billing/entitlement \
-H 'content-type: application/json' -d '{"organizationId":"<org id>"}'

The organisation id is in the subscription row above. The Plan tab shows the same answer, and it comes from that read rather than from a copy in the browser.

A renewal or a cancellation is easiest to see with a test clock:

Terminal window
pnpm stripe --clock

It prints the customer and clock commands, including how to advance a month.

The account is still created by the sign-up form and the plan is bought afterwards. ADR-0083 records the door that replaces it: anonymous Checkout, then the account created from the completed session. Its Remaining list is the work. Until that lands, “buy without an account” has no path, and step 5 starts from a signed-in page.

The hosted stack is https://app.suite.run, and it differs in three ways: the webhook is registered in the Stripe dashboard rather than by a CLI listener, the database is RDS rather than a local Postgres, and the promo code is already made.

Prerequisites, all already true: SUITE_BILLING_ENABLED=1, SUITE_BILLING_MODE=test, the two test price ids in billing.stripe.prices.test, and a webhook endpoint for https://app.suite.run/api/auth/stripe/webhook. The mode and the catalogue are on the operator panel’s Settings tab, which is the answer to “are we live?” without an SSH session.

  1. Sign in at https://app.suite.run and open a workspace.
  2. The account menu, then Plan. Two plans, personal and organization, come from the deployment’s billing.stripe.prices.test; a plan missing from that page is missing from the setting.
  3. Choose a plan. Stripe Checkout opens on Stripe’s own origin, so no card detail reaches us.
  4. Apply the promo code SUITE-TESTER: 100% off, 20 redemptions, made for exactly this walk. A test card (4242 4242 4242 4242, any future expiry, any CVC) also works.
  5. Back in the app, the Plan tab should show the tier and its limits, because that read comes from /api/billing/entitlement rather than from a copy in the browser.
  6. Open a workspace that was capped before. The sync control should stop offering plans and claim the workspace instead, which is the whole paid loop landing.
  7. Billing portal, then cancel. The entitlement should return to the free row, and the next claim should be capped again.

When a step does not do what this page says, the log is the first place to look:

Terminal window
AWS_PROFILE=suite deploy/cloudwatch/queries.sh webhooks 1
AWS_PROFILE=suite deploy/cloudwatch/queries.sh errors 1

webhooks shows the delivery and its signature check. A rejected signature while in test mode usually means billing.stripe.webhookSecret.test was replaced by a different endpoint’s secret.

Terminal window
docker compose --profile docs down # keeps the volumes
docker compose --profile docs down -v # forgets the accounts and the documents