view .md
How to · Ship

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.

Time
≈ 12 min
Outcome
A clear understanding of HubSpot's four real account types, how to wire each into HS-X today, and what the per-env deploy surface will look like when shipped.
Prerequisites
  • A working HS-X project (run through /docs/guides/getting-started first if you do not have one)
  • A HubSpot developer account at developers.hubspot.com (free), plus access to whatever production portal you intend to deploy against
  • A Cloudflare account with Workers enabled (free tier is fine for non-prod)
  • A GitHub repo for the project (only needed for step 6)

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

One project · many accounts
01
HS-X account
Owned by a user · binds one HubSpot dev account · optionally one Cloudflare account
02
HS-X project
Lives under the account · has a project id · has deploys
03
Target portal
HubSpot test account · sandbox · or production

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 with hs-x accounts switch and hs-x connect.
  • Promotion is by deploy id, not by env name. The real hs-x promote takes --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 in hsproject.json (the HubSpot project descriptor the CLI reads) and hsx.config.ts (the SDK config produced by defineApp). 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.

TypeWhat it isLinked to a prod portal?TierBest for
Developer test accountA free portal scoped to your developer accountNo (standalone)Enterprise (90-day reset)Daily iteration, agent loops, breaking-change tests
Development sandboxA CLI-managed portal linked to a real prod portalYes (one-way)EnterprisePre-release validation against your real schema
Standard sandboxA full mirror of a prod portal; supports deploy-to-prod via partition flowsYesEnterprisePre-release integration tests, sales demos
Production portalThe real customer-facing accountn/aPer your contractReal 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 for hs-x dev iteration 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 account

If 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.

Expect
$ 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 whoami

hs-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 is hs-x connect hubspot again, 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 hubspot tries to read ~/.hubspot/config.yml (the HubSpot CLI's config file). If you have never run hs accounts auth, that file does not exist. Either install the HubSpot CLI and authenticate, or pass --pak directly. 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 cloudflare

The 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 in wrangler.toml with placement = { 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 project

When 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:

  1. Run hs-x deploy against the staging-bound HS-X account. Note the deploy id from hs-x status or hs-x history.
  2. hs-x accounts switch <prod-account-id>, or pass --account-id <prod-id> explicitly on every command.
  3. 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 (aliases route, routes) — inspects which deploy is currently bound to which custom route for the project.

Design preview: per-env deploys

Design preview

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 production

hs-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, not acme-1, acme-2, acme-3. The displayName you pass to hs-x connect hubspot is the name accounts list shows; pick it once, keep it forever.
  • Keep one project per target portal. That is what works today, and it is the shape --env will reduce in command count without changing in topology.
  • Write your CI tooling against --account-id and --project-id explicitly. Those flags are not going away; --env will 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_PAK and HSX_CLOUDFLARE_API_TOKEN are the real env-var names the CLI reads. Do not invent HSX_HUBSPOT_TOKEN or HSX_CLOUDFLARE_TOKEN; those names are not recognized.
  • HSX_ACCOUNT_ID and HSX_PROJECT_ID are the real fallbacks for --account-id and --project-id. Passing them via the GitHub Actions env block lets you avoid duplicating them in command lines.
  • There is no --ci flag. 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 / -y skips the deploy-plan confirmation that hs-x deploy shows 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).

EnvSecretsVariablesWhat it gates
stagingHSX_HUBSPOT_PAK_STAGING, HSX_CLOUDFLARE_API_TOKEN_STAGINGHSX_ACCOUNT_ID_STAGING, HSX_PROJECT_ID_STAGINGAuto-deploy on merge to main
prodHSX_HUBSPOT_PAK_PROD, HSX_CLOUDFLARE_API_TOKEN_PRODHSX_ACCOUNT_ID_PROD, HSX_PROJECT_ID_PRODManual 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_ID or pass --account-id and there is no default in CI (because there is no ~/.hsx/config.json in 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 dev is 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.