Two topologies
Multi-service. The web/gateway, each runtime role, the sync layer, the durable-execution server, the integration provider and the channel adapters run as separate services behind a load balancer, with managed Postgres and managed Redis, and shared network storage for the runtime data directory and the sync replica. Services scale independently. Single-host. The whole stack runs on one machine under a container compose file, with a reverse proxy terminating TLS in front of it and the gateway published only on127.0.0.1:3000. Redis runs as a container instead of a managed service, host directories replace network storage, and a single sync process replaces the replication-manager and view-syncer split.
Both shapes are wired by the same service graph, which is why routing behaves identically. Beyond routing they are genuinely different topologies. The multi-service shape buys independent scaling and managed data services; the single host buys one instance whose entire definition is a compose file and an environment file, at the cost of scaling any part of it separately. Connector coverage is the same on both — it comes from the checked-in provider catalog, not from the topology.
The repository’s own guidance treats an in-place rollout on the single host as the default for routine application fixes, and says not to reach for the full infrastructure path unless an operator explicitly asks for it.
Addressing
How one service reaches another is deployment-defined — service discovery in the multi-service shape, compose service names on a single host. What is not deployment-defined is the routing table: a single declarative service graph fixes the route prefix, internal port, exposure and health path for every service, which is why local and deployed behave the same. See Processes, roles, and routing for that table. The endpoints you call yourself are few:
The web application answers
GET /health with {"ok":true}, and HEAD /health with an empty 200. Private services in the graph — the runtime’s own prefix, the durable-execution server and the sandbox controller — answer a bare 404 Not found to public requests, so a 404 there is the gateway working, not a broken service.
What the preflight enforces
The deploy preflight is a hard gate, run as its own step before any image is built:
If the container daemon is not running, the failure line starts with
Docker daemon check failed., repeats the daemon’s own first error line, and ends with this hint:
[preflight] Environment is ready. A failing run prints [preflight] Environment is not ready: followed by one bullet per unmet requirement.
Promoting an existing release skips this step along with the image build, because there is nothing left to build. The release rules below still apply.
Public origin rules
Set the public origin before you deploy, not after. Better Auth, the sync layer, Receipt Connect, the integration provider and the channel adapters all derive URLs from it, and they must agree.- The deploy refuses to start without one. You give it either a public domain or the full public base URL; a missing value fails with
RECEIPT_PUBLIC_DOMAIN or RECEIPT_PUBLIC_BASE_URL is required for AWS deploys. - The canonical base URL is validated. It must be HTTPS.
localhostis rejected. So is a CDN’s default hostname — put your own domain in front instead. - If your DNS is not managed in the deploy account, issue and validate the certificate yourself and point the deploy at it with
RECEIPT_PUBLIC_DOMAIN_CERT_ARN. - Trusted origins are validated strictly. The canonical origin must be an exact HTTP or HTTPS origin with no credentials and no path, query or fragment. Every legacy origin you list in
RECEIPT_LEGACY_PUBLIC_ORIGINSmust additionally be HTTPS with no wildcard host.
The secrets a deploy needs
The deploy pipeline reads a secrets bundle from a secret store you control and pushes images to a container registry you control. Described by role, the bundle must carry:
Optional connector credentials — the per-connector OAuth application pairs and the channel-adapter credentials — live in the same bundle. Missing optional values do not block a deploy; they print
Optional secret values are empty: <names>. Related integrations may not work.
Every base64 key in the bundle is length-validated before upload: it must decode to exactly 32 bytes, or the loader refuses it with
<name> must be a base64-encoded 32 byte key. The provider’s environment secret key is validated to be a UUID v4 in the same pass.Both AES keys are irreversible once data exists. Configuration, secrets, and keys explains why rotating one destroys what it protects.Release discipline worth copying
Four rules the repository enforces on itself, worth adopting whatever you deploy onto.Immutable image references only
Immutable image references only
A release manifest is validated before it is applied, and any mutable reference is rejected:A
:latest tag fails. So does a build-asset reference. If you cannot name the exact image, you cannot deploy it.A manifest that pins every image to a full commit
A manifest that pins every image to a full commit
The manifest is a versioned document carrying the release tag, the full 40-character commit SHA, the git ref, the stage it was built for, where the source archive came from, when it was created, and one immutable reference per service image.Applying a manifest turns off image building entirely — the deploy promotes exactly those references rather than producing new ones. A production deploy refuses to run without a release tag or a manifest location: it must promote an immutable release, never rebuild one.
A guard that refuses to deploy an unpushed commit
A guard that refuses to deploy an unpushed commit
The launcher refuses a detached HEAD, fetches the remote ref, and requires the commit being deployed to be an ancestor of it. The comment in the code records why: production once ran an unpushed local commit while the remote branch was behind, and that commit was later lost.There is an override flag. Treat needing it as a signal, not a step.
Migrations are a gated deploy step
Migrations are a gated deploy step
On the multi-service shape, the postdeploy phase launches a one-off task from the gateway task definition whose command is the migration runner, waits for it to stop, and fails the deploy on a non-zero exit — before it waits for services to become stable. The infrastructure deploy has already rolled the services by the time that task runs, so the migration gates whether the deploy succeeds, not the rollout itself.On the single-host shape, migrations run at host bootstrap, and again — ahead of the containers being recreated — on every rollout that changes the gateway image.See Postgres, migrations, and the sync publication.
AUTH_EMAIL_PROVIDER=ses and VITE_DISABLE_EMAIL_VERIFICATION_OTP=false, and it checks both production sending access and a verified sender identity before it will deploy. An account still inside the provider’s sandbox fails that check — move it out first.
On a self-hosted build with email disabled, organization invitations fall back to copyable signup links in the invite dialog rather than failing.
Before you call it production
The infrastructure is more careful elsewhere, which shows what the target looks like: runtime services get no cloud permissions at all, except the two worker roles that start, stop and describe the sandbox host; the sandbox instance role gets registry read-only, managed-instance access and read on exactly one parameter; the single-host instance role gets those same two managed policies plus an inline policy covering one parameter, two buckets, instance start/stop and describe, and sending mail through identities in its own account.Rollback
Rollback is re-promotion, not rebuilding.
Next step: check that a deployment is actually up — the endpoints, logs and traces that tell you whether the release you just promoted is serving.