Editing in the portal is the problem
HubSpot lets you edit a module, a template or a workflow action directly in the UI, and for a single change that feels faster than any alternative. It stops feeling fast the first time two people edit the same module in the same week, or someone asks what changed last Tuesday and why. There is no diff, no review, no branch, and no way back other than remembering what the file used to say. Everything below exists to move that work somewhere those four things are free.
One repository, not three
A portal is usually treated as several unrelated worlds: Design Manager assets on one side, projects with their private apps and serverless functions on another, workflow actions somewhere else again. They are not unrelated. A change to a template often needs a change to the function feeding it, and splitting them across repositories means one piece of work arrives as two pull requests that have to be merged in the right order. One repository per portal, with a folder per concern, keeps a change reviewable as one thing.
Two deploy mechanisms that look alike and are not
This is where most setups go wrong, because the CLI offers two commands that sound similar and behave nothing alike. Design Manager content is pushed with a single upload command taking a source and a destination. Project backends go through the Projects framework instead, which has no source or destination arguments at all: it runs against the current working directory, so a pipeline has to change into the project folder first.
# Design Manager content: source -> Design Manager path
hs cms upload "design-manager/my-module" "My Module" --account <portal>
# Project backend: no src/dest, runs against the working directory
cd projects/<name>/app
hs project upload --account <portal> The commands that do not exist
Search for how to deploy HubSpot assets and you will find posts using hs upload and hs template upload. Neither is a current command. They were real once, the posts were never updated, and the error you get back does not tell you that you are reading advice from a different CLI generation. Before building a pipeline on a command you found online, check it against the CLI you actually installed rather than against a search result.
# list what your installed CLI really supports
hs --help
hs cms --help
hs project --help Projects deploy in two steps
Uploading a project does not put anything live. It creates a build, a packaged candidate sitting in the portal waiting to be promoted. Deploying that build is a separate command, and in CI it needs the flag that makes it non-interactive, otherwise the pipeline hangs on a prompt nobody is there to answer. Treating upload as the deploy step is the most common reason a pipeline reports success while the portal stays exactly as it was.
hs project upload --account <portal> # creates a build
hs project deploy --deploy-latest-build -f --account <portal> # promotes it Your folder names are not yours to choose
The upload command maps a local path straight onto a Design Manager path, which means the repository tree has to mirror the portal exactly. A folder renamed locally because it reads better renames nothing in HubSpot; it creates a second folder beside the original and quietly leaves the live one untouched. Mirror first, tidy never. If the naming in the portal is bad, fix it in the portal and let the repository follow.
A branch per environment
The point of all this is not the repository, it is the flow it makes possible: work against a sandbox portal until it is right, then promote. A branch mapped to the sandbox and a branch mapped to production gives you that with no ceremony. Merging into the first deploys to the sandbox, merging into the second goes live, and the difference between the two branches is a readable list of everything still waiting to ship.
staging -> sandbox portal
main -> production portal Deploy only what changed
A portal repository accumulates deployable units quickly, and running every upload on every push turns a one-line change into a long pipeline that touches assets nobody edited. Most CI systems can gate a step on which paths changed in the push. Give each deployable folder its own step with its own path condition, and a change to one module deploys one module.
Authentication without committing secrets
The CLI expects a config file containing an access token, and the temptation is to commit it so the pipeline can find it. Do not. Generate it at build time from secured CI variables, use it, and delete it in the same step, with the filename in gitignore so an accidental local copy cannot follow it. Be strict about this one: a config file holding a live token in a folder that was never a git repository is fine right up to the day someone runs git init there, and by then the token is in history rather than in a file you can delete.
What to leave out
The Design Manager root also holds HubSpot's own default assets and anything installed from the marketplace. That is vendor code, managed by whoever ships it, and checking it in means every update arrives as a diff you did not write and cannot review meaningfully. The reasoning is the one that keeps a dependency folder out of an application repository: track what you author, not what you install.
What you actually get
None of this makes a single deploy faster than clicking save in the portal. What it buys is everything around the deploy: a review before a change reaches production, a history answering who changed what and why, a rollback that is a revert rather than an archaeology exercise, and a sandbox that is a rehearsal rather than a hopeful guess. On a portal one person touches occasionally, that is overhead. On a portal a team depends on, it is the difference between a platform and a shared document everyone is afraid to edit.