Configuration

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:

important

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

  1. The Reviewer approves, and the pipeline reaches the deploy stage.
  2. SHIP reads ship.yml at the pull request's head commit and finds deployments.preview.
  3. It dispatches the workflow named there, on the pull request's branch, with a fixed set of inputs (below).
  4. Your workflow builds and deploys, using its own secrets.
  5. It creates a GitHub Deployment and a success deployment status carrying the live URL.
  6. That status webhook resumes the pipeline, and QA runs against the URL.
  7. When the pull request merges or closes, SHIP dispatches the same workflow with action: delete to 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
FieldMeaning
typeThe deploy target's kind. cloudflare-worker today.
workflowThe 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.
configFileRepo-relative path to the deploy tool's config, passed through to your workflow.
envNameThe environment name within that config.
appNameBase name for the per-PR application.
accountSubdomainYour *.workers.dev subdomain, used to build the preview URL.
note

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.

InputAlways sentWhat it carries
actionyesdeploy or delete.
worker_nameyesThe per-PR application name.
config_fileyesYour configFile, verbatim.
env_nameyesThe resolved environment name.
refyesThe commit to deploy.
account_subdomainyesYour accountSubdomain.
pr_numberyesThe pull request being previewed.
preview_hostonly with previewHostThe rendered preview hostname.
servicesonly with servicesJSON array of the services to deploy, in dependency order.
primary_buildonly with servicesBuild command for the primary service.
bindingsonly with servicesJSON map of the primary service's bindings.
datastoresonly with datastoresJSON 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.

note

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.com

Each 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.

warning

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.*   **/*.txt

Set 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?

On this page