Showcase: run vsms from published images, no source build
No Rust or Node toolchain and no clone-and-build wait (only just and openssl, to make the dev keys below) — compose.demo.yaml pulls every vsms image from GHCR and brings up a working console you can open, with a message reaching a terminal state. This is not Local development (just demo, which builds from source and is what you want if you’re changing this codebase) and it is not Deployment (deploy/docker-compose.yml, the production-shaped stack with a real Caddy TLS edge, backups, and Prometheus). Read compose.demo.yaml's own header comment for the full three-way comparison.
Warning
This showcase is broken until the next release is cut. compose.demo.yaml pins the published v0.9.0 images, which predate the YAML configuration (VSC-1). They read their providers, pool sizes and pepper from flags and variables that this file no longer sets, and they ignore SMS_CONFIG and SMS_PROFILE, so the stack does not come up as designed (not exercised: the images this file needs are not yet published). The file is already written for the next release’s images; release-please moves every default tag in it (the release-please annotations on each tag) when that release is cut, and it works again from then on. Until then use Local development (just demo, which builds from source), or build the images from this commit and pass their tag as VSMS_IMAGE_TAG, VSMS_DEMO_IMAGE_TAG and VSMS_FAKE_ORANGE_IMAGE_TAG.
Prerequisites
Docker and the Compose plugin (docker compose version). Nothing else — no git clone of anything beyond this repo, no cargo, no pnpm. If your machine’s architecture isn’t linux/amd64 (Apple Silicon, for example), Docker Desktop’s own emulation handles it transparently; expect the first docker compose ... up to take a few minutes longer while it pulls --platform linux/amd64 layers.
Bring it up
The gateway signs every answer and the console pins its public keys, so the stack mounts three dev keys from the git-ignored dev-keys/ (just dev-keys writes them, and keeps any that exist): the gateway’s current and next sealing keys, and the console composer’s service key. The composer’s public half is registered by the register-composer service, which reads it from VSMS_COMPOSER_PUBLIC_KEY:
just dev-keys
VSMS_COMPOSER_PUBLIC_KEY="$(just dev-composer-public-key)" \
VSMS_DEMO_APP_PUBLIC_KEY="$(just dev-demo-app-public-key)" \
docker compose -f compose.demo.yaml --profile console --profile demo up -dThis runs, in order, entirely sequentially — Postgres, migrate (applies the migrations), seed-dispatch (a Provider + Route, without which sms-gateway refuses to bind its listener), seed-demo-app (an App
approved SenderId, seeded by the dedicated vsms-demo-seed binary/image — see backends/apps/vsms-demo-seed/src/main.rs's own doc for why this step exists only for this showcase and isn’t compiled into the production sms-gateway image), and, because both profiles are present, register-composer (the console composer’s service key), register-demo-app (the demo app’s service key, --profile demo) and provision-user (the account the console’s key is paired to, --profile console) alongside seed-demo-webhook (a WebhookEndpoint pointed at demo-app, --profile demo — deliberately a SEPARATE step from seed-demo-app, not a flag it always passes: R4 means a backend-only up -d with no profiles at all must not seed an endpoint aimed at a container that will never exist, since hooks would then mark every delivery attempt dead forever against a host that never resolves) — before finally starting sms-gateway, sms-worker (dispatch,scheduler,jobs,drain,hooks, unconditionally — see that service’s own comment for why an idle drain/hooks on a profile-less run is the correct shape, not a gap), sms-fake-orange, admin (--profile console), and demo-app (--profile demo — see "The demo app" below). Every one of those seed/provision steps is chained onto the previous one’s completion, not just onto migrate — found live, twice: two of them touching a genuinely fresh database’s catalog concurrently can race Postgres’s own pg_type unique index (compose.demo.yaml's own comment on seed-dispatch has the full mechanism), so this file trades a few seconds of extra startup time for never hitting that race instead of hoping not to.
Note
seed-demo-webhook passes --webhook-url, --webhook-audience and --webhook-endpoint-id-out (VS-8: the showcase seeds a sealed endpoint, so a delivery is signed with the gateway’s own key and there is no secret volume; the one thing it hands demo-app is the endpoint’s id, the binding between a delivery and its receiver, through the vsms_demo_endpoint volume). The seed still has --webhook-secret-out (VS-8R) as the alternative to --webhook-audience that makes an hmac endpoint and writes its secret to that file; the stacks do not use it, and demo-app takes DEMO_WEBHOOK_MODE=hmac with DEMO_WEBHOOK_SECRET_PATH for such an endpoint. That flag is a demo opt-in that does not read auth.methods: against a deployment that excludes hmac_webhooks the endpoint is created all the same and its deliveries are held back (they stay pending until the method is permitted again or the endpoint is switched to sealed, docs/runbooks/deployment.adoc, "Narrowing the authentication methods"). The two flags exist only in a release that contains VS-8, so showcasing a tag from before VS-8 fails at seed-demo-webhook with unexpected argument '--webhook-audience', and so does a worker from before it, which would still sign with an HMAC (an hmac endpoint keeps working across the upgrade; the showcase’s endpoint is a sealed one); use this file’s default tag or a later one. Showcasing a tag before v0.3.0 also cannot run demo-app at all (see "Bumping the showcased version" below): no demo-app image was ever published before v0.3.0. Watch it settle:
docker compose -f compose.demo.yaml --profile console --profile demo psEverything with a restart: unless-stopped service should show Up (healthy) for sms-gateway within about 30 seconds; the one-shot seed/ provision containers show Exited (0) — that’s success, not a crash.
Open the console
The console has no login (docs/design/console-pairing.md). Open http://localhost:3200/ (override with VSMS_DEMO_CONSOLE_PORT if that port collides with something else on your machine): it makes a key in your browser, which it cannot export, and shows a QR. There is no vaam admin phone in this stack, so pair it on the command line, with the k field of the QR (the page says where it is):
docker compose -f compose.demo.yaml --profile console run --rm --no-deps \
register-composer pair-console-key \
--user demo@vsms.local --public-key "<the k field of the QR>"The page notices within a couple of seconds and opens. The account is demo@vsms.local (provision-user made it; it holds no credential and prints none). The key lasts 30 days.
Note
whateverVSMS_IMAGE_TAG you’re pinned to may predate a console redesign in progress on main (a new app shell, dark-only theme). A console that looks different from a fresh just demo checkout is expected, not a sign this file is broken — check the tag before assuming otherwise. Note
the gateway and the worker read their provider from the YAML configuration (config/application-demo.yml, baked into the images; compose.demo.yaml sets SMS_PROFILE: demo), not from the ORANGE_CM_* variables earlier releases took (VSC-1, docs/architecture.md section 9.4). A VSMS_IMAGE_TAG that predates that change does not contain the file and refuses the new environment; use a tag at or after it. Send a message and watch it reach a terminal state
From the console, open the Composer and send a message to +237677123456 with any body and an approved sender id. It moves accepted → queued → routed → submitted → delivered within a few seconds on the Messages screen — the same pipeline Local development proves, just against sms-fake-orange instead of a real Orange sandbox — no real SMS is ever sent (backends/apps/sms-fake-orange/src/main.rs's own module doc).
(Every mutating action in the console — the Composer’s "Send message" included — used to fail here with a cross-origin request rejected error under a packaged, containerised admin image; see frontends/packages/api/src/context.ts's module doc for the mechanism and #243/#244 for the fix. A VSMS_IMAGE_TAG built from a commit before 2026-08-14 still has the bug — pin to a newer tag if the Composer’s Send button 403s.)
The demo app
You don’t have to open a browser to watch a message go end to end — demo-app (examples/node/demo-app, own README there) already did, as one of the containers up -d just started. It signs every request with @vaam-apps/vsms-node (the same SDK an integrator would use) under its own Ed25519 key, dev-keys/demo-app.pem, registered by register-demo-app; there is no token to acquire. It sends one real otp-class message, polls it to a terminal state, and verifies the hooks worker role’s own signed webhook for it against a receiver it runs itself. Read its timeline and verdict with:
docker compose -f compose.demo.yaml --profile console --profile demo logs demo-appA line reading [demo-app] SUCCESS: … reached delivered with N verified webhook(s) is the whole story. If it instead shows [demo-app] FAILURE: …, the reason is printed right there — and docker compose … wait demo-app would report the same failure via its own exit code, since nothing propagates it automatically the way just demo does for the from-source stack.
demo-app lives in its own demo Compose profile, deliberately never console — it’s a backend proof (real HTTP against sms-gateway directly, never through the admin console), so it has to be runnable without ever starting admin:
docker compose -f compose.demo.yaml --profile demo up -d
docker compose -f compose.demo.yaml --profile demo logs demo-appis a complete, console-free end-to-end proof on its own — no sign-in, no browser, nothing but sms-gateway/sms-worker/sms-fake-orange/ demo-app and the seed/provision steps each of those needs.
Backend-only (no console)
docker compose -f compose.demo.yaml up -dOmitting both --profile console and --profile demo skips admin, register-composer, provision-user, the permission-fix step, demo-app, and seed-demo-webhook entirely — not started-then-idle, not present-but-misconfigured. That last pair matters specifically: seed-demo-app (unconditional) seeds only the AppSenderId sendMessage needs, never a WebhookEndpoint — only seed-demo-webhook, gated behind --profile demo, does that, precisely because a WebhookEndpoint pointed at a demo-app container that will never exist would otherwise leave hooks marking every delivery attempt dead and logging an error every tick, forever, on a deployment that never asked for any of it. sms-worker’s `drain/hooks roles still run unconditionally (see that service’s own comment) — with no WebhookEndpoint in the database at all on this profile-less run, they simply have nothing to find, which is the verified, not just claimed, shape: docker ps (project vsms-demo, not vsms-dev/vsms-ci — never down a project that isn’t yours) and sms-worker’s own logs after about a minute show no delivery-attempt errors, and a query against the gateway or `psql on the compose Postgres confirms zero WebhookEndpoint rows. sms-gateway and sms-worker still come up healthy, so you can drive the gateway directly (register a key of your own with sms-gateway register-client to send through it — a backend-only stack holds none):
curl http://127.0.0.1:8280/healthz(override with VSMS_DEMO_GATEWAY_PORT). This is compose.demo.yaml's own proof of CONTRIBUTING.md’s R4 — "the admin console is optional, the backend must run without it."
Reset and re-run
docker compose -f compose.demo.yaml --profile console --profile demo down -v
docker compose -f compose.demo.yaml --profile console --profile demo up -d-v matters: the seed and register steps run once against an empty database, so a second up against the same named volumes would start from the last run’s rows. -v drops this file’s own named volumes (vsms_demo_pgdata, vsms_demo_secrets — scoped to the vsms-demo Compose project name, never touching anything from compose.yml, deploy/docker-compose.yml, or an unrelated project on the same machine) so every up after a down -v starts genuinely fresh.
Bumping the showcased version
compose.demo.yaml now defaults every image (all seven — the four production images plus sms-fake-orange, the demo seeder, and demo-app) to v0.3.1, so docker compose -f compose.demo.yaml --profile console --profile demo up -d with no override already showcases a real, working release, demo-app included. To pin a different one:
VSMS_IMAGE_TAG=v0.3.1 docker compose -f compose.demo.yaml --profile console --profile demo up -dsms-gateway/sms-worker/admin/migrate are published together on every v*.. tag; VSMS_IMAGE_TAG is the one thing this file expects you to change. sms-fake-orange (VSMS_FAKE_ORANGE_IMAGE_TAG), seed-demo-app (VSMS_DEMO_IMAGE_TAG), and demo-app (VSMS_DEMO_APP_IMAGE_TAG) are published on the same v*.. cadence too (.github/workflows/release.yml’s build-images matrix shares one `tag_primary/tag_secondary across every image on a given trigger, this repo’s nested pair included), so all three env vars now default to v0.3.1 as well rather than being required with no default.
v0.2.0 was not usable — that tag’s own release run failed (.github/workflows/release.yml, tag v0.2.0); confirmed against the GHCR API, not assumed, before this section first recommended v0.2.1 instead. VSMS_IMAGE_OWNER (default vaam-apps) is the other half of every image reference here — this repository’s GitHub owner has moved twice: v0.2.1 and earlier published under ghcr.io/vymalo/…; v0.3.0 alone published under ghcr.io/vaam-store/…; v0.3.1 and every tag after it lands under ghcr.io/vaam-apps/…, this file’s own default now. Showcasing an older release means setting the owner together with EVERY tag knob this file has (there are four: VSMS_IMAGE_TAG for the four production images, VSMS_DEMO_IMAGE_TAG for vsms/demo, VSMS_FAKE_ORANGE_IMAGE_TAG for vsms/sms-fake-orange, VSMS_DEMO_APP_IMAGE_TAG for vsms/demo-app), never a subset — a knob left at its v0.3.1 default resolves to ghcr.io/vymalo/…:v0.3.1 (or ghcr.io/vaam-store/…:v0.3.1), an image that was never published under that owner, and the pull fails with manifest unknown:
VSMS_IMAGE_OWNER=vymalo VSMS_IMAGE_TAG=v0.2.1 \
VSMS_DEMO_IMAGE_TAG=v0.2.1 VSMS_FAKE_ORANGE_IMAGE_TAG=v0.2.1 \
docker compose -f compose.demo.yaml --profile console up -dShowcasing v0.3.0 specifically — the one tag published under vaam-store — means the same four knobs with VSMS_IMAGE_OWNER=vaam-store and every tag set to v0.3.0 instead; unlike v0.2.1, --profile demo still works at that tag (see below).
Note the missing --profile demo above: demo-app first exists as a published image at v0.3.0 — there is no v0.2.1 tag for VSMS_DEMO_APP_IMAGE_TAG to name, so showcasing any tag before v0.3.0 means --profile demo cannot be used at all, not merely a knob left at its default.
VSMS_DEMO_IMAGE_TAG is one variable shared by two services, and used to have different requirements between them: seed-demo-webhook (--profile demo — covers the WebhookEndpoint half of the same image, --webhook-url/--webhook-audience) needs flags that exist only in a release containing VS-8. This file’s current default tag has them, so the default works for both services without any override — moot at v0.2.1 regardless, since --profile demo is already unusable there for the reason above. demo-app is a THIRD non-production image, published at ghcr.io/${VSMS_IMAGE_OWNER:-vaam-apps}/vsms/demo-app since v0.3.0 (confirmed live with docker manifest inspect) — it now follows the identical pattern sms-fake-orange/seed-demo-app already use, VSMS_DEMO_APP_IMAGE_TAG included, and compose.demo.yaml's own demo-app service carries no build: stanza any more. See compose.demo.yaml's own comments on all four services (seed-demo-app/seed-demo-webhook, sms-fake-orange, demo-app) for the current state and why, and this repo’s PR description for exactly which published tags this runbook was verified against.
No Postgres port, on purpose
docker compose -f compose.demo.yaml ps postgres
docker port $(docker compose -f compose.demo.yaml ps -q postgres)The second command prints nothing — Postgres has no published port at all. Every other service reaches it over the Compose network’s own internal DNS (postgres:5432). If you need a psql prompt for a real look, go through the container rather than opening the port to the host:
docker compose -f compose.demo.yaml exec postgres psql -U vsms