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:
- Keeps
rystic.yamlup to date with what your app needs: rystic.yaml. - Writes
.rystic/docker-compose.yamlbeside it, with a.gitignoreof*. That overlay holds the simulators, therystic-gatewayservice that routes vendor hosts to them, and the wiring for your services. Nothing else is written, and your own files are never edited. - Logs Docker in to
registry.rystic.aiwith your license or API key. - Runs the
docker composesubcommand on your files with the overlay last, adding--waittoup. 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.
- A later run adds each vendor newly found:
added resend 0.0.18 (found "resend" in package.json). - 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. rystic compose add <simulator>adds one detection can’t see, andrystic compose remove <name>drops one. Neither needs Docker.exclude: [resend]at the top level keeps a simulator out, by service or product name. It survives--update.- A service with no
versiongets the latest release (pinned stripe at 0.0.28). A version already set never changes, unless you runrystic compose --update up, which moves every simulator to its latest release. - Every edit keeps the file’s comments, key order and indent.
- 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
- A missing
rystic.yamlfails too. - A service with no
versionruns at the latest release, with a warning to pin it. - An older pinned version is not a failure: only
--updatemoves 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
| 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 |
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).
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:
- The proxy, the CA variables
rystic execsets and the CA bundle over the system trust store (In Docker Compose). NO_PROXY, naming loopback and every Compose service, so calls between your services stay direct.- The keys and URLs each simulator accepts, under the names your vendor’s SDKs read, such as
STRIPE_SECRET_KEYorNEXT_PUBLIC_CLERK_PUBLISHABLE_KEY. A variable your files already set to a non-empty value, throughenvironment,env_fileor 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.envwhen your shell doesn’t set the name; an empty value in your shell gets the simulator’s key. - 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. - 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:
RYSTIC_WEBHOOK_DELIVERY=on, so it sends webhooks. Setenv: {RYSTIC_WEBHOOK_DELIVERY: "off"}on the service inrystic.yamlto turn it off.- The volume
rystic-<service>-dataat its data directory and, on an API key,rystic-<service>-leasesat its lease cache, so a recreated container keeps the files it made (Docker → Volumes). - 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
- 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.
- An external
defaultnetwork gets the same warning, since Docker leaves it as it is. 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)
NAMEtakes 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.- 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 setsseed:, or a key repeated within one object. - 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-holdservice then releases them, andup --waitreturns after that. run, and anupthat names only some services, leaverystic-holdout, so held webhooks go out after 10 minutes.- A seed with a
_rystic_partialrecord runsRYSTIC_SEED_GATE=partial(The seed gate). - The stack’s
env:wins each of these settings. A simulator whoseenv:setsRYSTIC_ADMIN_TOKENholds 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}]}
- If one simulator can’t reset to its boot state, none is reset. The gateway returns
502when one fails. - 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.
rystic compose resetreaches the simulators through the gateway’s port on this machine, so it refuses a remote Docker host.- The gateway refuses a reset a browser page sends. It carries no admin token, so a simulator whose
env:setsRYSTIC_ADMIN_TOKENresets only throughrystic 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
- Each vendor host the gateway refused, with the simulator that adds it.
- 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.
- With nothing to report, one line:
gap report: the gateway refused no host, and the simulators supported every call (12 calls). downalso asks each simulator whose seed has a_rystic_partialrecord which calls showed a field its seed leaves unknown or got an empty list, and names its seed file. A foregrounduphas stopped the simulators by then, so its report leaves these out.- 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
- A simulator that claims no vendor hosts needs its base URL pointed at it: Which hosts each simulator claims.
- A call your app makes during
docker buildruns off the stack’s network and reaches the real vendor. - The gap report doesn’t count a GraphQL field a simulator doesn’t support: that returns
200with an error in the body. - Detection reads only the Compose project’s folder, not a build context outside it.
Next
- Intercept vendor calls → In Docker Compose — the gateway and the overlay in detail, and
rystic export compose. - Multi-simulator environments — every key of
rystic.yaml. - Boot configuration — the boot seed file and the variables the overlay sets.