Deploying
This page covers what changes when your services run in a cloud instead of on your machine. Set up your cloud first, as in Infrastructure. To run the whole stack on Vercel instead, see Deploy to Vercel.
Run on your cloud
Section titled “Run on your cloud”The framework defines the messaging and storage contracts as ports in
@evolve-framework/core. Each cloud has an adapter package that implements
them. You construct the adapter for your cloud and pass it to the module or
service that needs it.
| Cloud | Package | Publishers | Storage |
|---|---|---|---|
| AWS | @evolve-framework/cloud-adapter-aws |
SqsPublisher, EventBridgePublisher |
AwsS3StorageClient |
| Azure | @evolve-framework/cloud-adapter-azure |
ServiceBusPublisher, EventGridPublisher |
AzureBlobServiceStorageClient |
| Google Cloud | @evolve-framework/cloud-adapter-gcp |
PubSubPublisher |
GoogleCloudStorageClient |
| Local | @evolve-framework/core |
RedisPublisher, NoopPublisher |
LocalStorageClient |
The adapter packages also contain the entry points that deliver events to your handlers, and configuration sources that read settings from the cloud’s own store. For the entry points, see Deliver events to your handler.
Publish events
Section titled “Publish events”A module that publishes internal events, such as ContentfulModule, takes a
messagePublisher option. Construct the publisher for your cloud.
import { NoopPublisher, RedisPublisher } from "@evolve-framework/core/messaging";
const messagePublisher = new RedisPublisher("redis://localhost:6379/internal-events");// Or, to publish nothing:const disabled = new NoopPublisher();RedisPublisher pushes each message onto a Redis list, which
startDevelopmentListener reads. NoopPublisher logs each message and drops
it.
import { PubSubPublisher } from "@evolve-framework/cloud-adapter-gcp";
const messagePublisher = new PubSubPublisher("projects/my-project/topics/internal-events");The publisher uses the Pub/Sub client’s default credentials. It sends type
and origin as message attributes and messageGroupId as the ordering key.
import { EventBridgePublisher, SqsPublisher } from "@evolve-framework/cloud-adapter-aws";
const toQueue = new SqsPublisher("https://sqs.eu-west-1.amazonaws.com/123456789012/internal-events");const toBus = new EventBridgePublisher("arn:aws:events:eu-west-1:123456789012:event-bus/internal-events");Both use the AWS SDK’s default credentials and region.
SqsPublishertakes a queue URL. It sendstypeandoriginas message attributes andmessageGroupIdas theMessageGroupId.EventBridgePublishertakes an event bus ARN. It sends the event asDetail,typeasDetailTypeandoriginasSource.
import { EventGridPublisher, ServiceBusPublisher } from "@evolve-framework/cloud-adapter-azure";
const toQueue = new ServiceBusPublisher("https://my-namespace.servicebus.windows.net/internal-events");const toTopic = new EventGridPublisher("https://my-topic.westeurope-1.eventgrid.azure.net/api/events");ServiceBusPublishertakes a queue URL and signs in withDefaultAzureCredential. It also takes a connection string that includesEntityPath. It keeps a sender open, so callclose()when your service shuts down.EventGridPublishertakes a topic endpoint, signs in withDefaultAzureCredentialand sends CloudEvents. It logs a warning whenAZURE_CLIENT_IDis not set.
To pick the publisher per environment, resolve it from a setting. The framework has no resolver for this, so write a small one in your project:
import { SqsPublisher } from "@evolve-framework/cloud-adapter-aws";import { isRedisQueue, type MessagePublisher, NoopPublisher, RedisPublisher,} from "@evolve-framework/core/messaging";
export const resolvePublisher = (target: string | undefined): MessagePublisher => { if (!target) { return new NoopPublisher(); } if (isRedisQueue(target)) { return new RedisPublisher(target); } return new SqsPublisher(target);};Store files
Section titled “Store files”Some modules write files, such as sitemaps. They take a
StorageClient, which has one
method: save(bucket, key, data, options). For example, SitemapWriter takes
one as its storage option.
import { LocalStorageClient } from "@evolve-framework/core/storage";
const storage = new LocalStorageClient("./tmp/storage");A file is written to <path>/<bucket>/<key>. Without a path, the client
writes under the current working directory.
configureStorageClient
returns this client for a target that starts with / or ./, and throws for
any other target.
import { GoogleCloudStorageClient } from "@evolve-framework/cloud-adapter-gcp";
const storage = new GoogleCloudStorageClient();bucket is the Cloud Storage bucket. The client uses the default credentials.
import { AwsS3StorageClient } from "@evolve-framework/cloud-adapter-aws";
const storage = new AwsS3StorageClient();bucket is the S3 bucket. The client uses the AWS SDK’s default credentials
and region.
import { AzureBlobServiceStorageClient } from "@evolve-framework/cloud-adapter-azure";
const storage = new AzureBlobServiceStorageClient("https://myaccount.blob.core.windows.net");The client takes the Blob service URL and signs in with
DefaultAzureCredential. bucket is the container.
Only AzureBlobServiceStorageClient applies the ContentType option. The
other clients ignore it.
Run behind AWS Lambda
Section titled “Run behind AWS Lambda”To serve your Application from a Lambda function instead of a long-running
server, wrap its Fastify instance with
createHttpLambdaHandler:
import { createHttpLambdaHandler, lambdaHandlerFactory } from "@evolve-framework/cloud-adapter-aws";import { createApplication } from "@evolve-framework/core";import { applicationOptions } from "./application.ts";
const app = await createApplication(applicationOptions);
export const handler = lambdaHandlerFactory("purchase", () => createHttpLambdaHandler(app.fastify));applicationOptionsare the options your service already passes tocreateApplication.createApplicationinitializes the application. Do not callstart(): Lambda invokes the handler, so the application does not listen on a port. Thehttp.addressoption is still required, but it is not used.lambdaHandlerFactorystarts observability and runs each invocation in a trace span that carries the name you pass it.- For binary responses and load balancers with multi-value headers, see the
options of
createHttpLambdaHandler.
Environments
Section titled “Environments”The reference sites deploy one environment, tst. Each environment you add
gets its own copy of four things:
| What | Where in the reference sites |
|---|---|
| The setup of your vendors and cloud resources, in Terraform | configuration/, with the secrets in configuration/<env>.secrets.yaml |
| What runs, and with which environment variables | deploy/<env>.backend.yaml and deploy/<env>.site.yaml |
| The secrets the pipeline uses to deploy | A GitHub environment of the same name, which the deploy job runs in |
| The name on traces and errors | The ENVIRONMENT variable on every service |
The deploy job takes a concurrency group per environment, without
cancel-in-progress: two releases close together roll out in order, and a
rollout is never cut off halfway.
Leave the test-support service and SIMULATION_ENABLED out of production.
See Testing.
Secrets
Section titled “Secrets”A secret lives in one of three places, depending on who reads it.
| Who reads it | Where it is | How it gets there |
|---|---|---|
| You, running locally | .env |
Decrypted from .env.encrypted with SOPS: task env-decrypt |
| Terraform, to set up vendors and the cloud | configuration/<env>.secrets.yaml |
Encrypted with SOPS in the repository |
| Your services, at runtime | The cloud’s secret store | Written by Terraform from the file above |
In the repository
Section titled “In the repository”The reference sites encrypt secrets with SOPS
and commit them. .sops.yaml sets which values are encrypted: in a
*.secrets.yaml file, the keys stay readable and only the values matching
encrypted_regex are encrypted, so a diff shows which secret changed.
Terraform reads the file with the carlpett/sops provider:
data "local_file" "variables" { filename = "tst.secrets.yaml"}
data "sops_external" "variables" { source = data.local_file.variables.content input_type = "yaml"}A resource then reads one value, such as
data.sops_external.variables.data["secrets.commercetools.client_secret"].
Encrypt the file with your cloud’s key service, so that whoever may run
Terraform may decrypt it. The B2B site uses a key in Azure Key Vault, and has
task tst:decrypt and task tst:encrypt in configuration/Taskfile.yaml to
edit it. The B2C site uses Google Cloud KMS.
At runtime
Section titled “At runtime”Terraform writes each secret into the cloud’s secret store, and the
configuration around it into the cloud’s configuration store. The
configuration holds a reference to each secret, never its value. A service
that loads its settings with @evolve-framework/config reads the
configuration at startup, and the store resolves the references:
| Google Cloud (B2C) | Azure (B2B) | |
|---|---|---|
| Secrets | Secret Manager | Key Vault |
| Configuration | Parameter Manager: one JSON parameter per service | App Configuration: keys under one prefix per service |
| Source | gcpParameterManagerSource |
azureAppConfigSource |
| Set on the container | PARAMETER_MANAGER_PROJECT, PARAMETER_MANAGER_PARAMETER |
APP_CONFIG_ENDPOINT, APP_CONFIG_PREFIX |
Rotating a secret then needs no release. The secret’s value is not in the deploy config and not on the container.
Two kinds of values cannot come from that store:
- Values read before the configuration loads. Observability starts first,
so
SENTRY_DSN,ENVIRONMENT, and theOTEL_*variables are set on the container. See Observability. - Services that do not use
@evolve-framework/config, such as the gateway and the Next.js site. They get their whole environment from the deploy config, below.
The deploy config
Section titled “The deploy config”The reference sites deploy with evolve-deploy, a command-line tool from the
Evolve platform. It reads one YAML file per environment and part, compares it
with what the cloud runs, and rolls out the difference. It keeps no state of
its own, so running it twice changes nothing the second time.
The work is split:
- evolve-deploy sets the image tag, the container’s environment, and the container’s command.
- Terraform owns everything else: the services themselves, scaling, ingress, identity, and the values it publishes to the stores.
A shortened deploy/tst.backend.yaml from the B2C site:
cloud: provider: gcp project: evolve-tst region: europe-west4
strategy: type: blue-green smoke: - uses: http with: { url: '{{url_stage "graphql-gateway"}}/health', retry: 5 }
services: purchase: version: c8fd147 env: PARAMETER_MANAGER_PROJECT: evolve-tst PARAMETER_MANAGER_PARAMETER: purchase ENVIRONMENT: tst SENTRY_DSN: ${param:purchase-sentry-dsn} OTEL_EXPORTER_OTLP_HEADERS: ${secret:purchase-otel-exporter-otlp-headers} before: - >- hive schema:check backend/purchase/schema.generated.graphql --target <your-hive-target> --service purchase --commit {{.version}} --github after: - >- hive schema:publish backend/purchase/schema.generated.graphql --target <your-hive-target> --service purchase --commit {{.version}} --url {{url "cloud-run/purchase" }}/graphql targets: - { type: cloud-run, name: purchase } - type: cloud-run-job name: purchase-sweep-e2e command: ["node", "src/cli.ts", "sweep"]cloudnames where the services run. On Azure it names the subscription, the resource group, and the App Configuration store.versionis the image tag. The committed versions are placeholders: CI passes the version it just built with--set.envis the container’s whole environment.${param:<name>}reads a value from the configuration store and${secret:<name>}names a secret in the secret store; the config never holds a secret’s value.envFromreads a whole environment from one parameter, which is how the gateway and the site get theirs.targetsare what runs this service’s image: the service itself, and jobs and event workers that run the same image with anothercommand. The B2B site’s types arecontainer-appandcontainer-app-job.beforeandafterare hooks: a shell command, or a named action such asuses: honeycombfor a deploy marker oruses: sentryfor a release.{{.version}}is the version being deployed.
The environment the config gives a target is complete. When the config stops
naming a variable the target has, apply refuses to drop it unless you pass
--allow-env-removal.
evolve-deploy diff deploy/tst.backend.yaml # what would change, changes nothingevolve-deploy apply deploy/tst.backend.yaml # roll it outevolve-deploy apply deploy/tst.site.yaml --only site --set site=27ec167evolve-deploy traffic deploy/tst.site.yaml # which side serves whatIn CI, the reference sites install a pinned version with the
evolve-platform/setup-evolve-deploy action. Each release passes --only
with the services the commit built, so a service the commit did not change
keeps running what it runs.
Check and publish the schema
Section titled “Check and publish the schema”The gateway reads its supergraph from Hive, so a subgraph’s schema change reaches it through Hive and not through an image. Each subgraph has two hooks:
hive schema:check, before. Everybeforehook in a run runs before anything is written. One that fails deploys nothing at all, so a breaking schema change never reaches the cloud.hive schema:publish, after. It runs once the new version serves traffic, with--urlset to the subgraph’s address. Publishing is what makes the gateway route to the new schema.
The site works the other way round. Its before hooks register the
operations the new build sends, with hive app:create and hive app:publish,
so the gateway accepts them before the new site goes live.
Blue-green with smoke checks
Section titled “Blue-green with smoke checks”Every service is released blue-green. Each has two labels, blue and
green. evolve-deploy:
- Stages the new version on the label that carries no traffic.
- Runs the
smokechecks against the staged version’s own address,{{url_stage "<service>"}}. - Switches all traffic to it in one write.
The previous version keeps running at 0%, which is what makes a rollback fast. It also means two versions run against the same commerce engine at the same time, so make changes to data and custom types in two steps: add first, remove in a later release.
The backend smoke check in the reference sites goes through the gateway:
deploy/backend-smoke-test.sh sends the federated health query and passes
only when every service answers ok: true. The router’s own /health only
shows that the router is up. The query also reaches a service with internal
ingress that the CI runner cannot call. See
Check health.
Roll back
Section titled “Roll back”To go back to the version that served before the last release, move the traffic to the other label:
evolve-deploy traffic deploy/tst.site.yaml # which label serves whatevolve-deploy traffic deploy/tst.site.yaml --only site --to blue # serve blue againThis is one write and starts nothing new: the previous version is still running.
- Make it last. The next release deploys over the old version again. Fix the cause, or revert the commit that caused it, and release that.
- The gateway is not built by the reference sites. Its version is pinned in the deploy config, so reverting the commit that moved the pin and releasing rolls it back.
- The schema. The version you leave published its subgraph schema to Hive
in its
afterhook. If it changed the schema, check which schema Hive serves after you move the traffic. - A failed
afterhook rolls nothing back. By then, the new version serves traffic.
A release whose smoke checks fail does not need a rollback: the traffic never moved.
What your cloud needs before the first release, from Redis to the load balancer, is in Infrastructure.

