Environments: the real surface today, and the design preview.
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, and a designed-but-not-yet-shipped `--env` flag on top of it. This guide gives you the honest picture: what is shipped, what is not, and how to ship a project to a dev test account today and a production portal next quarter without misleading yourself in between.
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.
That is not how the surface is designed to look in steady state. The SDK and DX spec (see docs/hs-x-sdk-and-dx-spec.md, lines 857–870) describes hs-x deploy --env <name> and hs-x promote <from> <to> as first-class environment selectors that read environment blocks out of hsx.config.ts. The CLI's dispatch table already includes promote and rollback as real commands, and the planner already records deploy ids that promotion targets. But the deploy command itself does not yet parse --env. Promotion today is by deploy id, not by env name. Treat this guide as the bridge: how to ship cleanly with the surface that exists, and what to expect when the env-aware surface ships.
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. - Today, one HS-X project = one deploy target. The control plane records deploys against a
(project_id, account_id)pair. To ship to staging vs prod today, you have separate HS-X projects (typically one per target portal) and you switch between them withhs-x accounts switchandhs-x connect. - Promotion is by deploy id, not by env name. The real
hs-x promotetakes--deploy-id,--account-id,--project-id. That is the artifact-level promote primitive the env-aware surface will sit on top of. - 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? | Tier | Best for |
|---|---|---|---|---|
| Developer test account | A free portal scoped to your developer account | No (standalone) | Enterprise (90-day reset) | Daily iteration, agent loops, breaking-change tests |
| Development sandbox | A CLI-managed portal linked to a real prod portal | Yes (one-way) | Enterprise | Pre-release validation against your real schema |
| Standard sandbox | A full mirror of a prod portal; supports deploy-to-prod via partition flows | Yes | Enterprise | Pre-release integration tests, sales demos |
| Production portal | The real customer-facing account | n/a | Per your contract | Real users, real data |
A few things worth knowing 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 own documentation at developers.hubspot.com/docs/guides/apps/private-apps/types-of-hubspot-accounts. 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 ~/.hsx/config.json on macOS and Linux). There is no credentials.toml; the store is JSON, and the same file holds your magic-link session from hs-x login.
The first time you run it, you'll typically already have logged in:
hs-x login # magic-link auth against the control plane
hs-x connect hubspot # bind a HubSpot developer account to this HS-X accountIf you have the official HubSpot CLI installed and have already run hs accounts auth against the account you want to use, hs-x connect hubspot will discover those credentials and offer them as a default — no second OAuth round trip required. If not, it falls back to a personal access key (PAK), which you can supply with --pak <key> or HSX_HUBSPOT_PAK=<key> in the environment. The flag names are --pak, --developer-account-id, and --display-name; the env var is HSX_HUBSPOT_PAK.
$ hs-x connect hubspot → Discovered HubSpot CLI account "acme-dev" (portal 46993937, default) → Use this account? [Y/n] y → Stored HS-X account binding · portal 46993937 · display "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
- "Expected portal
<x>, got<y>from stored token." 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 ishs-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 logout <id>removes one cleanly. - No HubSpot CLI accounts found.
hs-x connect hubspottries to read~/.hubspot/config.yml(the HubSpot CLI's config file). If you have never runhs accounts auth, that file does not exist. Either install the HubSpot CLI and authenticate, or pass--pakdirectly. The PAK lives behind Settings → Integrations → Private apps in the HubSpot portal you want to bind.
Wire a Cloudflare account into HS-X
hs-x connect cloudflare is the second half of the first-run flow. It walks you through Cloudflare's OAuth (interactively) or accepts a scoped API token (--api-token <token>, or HSX_CLOUDFLARE_API_TOKEN in the env). The resulting credential is stored at $XDG_CONFIG_HOME/hs-x/cloudflare-oauth.json, separate from the HubSpot store — leaking one does not leak the other.
hs-x connect cloudflare
# or non-interactively:
HSX_CLOUDFLARE_API_TOKEN=<token> hs-x connect cloudflareThe env var is HSX_CLOUDFLARE_API_TOKEN. It is not HSX_CLOUDFLARE_TOKEN — that name does not exist anywhere in the CLI, and if you see it in a half-finished snippet on the internet, treat that snippet as untrusted. The token needs Workers Scripts: Edit on the account you intend to deploy to, plus Account: Read so the CLI can read account metadata. For production-shaped tokens you want the rest of your security team's standard ergonomics (rotation schedule, scoped to one account, named after the team that owns it). The token itself never leaves your machine — HS-X stores it locally and uses it directly against Cloudflare's API.
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 (one per HS-X project, distinguished by the script name the CLI derives from your project id), and HS-X's per-deploy script naming makes it safe to share one Cloudflare account across dev and staging. 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 today
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; runs inside `dev` and `deploy` too
hs-x deploy # build + push to Cloudflare + register with HubSpot
hs-x status # show the current deploy id and live route
hs-x history # list recent deploys for this projectWhen you need to move an exact tested artifact between projects, the real promote and rollback shapes are:
hs-x promote --deploy-id <id> --account-id <id> --project-id <id>
hs-x rollback --account-id <id> --project-id <id> [--to <deploy-id>]The signature is important: promotion is parameterized by --deploy-id, not by --env. The artifact tested in one project is the artifact you promote — not "rebuild from main." This is the same shape wrangler users will recognize from versioned deployments, and it is the primitive the env-aware surface in step 5 will sit on top of.
Where today's gap actually bites
Today, "promote staging to prod" is a multi-step ritual:
- Run
hs-x deployagainst the staging-bound HS-X account. Note the deploy id fromhs-x statusorhs-x history. hs-x accounts switch <prod-account-id>, or pass--account-id <prod-id>explicitly on every command.hs-x promote --deploy-id <staging-deploy-id> --account-id <prod-id> --project-id <prod-project-id>.
That works, and it is honest — every step maps to a real command — but it is more mechanical than the design intent. If you find yourself building scripts to wrap this, you are doing what the env-aware surface will do once shipped. Read on.
Other lifecycle commands you'll use
hs-x logs— tails the runtime checkpoint log for the active project. Same data the dashboard's logs panel shows.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— runs the local drift attestation plus token health checks. The thing to run when something feels off and you do not yet know what.hs-x routing(aliasesroute,routes) — inspects which deploy is currently bound to which custom route for the project.
Design preview: per-env deploys
This part of the surface is documented but not yet shipped — current ETA TBD. The dispatch table for hs-x deploy does not currently parse --env, and hsx.config.ts does not currently consume env blocks. Track progress against docs/hs-x-sdk-and-dx-spec.md (the section starting at line 857) and the CLI's deployCommand implementation. Treat everything in this step as forward-looking; the rest of this guide is what works today.
The intended surface, once shipped, collapses the multi-step promotion ritual from step 4 into the shape you'd expect:
hs-x deploy --env staging
hs-x deploy --env production
hs-x promote staging production # promotes the exact deploy id tested in staging
hs-x rollback --env productionhs-x deploy --env <name> will read an env block out of hsx.config.ts, select the HubSpot account, Cloudflare account, and (for HubSpot-only artifacts) the --hubspot-only strategy, and run the two-cloud deploy against that target. The flags that exist today on deploy — --plan for a preview, --resume for a partial replay, --hubspot-only for the migration-cards path — are designed to compose with --env, not to be replaced by it.
The env block in hsx.config.ts is intended to look roughly like this:
import { defineApp } from '@hs-x/sdk';
export default defineApp({
name: 'acme-portal',
envs: {
dev: {
hubspotAccount: 'acme-dev-test',
cloudflareAccount: 'acme-personal',
},
staging: {
hubspotAccount: 'acme-sandbox',
cloudflareAccount: 'acme-personal',
},
production: {
hubspotAccount: 'acme-prod',
cloudflareAccount: 'acme-prod-cf',
},
},
// ... workers, sources, schema as today
});The names on the right-hand side resolve to HS-X account ids (the same ids hs-x accounts list shows). Until this lands, hsx.config.ts does not have an envs field; you select an account by switching the active default or passing --account-id. When the spec ships, the migration path is "add an envs block, drop the accounts switch step from your scripts." No changes to the underlying account-store or control-plane records are required.
What you should do today, in anticipation:
- Name your HS-X accounts after the env they map to.
acme-dev,acme-staging,acme-prod, notacme-1,acme-2,acme-3. ThedisplayNameyou pass tohs-x connect hubspotis the nameaccounts listshows; pick it once, keep it forever. - Keep one project per target portal. That is what works today, and it is the shape
--envwill reduce in command count without changing in topology. - Write your CI tooling against
--account-idand--project-idexplicitly. Those flags are not going away;--envwill be a convenience that resolves to them. Scripts written against the explicit flags will keep working unchanged.
Wire CI/CD with GitHub Actions
CI does the same hs-x deploy you've been running locally. The only differences are that the auth tokens live in GitHub Actions secrets instead of your local store, and 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 the project on every push to main against your staging-bound HS-X account, and gates production behind a manual workflow_dispatch plus a GitHub Environments required-reviewer rule. GitHub's "Environments" feature is unrelated to HS-X envs — it's the GitHub-native approval-gate primitive — and the two pair cleanly.
# .github/workflows/deploy.yml
name: deploy
on:
push:
branches: [main]
workflow_dispatch:
inputs:
deploy_id:
description: Deploy id to promote into production (from a prior staging run)
required: true
type: string
jobs:
staging:
if: github.event_name == 'push'
runs-on: ubuntu-latest
environment: hsx-staging
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bunx hs-x validate
- run: bunx hs-x deploy --account-id "$HSX_ACCOUNT_ID" --project-id "$HSX_PROJECT_ID" --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 }}
promote-to-prod:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
environment: hsx-prod # required reviewers configured in repo settings
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: |
bunx hs-x promote \
--deploy-id "${{ inputs.deploy_id }}" \
--account-id "$HSX_ACCOUNT_ID" \
--project-id "$HSX_PROJECT_ID"
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:
HSX_HUBSPOT_PAKandHSX_CLOUDFLARE_API_TOKENare the real env-var names the CLI reads. Do not inventHSX_HUBSPOT_TOKENorHSX_CLOUDFLARE_TOKEN; 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 CLI does not need one — 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. The interactive checks only run when a TTY is present and the value is not already supplied. --yes/-yskips the deploy-plan confirmation thaths-x deployshows in interactive mode when it would change anything. In CI you almost always want this; locally you almost never do.
Tokens to create
For each env you deploy from CI, create at minimum two GitHub Actions secrets — 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). Scope the Cloudflare token to Workers Scripts: Edit on one account and nothing else. Scope the HubSpot PAK to the minimum CRM and project scopes your runtime actually uses; the canonical scope strings live at developers.hubspot.com/docs/api/working-with-oauth#scopes (they look like crm.objects.contacts.write, crm.schemas.contacts.read, and similar dotted paths).
| Env | Secrets | Variables | What it gates |
|---|---|---|---|
| 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_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 promote-to-prod 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 is well worth the five minutes to set up.
Common CI issues
- "Token has insufficient scopes." The most common cause is using a Cloudflare API token that was scoped to a different account than the HS-X account is bound to. Token scopes are per-Cloudflare-account; re-issue from the Cloudflare dashboard scoped to the right account.
- "Missing --account-id." You forgot to set
HSX_ACCOUNT_IDor pass--account-idand there is no default in CI (because there is no~/.hsx/config.jsonin a fresh GitHub runner). Add the env var or the flag. - Two pushes to main race each other. Two merges within the same minute will trigger two parallel staging deploys against the same project. The second one will hit a control-plane conflict on the deploy record. Add
concurrency: { group: hsx-staging, cancel-in-progress: false }to the staging job so they queue. - 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.
Where next
- How to · Start · Getting started — if you skipped it, the foundation for everything in this guide.
- Architecture overview — the runtime and control-plane model that the account/project/deploy taxonomy comes from.
- HubSpot's account-type matrix at
developers.hubspot.com/docs/guides/apps/private-apps/types-of-hubspot-accounts— the canonical reference for which sandbox kind is available at which tier, kept current by HubSpot.