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:
SUITE_DOMAIN=localhost # Caddy's internal CA, not Let's EncryptSUITE_ADMIN_EMAILS=you@example.com # who may use the operator surfaceSTRIPE_SECRET_KEY=sk_test_... # the sandbox keySTRIPE_WEBHOOK_SECRET=whsec_... # printed by `pnpm stripe --listen`, step 4STRIPE_PRICE_TIERS='{"price_…":{"tier":"personal",…}}' # printed by `pnpm stripe`SUITE_BILLING_ENABLED=1SUITE_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.
2. Bring it up
Section titled “2. Bring it up”docker compose --profile docs up -d --builddocker compose ps # five services, four healthy and one runningCaddy 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:
curl -sk --resolve app.localhost:443:127.0.0.1 https://app.localhost/healthAccepting 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:
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crtSUITE_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.
3. Become the operator
Section titled “3. Become the operator”An operator is an address, not a token, so the account has to exist before its address can be trusted:
-
Open
https://app.localhostand create the account with the address inSUITE_ADMIN_EMAILS. -
Read the confirmation link out of the log:
Terminal window docker compose logs control-plane | grep "auth mail" | tail -1The line is JSON and the URL is in
line. Open it. -
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 mintsoperator: falserather 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.
pnpm stripe # creates or reuses the two prices, prints STRIPE_PRICE_TIERSpnpm 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:
echo '127.0.0.1 app.localhost docs.localhost suite.localhost' | sudo tee -a /etc/hostsCopy the whsec_… it prints into STRIPE_WEBHOOK_SECRET and restart the control plane:
docker compose up -d control-planeLeave 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.
5. Pay with a test card
Section titled “5. Pay with a test card”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.
6. Check what the payment did
Section titled “6. Check what the payment did”Three places, in increasing order of directness:
# 1. the webhook arrived and was verifieddocker compose logs control-plane | grep stripe/webhook
# 2. the subscription row existsdocker compose exec postgres psql -U suite -d suite -c 'select plan, status, "referenceId" from subscription'
# 3. the entitlement the relay will enforcecurl -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:
pnpm stripe --clockIt prints the customer and clock commands, including how to advance a month.
7. What this cannot test yet
Section titled “7. What this cannot test yet”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.
8. The same walk on the deployment
Section titled “8. The same walk on the deployment”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.
- Sign in at
https://app.suite.runand open a workspace. - The account menu, then Plan. Two plans,
personalandorganization, come from the deployment’sbilling.stripe.prices.test; a plan missing from that page is missing from the setting. - Choose a plan. Stripe Checkout opens on Stripe’s own origin, so no card detail reaches us.
- 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. - Back in the app, the Plan tab should show the tier and its limits, because that read comes
from
/api/billing/entitlementrather than from a copy in the browser. - 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.
- 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:
AWS_PROFILE=suite deploy/cloudwatch/queries.sh webhooks 1AWS_PROFILE=suite deploy/cloudwatch/queries.sh errors 1webhooks 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.
9. Put it down
Section titled “9. Put it down”docker compose --profile docs down # keeps the volumesdocker compose --profile docs down -v # forgets the accounts and the documents