Configuration

Deployments

Give a mission a live preview environment to test against. Your own pipeline deploys it with your own credentials.

Overview

After the Reviewer approves a change, SHIP asks your delivery pipeline to deploy the pull request to a preview environment. QA then tests the change there, on a live application and not on a diff. The deployments block in your ship.yml makes this possible.

Note what the block does not do:

important

SHIP never holds a credential for your cloud account, and it does not deploy your application. It asks your delivery pipeline to deploy it, and your pipeline uses the secrets that it already has.

This design is intentional. Your CI already has the exact authority that it needs to deploy your app. That authority applies to one account, and only you can revoke it. If you give a second copy of that authority to a third party, you get no benefit, and a breach can reach more.

Most of the configuration below describes a workflow that you already own. The rest gives the values that SHIP must send to that workflow.

How a deploy happens

  1. The Reviewer approves, and the pipeline gets to the deploy stage.
  2. SHIP reads ship.yml at the head commit of the pull request and finds deployments.preview.
  3. SHIP dispatches the workflow that the block names, on the branch of the pull request. It sends a fixed set of inputs (see below).
  4. Your workflow builds and deploys with its own secrets.
  5. Your workflow creates a GitHub Deployment and a success deployment status with the live URL.
  6. The webhook for that status 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 remove the environment.

Step 5 is the return channel, and it is a contract. The deployment status must give the preview URL as environment_url, and the deployment payload must include pr_number. Without these values, the pipeline cannot match the deploy to the mission that requested it. The mission then waits until its stage timeout.

Minimal configuration

This example is for 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 kind of deploy target. Today, this is cloudflare-worker.
workflowThe workflow file that SHIP dispatches. The file must exist on your default branch, or GitHub does not accept the dispatch. But the version that runs is the one on the branch of the pull request. After the first version of the file is on the default branch, you can test a change to the workflow in its pull request.
configFileThe path to the config file of the deploy tool, relative to the repository root. SHIP passes it to your workflow unchanged.
envNameThe name of the environment in that config.
appNameBase name for the per-PR application.
accountSubdomainYour *.workers.dev subdomain. SHIP uses it to make the preview URL.
note

The key under deployments must be preview. The block is a map so that it can name other environments later, but preview is the entry that the pipeline reads. If the manifest has no preview entry, or if there is no ship.yml, that is not an error. SHIP skips the deploy, and the mission goes directly to QA.

The per-PR application name is {appName}-pr{N}-{issueId}. The name stays the same for all pushes. Each push to a pull request replaces the same application, so no extra application stays behind with nobody to remove it.

The workflow you provide

SHIP dispatches your workflow with these inputs. workflow_dispatch rejects inputs that a workflow does not declare, so declare each input that you expect. If the manifest does not use an optional input, SHIP leaves that input out and does not send it empty. Because of this, a workflow that declares the optional inputs works whether or not the manifest opts in.

InputAlways sentWhat it contains
actionyesdeploy or delete.
worker_nameyesThe per-PR application name.
config_fileyesYour configFile, unchanged.
env_nameyesThe resolved environment name.
refyesThe commit to deploy.
account_subdomainyesYour accountSubdomain.
pr_numberyesThe number of the pull request for the preview.
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, remove everything that the deploy created. SHIP does not expect a callback for a teardown, unless the environment claimed datastores. In that case, report the destroy result the same way: send a deployment status with teardown: "true" in the payload. SHIP then clears the claim only after the destroy succeeds.

Scenarios

No preview environment

Have no ship.yml, or declare no preview entry. The deploy stage records that there is nothing to deploy, and the mission goes directly to QA.

version: 1
deployments: {}

deployments is a required key, so write it empty and do not remove it. A file without it fails validation, and all the other settings in the file fail with it (see The full schema).

QA still runs. The qaSurface block then sets what QA tests. By default, SHIP detects the surface from your project, but you can also set it explicitly:

  • web: build and serve the app locally.
  • browser-extension: load an unpacked browser extension.
  • api: test an API.
  • none: use executed checks as proof, when there is no surface at all that QA can run.

A project with no deployable preview is a supported configuration, not a degraded one.

note

If you set qaSurface.servePort, it cannot be 3000, because the sandbox reserves that port for itself. The platform keeps the rest of the range free, so your project does not need to move off its default port.

One deployable application

Use the minimal configuration above. SHIP treats the top-level fields as one implicit service, and your pipeline builds and deploys it.

A full-stack environment

If one preview needs several services, such as a web app and the API that it calls, declare the services. Also 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 that affect it when they change, so a pull request that changes only apps/web deploys only the web service.

The bindings field then sets the deploy order, so a service always deploys after the services that it binds. The baseline value is what a binding resolves to when its target is not part of this deploy. This keeps a partial deploy consistent. For example, a web preview can bind to an API that this deploy did not rebuild. The preview then points at the deployed API, not at nothing.

An environment with its own data

A preview that needs its own database declares it. It also declares where the migrations come from and how the database gets its data:

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]

The migrations run in ascending order of their numeric filename prefix, before any service in the environment deploys. A datastore is provisioned only when a service that binds it is part of this deploy. As a result, a change that does not deploy the API does not provision the API's database.

The seed options have strict limits on purpose. from: fixtures reads a committed file. from: environment reads from a named live environment. SHIP refuses it unless you set allowLiveSeed: true explicitly, and always refuses it when the named environment is production.

warning

Never seed a preview environment from production data. An autonomous agent tests the preview.

If you leave out datastores, the behavior does not change, and nothing is provisioned.

Changes that do not need a deploy

A pull request that changes only documentation has nothing to preview. SHIP skips the deploy, and the mission goes directly to QA. The default list covers the usual cases:

**/*.md   **/*.mdx   docs/**   **/__tests__/**   **/__mocks__/**
**/*.test.*   **/*.spec.*   **/LICENSE   **/LICENSE.*   **/*.txt

To replace that list, set skipDeployPaths. A documentation site needs this, because there a .md change is the change that you want to preview:

deployments:
  preview:
    # ...
    skipDeployPaths:
      - "**/__tests__/**"
      - "**/*.test.*"

The rule is cautious on purpose, in one direction: SHIP skips a deploy only when every changed file is non-deployable. A deploy of a docs-only change wastes a few minutes. But if SHIP skips a real change, QA approves it against an out-of-date environment. For this reason, an unknown or mixed changeset always deploys.

A preview on your own hostname

By default, a preview is on *.workers.dev. To use a hostname on a zone that you control, set previewHost. SHIP puts the pull request number in place of {pr}:

deployments:
  preview:
    # ...
    previewHost: "pr{pr}.preview.example.com"

For an application that depends on zone-level features, this setting is required, not cosmetic. Cloudflare reserves /cdn-cgi/* at the edge on every hostname. But Cloudflare serves image transformations only on a zone that has them enabled. On a workers.dev preview, each /cdn-cgi/image/… request therefore fails at the edge and never gets to your application. A Next.js app whose image loader targets /cdn-cgi/image/ shows no images until the preview is on your zone.

The hostname must also be at a subdomain depth that the certificate of your zone covers.

The full schema

The schema reference lists every field of the deployments block, also the fields that this page does not explain. It also gives the JSON Schema for your editor, so your editor finds an invalid file while you type, not on the next mission.

How is this page?

On this page