Skip to content

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.

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.

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.

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);
};

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.

Only AzureBlobServiceStorageClient applies the ContentType option. The other clients ignore it.

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));
  • applicationOptions are the options your service already passes to createApplication.
  • createApplication initializes the application. Do not call start(): Lambda invokes the handler, so the application does not listen on a port. The http.address option is still required, but it is not used.
  • lambdaHandlerFactory starts 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.

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.

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

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.

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 the OTEL_* 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 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"]
  • cloud names where the services run. On Azure it names the subscription, the resource group, and the App Configuration store.
  • version is the image tag. The committed versions are placeholders: CI passes the version it just built with --set.
  • env is 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. envFrom reads a whole environment from one parameter, which is how the gateway and the site get theirs.
  • targets are what runs this service’s image: the service itself, and jobs and event workers that run the same image with another command. The B2B site’s types are container-app and container-app-job.
  • before and after are hooks: a shell command, or a named action such as uses: honeycomb for a deploy marker or uses: sentry for 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 nothing
evolve-deploy apply deploy/tst.backend.yaml # roll it out
evolve-deploy apply deploy/tst.site.yaml --only site --set site=27ec167
evolve-deploy traffic deploy/tst.site.yaml # which side serves what

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

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:

  1. hive schema:check, before. Every before hook in a run runs before anything is written. One that fails deploys nothing at all, so a breaking schema change never reaches the cloud.
  2. hive schema:publish, after. It runs once the new version serves traffic, with --url set 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.

Every service is released blue-green. Each has two labels, blue and green. evolve-deploy:

  1. Stages the new version on the label that carries no traffic.
  2. Runs the smoke checks against the staged version’s own address, {{url_stage "<service>"}}.
  3. 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.

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 what
evolve-deploy traffic deploy/tst.site.yaml --only site --to blue # serve blue again

This 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 after hook. If it changed the schema, check which schema Hive serves after you move the traffic.
  • A failed after hook 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.