Skip to Content
Run your Compose app

Guides

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, 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).

Quick start

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

# compose.yaml
services:
  web:
    build: .
    ports: ["3000:3000"]
# 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.
  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 with one simulator for each vendor it finds, the latest release, and a comment saying why:

# 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:

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):

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

Subcommands

SubcommandWhat it does
up, run, create, pull, watch, scale, convertkeeps rystic.yaml, writes the overlay, then runs the docker compose subcommand
configthe same, and prints the merged file with a placeholder for the credential
generatekeeps rystic.yaml and writes the overlay, and starts nothing: for tools that run docker compose themselves
resetreturns every simulator to its boot state: Reset
add, removeedit rystic.yaml
down, logs, ps, exec and every otherrun 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).

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

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 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:

# compose.yaml
services:
  web:
    build: .
    environment:
      STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:?set STRIPE_SECRET_KEY}
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. 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).
  3. With clock: now, the time the stack starts, which every simulator in it shares (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. 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:

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, 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:

{
  "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"}}
}
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).
  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:

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:

# 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), 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:

{
  "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.
  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 — the gateway and the overlay in detail, and rystic export compose.
  2. Multi-simulator environments — every key of rystic.yaml.
  3. Boot configuration — the boot seed file and the variables the overlay sets.
Last updated on