Environments
HubSpot has four account types, and only one of them is your real customer-facing portal. HS-X has a control-plane-driven account model, an --env axis that scopes a deploy's identity and state inside a project, and a promote/rollback pair that moves a project's active route by deploy id. This guide gives you the real picture: how the pieces fit, where the model stops, and how to ship a project to a dev test account today and a production portal next quarter without misleading yourself in between.
TL;DR — HubSpot gives you four account types and only the customer portal is production. This guide maps each onto HS-X’s account model: develop against a developer test account, ship to the real portal, one HS-X project per target portal. Inside a project, hs-x deploy --env scopes identity and state (installs, secrets, flags, routes); hs-x promote and hs-x rollback move that project’s active route by deploy id. --env does not pick accounts, and there is no cross-project promotion.
Before you begin
The first thing to understand about environments in HS-X today is that the per-env shape you might expect from wrangler or from HubSpot’s hubspot.config.yml profiles is not how the CLI works yet. HS-X’s account model is centered on the control plane: an HS-X account is bound to one HubSpot developer account and (optionally) one Cloudflare account, and an HS-X project lives inside that account. When you want to ship the same source tree to a different HubSpot portal, the path today is to point your local working copy at a different control-plane project — not to flip a --env staging flag.
Inside a project there is a second axis. hs-x deploy --env <name> (default production) bakes the environment into the Worker, provisions environment-named install, tenant, and flags stores, and reads that environment’s OAuth secret; the deployed Worker attests the value back, and promotion writes the route entry for that attested environment. The control-plane deploy record itself carries no environment (it follows from the Worker’s attestation), and --env does not pick the HubSpot or Cloudflare account for you. Promotion is by deploy id inside the project that recorded it, never by environment name and never across projects. Treat this guide as the map: which portal you are pointed at, what --env scopes once you are, and how promote, rollback, and CI fit around that.
The mental model
A few things to internalize before you wire anything:
- HS-X account ≠ HubSpot account. An HS-X account is a control-plane record that references a HubSpot developer account by its portal id. The terms are easy to confuse; in this guide “HS-X account” always means the control-plane object and “HubSpot account” always means the portal in
app.hubspot.com. - One HS-X project = one deploy target. The control plane records deploys against a
(project_id, account_id)pair, and an HS-X account binds one HubSpot developer account. To ship to a staging portal and a production portal, you have separate HS-X projects (one per target portal) and you switch between them withhs-x accounts switchandhs-x connect. - Promotion is by deploy id, inside one project.
hs-x promotetakes--deploy-id,--account-id,--project-id, and the control plane refuses a deploy id recorded under a different account or project. Promotion moves a project’s active route; it never copies a build somewhere else. - There is no
hs-x.toml. Project configuration lives inhsproject.json(the HubSpot project descriptor the CLI reads) andhsx.config.ts(the SDK config produced bydefineApp). Both are real; neither has TOML.
What this guide does not cover
Secrets storage and rotation per env, runtime feature flags, custom domains, and the full schema-management story all live in their own guides. This guide stops at which portal you are pointed at and how to get a clean deploy to it.
Pick the right HubSpot account type per env
HubSpot has four account types you actually deploy to. The naming has shifted over the past year — what some HubSpot docs still call a “developer sandbox” is now called a “development sandbox” — and getting the names wrong is the most common source of confusion when you read three blog posts that disagree with each other. Here is the current set, plus what each one is good for.
| Type | What it is | Linked to a prod portal? | Best for |
|---|---|---|---|
| Developer test account | A free portal scoped to your developer account, on an Enterprise trial that resets every 90 days | No (standalone) | Daily iteration, agent loops, breaking-change tests |
| Development sandbox | A CLI-managed portal linked to a real prod portal; needs an Enterprise parent | Yes (one-way) | Pre-release validation against your real schema |
| Standard sandbox | A full mirror of a prod portal (Enterprise); supports deploy-to-prod via partition flows | Yes | Pre-release integration tests, sales demos |
| Production portal | The real customer-facing account, on whatever tier your contract says | n/a | Real users, real data |
A few things that are not obvious from the table:
- Developer test accounts are not “lite” portals. Each one ships with a 90-day Enterprise trial — you get the full feature set during that window. They reset (or expire and need to be recreated) every 90 days, which is exactly why they are good for breaking experiments: you cannot accidentally hoard production-shaped data in one.
- There is a per-developer cap on test accounts. Your HubSpot developer account allows up to 10 active developer test accounts at a time. Delete old ones from developers.hubspot.com → Test accounts before creating new ones.
- “Developer sandbox” is the old name for “development sandbox.” If a doc or a colleague says “developer sandbox” in 2026, they almost certainly mean development sandbox. They are the same thing; HubSpot renamed it.
- Standard sandboxes are an Enterprise-tier feature on the parent portal. If your prod portal is not Enterprise, you do not have access to standard sandboxes — only development sandboxes (which require Enterprise too, but are CLI-provisioned).
For the canonical, always-current account-type matrix, the source of truth is HubSpot’s account types page. The shape there can change; check it before you architect a multi-env story for a customer engagement.
Pragmatic recommendation
Most teams want this layout:
dev— a developer test account. Free, isolated, resets every 90 days. Perfect forhs-x deviteration and for agent loops that may corrupt schema.staging— a development sandbox linked to the prod portal. Inherits the schema, so a UI extension that works here will work in prod.prod— your real production portal.
If you are an agency with many customers, you’ll typically have one dev test account per project (for the build) and a development sandbox per customer (linked to their prod portal) for staging. The HS-X account model lets you switch between them without changing your source tree.
Wire a HubSpot account into HS-X
The command that binds a HubSpot account into an HS-X account is hs-x connect hubspot. It is one of the two real subcommands of hs-x connect (the other being hs-x connect cloudflare), and it stores the resulting binding in the local HS-X store at $XDG_CONFIG_HOME/hs-x/config.json (defaulting to ~/.config/hs-x/config.json). There is no credentials.toml; the store is JSON, and the same file holds the API-token session from hs-x login.
The first time you run it, you’ll typically already have logged in:
hs-x login # paste an API token generated in the HS-X dashboard
hs-x connect hubspot # bind a HubSpot developer account to this HS-X accountIf you have already run hs accounts auth in the official HubSpot CLI, hs-x connect hubspot discovers those credentials and offers them as a default; otherwise pass a personal access key with --pak <key> or HSX_HUBSPOT_PAK=<key>. Other flags: --account-id (the target HS-X account), --developer-account-id, --display-name, --developer-api-key (or HSX_HUBSPOT_DEVELOPER_API_KEY), and --yes for a promptless run. The getting-started wiring step covers the discovery flow in full.
$ hs-x connect hubspot # hs-x connect hubspot * Discovered HubSpot CLI account "acme-dev" (portal 46993937, default) * Use this account? [Y/n] y [ok] Connected HubSpot developer account acme-dev * Run `hs-x accounts list` to see all connected accounts
You should see one row appear under hs-x accounts list per account you have connected, each with its own HS-X account id and the underlying HubSpot portal id. The HS-X account id is the handle you’ll pass to --account-id or HSX_ACCOUNT_ID in everything that follows.
Switching between accounts
Once you have more than one HS-X account connected (for example, one per environment-portal pair), you switch the default with:
hs-x accounts list
hs-x accounts switch <id>
hs-x accounts current # or: hs-x whoamihs-x accounts switch only changes the local default — it does not delete any other binding. Any command that takes --account-id will use the explicit flag in preference to the default, which is the pattern you want in CI (more on that in step 6).
Common wiring issues
- The stored token belongs to a different portal than the one you expected. The HubSpot account behind the token does not match the developer account id you (or the discovery flow) told HS-X to expect. The fix is
hs-x connect hubspotagain, picking the right account; if you accidentally stored two bindings for the same portal under different HS-X account ids,hs-x accounts logout <account-id>(aliasremove) drops one cleanly. Barehs-x logoutends the login session instead. - No HubSpot CLI accounts found. You have never run
hs accounts auth, so there is no HubSpot CLI config to discover. Authenticate the HubSpot CLI first, or pass--pakdirectly; the getting-started guide covers where the PAK lives.
Wire a Cloudflare account into HS-X
hs-x connect cloudflare is the second half of the first-run flow, and it is OAuth-only: your browser opens Cloudflare’s consent screen, which grants exactly the scopes HS-X needs, and there is no API token to create, scope, or paste. On an unlinked machine the CLI completes the exchange itself and keeps the refresh token in $XDG_CONFIG_HOME/hs-x/cloudflare-oauth.json, separate from the HubSpot store, so leaking one does not leak the other. Once the account is linked (hs-x login or hs-x link), the exchange is brokered through the control plane and the credential is stored server-side against the HS-X account. The same flow is available from the dashboard under Connections → Connect Cloudflare.
hs-x connect cloudflare
# no TTY? the CLI prints the authorize URL for you to open manuallyAccount connections are distinct from the deploy-time environment credential: CI pipelines that deploy with CLOUDFLARE_API_TOKEN in the environment (the unlinked, direct-to-Cloudflare path) keep working — that token goes straight from your CI secret store to Cloudflare’s API and never touches an HS-X account.
One Cloudflare account vs three
Cloudflare accounts and HS-X accounts are not 1:1 by design. A single Cloudflare account can host many Worker scripts, and HS-X names them hsx-<account>-<project>-<worker>, so distinct projects never collide inside one Cloudflare account and it is safe to share one across dev and staging. Two environments of the same project do share (and overwrite) one script, which is the reason the one-project-per-portal layout above matters. For production, most teams still want a separate Cloudflare account — the blast radius of a leaked dev token is one of the few things that genuinely benefits from account-level isolation. The cost is one extra Workers Paid subscription if you need that tier; the benefit is that no dev-machine credential can ever reach prod.
You can run with one Cloudflare account behind a personal-iteration HS-X account and a second Cloudflare account behind a prod-only HS-X account. To switch, run hs-x connect cloudflare while the prod HS-X account is the active default.
What is not configurable
A few things you might expect to find here, but won’t:
- No top-level
region = "auto"field. Cloudflare Workers run on every edge by default. The CLI does not surface a region knob because there is not one to set at the Worker-script level. - No
placement = { mode = "smart", hint = "fra" }either. Cloudflare’s Smart Placement is generally available across paid Workers plans, and you enable it inwrangler.tomlwithplacement = { mode = "smart" }. It does not take a region hint — placement decisions are made by Cloudflare based on actual upstream latency, not by you. If you need EU residency for a Worker, the right primitives are D1 region selection at database creation time and Workers Logpush destinations, neither of which lives in HS-X’s surface today.
Deploy, promote, and roll back
With one HS-X account connected and one Cloudflare account behind it, hs-x deploy ships the project to the active account’s Cloudflare account and registers any HubSpot-side artifacts (cards, workflow actions, UI extensions) against the active account’s HubSpot portal. The four commands you’ll actually run, in order, look like this:
hs-x validate # cheap static checks (alias: hs-x check); deploy runs them too
hs-x deploy # build + push to Cloudflare + register with HubSpot
hs-x status # show the current deploy id and live route
hs-x list # every project on the account with its last recorded deployWhat --env adds to a deploy
hs-x deploy --env <dev|staging|production> (also --environment; default production) bakes the environment into the generated Worker. That one value scopes everything identity-shaped: the installed-portal record, the sealed install tokens and the tenant database (provisioned as environment-named stores), the OAuth client secret the deploy reads, flag definitions, billing entitlements, and the route entry promotion writes. It does not pick the HubSpot or Cloudflare account, and it does not give you a second set of Worker scripts: a project has one script per worker, so hs-x deploy --env staging after a production deploy replaces the code production traffic hits. The environments reference lists exactly what is keyed by the value and what every environment of a project shares.
Promote
hs-x deploy --env production --promote-when-healthy # record, wait for a healthy heartbeat, promote
hs-x promote --deploy-id <id> --account-id <id> --project-id <id> [--yes]A deploy on a linked project records a revision; promotion makes that revision the project’s active deploy by rewriting the route entry for the environment the Worker attested. The usual shape is hs-x deploy --promote-when-healthy, which records, waits for the runtime’s first healthy heartbeat, and promotes in one run. hs-x promote is the manual form of the last step: it takes the deploy id you want active and needs a logged-in HS-X session (hs-x login, or hs-x login --token in CI). Two gates apply. The deploy id must have been recorded under the same account and project you name; the control plane answers 409 deploy_promotion_mismatch otherwise. And the deploy’s latest attestation must be healthy (409 deploy_not_healthy until it is), which is why a Worker that has received no traffic since it was deployed cannot be promoted yet.
Roll back
hs-x rollback --account-id <id> --project-id <id> [--deploy-id <id>] [--environment <name>]A linked rollback restores live Cloudflare traffic to a retained revision’s captured Worker version and re-promotes its record; without --deploy-id it offers the retained candidates. --environment (or HSX_ENVIRONMENT) scopes which environment’s route it looks at, defaulting to production. An unlinked project rolls back from local tenant state instead: it needs --project-id, moves the local active pointer for --environment (default production, no environment-variable fallback), and refuses a deploy id recorded under a different environment (Deploy <id> is for environment staging, not production. Pass --environment staging to roll back its environment.). The deploys guide covers the revision states and retention rules behind both.
Shipping the same build to a second portal
There is no cross-project promotion. Promotion moves the active route inside the project that recorded the deploy, and a HubSpot portal maps to an HS-X account and project, so “promote staging to prod” across two portals is two deploys:
hs-x deployagainst the staging-bound account and project, test in that portal.hs-x accounts switch <prod-account-id>(or--account-idon every command), thenhs-x deploy --env production --promote-when-healthyagainst the production account and project from the same source revision. That yields a new deploy id, recorded and promoted in the production project.
Pin the git revision you tested rather than a deploy id, and let each project mint its own deploy. Step 6 shows the CI shape.
Other lifecycle commands you’ll use
hs-x logs— reads recent deployed Worker invocations and per-invocation trace lines. Usehs-x checkpointfor aggregate runtime counts and latency.hs-x drift— compares the recorded manifest against what is actually live on Cloudflare. Useful when you suspect someone has hand-edited a Worker.hs-x doctor— checks stored accounts, link state, machine id, the HubSpot CLI config, the PAK, and control-plane reachability. The thing to run when something feels off and you do not yet know what.hs-x routes(aliasesroute,routing) — one line per environment: which deploy id is active, what it replaced, and whether a promote or rollback put it there.
What --env does and does not select
--env is an identity axis inside one project. It is not a way to select accounts, and it is not a second copy of your Workers. Everything below the table is not shipped; do not script against it.
| Shipped | Not shipped | |
|---|---|---|
| Identity | hs-x deploy --env bakes dev / staging / production into the Worker; installs, secrets, flags, billing, and routes are keyed by it | |
| Accounts | --account-id / hs-x accounts switch pick the HubSpot and Cloudflare account | An envs block in hsx.config.ts that maps an environment to accounts |
| Promotion | hs-x promote --deploy-id inside the project that recorded the deploy; --promote-when-healthy on deploy | hs-x promote staging production, or any promotion across projects |
| Compute | One Worker script per worker per project | Isolated scripts per environment |
| Rollback | hs-x rollback (linked: by route entry; unlinked: by local pointer and --environment) |
The dev literal labels deployed installations; the local hs-x dev loop takes no --env at all. Because Worker scripts are shared across environments, the layout that gives you two environments genuinely live at once is the one this guide has used throughout: one HS-X project per target portal, with --env marking which is which.
What you should do today, so nothing has to change if the config-driven half ever lands:
- Name your HS-X accounts after the portal they map to.
acme-dev,acme-staging,acme-prod, notacme-1,acme-2,acme-3. The--display-nameyou pass tohs-x connect hubspotis the nameaccounts listshows; pick it once, keep it forever. - Keep one project per target portal. That is the shape the model supports, and it is what makes promote and rollback unambiguous.
- Pass
--envexplicitly on every deploy of a project, even when it isproduction, so the attested environment is never a surprise. Secrets (hs-x secrets hubspot-oauth set --env) and flags are scoped by the same value. - Write your CI tooling against
--account-id,--project-id, and--envexplicitly. Those flags are the contract.
Wire CI/CD with GitHub Actions
CI does the same hs-x deploy you’ve been running locally. The differences are that the credentials live in GitHub Actions secrets instead of your local store, that a linked deploy or promotion first needs an HS-X session (hs-x login --token, since a fresh runner has no ~/.config/hs-x/config.json), and that you pass --account-id / --project-id explicitly because there is no interactive default to fall back on. The pattern that pairs well with HubSpot’s existing tooling is to use HubSpot’s official HubSpot/hubspot-project-actions Action for the HubSpot side of the build, then layer hs-x deploy on top for the Cloudflare side and the control-plane recording.
The workflow below auto-deploys every push to main against your staging-bound HS-X account, and gates the production deploy behind a manual workflow_dispatch plus a GitHub Environments required-reviewer rule. Both jobs deploy the same checked-out revision; each project mints and promotes its own deploy id. GitHub’s “Environments” feature is unrelated to HS-X envs (it is the GitHub-native approval-gate primitive), and the two pair cleanly.
# .github/workflows/deploy.yml
name: deploy
on:
push:
branches: [main]
workflow_dispatch:
inputs:
ref:
description: Git ref to ship to production (the revision that passed staging)
required: true
type: string
jobs:
staging:
if: github.event_name == 'push'
runs-on: ubuntu-latest
environment: hsx-staging
concurrency: { group: hsx-staging, cancel-in-progress: false }
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bunx @hs-x/cli validate
- run: bunx @hs-x/cli login --token "$HSX_API_TOKEN"
env:
HSX_API_TOKEN: ${{ secrets.HSX_API_TOKEN_STAGING }}
- run: |
bunx @hs-x/cli deploy \
--account-id "$HSX_ACCOUNT_ID" --project-id "$HSX_PROJECT_ID" \
--env staging --promote-when-healthy --yes
env:
HSX_HUBSPOT_PAK: ${{ secrets.HSX_HUBSPOT_PAK_STAGING }}
HSX_CLOUDFLARE_API_TOKEN: ${{ secrets.HSX_CLOUDFLARE_API_TOKEN_STAGING }}
HSX_ACCOUNT_ID: ${{ vars.HSX_ACCOUNT_ID_STAGING }}
HSX_PROJECT_ID: ${{ vars.HSX_PROJECT_ID_STAGING }}
production:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
environment: hsx-prod # required reviewers configured in repo settings
concurrency: { group: hsx-prod, cancel-in-progress: false }
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bunx @hs-x/cli login --token "$HSX_API_TOKEN"
env:
HSX_API_TOKEN: ${{ secrets.HSX_API_TOKEN_PROD }}
- run: |
bunx @hs-x/cli deploy \
--account-id "$HSX_ACCOUNT_ID" --project-id "$HSX_PROJECT_ID" \
--env production --promote-when-healthy --yes
env:
HSX_HUBSPOT_PAK: ${{ secrets.HSX_HUBSPOT_PAK_PROD }}
HSX_CLOUDFLARE_API_TOKEN: ${{ secrets.HSX_CLOUDFLARE_API_TOKEN_PROD }}
HSX_ACCOUNT_ID: ${{ vars.HSX_ACCOUNT_ID_PROD }}
HSX_PROJECT_ID: ${{ vars.HSX_PROJECT_ID_PROD }}A few notes on what is and is not real in that workflow:
hs-x login --token(orHSX_API_TOKEN) is the only headless way into a session. Every control-plane operation, which includes recording a linked deploy,--promote-when-healthy,hs-x promote, and linkedhs-x rollback, fails withHSX_E_NOT_LOGGED_INwithout one. An unlinked deploy (HubSpot PAK plus Cloudflare token, no control plane) does not need it, and does not record or promote anything.- Cloudflare token:
CLOUDFLARE_API_TOKENorHSX_CLOUDFLARE_API_TOKEN— both are read.CLOUDFLARE_API_TOKENis Cloudflare’s own standard name (what the getting-started guide uses); theHSX_-prefixed alias exists so you can namespace it per environment in CI (HSX_CLOUDFLARE_API_TOKEN_STAGING, etc.). The HubSpot key isHSX_HUBSPOT_PAK. Do not inventHSX_HUBSPOT_TOKENorHSX_CLOUDFLARE_TOKEN(no_API_); those names are not recognized. HSX_ACCOUNT_IDandHSX_PROJECT_IDare the real fallbacks for--account-idand--project-id. Passing them via the GitHub Actionsenvblock lets you avoid duplicating them in command lines.- There is no
--ciflag. The same command works the same way in a TTY and in a non-TTY shell. Where it would prompt interactively (for an account id, for a project id), passing the relevant flags or env vars makes the command non-interactive. --yes/-yskips the deploy-plan confirmation thaths-x deployshows in interactive mode when it would change anything, and the consent prompths-x promoterequires outside a TTY. In CI you almost always want it; locally you almost never do.hs-x promote --deploy-idstill has a place: re-promoting a recorded revision of the same project after a health wait timed out, or making a retained revision active again by hand. It is never the bridge between the two jobs above.
Tokens to create
For each environment you deploy from CI, create three GitHub Actions secrets (the HS-X API token, the HubSpot PAK, and the Cloudflare API token) plus two repository variables for the HS-X account and project ids (these are not secret and benefit from being visible in the workflow file). Generate the HS-X API token in the dashboard for a user who can act on that account. Create the Cloudflare token from the HS-X template link that hs-x deploy prints when it has no credential (https://dash.cloudflare.com/profile/api-tokens?token-name=HS-X), or grant the same permission groups by hand on exactly one account: Workers Scripts Write, Workers KV Storage Write, D1 Write, Queues Write, Workers Tail Read, Workers Observability Write, Account Settings Read, Workers R2 Storage Write, and Account Analytics Read. Cloudflare requires Observability Write for the telemetry query endpoint even though HS-X only reads historical logs. A token scoped to Workers Scripts alone is rejected at preflight with HSX_E_CLOUDFLARE_TOKEN_PERMISSIONS. Scope the HubSpot PAK to the minimum CRM and project scopes your runtime actually uses; the canonical scope strings live on HubSpot’s scopes reference (they look like crm.objects.contacts.write, crm.schemas.contacts.read, and similar dotted paths).
| Env | Secrets | Variables | What it gates |
|---|---|---|---|
| staging | HSX_API_TOKEN_STAGING, HSX_HUBSPOT_PAK_STAGING, HSX_CLOUDFLARE_API_TOKEN_STAGING | HSX_ACCOUNT_ID_STAGING, HSX_PROJECT_ID_STAGING | Auto-deploy on merge to main |
| prod | HSX_API_TOKEN_PROD, HSX_HUBSPOT_PAK_PROD, HSX_CLOUDFLARE_API_TOKEN_PROD | HSX_ACCOUNT_ID_PROD, HSX_PROJECT_ID_PROD | Manual workflow_dispatch with required reviewer |
The GitHub Environments feature (under Repo Settings → Environments) is where you wire the required-reviewers rule. Configure hsx-prod to require a reviewer from your release-management team, and the production job will pause until someone approves it. That is the structural fix for “I deployed main to prod by accident”: it lives in GitHub, not in HS-X, and it takes five minutes to set up.
Common CI issues
HSX_E_CLOUDFLARE_TOKEN_PERMISSIONS— Cloudflare token is missing required permissions. The token was created with fewer permission groups than HS-X exercises (most often only Workers Scripts), or on a different Cloudflare account than the one the HS-X account is bound to. Token permissions are per Cloudflare account; regenerate from the template link scoped to the right account.HSX_E_NOT_LOGGED_IN— No local HS-X session. A linked deploy or apromoteran on a runner that never calledhs-x login --token. Add the login step (or exportHSX_API_TOKEN) before it.- “Missing --account-id.” You forgot to set
HSX_ACCOUNT_IDor pass--account-idand there is no default in CI. Add the env var or the flag; a checkout that carries.hs-x/project.jsoncan also supply both ids. - Two pushes to main race each other. Two merges within the same minute trigger two parallel staging deploys against the same project and the same Worker scripts. The
concurrencyblock on each job makes them queue instead. - Trying to wrap
hs project dev. Don’t. HS-X owns the UI-extension dev loop end-to-end;hs-x devis what you run locally. The official HubSpot CLI’s project-dev command is for HubSpot projects that don’t use HS-X.
