StackShip deploys six Workers to the Cloudflare account that owns the
stackship.run zone. The orchestrator and credential broker are internal
service-binding targets and do not have public domains.
Domains
| Worker | Custom domain |
|---|---|
stackship-web |
stackship.run |
stackship-api |
api.stackship.run |
stackship-mcp |
mcp.stackship.run |
stackship-docs |
docs.stackship.run |
stackship-orchestrator |
internal only |
stackship-credential-broker |
internal only |
Wrangler custom domains create the required DNS records and certificates. A hostname cannot already have a conflicting CNAME.
r.stackship.run is a separately provisioned PostHog-managed US reverse proxy,
not a Worker custom domain. Its DNS record is a DNS-only CNAME to the unique
PostHog-managed proxyhog.com target issued for the production organization.
The dashboard keeps the public phc_ project token and uses this first-party
origin as api_host; no privileged PostHog key is stored in a Worker.
Do not use GET / as the relay health check: the managed proxy intentionally
returns 404 there. Verify GET /static/array.js and the production project’s
/array/<public-project-token>/config.js path instead; both must return
JavaScript successfully, and an OPTIONS /e/ preflight must advertise
GET,POST,OPTIONS.
The credential broker and orchestrator must set workers_dev = false and
must not declare routes. They are reachable only through explicit Worker
service and Workflow bindings; an accidental *.workers.dev deployment is a
release-blocking public surface.
Database
The PlanetScale PostgreSQL database is linked through Hyperdrive configuration
397048f06dbc43c1a921c92258384a40, bound to the API Worker as HYPERDRIVE.
Query caching is disabled on this configuration because it serves Better Auth
sessions, permissions, organizations, and other consistency-sensitive reads.
Do not use SQL comments as cache hints. Cloudflare does not document them as a cache-control API. If product reads later benefit from caching, create a second cache-enabled Hyperdrive configuration and keep identity traffic on this cache-disabled binding.
The repository root contains an ignored .env.migrations file with the direct
administrative connection:
DATABASE_URL=postgresql://…Migration commands use Node 24’s optional env-file flag. A local command loads
this file when it exists; a clean CI checkout without it continues with its
already-exported DATABASE_URL:
pnpm db:generate
pnpm db:migrateDrizzle reads the shared schema from packages/db/src/schema/ and writes one
ordered migration stream to packages/db/migrations/. Platform tables repeat
organization_id directly, and tenant-owned unique and lookup indexes begin
with that column. Repository methods require an explicit tenant context and
query with both the organization ID and the object ID; an active organization
stored in a browser session is never a database authorization boundary.
Migration 0021_noisy_karen_page adds the global GitHub installation
lifecycle fence, tenant-free delivery identity authority, and global delivery
constraint. It rejects ambiguous legacy IDs and backfills every tenant
delivery before adding a restrictive foreign key. Insert and update triggers
mirror legacy Worker writes into the global identity, reject identity changes,
and prevent a tenant write from claiming an existing global-only result. Apply
it before deploying Workers that use global delivery identities. The migration
intentionally retains the older tenant-scoped unique index beside the global
index so old and new Workers remain compatible during a rolling deployment.
Migration 0026_tofu_oauth_callback_range repairs the fixed public
tofu-cli OAuth client on databases that already applied migration 0006.
It expands the exact loopback callback range from ports 10000–10009 to
ports 10000–10010 without changing the historical migration. The update
is conditional and can be replayed safely; deploy it before relying on
OpenTofu’s port-10010 login listener.
Migration 0030_generic_secret_envelope_constraint replaces oversized
PostgreSQL regular-expression repetition bounds with explicit length checks
for generic-secret ciphertext and wrapped keys. PostgreSQL limits bounded
regular-expression repetitions to 255, so the earlier constraint raised a
database error only when a non-null encrypted envelope was inserted. Apply
0030 before deploying or enabling generic-secret credential versions. The
migration keeps the constraint name, validates existing rows, and never
changes or decrypts stored envelope material.
Migration 0031_organization_membership_uniqueness enforces one authoritative
Better Auth membership per user and organization. Before applying it, run this
read-only preflight; it must return no rows:
SELECT organization_id, user_id, count(*) AS membership_count
FROM member
GROUP BY organization_id, user_id
HAVING count(*) > 1;The migration fails closed if duplicate membership records already exist. It does not choose a role, delete a record, or otherwise rewrite identity data. Stop the rollout and reconcile any returned records through an audited identity operation before retrying the migration.
Migration 0032_authentication_rate_limits creates the durable Better Auth
rate-limit counter table. Apply it before deploying the API build that enables
database-backed rate limiting. The table contains normalized client IP and
authentication-route keys, counters, and request timestamps; it does not
contain credentials, request bodies, email addresses, or bearer tokens. Treat
the table as operational personal data and retain the existing database access
boundary.
Migration 0033_authentication_rate_limit_identities upgrades that table to
Better Auth 1.6.25’s Drizzle adapter contract. It assigns each existing counter
an adapter id equal to its already-unique key, makes that id the primary
key, and retains a unique constraint on the request key. Existing counts and
timestamps are preserved. Apply 0033 before deploying the API schema that
includes database-backed rate limiting; otherwise Better Auth fails closed
during adapter initialization because the id field is absent.
Migration 0034_cold_omega_red permits a policy evaluation’s input_digest
to be null only while the evaluation is running. This supports atomically
reserving the deterministic policy-check row during a successful plan commit,
before the policy runner has produced its sanitized-input digest. The existing
digest-format constraint still applies once a digest is present, and the
completion constraint requires a non-null digest plus complete outcome,
composition, result-count, and timestamp evidence. Apply 0034 before
deploying the API build that reserves pending policy checks; older Workers
continue to read existing non-null evaluations during the rolling deployment.
Runs whose successful plan committed before this API build may not yet have a
policy-evaluation row. The compatibility path is intentionally limited to a
run whose persisted workflow_build_sha is a valid earlier build SHA and
differs from the currently deployed APPLICATION_BUILD_SHA. It derives the
policy-check ID from the exact run ID, then binds the same tenant-scoped plan,
policy snapshot, and plan digest used by normal reservation. A current-build
run with a missing reservation, a changed identifier, or mismatched evidence
still fails closed. This bridge creates no broad migration backfill and does
not change the atomic reservation invariant for new plan commits.
Never point .env.migrations at Hyperdrive or commit it to source control.
PlanetScale’s sslrootcert=system URL parameter is normalized in memory for
node-postgres; sslmode=verify-full remains enabled.
PlanetScale’s stable postgres role must own the application tables and the
drizzle.__drizzle_migrations schema. The credential in .env.migrations
must inherit PlanetScale’s postgres permission so Drizzle can perform DDL,
but the credential itself must not become the long-lived object owner.
If a Hyperdrive or other integration role bootstraps the schema, use
PlanetScale’s Reassign objects action to transfer its objects to
postgres before running later migrations. Verify the transfer and run
pnpm db:migrate before rotating or deleting the original role.
Secrets
Set Worker secrets without printing them:
wrangler secret put BETTER_AUTH_SECRET --config apps/api/wrangler.jsonc
wrangler secret put CLOUD_TOKEN_HMAC_KEY --config apps/api/wrangler.jsonc
wrangler secret put ARTIFACT_CAPABILITY_HMAC_KEY --config apps/api/wrangler.jsonc
wrangler secret put GOOGLE_CLIENT_ID --config apps/api/wrangler.jsonc
wrangler secret put GOOGLE_CLIENT_SECRET --config apps/api/wrangler.jsonc
wrangler secret put GITHUB_APP_ID --config apps/api/wrangler.jsonc
wrangler secret put GITHUB_APP_PRIVATE_KEY --config apps/api/wrangler.jsonc
wrangler secret put GITHUB_WEBHOOK_SECRET --config apps/api/wrangler.jsonc
wrangler secret put IDEMPOTENCY_HMAC_KEY --config apps/api/wrangler.jsonc
wrangler secret put LOG_CAPABILITY_HMAC_KEY --config apps/api/wrangler.jsonc
wrangler secret put INTERNAL_GRANT_PRIVATE_JWK --config apps/api/wrangler.jsonc
wrangler secret put INTERNAL_GRANT_PUBLIC_JWK --config apps/credential-broker/wrangler.jsonc
wrangler secret put PLATFORM_KEK_V1 --config apps/credential-broker/wrangler.jsonc
wrangler secret put RUNNER_BOOTSTRAP_PRIVATE_JWK --config apps/credential-broker/wrangler.jsonc
wrangler secret put RUNNER_BOOTSTRAP_PUBLIC_JWK --config apps/api/wrangler.jsonc
wrangler secret put RUNNER_CALLBACK_HMAC_KEY --config apps/api/wrangler.jsonc
wrangler secret put RUNNER_RUN_TOKEN_HMAC_KEY --config apps/api/wrangler.jsonc
wrangler secret put REGISTRY_DOWNLOAD_HMAC_KEY --config apps/api/wrangler.jsonc
wrangler secret put WORKLOAD_IDENTITY_PRIVATE_JWK --config apps/credential-broker/wrangler.jsonc
wrangler secret put WORKLOAD_IDENTITY_PUBLIC_JWK --config apps/api/wrangler.jsonc
# Set only while old workload-identity keys overlap the active key.
wrangler secret put WORKLOAD_IDENTITY_RETIRING_PUBLIC_JWKS --config apps/api/wrangler.jsonc
wrangler secret put BROKER_CONTROL_SHARED_SECRET --config apps/api/wrangler.jsonc
wrangler secret put BROKER_CONTROL_SHARED_SECRET --config apps/orchestrator/wrangler.jsonc
wrangler secret put BROKER_CONTROL_SHARED_SECRET --config apps/credential-broker/wrangler.jsonc
wrangler secret put CONTROL_PLANE_SHARED_SECRET --config apps/api/wrangler.jsonc
wrangler secret put CONTROL_PLANE_SHARED_SECRET --config apps/orchestrator/wrangler.jsonc
wrangler secret put DAYTONA_API_KEY --config apps/orchestrator/wrangler.jsonc
wrangler secret put POLICY_BUILD_HMAC_KEY --config apps/api/wrangler.jsonc
wrangler secret put PLATFORM_ADMIN_PRINCIPAL_IDS --config apps/api/wrangler.jsonc
wrangler secret put RUNNER_SNAPSHOT_CATALOG --config apps/api/wrangler.jsoncThe production GitHub App slug is the non-secret Wrangler variable
GITHUB_APP_SLUG=stackship-run. Configure its setup URL as
https://stackship.run/api/control-plane/v1/github/installations/callback, its webhook URL
as https://api.stackship.run/v1/github/webhooks, and do not enable GitHub
user OAuth. Grant only Checks read/write, Contents read, Metadata read, and
Pull requests read. Select only push and pull_request under manual event
subscriptions. GitHub automatically sends installation and
installation_repositories lifecycle events to every GitHub App; there is no
manual subscription checkbox for those lifecycle events.
CLOUD_TOKEN_HMAC_KEY is a dedicated high-entropy secret used only for keyed
service-account-token lookup. Generate it independently, do not reuse
BETTER_AUTH_SECRET, and rotate it through the documented token migration
because changing it immediately invalidates every service-account token.
IDEMPOTENCY_HMAC_KEY is a separate API-only secret of at least 32 UTF-8
bytes. It keys the deterministic fingerprint used for exact retries of
generic-secret version creation, so PlanetScale never receives either the
plaintext environment map or an offline-guessable hash of it. Generate it
independently and keep it stable. If it must rotate, wait for the 24-hour
idempotency retention window or require clients to use new idempotency keys;
old-key replays fail closed.
The API also requires five independent high-entropy HMAC secrets:
ARTIFACT_CAPABILITY_HMAC_KEY, LOG_CAPABILITY_HMAC_KEY,
RUNNER_CALLBACK_HMAC_KEY, RUNNER_RUN_TOKEN_HMAC_KEY, and
REGISTRY_DOWNLOAD_HMAC_KEY. They bind artifact transfers, first-release
bounded log reads, terminal runner callbacks, runner control requests, and
the five-minute module-registry download capability URLs, respectively.
Generate each from at least 32 random bytes.
Do not reuse the cloud-token, idempotency, control-plane, broker-control,
policy-build, Better Auth, or wrapping keys. Rotation invalidates outstanding
capabilities for that exact purpose, so disable new runs and wait through the
documented maximum capability lifetime before replacement.
Migration 0018_keyed_generic_secret_fingerprints removes every
pre-HMAC generic_secret profile version because StackShip has no plaintext
read path with which to re-encrypt it. It also removes only the idempotency
rows for the credential-version creation route. Generic profile records,
federated provider versions, audit records, and all unrelated idempotency
routes remain. This pre-customer migration is intentionally destructive:
configure IDEMPOTENCY_HMAC_KEY, apply the migration, deploy the updated
credential broker and API, then recreate each generic-secret version through
the write-only form.
Migration 0022_wet_black_queen makes the manual-review rule a
database invariant. Before applying it to a pre-release environment, disable
new runs and confirm that no nonterminal row has auto_apply=true; a durable
workflow may already have replayed that old value. The migration normalizes
all historical flags to false and then adds the
runs_auto_apply_disabled check constraint. Deploy the matching API and
orchestrator build before re-enabling run creation.
Identity provider callbacks
Configure Google with the complete secret pair and register the dashboard callback, not the API issuer callback:
https://stackship.run/api/auth/callback/googleRegister the shared enterprise OIDC callback as:
https://stackship.run/api/auth/sso/callbackFor each operator-provisioned SAML connection, replace <provider-id> with
the exact Better Auth provider identifier and register:
https://stackship.run/api/auth/sso/saml2/sp/acs/<provider-id>Use that same URI as the connection’s samlConfig.callbackUrl. Do not register
an api.stackship.run callback: browser identity exchanges must terminate
through the dashboard auth boundary so the session remains a host-only
stackship.run cookie.
BROKER_CONTROL_SHARED_SECRET is a separate, high-entropy value of at least
32 bytes. The API, orchestrator, and credential broker receive the same value;
it must not reuse an auth, token-HMAC, or encryption key. Capability requests
also carry an exact X-StackShip-Caller service identity, and the broker
accepts each operation only from its expected internal caller.
CONTROL_PLANE_SHARED_SECRET is another independent high-entropy value shared
only by the API and orchestrator. It authenticates internal Workflow control
calls, including the scheduled run-signal, Workflow-outbox, and artifact-orphan
reconcilers. Never reuse the broker, Better Auth, token-HMAC, or encryption
secret.
Scheduled recovery
The orchestrator Worker must retain the committed */5 * * * * recovery cron,
the 17 3 * * * daily artifact-orphan cron, and its CONTROL_PLANE service
binding to stackship-api. Every five-minute tick performs Daytona cleanup
reconciliation, run-signal delivery, and a bounded Workflow outbox pass in
parallel. Daytona cleanup is database-first: the authenticated API returns no
more than 32 terminal attempt identities that still own their exact active
capacity lease. The orchestrator proves the complete recorded identity before
any provider mutation. Ordinary terminal sandboxes are stopped, deleted, and
re-inspected as deleted before the API atomically records cleaned_at and
releases the exact lease. A terminal uncertain apply with no live runner
session uses absence-only mode: an existing, transitional, unknown, or
mismatched sandbox remains quarantined and is never deleted as part of that
check. Provider absence can release capacity without deleting the run’s
durable recovery records or encrypted artifacts. No provider-list result and
no operator database update can substitute for the authenticated
acknowledgement. One target failure does not starve the rest of the bounded
page: reconciliation continues without acknowledging that target, then raises
a sanitized aggregate sentinel containing only bounded internal failure codes.
The outbox pass leases no more than 25 due run, source-ingestion, registry-ingestion, or policy-publication dispatches for 60 seconds and releases failures with bounded exponential backoff. A missing cron trigger, service binding, or matching control-plane secret leaves committed work pending rather than weakening authentication or starting unbound work.
The daily cron creates one deterministic
ArtifactOrphanSweepWorkflowV1 instance per UTC date. Each durable step asks
the API to scan at most 1,000 R2 objects and carries only the opaque next
cursor and aggregate counts. The API deletes a key only when it has a managed
artifact shape, is older than 24 hours, and is absent from the exact committed
R2-key set in PlanetScale. Do not shorten that grace period during an incident;
uncertain metadata acknowledgements intentionally preserve their object until
the delayed proof.
POLICY_BUILD_HMAC_KEY is an API-only, independent high-entropy secret of at
least 32 bytes. It signs the 15-minute bootstrap used by one Daytona policy
build and must not reuse the Better Auth, service-token, control-plane, or
artifact-encryption keys.
PLATFORM_ADMIN_PRINCIPAL_IDS is a comma-separated, bounded allowlist of
Better Auth principal IDs authorized to read or mutate internal launch
controls. A caller must also hold organization administration permissions.
Missing or malformed configuration denies every control request. Control
snapshots and mutations belong in PlanetScale, never Wrangler variables.
RUNNER_SNAPSHOT_CATALOG is schema-v3 compatibility configuration:
{ "schemaVersion": 3, "imageDigest": "sha256:<64 lowercase hex>" }One logical release digest covers every OpenTofu version. The regional runner
name persisted on a run is derived from the workspace version, this digest’s
first 12 hex characters, and the execution region, ending in -us or -eu.
These values preserve the existing database and launch-control contract; they
are not OCI image digests or Daytona snapshot names. The catalog contains no
credential, but install it through wrangler secret put so a runner release
does not require a committed Wrangler change. Managed execution and policy
publication fail closed when the document is missing, malformed, carries an
unknown schema version, or has extra fields. Rotate the secret atomically
with the deploy that changes its schema; a schema-v2 document is rejected by
schema-v3 code and vice versa. Per-version OpenTofu digests live in the
opentofu_toolchains database table (seeded by migration 0043), not in
this catalog.
StackShip does not create or publish custom runner snapshots. The orchestrator
creates each private, ephemeral sandbox from daytona-medium, transfers its
two generated runner bundles and policy-capabilities manifest through bounded
gzip-compressed Daytona process-command chunks, and installs the manifest’s
exact OpenTofu version and pinned OPA release. Each asset is reconstructed at a
temporary path, verified against the digest of the reviewed Worker-embedded
bytes, and only then moved into place. Bootstrap then verifies the OpenTofu
archive and extracted binary plus the OPA binary against committed SHA-256
values before runner start. Runner bootstrap does not use Daytona’s serverless
multipart uploader. Each command carries at most 256 KiB of encoded compressed
data; an uncompressed asset is capped at 3 MiB and its compressed encoded
transfer at 4 MiB. A larger generated asset disables managed execution until
the release is reduced or the reviewed transfer contract changes. Chunks use
deterministic per-index staging files so the orchestrator can replay a command
once after a thrown Daytona transport error without appending duplicate bytes.
Nonzero exits, digest failures, and install failures are not converted into
successful retries, and verified bootstrap removes the staging files. The
orchestrator records the exact sandbox identity and capacity
lease before the first transfer, preserving cleanup authority when preparation
fails. The orchestrator generates the bundles before build, test,
development, and deployment, so the deployed Worker and uploaded runner code
come from the same reviewed checkout. The Daytona API key is needed only by
the orchestrator Worker and never belongs in Wrangler vars, Workflow state,
a runner bundle, or the catalog.
RUNNER_BOOTSTRAP_PRIVATE_JWK and RUNNER_BOOTSTRAP_PUBLIC_JWK are the
private and public sides of one EC P-256 key pair used only to sign and verify
five-minute runner bootstrap tokens. The private side exists only in the
credential broker; the API receives only the public side. Set kid to
runner-v1 on the public JWK.
INTERNAL_GRANT_PRIVATE_JWK and
INTERNAL_GRANT_PUBLIC_JWK are the two sides of the separate ES256 data-key
grant signer. PLATFORM_KEK_V1 is a base64url-encoded 32-byte platform
key-encryption key used by the broker to wrap random artifact, variable, and
generic-secret data keys. Generic-secret wrapping and authenticated data bind
the organization, workspace, profile-version id, and environment-name set.
Only the credential broker receives the KEK. Its
ACTIVE_PLATFORM_KEY_VERSION must be 1 for the currently configured
PLATFORM_KEK_V1; a version must remain configured while any envelope names
it. Generate all three key sets independently and never place a private value
in Wrangler vars.
WORKLOAD_IDENTITY_PRIVATE_JWK and WORKLOAD_IDENTITY_PUBLIC_JWK are an
independent RSA signing pair with a modulus of at least 2048 bits. The private
side exists only in the credential broker. Set
WORKLOAD_IDENTITY_KEY_ID to the same rotation version in the API and broker
Wrangler vars; never reuse the runner-bootstrap or data-key-grant keys.
Before enabling credential profiles, confirm that
https://api.stackship.run/workload-identity/jwks.json publishes that exact
public key and that every cloud provider can fetch it.
RSA modulus values must use canonical base64url unsigned-integer encoding: leading zero octets are rejected. Both Workers also import the configured key and independently require the resulting Web Crypto RSA modulus length to be at least 2048 bits; padding a smaller modulus cannot satisfy the check.
Rotate the workload-identity signing key
The broker signs with exactly one active private key. The API publishes that
key first and may also publish up to four retiring public keys from
WORKLOAD_IDENTITY_RETIRING_PUBLIC_JWKS. The optional value is a standard
JWKS object, is limited to 16 KiB, and has this shape:
{
"keys": [
{
"alg": "RS256",
"e": "AQAB",
"kid": "workload-v1",
"kty": "RSA",
"n": "<old-public-modulus>",
"use": "sig"
}
]
}Every kid must be unique, including the active
WORKLOAD_IDENTITY_KEY_ID. A malformed, duplicate, non-RSA, non-RS256,
non-signing, or private key makes the JWKS endpoint return 503 with
Cache-Control: no-store; StackShip never publishes a partial key set.
Retiring private keys must not be placed in either Worker.
Use this order so cached provider JWKS documents and already-issued tokens remain valid:
- Generate a new independent RSA key pair of at least 2048 bits and retain
the old public key and
kid. - Prepare one API Worker version with the new
WORKLOAD_IDENTITY_PUBLIC_JWK, the newWORKLOAD_IDENTITY_KEY_ID, and a retiring JWKS containing the old public key with its oldkid. Deploy those three values together. A non-atomic partial update safely returns503, but must not be left serving. - Confirm the public JWKS contains the new key first and the old key second.
Verify that neither entry contains
d,p,q,dp,dq,qi, oroth. - Wait at least five minutes, the published JWKS cache lifetime, before changing the signer. This gives provider caches time to learn the new key.
- Prepare and deploy one credential-broker version with the new
WORKLOAD_IDENTITY_PRIVATE_JWKand matchingWORKLOAD_IDENTITY_KEY_ID. Do not configure the old private key there. - Decode a newly issued test token and confirm its header uses the new
kid; complete one provider exchange before enabling normal issuance. - Keep the old public key in the retiring set for at least six minutes after the final token signed by the old key: the five-minute token lifetime plus the bounded clock-skew allowance.
- Remove the old key by deploying the API with
WORKLOAD_IDENTITY_RETIRING_PUBLIC_JWKSunset or exactly{"keys":[]}. Recheck the endpoint before deleting the retired private material from the secure rotation workspace.
Never switch the broker first. A token signed by a key that provider caches have not observed fails federation even when the API is corrected moments later. For emergency revocation, stop credential issuance first and remove the compromised public key immediately; availability is secondary to preventing acceptance of newly forged identities.
The credential broker binds CAPABILITY_HANDLES to its
CapabilityHandleStore Durable Object. Bootstrap, runner-secret, and
apply-start handles are stored there with an alarm-backed expiry. Generic
secret handles store only exact execution ids and digests, environment names,
expiry, and the opaque handle; they never store the database envelope or
plaintext. Runner-secret and generic-secret records are deleted atomically on
first exact redemption. Bootstrap and apply-start redemption retain their exact
replay authority until the existing alarm expires, allowing recovery from a
lost response without authorizing changed bindings or extending the original
expiry. Handle records contain immutable
identifiers, digests, encrypted-secret version references, and non-secret
workload-federation metadata such as role ARNs or application ids. Plaintext
workspace variables and temporary provider credentials are never persisted in
a handle or Cloudflare Workflow history. Workflow step results also exclude
raw capability handles, bootstrap tokens, execution and callback nonces, and
bearer credentials. The orchestrator reacquires a bootstrap handle only inside
the authenticated runner-start service call, redeems it in Worker memory, and
persists only the attempt id as step evidence. Generic-secret envelopes remain
in PlanetScale and never enter Workflow history.
This boundary prevents capability material from entering newly executed step results; deployment does not rewrite older Cloudflare Workflow histories. Treat pre-fix histories as containing expired authority until Cloudflare retention removes them, keep Workflow inspection access restricted, and verify that their runner sessions and apply authorizations are expired or revoked.
Internal resource topology
Provision the resources and deploy their owners before deploying Workers that bind to them.
| Owner | Binding | Target | Provisioning and order |
|---|---|---|---|
stackship-credential-broker |
CAPABILITY_HANDLES |
CapabilityHandleStore Durable Object |
Deploy the broker first so Wrangler applies its Durable Object migration. Never delete or rename the class without a data migration. |
stackship-api |
ARTIFACTS |
stackship-artifacts R2 bucket |
Create the private R2 bucket before the API. It stores encrypted state, configuration, lock files, plans, logs, and other restricted artifacts. |
stackship-api |
CREDENTIAL_BROKER |
stackship-credential-broker service binding |
Deploy the broker before the API. No public broker route is required. |
stackship-orchestrator |
CREDENTIAL_BROKER |
stackship-credential-broker service binding |
Deploy the broker before the orchestrator. |
stackship-orchestrator |
CONTROL_PLANE |
stackship-api service binding |
Deploy the API before starting workflow instances. |
stackship-orchestrator |
RUN_WORKFLOW |
RunWorkflowV1 / stackship-run-workflow-v1 |
The orchestrator owns the Workflow class and must be deployed before the API dispatches runs. |
stackship-orchestrator |
SOURCE_INGESTION_WORKFLOW |
SourceIngestionWorkflowV1 / stackship-source-ingestion-workflow-v1 |
The orchestrator owns the Workflow class and must be deployed before the API dispatches source ingestion. |
stackship-orchestrator |
POLICY_PUBLICATION_WORKFLOW |
PolicyPublicationWorkflowV1 / stackship-policy-publication-workflow-v1 |
The orchestrator owns the Workflow class and must be deployed before the API dispatches policy builds. |
stackship-orchestrator |
REGISTRY_INGESTION_WORKFLOW |
RegistryIngestionWorkflowV1 / stackship-registry-ingestion-workflow-v1 |
The orchestrator owns the Workflow class and must be deployed before the API dispatches module-registry ingestion. |
stackship-orchestrator |
ARTIFACT_ORPHAN_SWEEP_WORKFLOW |
ArtifactOrphanSweepWorkflowV1 / stackship-artifact-orphan-sweep-workflow-v1 |
The orchestrator owns the daily, cursor-persisted R2 orphan sweep. It calls the API through CONTROL_PLANE; it does not receive an R2 binding. |
stackship-api |
RUN_WORKFLOW |
stackship-run-workflow-v1 |
Bind after the orchestrator has registered the Workflow. |
stackship-api |
SOURCE_INGESTION_WORKFLOW |
stackship-source-ingestion-workflow-v1 |
Bind after the orchestrator has registered the Workflow. |
stackship-api |
POLICY_PUBLICATION_WORKFLOW |
stackship-policy-publication-workflow-v1 |
Bind after the orchestrator has registered the Workflow. The API sends digest-bound completion events to this same instance. |
stackship-api |
REGISTRY_INGESTION_WORKFLOW |
stackship-registry-ingestion-workflow-v1 |
Bind after the orchestrator has registered the Workflow. |
For a new account, create R2, Hyperdrive, Analytics Engine, and Email Sending resources first. Then deploy the credential broker, apply database migrations, and deploy the one-time orchestrator bootstrap only when the orchestrator Worker does not exist. That bootstrap registers the same Workflow owners but has no cron trigger or API/credential-broker service bindings. Next deploy the API, the full orchestrator, and the API again if its Workflow bindings could not be resolved during the first pass. Deploy the dashboard, MCP server, and documentation last. Do not start a run or request policy publication until the API readiness check passes and all Workflow bindings resolve.
The production Daytona account must support unrestricted public outbound
network access with domainAllowList omitted. Bootstrap needs the official
OpenTofu and OPA GitHub release endpoints, and customer plan/apply operations
need representative registry and provider endpoints. Policy sandboxes use the
same unrestricted network setting; fixed OPA capabilities, sanitized inputs,
and the absence of customer cloud credentials are their execution boundaries.
Public egress does not provide connectivity to customer private networks.
The current Daytona organization exposes the required container class only in
us. Keep the production eu launch control disabled. Do not silently create
an EU workspace in US or count a US canary as EU evidence. Enabling EU requires
Daytona to expose the same class there, a successful daytona-medium
bootstrap, and a dedicated EU production-dark canary before the audited region
switch changes.
Migration 0019_thick_goliath is the immutable historical migration that
introduced runner_snapshot_name; never edit it. New databases apply it in
sequence before the regional migration. Existing installations retain its
logical version-and-digest evidence as the input to the next forward
migration.
Migration 0027_regional_runner_snapshot_names is a later forward-only
contract change. Before applying it, disable new runs, recovery dispatch,
applies, and policy publication; drain all nonterminal run and policy
execution; and export a stable inventory including execution_region. Install
the complete schema-v2 catalog and exact regional runner and policy allowlists,
then compare the database inventory again. Apply 0027 only when the two
inventories match. The migration appends each row’s persisted execution region
and replaces the database constraint so version, binary digest, logical
release digest, logical runner name, and region remain one tuple. The historical
runner_image_digest and runner_snapshot_name column names do not make those
compatibility values an OCI image or Daytona snapshot.
After 0027, rollback is roll-forward and schema-v2 only. Do not restore a
schema-v1 catalog, remove regional suffixes, or revert persisted rows. Keep
execution disabled while repairing the base-profile bootstrap, runner bundle,
or checksum-verified toolchain installation, or while installing a prior
known-good complete schema-v2 catalog and regional allowlists.
Migration 0028_policy_version_execution_identity is a writer-contract
cutover and requires a policy-publication maintenance window. An old API
version does not write policy_versions.workspace_settings_version_id; after
0028 makes that column NOT NULL, any policy version it creates fails. Do
not use the ordinary migrate-before-API deployment order for this migration
unless the publication endpoints are already fenced.
Use this order:
-
At API ingress, stop both policy-version creation routes:
POST /v1/policy-sets/{policySetId}/versionsandPOST /v1/workspaces/{workspaceId}/policy-sets/{policySetId}/versions. Drain already accepted policy publications, including their durable dispatch records and completion callbacks, while keeping both routes fenced. -
Run the following read-only preflight against the same database and search path that the migration will use. It must return no rows:
SELECT policy_version.organization_id, policy_version.public_id, policy_version.source_artifact_id FROM policy_versions AS policy_version WHERE NOT EXISTS ( SELECT 1 FROM artifacts AS artifact INNER JOIN workspaces AS workspace ON workspace.organization_id = artifact.organization_id AND workspace.id = artifact.workspace_id INNER JOIN workspace_settings_versions AS settings ON settings.organization_id = workspace.organization_id AND settings.workspace_id = workspace.id WHERE artifact.organization_id = policy_version.organization_id AND artifact.public_id = policy_version.source_artifact_id AND settings.created_at <= policy_version.created_at ) ORDER BY policy_version.organization_id, policy_version.public_id;If it returns any row, stop and repair the unresolved historical identity. The migration fails closed with
POLICY_VERSION_EXECUTION_IDENTITY_BACKFILL_FAILEDand rolls back instead of guessing. -
Apply
0028while the old writer remains fenced. The backfill chooses the latest tenant- and workspace-matching settings row at or before policy creation, breaking timestamp ties by settings version and then UUID. -
Deploy the API build that always writes the pinned settings identity, pass readiness checks, and only then remove the ingress fence.
After 0028, do not roll the API back to a writer from before this contract.
Database rollback is roll-forward: keep publication fenced and deploy a
compatible writer. Reads and unrelated execution can remain available during
the cutover.
Migration 0043_opentofu_open_versions opens the OpenTofu version set. It
creates the opentofu_toolchains pin table seeded with the three launch
releases, replaces the closed IN-list workspace-settings constraint with a
format constraint, and removes the hard-coded (version, sha256) pair table
from runs_digests_valid while keeping the snapshot-name derivation
identity. No rows change, and the previous three-version code still satisfies
the new constraints, so the ordinary migrate-before-API order applies. Deploy
the API, orchestrator, and web builds together with the schema-v3
RUNNER_SNAPSHOT_CATALOG secret rotation: schema-v2 documents are rejected
by the new code and schema-v3 documents by the old code. Rollback before the
catalog rotation is a normal redeploy; after it, restore the schema-v2
secret together with the previous build. To admit newly pinned versions
without per-version allowlist entries, add the full sha256: release digest
to the launch-control runner snapshot allowlist; retained exact regional
names keep working.
Transactional email
In Compute → Email Service → Email Sending, onboard stackship.run and
publish every Cloudflare-provided bounce MX, SPF, DKIM, and DMARC record.
Wait until the sending domain and all authentication records show as verified
before exercising an auth flow.
The API Worker exposes only the restricted EMAIL binding and may send only
from no-reply@stackship.run. Do not create an SMTP credential or a long-lived
Email Service API token for application code. Remove development recipient
restrictions before production canaries so account mail can reach arbitrary
users.
Disable Email preview in the sending-domain settings before sending any verification or reset canary. New Cloudflare sending domains may enable preview by default; when enabled, rendered bodies and bearer links are retained in the Email Service activity log for approximately seven days.
Deploy
pnpm install --frozen-lockfile
pnpm verify:bindings
pnpm verify:github-workflows
pnpm verify:workers
pnpm check
pnpm test
pnpm buildThose commands build and verify the candidate without changing production.
Production deploys use the protected workflow, which serializes the credential
broker, database migration, API, Workflow owner, API binding refresh, MCP,
OpenNext dashboard, and Nimbus documentation. Do not use the root
pnpm deploy command for production: Turbo may schedule independent Worker
owners concurrently.
The dashboard keeps OpenNext’s asset-first routing so Cloudflare Static
Assets serves static requests before the Worker route. Its checked-in
public/_headers file must be present in .open-next/assets/_headers after
the OpenNext build.
Validate the security-header baseline on both a prerendered page and an
immutable /_next/static/* asset; the static manifest must not contain a
document Content Security Policy.
The documentation Worker is an assets-only Nimbus deployment. Its checked-in
apps/docs/public/_headers file must be present as
apps/docs/dist/_headers after the Astro build. Validate the security-header
baseline on both a rendered documentation page and a static asset; the
manifest intentionally uses Referrer-Policy: no-referrer and must not contain
a document Content Security Policy.
The API and orchestrator are one provenance-coupled execution release. Their supported repository deployment entry point is:
STACKSHIP_BUILD_SHA="$(git rev-parse HEAD)" pnpm deploy:execution-workersThe wrapper requires a lowercase 40-character Git commit SHA, requires it to
equal the checked-out HEAD, and rejects staged, unstaged, or untracked
worktree changes.
It deploys API, orchestrator, then API again with one identical
APPLICATION_BUILD_SHA, --strict, and --keep-vars. The final API pass
refreshes Workflow bindings after the owner is installed. The protected
workflow supplies ${{ github.sha }} as STACKSHIP_BUILD_SHA and calls this
same wrapper.
pnpm deploy:api and pnpm deploy:orchestrator use the same validation for a
bounded recovery operation, but they do not constitute a complete execution
release. Do not bypass the wrapper with pnpm --filter ... deploy or bare
Wrangler: a caller that omits the candidate SHA can preserve an old value or
erase the non-configured production variable, desynchronizing durable
Workflow provenance from API dispatch evidence. Cloudflare and database
credentials remain environment-only and are never command arguments or
wrapper output.
The first basic deployment can ship public health surfaces before execution is
enabled. In that state, auth or readiness failures return 503, MCP returns
401 without a valid audience-bound token, and managed runs fail closed.
Quiesced execution-contract cutover
An execution release that changes the runner manifest, result, session, or
state-publication contract without supporting both deployed contract shapes
must use a quiesced cutover. Before the first Worker deployment, replace the
production launch control through its audited compare-and-swap interface with
both newRunsEnabled and applyEnabled set to false, while preserving the
complete prior control value for restoration.
Reject or otherwise terminalize every Workflow created by the previous
execution release, then verify that no old run Workflow is waiting or running
and that no runner attempt remains active. Only then run
deploy:execution-workers, verify that the API and orchestrator expose the
same expected APPLICATION_BUILD_SHA, and restore the exact prior launch
control through another audited compare-and-swap mutation. If draining,
deployment, provenance verification, or restoration fails, keep execution
disabled and follow the recovery runbook. Never rely on an API-first mixed
version interval to make an incompatible execution contract safe.
When the same release adds a field to a strict dashboard response schema, make the new reader accept the field as optional and retain a fail-closed fallback for an old API. Deploy and verify that dashboard reader first, then perform the quiesced API and orchestrator cutover that starts returning the field. Do not deploy a strict API writer before every public reader accepts its response shape.
Release delivery gate
Pull requests and main run the pinned GitHub Actions CI workflow with a
frozen pnpm install, generated API and deployment-boundary checks, full
typecheck/test/build, real PostgreSQL transaction tests, focused
GitHub/policy/speculative/fault/security/canary suites, pinned browser and
OpenTofu gates, accessibility tests, the OpenTofu matrix, Nimbus lint, and
patch-hygiene validation. These checks validate the external-canary harness
but do not claim that external services passed.
Production deployment is manual and uses GitHub’s protected production
environment. Every dispatch explicitly checks out github.sha and runs the
complete release gate again in the protected deployment job. It does not trust
a prior CI result, reuse a different revision’s build, or delegate validation
to another job. Before the first production mutation, that same job performs:
- OpenAPI, binding, email-domain, GitHub-workflow, and Worker build-boundary verification.
- Full typecheck, test, and build.
- The critical transaction suite against its own healthy PostgreSQL 17 service.
- The focused GitHub, policy, speculative, fault, security, and canary suites.
- Checksum-verified OpenTofu 1.12.5 and OPA 1.18.2 installation, the real
rooted-bundle compatibility gate, browser and accessibility gates, the
native OpenTofu matrix, Nimbus lint, and
git diff --check.
The OPA compatibility test is optional for ordinary local unit-test runs.
Release workflows set STACKSHIP_OPA_REQUIRED=1, install the exact pinned
Linux binary after verifying its SHA-256 digest, and export its absolute path
as STACKSHIP_OPA_BINARY. A missing binary, checksum mismatch, invalid rooted
bundle, unredacted policy input, or unexpected decision fails the release
before deployment.
The workflow has a 60-minute timeout; a failed or timed-out validation step prevents every delivery command from starting.
Store the Cloudflare account ID, least-privilege Cloudflare API token, and
direct TLS-enforced PlanetScale DATABASE_URL only as protected environment
secrets. The workflow passes the Cloudflare credentials only to Worker
delivery steps and DATABASE_URL only to the migration step; validation and
certification steps receive none of them. A dispatch with certify=false
orders the credential broker, reviewed Drizzle migrations, the optional
fresh-account Workflow bootstrap, API, full Workflow owner, API binding
refresh, then public read surfaces.
The API and full Workflow-owner deployments both receive the exact
APPLICATION_BUILD_SHA. The API persists that value as expected
pre-dispatch evidence. At its first durable step, each source-ingestion and
run Workflow reports the build SHA from its own Worker environment through
the authenticated internal service binding. The database stores that observed
execution SHA separately and rejects a mismatch before source fetch or
sandbox execution.
When a run reports a mismatched build, the API also finalizes that unstarted
run as a terminal plan failure before it returns the conflict. The Workflow
still fails closed, while OpenTofu receives a terminal Cloud run instead of
polling an orphaned plan_queued run indefinitely.
The checked-in bootstrap executor first lists deployments for the exact
stackship-orchestrator Worker. A successful JSON array, empty or non-empty,
means no bootstrap. Only process exit 1 with Cloudflare code 10007, the
exact orchestrator deployments endpoint, and Cloudflare’s missing-Worker
message permits one restricted bootstrap deployment. Authentication,
permission, network, rate-limit, malformed-output, unknown-code, and bootstrap
deployment failures stop the release without a fallback or retry.
A dispatch with certify=true runs the same complete, exact-SHA release gate
and then only exact-SHA canary and readiness validation; it never deploys a
Worker, applies a database or Durable Object migration, or creates a Worker
version ID. Neither path changes the server-side public launch control.
After the dark deployment, an operator adds only dedicated canary entries to
darkCanaryWorkspaceAllowlist through the audited, compare-and-swap
launch-control API. Each entry is the exact object
{ "organizationId": "org_<32 lowercase hex>", "workspaceId": "ws_<32 lowercase hex>" };
workspace public IDs are organization-local, so a ws_ value alone is never
an authorization boundary. publicLaunchEnabled remains false. Only those
exact organization-and-workspace pairs may cross the public gate; new-run,
apply, federation-provider, Daytona-region, suspension, capacity, and
logical runner release checks still apply at every phase. The list accepts at
most 16 unique pairs and rejects malformed entries, duplicate pairs, and
unknown entry fields.
The operator then dispatches the protected, manual
production-dark-canary.yml workflow for the exact deployed SHA. It is
serialized, has a 60-minute timeout, checks out exactly github.sha, and has
only repository contents: read permission. Protected environment
configuration currently identifies one dedicated branch, pre-existing nonce
file, and workspace in Daytona us. EU canary configuration remains absent
while that region is launch-disabled. Step-scoped secrets provide a
canary-tenant StackShip token with only workspace/state read and run
read/approve, a disposable-repository GitHub token with contents-update and
Checks-read authority, a least-privilege Daytona list credential, and the
existing federation/email operator attestations.
Updating each pre-existing nonce file is the only canary mutation outside
StackShip’s normal run lifecycle. The GitHub App webhook is the only run
trigger. The harness does not create runs directly and does not call internal
Workflow, internal service, deployment, migration, or launch-control
endpoints. Through bounded public responses it reads the starting state
serial, discovers the exact run ID only from the new commit’s public GitHub
Check external_id, then polls only /v1/runs/{runId}/review. It observes the
exact ready source ingestion and separately attested source/run Workflow
execution SHAs. Before approval and again at terminal observation, the review
must report the configured workspace ID and execution region. The harness
approves the exact binding only after the first identity check and waits for
the real apply. It never requests logs, state bodies, artifacts, or source
archives.
Version 2 evidence contains one cross-linked execution record for every
enabled Daytona region. Each record requires the exact source commit, both
observed Workflow execution SHAs equal to the candidate, exactly one apply
process start derived from persisted runner start-grant evidence, state serial
exactly +1, and one successful StackShip GitHub Check whose external_id is
the run ID. The harness polls exact run-labelled Daytona inventory until it is
empty or the shared deadline expires, using the execution region verified from
the terminal run review. Evidence records that verified workspace and region;
it does not derive them from display labels. The workflow validates that
exact-build document before artifact upload.
AWS/GCP/Azure federation and Cloudflare Email fields retain their existing
operator-attested semantics until dedicated live harnesses exist: short-lived
credentials, deleted disposable resources, two email deliveries, disabled
Email preview, and absence of bearer links from prohibited stores remain
required. Missing or stale evidence fails closed.
After collecting evidence, remove every canary
organization-and-workspace pair from the allowlist with a second audited
mutation and confirm publicLaunchEnabled is still false before signing
readiness. The canary Action never performs that control mutation.
See the repository deployment and transactional-email runbooks for rollback, evidence fields, and the separately audited first-release activation procedure.
The repository incident runbooks also cover state and fence recovery, uncertain apply containment, Daytona regional outages and sandbox leaks, signing/HMAC and wrapping-key rotation limits, GitHub App compromise, and policy evaluator incidents. These procedures preserve reads and recovery, never retry apply, never fail over a pinned Daytona region, and never weaken policy or identity checks.
Production checks
GET https://api.stackship.run/healthchecks Worker liveness and configured bindings.GET https://api.stackship.run/readyfirst requires the complete identity configuration as one coarse, fail-closed gate: Better Auth, the Google client pair, Cloudflare Email, and an independent cloud-token HMAC key of at least 32 UTF-8 bytes. Managed-run authentication additionally requires a distinctRUNNER_RUN_TOKEN_HMAC_KEYof at least 32 UTF-8 bytes. It then connects through Hyperdrive and checks the core Better Auth tables plus therunner_sessions.run_token_digestmigration. It returns503withidentity: configuration_requiredfor an incomplete identity boundary, orschema: migration_requireduntil the generated migration is applied. It never names a missing secret or returns configuration values.GET https://stackship.run/.well-known/terraform.jsonandGET https://api.stackship.run/.well-known/terraform.jsonreturn the OpenTofu service-discovery document. Both must advertisemodules.v1athttps://api.stackship.run/registry/modules/v1/for the private module registry, alongside the existing login and/api/v2keys.GET https://mcp.stackship.run/healthchecks MCP Worker liveness.GET https://mcp.stackship.run/.well-known/oauth-protected-resourcereturns the OAuth protected-resource metadata.
Before any public activation, confirm the production launch_controls row
exists, publicLaunchEnabled remains false, and
darkCanaryWorkspaceAllowlist is empty. Confirm the us Daytona region is
enabled, eu is disabled, and a private daytona-medium sandbox in US can
upload the reviewed runner bundles, install the checksum-verified toolchain,
execute the canary, and clean up. Exercise the
new-run disable,
apply disable,
regional disable,
and sandbox leak
runbooks in staging. Production activation is a later separately audited
mutation after the readiness record is signed.