Deploy from the CLI
Declare deployments from CI with unhandled deploy, including flags, environment variables, CI auto-detection, exit codes and examples.
unhandled deploy is the shortest way to declare a deployment from a pipeline. It ships in the @unhandled/cli package, reads what it can from your CI provider, and sends one request to the Deployments API. It needs Node 24 or later. For installation and the other commands in the package, see CLI.
Add the package as a dev dependency so CI installs it with the rest of your project:
npm install --save-dev @unhandled/cli
npx --no-install unhandled deploy --service shop-api --environment production--no-install makes npx fail instead of fetching an unrelated package if the dependency is missing.
Requirements
- A deploy key for the project, created by an admin under Settings > Deploy keys. It starts with
dk_. A DSN key does not work here. - A version or a commit. The command uses
--versionorUNHANDLED_VERSIONwhen set, and finds the commit on its own in CI or in a local git checkout.
Flags
| Flag | Default or fallback | Notes |
|---|---|---|
--service <name> | none | Required. The service's telemetry name. |
--environment <slug> | UNHANDLED_ENVIRONMENT | Required one way or the other. production, staging or development. |
--version <version> | UNHANDLED_VERSION | Optional. |
--commit <sha> | CI provider, then local .git | Sent as commitSha. |
--status <status> | succeeded | started, succeeded, failed or canceled. |
--started-at <time> | none | ISO 8601 with a timezone, Z or an offset like +02:00. |
--finished-at <time> | none | Same format as --started-at. |
--external-id <id> | derived from the CI run | Outside CI, a fresh local: ID per call. |
--actor <actor> | the CI user | |
--ci-url <url> | the CI run URL | |
--api-key <key> | UNHANDLED_API_KEY | The deploy key. |
--url <url> | UNHANDLED_URL, then https://unhandled.net | Base URL. The command appends /api/v1/deployments. |
--strict | off | Exit 1 on warnings instead of 0. |
--dry-run | off | Print the payload and where each field came from. Sends nothing. |
A flag always wins over a value detected from CI or git. Passing a flag with an empty value is an error.
The repository URL is only sent when a CI provider is detected; there is no flag for it. The CLI does not send metadata. Use the API directly if you need either.
Environment variables
| Variable | Used for |
|---|---|
UNHANDLED_API_KEY | Deploy key, when --api-key is not passed. |
UNHANDLED_ENVIRONMENT | Environment, when --environment is not passed. |
UNHANDLED_VERSION | Version, when --version is not passed. |
UNHANDLED_URL | Base URL, when --url is not passed. A self-hosted instance sets it to its APP_URL, not the DSN host. |
Empty variables count as unset.
CI detection
The command recognizes six CI providers and reads the commit, actor, run URL, repository URL and an external ID from their environment:
| Provider | Detected by | Commit from |
|---|---|---|
| GitHub Actions | GITHUB_ACTIONS=true | GITHUB_SHA |
| GitLab CI | GITLAB_CI=true | CI_COMMIT_SHA |
| CircleCI | CIRCLECI=true | CIRCLE_SHA1 |
| Buildkite | BUILDKITE=true | BUILDKITE_COMMIT |
| Bitbucket Pipelines | BITBUCKET_BUILD_NUMBER is set | BITBUCKET_COMMIT |
| Cloudflare Workers Builds | WORKERS_CI=1 | WORKERS_CI_COMMIT_SHA |
The external ID combines the provider, the run (and attempt or job where the provider has one), the service and the environment. Two calls in the same run for the same service and environment share it, so a started call followed by a succeeded call updates one deployment instead of creating two. A GitHub Actions re-run has a new attempt number and creates a new deployment. If your steps would derive different IDs, pass the same --external-id to both.
Outside CI, the command reads the commit from the .git directory without running git, including worktrees and packed refs. It generates a new external ID for every call.
The detected commit is always sent as commitSha, never as version. If neither a version nor a commit is found, the command warns and sends nothing.
Exit codes and retries
The command is built not to break your deploy. A missing API key, a missing version and commit, a network failure or an error response print a warning: line and exit 0. With --strict the same cases exit 1.
Usage errors always exit 1: an unknown flag, a missing --service or environment, an invalid --status, a timestamp without a timezone, an invalid --url, or an empty flag value.
Each request times out after 5 seconds. After a network error, a timeout or a 5xx response, the command retries once. It never retries a 4xx. On success it prints the deployment ID, status, whether it merged with a detected release, and a link.
Dry run
--dry-run resolves every field, prints the request it would send and the source of each value, and exits. It needs no API key, which makes it useful for checking CI detection on a branch.
npx --no-install unhandled deploy --service shop-api --environment staging --dry-runThe source column shows flag, env:<NAME>, ci:<provider>, git, default or generated.
GitHub Actions example
This workflow deploys a storefront API, declares the deployment as started, and reports the outcome either way.
name: release
on:
push:
branches: [main]
jobs:
ship:
runs-on: ubuntu-latest
env:
UNHANDLED_API_KEY: ${{ secrets.UNHANDLED_DEPLOY_KEY }}
UNHANDLED_ENVIRONMENT: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm ci
- run: npx --no-install unhandled deploy --service shop-api --status started
- id: rollout
run: ./scripts/rollout.sh
- if: success()
run: npx --no-install unhandled deploy --service shop-api --status succeeded
- if: failure() && steps.rollout.outcome == 'failure'
run: npx --no-install unhandled deploy --service shop-api --status failedThe three calls share the run's external ID, so the history shows one deployment that moves from started to succeeded or failed.
GitLab CI example
deploy:
stage: deploy
image: node:24
rules:
- if: $CI_COMMIT_TAG
variables:
UNHANDLED_ENVIRONMENT: staging
script:
- npm ci
- export UNHANDLED_VERSION="$CI_COMMIT_TAG"
- npx --no-install unhandled deploy --service shop-worker --status started
- ./scripts/rollout.sh
- npx --no-install unhandled deploy --service shop-worker --status succeededThe job only runs for tags, so the tag doubles as the version. Store the deploy key as a masked CI/CD variable named UNHANDLED_API_KEY.