Version skew protection requires
@trigger.dev/sdk and the trigger.dev CLI on v4.5.12 or
later, the release that
introduced external deployment ids. On an older version no id is sent, and runs execute on the
current version with no warning.The guarantee
Once your app is sending an external deployment id, a triggered run has one of four outcomes:
The id is the contract. There is no dashboard setting and nothing to switch on: if a trigger carries an external deployment id, that id is honoured. If it doesn’t, behaviour is exactly what it is today.
This works identically in production, staging and preview (including per-branch preview deployments), with no per-environment configuration.
Quick start
The id is free-form and opaque to Trigger.dev — a commit SHA, a release tag, a CI run id, anything up to 128 characters. The only rule is that both halves carry the same value.1
Deploy your tasks with an external id
2
Give your running app the same value
Set this in your hosting platform’s runtime environment variables, alongside
TRIGGER_SECRET_KEY:Deploying with an external id
Reusing an id
Because deploys are often retried, redeployed and re-run by CI, repeating an id is a normal thing to do rather than an error. What happens depends on the state of the deployment that already holds it:
Deploying an id that is already live prints the existing version and stops:
--external-id useful on its own as deploy idempotency: a CI job that runs twice for the same commit builds once — provided the second run starts after the first has finished — and the short-circuited run still gets the version number in its output.
A second run that starts while the first is still building hits the Building row above and errors. Only in the brief window before the first run has registered its build do both see “nothing deployed yet”; then both build, and the higher version wins.
--force starts a new build for an id that already has one. It is non-destructive with respect to deployments that already succeeded — both remain, and the newer version wins. It is destructive to a build still in flight, which it cancels, because one id should not have two live builds racing to define it. It requires --external-id: on its own there is no previous deployment for it to build over, so it errors.
A cancelled deployment can never be deployed, so the build it replaces can’t land. The build
itself is signalled to stop and usually does within seconds — but one running elsewhere (a
--local-build, or a deploy from someone else’s machine) can keep going for a few minutes before
it notices.Redeploying the same commit
The short-circuit is keyed on the id alone, not on what went into the build. If you use the commit SHA as your id and then change something the commit doesn’t capture — a synced environment variable, a secret, a dependency resolved at build time — redeploying that commit produces no new build, and the deployed image keeps the older inputs. Three ways out, in order of preference:- Make a new commit.
git commit --allow-empty -m "redeploy"gives you a new SHA, which is a new id, which builds. This is the only remedy that also works for the Vercel and GitHub integrations, which never pass--force. - Pass
--force, if you drive deploys yourself. - Use an id that captures more than the commit —
${GITHUB_SHA}-${GITHUB_RUN_ID}, say — if your builds legitimately depend on inputs outside the tree. You lose idempotency in exchange.
Automatic discovery
Setting the id yourself is the explicit path, and it always works. The SDK can also discover an id from the variables your hosting platform or CI system already injects, so you don’t have to wire anything up. Discovery happens at runtime, on each trigger call — never at module load — so a prebuilt bundle can’t pin a stale value. The SDK takes the first of these that yields a value:externalDeploymentIdpassed to the trigger call — always honoured.externalDeploymentIdpassed toconfigure()— always honoured.TRIGGER_EXTERNAL_DEPLOYMENT_ID— always honoured.- Platform and CI variables — read only when
TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTIONis set to1ortrue.
true, TRUE and True are all the same thing. Any other value — including 0, an empty string, or the variable being absent — leaves automatic discovery off. The variable gates discovery only; an id you set explicitly is always honoured either way.
Hosting platforms
Read first, because a hosting variable describes the deployment that is running.CI systems
Read second. These are correct when your application is the CI job, and they are the natural source for--external-id in a pipeline. Once the job has ended they no longer exist, so they cannot be discovered from a long-running app.
Generic fallbacks
Read last, after every named source above is exhausted:The manual recipe
If your platform only exposes the commit at build time — or injects nothing at all — set the id yourself. This is not a lesser mode: it is the same mechanism, and the same code path the Vercel integration uses with both halves filled in automatically. Read the commit in the place where it is available, and write it into both halves:--external-id "$COMMIT_SHA" would quietly deploy with no id and leave you unprotected.
Whatever your platform calls its build-time commit variable — COMMIT_REF on Netlify, AWS_COMMIT_ID on Amplify, WORKERS_CI_COMMIT_SHA on Cloudflare Workers Builds, $(git rev-parse HEAD) in a bespoke pipeline — read it there and forward it.
Half two can be a runtime environment variable set in your platform’s configuration, or a build-time constant inlined into your bundle. Either works.
Three things are worth knowing:
- It needs no
TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION. That variable gates discovery, not pinning, and an explicitly set id is always honoured. - It is not Vercel-specific, or even git-specific. A release tag or CI run id is fine — the value only has to match on both sides.
- Any name the SDK reads works.
TRIGGER_EXTERNAL_DEPLOYMENT_IDis the clearest, but one of the generic fallbacks does the same job.
Example: Netlify
Netlify exposesCOMMIT_REF to the build only, so forward it:
TRIGGER_EXTERNAL_DEPLOYMENT_ID to the same commit in your site’s runtime environment variables — for example by writing it during the build, or by having your deploy pipeline set it through the Netlify API.
Setting the id in code
Environment variables are the usual way, but you can also pass the id directly. Both forms always win over discovery. Per trigger call:batchTrigger resolves it per item exactly as trigger does, and it survives the asynchronous materialisation of batch items — so a large batch triggered during a deploy waits and releases item by item, each pinned to the deployment its calling code came from.
Waiting and expiry
When a run arrives with an id that isn’t deployed yet, it doesn’t fail — it waits. This is the ordinary case, not an edge case: your app frequently goes live a few seconds before your task build finishes. A waiting run shows in the dashboard with the status Pending version and the reason “Waiting for a deployment with this run’s external deployment id”. As soon as a deployment carrying that id is deployed, the run is released and executes pinned to it. An unknown id waits too, for the same reason: “we haven’t seen this deployment” and “this deployment hasn’t been created yet” look identical from the outside, and the second is common — your app often goes live before your deploy pipeline has even claimed a runner. A shorter run-levelttl that you set yourself still wins — the one-hour deadline is a backstop, not an extension. Note that the run still expires as an external-deployment expiry: the status reason and message name the id it was waiting for, not the ttl.
This deadline applies only to runs waiting on an external deployment id. Runs that wait for the
reasons that already exist — such as triggering a task before your first deploy — keep waiting
indefinitely, unchanged.
When nothing ever lands
Waiting is the right answer to a race and the wrong answer to a misconfiguration, and for the first hour the two look identical. Three setups produce an id that will never be deployed, and each expires every run from the affected release.The commit didn’t build any tasks
The most damaging one, and the easiest to create by accident. If your deploy workflow is filtered on paths —paths: ['trigger/**'] in a monorepo is the usual shape — a commit that only touches your application produces no Trigger.dev deployment at all. Your app still sends that commit’s SHA, nothing ever holds it, and every run from that release waits an hour and expires.
If you filter deploys by path, derive the id from the same filter on both sides — the SHA of the last commit that touched your task directory, not the commit being deployed:
fetch-depth: 0 on actions/checkout — the default shallow clone can’t see far enough back.
Two applications, one Trigger.dev project
If two deployables share one Trigger.dev project and only one of them deploys tasks, the other still sends its own commit SHA — an id nothing will ever hold. Give the application that doesn’t build tasks either no id, or the id of the deployment it should run against.A preview deployment promoted to production
Ids resolve within a single environment. An id deployed to a preview branch does not exist in production, so promoting that preview build in your hosting platform — without a production deploy carrying the same id — parks and expires every production run. Deploy to production the normal way rather than promoting a preview artifact.Precedence
Several things can pin a run. The SDK sends an explicit version — theversion option if you passed one, otherwise TRIGGER_VERSION — and the external deployment id, whenever it has them. The server then prefers the explicit version, then the id, then the current version:
- An explicit
versionpassed totrigger()always wins. Because the SDK folds the option and the variable into a single value before sending, theversionoption overridesTRIGGER_VERSIONon the client, and the server only ever sees the winner. - An explicit version therefore beats an external deployment id. This is what makes migration from atomic deploys safe: the legacy pin keeps governing until you remove the variable.
- An external deployment id sent alongside an explicit version is ignored, not an error.
- A trigger carrying neither runs on the current version.
Automatic skew protection on Vercel
If you use the Vercel integration, version skew protection is set up for you and both halves are automatic:- The integration sets
TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION=1on your Vercel project when you connect it, and adds it on the next build if it is missing — so projects connected before this existed pick it up without reconnecting. It only ever adds the variable, never overwrites one that is already there. - The integration passes your commit SHA as the deploy’s external id.
VERCEL_GIT_COMMIT_SHAis available at runtime on Vercel, so the SDK discovers the matching id with no work from you.
Opting out
SetTRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION to 0 on your Vercel project. A 0 you set today stays 0 through every subsequent build.
Opting out only disables automatic discovery. You can still pin explicitly at any time by setting TRIGGER_EXTERNAL_DEPLOYMENT_ID yourself.
Replacing automatic atomic deployments
Atomic deployments solved the same problem by gating your Vercel deployment on the Trigger.dev build, then spawning a second Vercel deployment withTRIGGER_VERSION baked in and promoting it.
Version skew protection replaces it and is strictly less invasive:
Migrating
- Make sure your app and your deploys are on v4.5.12 or later — the release that introduced external deployment ids. The CLI (
trigger.dev) and the SDK (@trigger.dev/sdk) version together, sonpx trigger.dev@latest updatemoves both; see the v4.5.12 release notes. - Confirm skew protection is working — deploy, then check that runs triggered by the new release are pinned to the new version.
- Turn Atomic deployments off in your project’s Vercel settings.
- Remove the
TRIGGER_VERSIONvariable from your Vercel project. Until you do, it outranks the external deployment id and keeps governing. - Decide who promotes. Under atomic deployments Trigger.dev promoted for you; without it, either re-enable
Auto-assign Custom Production Domainsin Vercel or promote deliberately from your own pipeline.
TRIGGER_VERSION simply keeps winning — so there is no broken intermediate state and no need to do all of this at once.
If you want your Vercel deployment to stay gated on the Trigger.dev build, atomic deployments
remain the way to do that. Version skew protection never blocks a deployment; it makes blocking
unnecessary by making runs wait instead.
Related
- Deployment overview — versions, current version and version locking
- Deploy CLI command — every deploy flag
- Vercel integration
- Preview branches
- Atomic deploys (deprecated for Vercel)

