# Run your Compose app

rystic compose runs your own Docker Compose app against simulators in one command — it finds the vendors your app calls, pins a simulator for each in rystic.yaml, routes every vendor call to them, seeds them and reports what they couldn't do.

`rystic compose` runs your own Docker Compose app against [simulators](https://www.rystic.ai/docs/concepts/glossary.md#simulator) in one command. It finds the vendors your app calls, pins a simulator for each in `rystic.yaml`, and starts your services and the simulators together. Every call your app makes to a vendor host one of those simulators covers reaches it, with no code change. Its subcommands are `docker compose`'s, and any it doesn't know pass through.

It needs Docker Engine 26 or later and Docker Compose 2.24 or later, and uses your license or API key ([Installation](https://www.rystic.ai/docs/installation.md#install)).

## Quick start

An app that uses Stripe, with a `build:` key in its Compose file:

```yaml
# compose.yaml
services:
  web:
    build: .
    ports: ["3000:3000"]
```

```bash
# requirements.txt holds stripe, .env.example names STRIPE_SECRET_KEY
rystic compose up
# -> added stripe 0.0.28 (found STRIPE_SECRET_KEY in .env.example; found "stripe" in requirements.txt)
# -> wrote rystic.yaml: commit it with your app
# -> simulators (listed in rystic.yaml):
# ->   stripe: stripe-twin 0.0.28, answers its vendor's hosts, webhook delivery on
# -> apps wired to the simulators (your services with a build: key): web
# -> network: the default network has no route out; reach the simulators and wired apps by name through the gateway's proxy (rystic compose port rystic-gateway 3128), or set gateway: {egress: open}
# -> ports the gateway publishes and forwards to your services:
# ->   web 3000 -> rystic-gateway -> web:3000
# -> variables set:
# ->   web: STRIPE_SECRET_KEY, STRIPE_API_KEY, STRIPE_PUBLISHABLE_KEY (also a build arg), NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY (also a build arg), NO_PROXY
# -> ...
```

`web` now calls `api.stripe.com` as it does in production and reaches the Stripe simulator. `localhost:3000` still responds on your machine. `rystic compose down` stops everything; `down -v` also deletes the stack's named volumes, your own included.

Each run that starts containers does four things:

1. Keeps `rystic.yaml` up to date with what your app needs: [rystic.yaml](#rysticyaml).
2. Writes `.rystic/docker-compose.yaml` beside it, with a `.gitignore` of `*`. That overlay holds the simulators, the `rystic-gateway` service that routes vendor hosts to them, and the wiring for your services. Nothing else is written, and your own files are never edited.
3. Logs Docker in to `registry.rystic.ai` with your license or API key.
4. Runs the `docker compose` subcommand on your files with the overlay last, adding `--wait` to `up`. The credential goes to Compose in its environment, never in a file.

Your Compose files are found as Docker Compose finds them: `-f`, else `COMPOSE_FILE`, else `compose.yaml` (or `docker-compose.yml`) and its override, in the folder or a parent.

## rystic.yaml

The first run reads your app's package manifests, Compose files and `.env.example` files, never `.env`. It writes a [stack file](https://www.rystic.ai/docs/environments.md#the-stack-file) with one simulator for each vendor it finds, the latest release, and a comment saying why:

```yaml
# rystic compose adds each simulator your app needs; commit this file.
# rystic compose remove <name> drops one, and exclude: [<name>] keeps one out.
version: 1
clock: now
services:
  # found STRIPE_SECRET_KEY in .env.example; found "stripe" in requirements.txt
  stripe:
    product: stripe-twin
    version: 0.0.28
    intercept: true
```

Commit it. It is an ordinary stack file, so `rystic up` and `rystic export compose` read it too.

1. A later run adds each vendor newly found: `added resend 0.0.18 (found "resend" in package.json)`.
2. A simulator no longer found stays, and the run says so: `stripe is no longer detected; remove it with rystic compose remove stripe`. Nothing is removed automatically.
3. `rystic compose add <simulator>` adds one detection can't see, and `rystic compose remove <name>` drops one. Neither needs Docker.
4. `exclude: [resend]` at the top level keeps a simulator out, by service or product name. It survives `--update`.
5. A service with no `version` gets the latest release (`pinned stripe at 0.0.28`). A version already set never changes, unless you run `rystic compose --update up`, which moves every simulator to its latest release.
6. Every edit keeps the file's comments, key order and indent.
7. A first run that finds no vendor writes nothing and exits 2. Add a simulator with `rystic compose add`.

Detection reads the Compose project's folder: `--project-directory`, else the first Compose file's folder. `--stack <file>` names a stack file other than `./rystic.yaml`.

### In CI

Run `--frozen`. It never writes `rystic.yaml`, and fails (exit 1) when your app needs a simulator the file lacks:

```bash
rystic compose --frozen up -d
# -> rystic: resend is detected but not in rystic.yaml: run `rystic compose up` locally and commit rystic.yaml
```

1. A missing `rystic.yaml` fails too.
2. A service with no `version` runs at the latest release, with a warning to pin it.
3. An older pinned version is not a failure: only `--update` moves versions, so a new Rystic release doesn't fail a job.

With the API key in the job as `RYSTIC_TOKEN` ([One secret](https://www.rystic.ai/docs/integration-testing.md#one-secret)):

```bash
rystic compose --frozen up -d
rystic compose exec -T web pytest
rystic compose down -v
```

## Subcommands

| Subcommand | What it does |
|---|---|
| `up`, `run`, `create`, `pull`, `watch`, `scale`, `convert` | keeps `rystic.yaml`, writes the overlay, then runs the `docker compose` subcommand |
| `config` | the same, and prints the merged file with a placeholder for the credential |
| `generate` | keeps `rystic.yaml` and writes the overlay, and starts nothing: for tools that run `docker compose` themselves |
| `reset` | returns every simulator to its boot state: [Reset](#reset) |
| `add`, `remove` | edit `rystic.yaml` |
| `down`, `logs`, `ps`, `exec` and every other | run `docker compose` with the overlay, arguments unchanged. They never touch `rystic.yaml` |

`generate` prints the `docker compose` command that runs the overlay without `rystic`. Log Docker in to the registry and set `RYSTIC_TOKEN` or `RYSTIC_LICENSE` first ([Docker → Pull](https://www.rystic.ai/docs/docker.md#pull)).

## What your services get

`rystic compose` wires every service of yours with a `build:` key, or the services the stack file's `gateway: {apps}` names. A service with `network_mode` is skipped with a warning. Each wired service gets:

1. The proxy, the CA variables `rystic exec` sets and the CA bundle over the system trust store ([In Docker Compose](https://www.rystic.ai/docs/intercept.md#in-docker-compose)).
2. `NO_PROXY`, naming loopback and every Compose service, so calls between your services stay direct.
3. The keys and URLs each simulator accepts, under the names your vendor's SDKs read, such as `STRIPE_SECRET_KEY` or `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`. A variable your files already set to a non-empty value, through `environment`, `env_file` or a build arg, stays yours. Docker Compose also gets each of these keys in its own environment while it reads your files, so a variable your files require, such as `${STRIPE_SECRET_KEY:?}`, resolves. A value your shell sets wins, or your `.env` when your shell doesn't set the name; an empty value in your shell gets the simulator's key.
4. Values your build inlines, such as `NEXT_PUBLIC_` publishable keys, also as build args. Secrets are not build args: a build runs off the stack's network, so a call it makes reaches the real vendor.
5. The CA bundle mounted over the CA file an SDK ships, for SDKs that read no CA variable: [SDKs that ship their own CA file](https://www.rystic.ai/docs/intercept.md#sdks-that-ship-their-own-ca-file).

The run prints each variable it set, never a value. A webhook endpoint's signing secret, such as `STRIPE_WEBHOOK_SECRET`, is never set, since your app's value must match the endpoint's: [seed the endpoint](#seeds) with the secret your app holds.

A file that requires a key, as many do, starts too. To try it, run these in an empty directory, so the `.env` step replaces no file of yours:

```yaml
# compose.yaml
services:
  web:
    build: .
    environment:
      STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:?set STRIPE_SECRET_KEY}
```

```bash
docker compose config
# -> error while interpolating services.web.environment.STRIPE_SECRET_KEY: required variable STRIPE_SECRET_KEY is missing a value: set STRIPE_SECRET_KEY
rystic compose config | grep STRIPE_SECRET_KEY:
# ->       STRIPE_SECRET_KEY: sk_test_rystic_simulator
STRIPE_SECRET_KEY=sk_test_mine rystic compose config | grep STRIPE_SECRET_KEY:
# ->       STRIPE_SECRET_KEY: sk_test_mine
echo STRIPE_SECRET_KEY=sk_test_from_dotenv > .env
rystic compose config | grep STRIPE_SECRET_KEY:
# ->       STRIPE_SECRET_KEY: sk_test_from_dotenv
STRIPE_SECRET_KEY= rystic compose config | grep STRIPE_SECRET_KEY:
# ->       STRIPE_SECRET_KEY: sk_test_rystic_simulator
```

Each simulator gets:

1. `RYSTIC_WEBHOOK_DELIVERY=on`, so it sends [webhooks](https://www.rystic.ai/docs/webhooks.md#receive-signed-webhooks). Set `env: {RYSTIC_WEBHOOK_DELIVERY: "off"}` on the service in `rystic.yaml` to turn it off.
2. The volume `rystic-<service>-data` at its data directory and, on an API key, `rystic-<service>-leases` at its lease cache, so a recreated container keeps the files it made ([Docker → Volumes](https://www.rystic.ai/docs/docker.md#volumes)).
3. With `clock: now`, the time the stack starts, which every simulator in it shares ([Export to Compose](https://www.rystic.ai/docs/environments.md#export-to-compose)).

Your Compose files may not declare a volume named `rystic-ca`, `rystic-clock`, `rystic-<service>-data` or `rystic-<service>-leases` for a stack service: Compose would merge it with the overlay's. The run exits 2 and names each one.

## The network

Unless the stack file sets `gateway: {egress: open}`, the default network has no route out. A call your app makes that skips the gateway fails. The gateway and the simulators also join `rystic-egress`, a network with a route out, for the calls a simulator makes itself, such as an API key's lease checks.

Most vendor hosts no simulator in the stack stands in for resolve to the gateway, which refuses them and counts them for the [gap report](#gap-report). A proxied call to a vendor host Rystic simulates but no simulator in the stack stands in for is refused the same way. A proxied call to any other host still goes out, unless the stack sets `gateway: {strict: true}`, which refuses every host outside its `allow` list, the simulators and the services `rystic compose` wires.

Docker publishes no port of a service on a network with no route out, so each TCP port your services publish moves to `rystic-gateway`. The gateway publishes the same host address and port and forwards it to your service, and the run names each move (`web 3000 -> rystic-gateway -> web:3000`). The overlay clears your service's own `ports` with `!reset []`. A UDP port gets a warning.

Reach the simulators and your services from your machine through the gateway's proxy, trusting its CA:

```bash
rystic compose cp rystic-gateway:/work/ca.pem ./ca.pem
PROXY=$(rystic compose port rystic-gateway 3128)
curl -s -x http://$PROXY --cacert ca.pem https://api.stripe.com/v1/customers -u sk_test_x: | jq -r .object
# -> list
curl -s -x http://$PROXY http://web:3000/   # your own service, by its Compose name
```

1. A wired service that also joins an external network, or one with a route out, gets a warning: its calls can leave past the gateway.
2. An external `default` network gets the same warning, since Docker leaves it as it is.
3. `gateway: {egress: open}` keeps Compose's own default network. No port moves, and a call to a host no simulator claims that skips the gateway reaches the real vendor.

## Seeds

A `rystic/seed/<service>.json` beside `rystic.yaml` is that simulator's [boot seed](https://www.rystic.ai/docs/boot-configuration.md#the-boot-seed-file), so your app finds its prices, webhook endpoints or users from its first request. `{"$env": "NAME"}` takes the value of `NAME`, so a seeded webhook endpoint signs with the secret your app verifies with:

```json
{
  "state_product": {"prod_pro": {"id": "prod_pro", "object": "product", "name": "Pro", "active": true}},
  "state_price": {"price_pro_monthly": {"id": "price_pro_monthly", "object": "price", "product": "prod_pro", "active": true, "currency": "usd", "unit_amount": 2000, "lookup_key": "pro_monthly", "type": "recurring", "recurring": {"interval": "month", "interval_count": 1}}},
  "state_webhook_endpoint": {"we_app": {"id": "we_app", "object": "webhook_endpoint", "url": "http://web:3000/webhooks/stripe", "enabled_events": ["customer.created"], "secret": {"$env": "STRIPE_WEBHOOK_SECRET"}, "status": "enabled"}}
}
```

```bash
rystic compose up -d
# -> ...
# -> seeds loaded at boot:
# ->   stripe: rystic/seed/stripe.json, reads STRIPE_WEBHOOK_SECRET; its webhooks wait for web (rystic-hold releases them)
```

1. `NAME` takes the value your Compose services set, else your shell's, else the project's `.env`. Where Compose can supply the value itself, the overlay writes `${NAME}` rather than the value.
2. A name nothing sets stops the run before anything starts. So does a file naming no stack service, a file that isn't `.json`, a folder, a stack service that also sets `seed:`, or a key repeated within one object.
3. The webhooks the seed's records send wait until every wired service has started, or passed the healthcheck your Compose files declare for it. The `rystic-hold` service then releases them, and `up --wait` returns after that.
4. `run`, and an `up` that names only some services, leave `rystic-hold` out, so held webhooks go out after 10 minutes.
5. A seed with a `_rystic_partial` record runs `RYSTIC_SEED_GATE=partial` ([The seed gate](https://www.rystic.ai/docs/concepts/worlds.md#the-seed-gate)).
6. The stack's `env:` wins each of these settings. A simulator whose `env:` sets `RYSTIC_ADMIN_TOKEN` holds nothing.

Each simulator's page lists the registers it seeds.

## Reset

`rystic compose reset` returns every simulator to its boot state, its seed included, and waits for each to settle:

```bash
rystic compose reset
# -> stripe: back to its boot state, settled
```

From your tests, `POST /_rystic/reset` to the gateway does the same for every `intercept: true` simulator:

```bash
# from a service in the stack
curl -s -X POST http://rystic-gateway:3128/_rystic/reset
# -> {"simulators":[{"name":"stripe","reset":true,"settled":true}]}
```

1. If one simulator can't reset to its boot state, none is reset. The gateway returns `502` when one fails.
2. Once released, a seed's webhooks are sent again at each reset, so your app must treat an event it has seen as seen. Reset your app's own data before the simulators.
3. `rystic compose reset` reaches the simulators through the gateway's port on this machine, so it refuses a remote Docker host.
4. The gateway refuses a reset a browser page sends. It carries no admin token, so a simulator whose `env:` sets `RYSTIC_ADMIN_TOKEN` resets only through `rystic compose reset`, which sends it.

## Gap report

As the stack comes down, with `rystic compose down` or once a foreground `up` ends (such as `rystic compose up --exit-code-from web`), `rystic` prints what the simulators couldn't do in that run:

```
gap report for this run:
  api.resend.com: your app called it 3 times, and no simulator in the stack stands in for it — add one with `rystic compose add resend`
  stripe doesn't support POST /v1/terminal/readers yet (2 calls)
To ask Rystic to support a call, see https://www.rystic.ai/docs/agent-quick-start.md#tell-rystic
```

1. Each vendor host the gateway refused, with the simulator that adds it.
2. Each call a simulator doesn't support yet ([Status codes](https://www.rystic.ai/docs/control-plane.md#status-codes)), calls whose query and headers matched none of a route's operations, and calls that returned a server error.
3. With nothing to report, one line: `gap report: the gateway refused no host, and the simulators supported every call (12 calls)`.
4. `down` also asks each simulator whose seed has a `_rystic_partial` record which calls showed a field its seed leaves unknown or got an empty list, and names its seed file. A foreground `up` has stopped the simulators by then, so its report leaves these out.
5. A dry run prints no report.

The same counts go to `.rystic/results.json`:

```json
{
  "schema": 1,
  "simulators": [
    {"name": "stripe", "product": "stripe-twin", "requests": 12, "errors": 0, "unmatched": 0,
     "unsupported": [{"method": "POST", "route": "/v1/terminal/readers", "requests": 2}]}
  ],
  "gateway": {
    "tallied": true,
    "started_at": "2026-11-12T17:00:00Z",
    "refused_hosts": [
      {"host": "api.resend.com", "requests": 3, "simulated_by": ["resend-twin"], "add": ["resend"]}
    ]
  }
}
```

`tallied: false`, with an empty `simulators`, means the gateway kept no count of this run, as when its image predates the count. A later run's "no longer detected" line reads the file to say whether the gateway routed calls to that simulator.

## Limits

1. A simulator that claims no vendor hosts needs its base URL pointed at it: [Which hosts each simulator claims](https://www.rystic.ai/docs/intercept.md#which-hosts-each-simulator-claims).
2. A call your app makes during `docker build` runs off the stack's network and reaches the real vendor.
3. The gap report doesn't count a GraphQL field a simulator doesn't support: that returns `200` with an error in the body.
4. Detection reads only the Compose project's folder, not a build context outside it.

## Next

1. [Intercept vendor calls → In Docker Compose](https://www.rystic.ai/docs/intercept.md#in-docker-compose) — the gateway and the overlay in detail, and `rystic export compose`.
2. [Multi-simulator environments](https://www.rystic.ai/docs/environments.md#the-stack-file) — every key of `rystic.yaml`.
3. [Boot configuration](https://www.rystic.ai/docs/boot-configuration.md#the-boot-seed-file) — the boot seed file and the variables the overlay sets.

---

Source: https://www.rystic.ai/docs/compose · Markdown: https://www.rystic.ai/docs/compose.md
