Deployments
Give a mission a live preview environment to test against, deployed by your own pipeline with your own credentials.
Overview
After the Reviewer approves a change, SHIP deploys the pull request to a preview environment and QA exercises the change there, against a running application rather than a diff. The deployments block in your ship.yml is what makes that possible.
The important thing about it is what it does not do:
SHIP never holds a credential for your cloud account. It does not deploy your application. It asks your delivery pipeline to, and your pipeline uses the secrets it already has.
That shape is deliberate. Your CI already has exactly the authority needed to deploy your app, scoped to one account and revocable by you alone. Handing a second copy of that authority to a third party buys nothing and widens what a breach reaches.
So the configuration below is mostly a description of a workflow you already own, plus the values SHIP needs to pass into it.
How a deploy happens
- The Reviewer approves, and the pipeline reaches the deploy stage.
- SHIP reads
ship.ymlat the pull request's head commit and findsdeployments.preview. - It dispatches the workflow named there, on the pull request's branch, with a fixed set of inputs (below).
- Your workflow builds and deploys, using its own secrets.
- It creates a GitHub Deployment and a
successdeployment status carrying the live URL. - That status webhook resumes the pipeline, and QA runs against the URL.
- When the pull request merges or closes, SHIP dispatches the same workflow with
action: deleteto tear the environment down.
Step 5 is the return channel, and it is a contract: the deployment status must carry the preview URL as environment_url, and the deployment payload must include pr_number. Without those, the pipeline has no way to match the deploy back to the mission that asked for it, and the run waits until its stage timeout.
Minimal configuration
One deployable application, on *.workers.dev:
version: 1
deployments:
preview:
type: cloudflare-worker
workflow: deploy-preview.yml
configFile: wrangler.toml
envName: preview
appName: my-app
accountSubdomain: my-account| Field | Meaning |
|---|---|
type | The deploy target's kind. cloudflare-worker today. |
workflow | The workflow file SHIP dispatches. It must exist on your default branch for GitHub to accept the dispatch at all, but the version that runs is the one on the pull request's branch, so a change to the workflow itself is testable in the pull request that makes it, once the file has landed on the default branch once. |
configFile | Repo-relative path to the deploy tool's config, passed through to your workflow. |
envName | The environment name within that config. |
appName | Base name for the per-PR application. |
accountSubdomain | Your *.workers.dev subdomain, used to build the preview URL. |
The key under deployments must be preview. The block is a map so other environments can be named later, but preview is the entry the pipeline reads. A manifest with no preview entry, or no ship.yml at all, is not an error: the deploy is skipped and the mission goes straight to QA.
The per-PR application name is {appName}-pr{N}-{issueId}, and it is stable across pushes: every push to a pull request replaces the same application in place rather than minting a new one to leak.
The workflow you provide
SHIP dispatches your workflow with these inputs. workflow_dispatch rejects inputs a workflow does not declare, so declare each one you expect to receive. Note that the optional ones are omitted entirely rather than sent empty, so a workflow that declares them stays compatible whether or not the manifest opts in.
| Input | Always sent | What it carries |
|---|---|---|
action | yes | deploy or delete. |
worker_name | yes | The per-PR application name. |
config_file | yes | Your configFile, verbatim. |
env_name | yes | The resolved environment name. |
ref | yes | The commit to deploy. |
account_subdomain | yes | Your accountSubdomain. |
pr_number | yes | The pull request being previewed. |
preview_host | only with previewHost | The rendered preview hostname. |
services | only with services | JSON array of the services to deploy, in dependency order. |
primary_build | only with services | Build command for the primary service. |
bindings | only with services | JSON map of the primary service's bindings. |
datastores | only with datastores | JSON array of the datastores to provision. |
On action: delete, tear down whatever the deploy created. SHIP expects no callback for a teardown unless the environment claimed datastores, in which case report the destroy result the same way, as a deployment status with teardown: "true" in the payload, so the claim is only cleared once the destroy actually succeeded.
Scenarios
No preview environment
Either have no ship.yml at all, or declare no preview entry. The deploy stage records that there is nothing to deploy and hands straight to QA.
version: 1
deployments: {}deployments itself is a required key, so write it empty rather than dropping it. A file missing it fails validation, and everything else in it goes down with that (see The full schema).
QA still runs. What it tests against is then governed by the qaSurface block, which is detected from your project by default and can be set explicitly: build and serve the app locally (web), load an unpacked browser extension (browser-extension), exercise an API (api), or fall back to executed-check proof when there is no runnable surface at all (none). A project with no deployable preview is a supported configuration, not a degraded one.
If you set qaSurface.servePort, it cannot be 3000, which the sandbox reserves for itself. The platform vacates the rest of the range rather than asking your project to move off its default.
One deployable application
The minimal configuration above. SHIP treats the top-level fields as one implicit service, builds it and deploys it.
A full-stack environment
When one preview needs several services, such as a web app and the API it calls, declare them and declare how they find each other:
deployments:
preview:
type: cloudflare-worker
workflow: deploy-preview.yml
configFile: apps/web/wrangler.toml
envName: preview
appName: my-app
accountSubdomain: my-account
services:
- name: api
kind: cloudflare-worker
configFile: apps/api/wrangler.toml
appName: my-api
build: pnpm --filter api build
paths:
- apps/api/**
- packages/contract/**
- name: web
kind: cloudflare-worker
configFile: apps/web/wrangler.toml
appName: my-app
build: pnpm --filter web build
paths:
- apps/web/**
bindings:
- name: API
service: api
baseline: https://api.example.comEach service declares the paths whose change affects it, so a pull request touching only apps/web deploys only the web service. bindings then orders the deploy, so a service is always deployed after the services it binds, and baseline is what a binding resolves to when its target is not part of this deploy. That is what keeps a partial deploy coherent: the web preview bound to an API that was not rebuilt points at the standing API instead of at nothing.
An environment with its own data
A preview that needs a database of its own declares it, along with where its migrations come from and how it is populated:
deployments:
preview:
# ...
datastores:
- name: app-db
kind: cloudflare-d1
binding: DB
migrations:
source: packages/infra/migrations
seed:
from: fixtures
path: fixtures/preview-seed.sql
services:
- name: api
# ...
datastores: [app-db]Migrations are applied in ascending order of their numeric filename prefix, before any service in the environment is deployed. A datastore is only provisioned when a service that binds it is actually part of this deploy, so a change that never deploys the API never provisions its database either.
Seeding is deliberately constrained. from: fixtures reads a committed file. from: environment reads from a named live environment and is refused unless allowLiveSeed: true is set explicitly, and always refused when the named environment is production.
A preview environment is exercised by an autonomous agent. Never seed one from production data.
Omitting datastores entirely leaves the behaviour exactly as it was, with nothing provisioned.
Changes that do not need a deploy
A pull request that only edits documentation has nothing to preview, so the deploy is skipped and the mission goes to QA directly. The defaults cover the obvious cases:
**/*.md **/*.mdx docs/** **/__tests__/** **/__mocks__/**
**/*.test.* **/*.spec.* **/LICENSE **/LICENSE.* **/*.txtSet skipDeployPaths to replace that list. A documentation site, where a .md change is exactly what wants previewing, is the case that needs it:
deployments:
preview:
# ...
skipDeployPaths:
- "**/__tests__/**"
- "**/*.test.*"The rule is deliberately cautious in one direction: a deploy is skipped only when every changed file is non-deployable. Deploying a docs-only change wastes a few minutes; skipping a real one means QA signs off against a stale environment, so an unknown or mixed changeset always deploys.
A preview on your own hostname
By default a preview is served on *.workers.dev. Set previewHost to bind a hostname on a zone you control instead, with {pr} substituted for the pull request number:
deployments:
preview:
# ...
previewHost: "pr{pr}.preview.example.com"This is required rather than cosmetic for any application that depends on zone-level features. Cloudflare reserves /cdn-cgi/* at the edge on every hostname but only serves image transformations on a zone that has them enabled, so on a workers.dev preview every /cdn-cgi/image/… request fails at the edge and never reaches your application. A Next.js app whose image loader targets /cdn-cgi/image/ renders no images at all until the preview lives on your zone.
The hostname must also sit at a subdomain depth your zone's certificate covers.
The full schema
Every field of the deployments block, including the ones this page does not walk through, is listed in the schema reference, along with the JSON Schema to point your editor at, so an invalid file is caught as you type rather than on the next mission.
How is this page?