Everything you build goes to staging first. Only a named release reaches production, and only by promoting the image staging ran, or a production build of the same commit. That is an app with a staging target and production pinned to stable; an app with no staging target ships differently.
Overview
The whole system rests on one idea: building and releasing are different verbs. Confusing them is what made production scary.
Iterate · many times a day
sha-8b966e9 from a clean tree,
dev-20260825T0924-8b966e9 (with the build time) from a dirty one.Release · when something is ready
main and the staged image built from it.<version>-production build comes first,
from the same commit, before the bump.promote --allow-unreleased
is the escape hatch, and says so loudly).The rule that makes this safe
On an app with a staging target, production runs whatever the
stable tag points at, and only promote and rollback ever
move it. So an ordinary build, however careless, physically cannot reach production.
Bare hetge opens a menu whose Ship section lists these steps in
order.
Part One
If you have just cloned the repo, or need fresh secrets:
hetge env:pull # secrets from 1Password
hetge dev:up # the local database, Redis and S3
The first fills your .env files from 1Password using the
committed templates. It asks before overwriting a file you already have: a no skips that
file, and Ctrl-C stops the run. The second starts the shared local stack and creates what your app
needs in it. Never point a local app at staging's database: how the stack works is its own
page, Run It Locally.
hetge stage
Builds the image, pushes it, and deploys it to staging,
whatever the app's default target is. It takes no --target.
After the line naming the hetge version (hetge v1.0.0 ·
stage), it prints where it is going: the target and its env file, the image
reference, and the Dokploy URL, project, environment, compose and domains.
stage, deploy, sync, release,
promote, and build with --deploy or --sync
all show that card before they act, so the destination is on screen before anything
lands; release and promote then ask before going on.
rollback instead shows the image it is going back to, and asks.
Run hetge status if you are ever unsure what each target is
running.
Your tree can be dirty. The image is tagged so that everyone can see it was: that is the point of staging.
Open the staging site: account.hetge.dev,
flux.hetge.dev, and so on. Log in, click around, upload something.
Need to run a tool against staging's database or storage?
hetge exec --target staging -- pnpm prisma migrate status
Everything after -- runs with staging's environment
variables.
hetge sync --target staging --deploy
Uploads the compose file, environment and domains to Dokploy, then
deploys. No rebuild: it ships the image that is already there. It asks before removing any
variable only Dokploy holds, naming each one. To see what it would change first, run
hetge sync --target staging --dry-run: it writes nothing.
Name the target. Like build, deploy and
exec, sync goes to the default target (deploy.default
in hetge.jsonc) when you do not name one, and that is production unless you set
it.
A plain hetge stage does not upload config.
Once staging has been synced, it compares the env and compose file with what Dokploy holds,
and when they differ it says so and offers to sync first (--yes accepts). It
cannot see a change to targets or domains in hetge.jsonc, so after editing
those run this, or hetge stage --sync to do both at once.
Repeat as often as you like. This loop has no ceremony and touches nothing that customers can see.
Part Two
Stage a commit, test it, then two commands. Never edit anything in Dokploy by hand.
Release names only a clean build of HEAD, so what you test
has to be staged from a commit. If you changed an env file, push it first:
env:push rewrites the committed template (.tpl), so it belongs in
the commit.
hetge env:push # only if you changed an env file
hetge stage
Commit everything between the two. If Dokploy's staging env or compose
file then differs from yours, stage offers to sync it first; release refuses
until they agree.
hetge status
Shows what staging, the release and production each run, and whether there is anything to promote. Safe to run any time: it changes nothing.
This is the step that matters, and the only one no tool can do for you. The image you are about to release is the one now running on staging, so whatever you check here is what customers get.
Release names whatever staging's tag (latest) points at, and
every build moves latest. If you ran a plain hetge build after
staging, stage again before you release, so the image you tested is the one you name.
Pick one:
hetge release patch # 1.0.1 to 1.0.2, fixes (the default)
hetge release minor # 1.0.1 to 1.1.0, new things
hetge release major # 1.0.1 to 2.0.0, breaking changes
hetge release 1.4.0 # or say it exactly
It shows you the version, the tag, the release notes and the image it is about to name, and waits for you to confirm. Nothing has happened yet at that point: pressing Ctrl-C, or answering no, is completely safe.
After you confirm it bumps the version, commits, tags, pushes, creates the
GitHub Release, and tags the image staging is running as the release. It builds no new app
image; an app that is not promote-clean gets one extra <version>-production
build first, from the same commit, before the bump. With a staging target it deploys
nothing; with none, it deploys production once (see No staging
target).
If a step after the confirmation fails, nothing is undone for you: the
release says what was written and prints what to run next, whether that undoes a bump that
was never committed or finishes a release whose tag exists. Once the release commit and tag
exist, running hetge release again is refused (there is nothing new to
release), so the printed lines are how it gets finished.
hetge promote
It shows the image it is about to ship and asks first. Then it copies the
released image onto stable, keeping the old one as stable-previous
(what rollback goes back to), and deploys production.
Promote never rebuilds. Production gets the image staging
ran, not a fresh one that happens to come from the same code. The one exception is an app
that is not promote-clean: there it ships the <version>-production build
that release made from the same commit.
If it says stable moved but production was not deployed,
finish with hetge deploy --target production.
Release will refuse you, on purpose
It says no if the tree has uncommitted changes or you are not on
main (release.branch), if staging is not running an image built from
HEAD, if that image came from a dirty tree or is already released, if there is
nothing new since the last version tag, or if the staging env is out of step with its template,
1Password or Dokploy (the refusal says whether to push and sync, or to stage again). Each
refusal names its reason, and most name the next step.
Promote will refuse you, on purpose
It says no if the app has no staging target or production does not pin
stable, if nothing has been released yet, if stable already points at
the released image (it then names hetge deploy --target production, for when
production is not running it yet), if the build settings differ between staging and production
and there is no production flavour from the same clean commit to ship instead, or if the
production env is out of step with its template, 1Password or Dokploy (again, the refusal says
whether to push and sync, or to stage again). Each refusal names its reason. Treat a refusal as
information, not an obstacle: it is the check that keeps untested code out of production.
What a script sees
A refusal for env drift exits 2, and --allow-drift goes ahead
anyway, printing a warning that names what it overrode. Any other refusal exits 1. Answering no
at the confirmation exits 130, and so does a run with no terminal and no --yes.
Nothing has been written in any of those cases.
Part Three
hetge rollback
Puts production back on the image it ran before the last promote, and
redeploys. It shows the image it is going back to and asks first; in a script, add
--yes (with no terminal and no --yes it stops with exit 130 and changes
nothing). Do this first and investigate afterwards: it takes seconds and there is no prize for
diagnosing a live outage.
Going further back: hetge rollback --tag 1.2.6.
Rollback swaps stable and stable-previous, so
running it a second time puts production back on the image you just left. If it says
stable moved but production was not deployed (or the deploy request failed), finish
with hetge deploy --target production, not another rollback.
Rollback never touches the release: hetge status will keep
showing production as behind it. Do not promote that release again: fix forward, then release
and promote the fix.
Careful with databases
Rolling back the app does not roll back the database. Migrations run when a version deploys, and older code may not understand the newer schema. If the release included a migration, say so to whoever is helping before rolling back.
Part Four
Some apps have only production. There is nothing to stage to or promote from, so
hetge stage and hetge promote refuse, and so does hetge
rollback: production cannot pin stable without a staging target, so it runs
latest by default.
hetge build --deploy # try HEAD on production
hetge release patch # name it, then deploy production
hetge build --deploy builds what you have checked out and
deploys it to production. Every build moves latest, the tag production runs, so
even a plain hetge build is what production's next deploy ships.
hetge release then names the image production runs. When
production runs latest but not HEAD's clean, unreleased build, release
builds HEAD first, before the bump. Either way it deploys production once, so the app
reports the new version. The summary says so, and there is no second question, also under
--yes: never follow it with hetge deploy.
In the menu, the Ship section shows these two as Build → Push
→ Deploy and Release.
There is no rollback here. To go back, check out the last good commit and
run hetge build --deploy.
Part Five
op signin # sign in to 1Password
hetge login # everything else
Install hetge first (it needs Node.js 24 or newer): see the install page.
hetge login signs you into GitHub, logs Docker into the image
registry, pulls the credentials the whole team shares into ~/.hetge/env, and sets up
~/.npmrc so pnpm install can fetch our private packages. In a project
that names its own operator profile, it fills ~/.hetge/env.<profile> instead
(hetge login --profile <name> works from anywhere).
If a command ever complains about a missing
HETGE_DOKPLOY_API_KEY or similar, the answer is hetge login, not editing
a file in the repo. Those credentials deliberately live outside every project, and when they are
missing or out of date hetge offers to run login for you.
Reference
Every flag of every command is in the Command Reference.
| Command | What it does | Touches production? |
|---|---|---|
| hetge status | Shows what each environment runs (--json for scripts) |
No, read only |
| hetge env:pull | Fills your .env files from 1Password, asking before it overwrites one |
No |
| hetge env:push | Saves your .env changes back to 1Password |
No, but affects teammates |
| hetge env:check | Reports whether an env file, its template, 1Password and Dokploy agree; exits 2 on drift, 1 when a comparison failed | No, read only |
| hetge build | Builds and pushes an image, moving latest, the tag release names |
No with a staging target. Without one, latest is the tag production
runs, so production's next deploy ships this build |
| hetge build --deploy | Builds, pushes, deploys to the default target (deploy.default) |
Yes when that target is production. With no staging target, this is how a build
reaches production. With one, production stays on stable but is
redeployed |
| hetge stage | Builds, pushes, deploys to staging; refuses with no staging target | No |
| hetge sync --deploy | Uploads config, then deploys, with no rebuild (--dry-run shows what it
would change and writes nothing) |
Yes when the target is production: with --target production, or with
no --target when deploy.default is production |
| hetge deploy --target production | Redeploys what production's tag points at, with no rebuild | Yes |
| hetge exec --target … -- | Runs a local command with that target's env | Depends what you run |
| hetge release … | Version, tag, notes, GitHub Release; names the image staging runs (with no staging
target: production's, building HEAD first when production does not run
it) |
Not with a staging target, though it pushes to git. With no staging target, yes: it deploys production |
| hetge promote | Sends the released image to production, keeping the old one as
stable-previous |
Yes |
| hetge rollback | Puts production back on the previous image (a swap: running it twice goes back) | Yes |
| hetge login | Sets up this machine | No |
Summary
Do
hetge status before anything that touches production.Don't
sync overwrites them, and nobody can see what you did.--target production to skip a promote.hetge build between testing staging and releasing: it moves
latest, which is what release names.--yes unless a script is running unattended: it says yes for
you, including to a sync that removes variables only Dokploy holds. It prints their
names, and still refuses to leave the app with no variables, or to remove more than half
of them, without --allow-env-removals.The staging environment exists so that production stops being an experiment. Getting the app running on your own machine is the other page: Run It Locally.