Your local development
sidekick for Upstash.
downstash is a single binary that mocks Upstash QStash and Upstash Redis on your laptop — same HTTP wire shape, same SDKs, zero internet. Develop on planes. Run integration tests in CI without a credential.
Local Upstash, without the workarounds.
QStash can't reach localhost. Redis-over-HTTP needs internet for every request. We built the sidekick that closes both gaps.
No tunnels.
Skip ngrok, env var juggling, and the dance of remembering to undo it before you commit. downstash speaks plain http://localhost.
Works offline.
Plane, train, hotel wifi, blackout — fine. Your test suite stops depending on a remote service that can rate-limit, retry, or simply be down.
CI-friendly.
Spin it up in a workflow step. Run integration tests against QStash and Redis with no external dependency, no flaky network, no shared sandbox.
- ✕Spin up ngrok, copy URL, paste into envRepeat every session. Don't forget to remove it.
- ✕Hit a remote Redis for every testSlow iteration, flaky CI, real latency in unit tests.
- ✕Burn through QStash quota in devReal messages, real billing.
- ✕No way to inspect or replay messagesHope your logs are good.
- ✓Point your SDKs at
localhost:8080Same constructors, no code changes. - ✓In-memory Redis with full command coverageStrings, hashes, lists, sets, sorted sets, pipelines.
- ✓Real signed JWTs, verified by the Upstash SDKSigning works exactly like production.
- ✓Inspect any message with one curl
GET /v2/messages/:id— state, retries, body.
Three commands. Two SDKs. Zero config drift.
Install once, drop the same .env.local into every project, and your existing Upstash code keeps working untouched.
# Requires Bun >= 1.1.0 $ git clone https://github.com/sskcfC15Xfoxd7X1sVFgipdzMRAkP/downstash $ cd downstash $ bun install $ bun link # makes the `downstash` command available globally $ downstash 2026-04-29T12:00:00.000Z INFO downstash listening port=8080 db=.downstash/db.sqlite tickMs=250
# QStash QSTASH_URL=http://localhost:8080 QSTASH_TOKEN=dev QSTASH_CURRENT_SIGNING_KEY=sig_downstash_current_dev_key_do_not_use_in_prod QSTASH_NEXT_SIGNING_KEY=sig_downstash_next_dev_key_do_not_use_in_prod # Redis UPSTASH_REDIS_REST_URL=http://localhost:8080 UPSTASH_REDIS_REST_TOKEN=dev
import { Client, Receiver } from "@upstash/qstash"; const client = new Client({ baseUrl: process.env.QSTASH_URL!, token: process.env.QSTASH_TOKEN!, }); await client.publishJSON({ url: "http://localhost:3000/api/echo", body: { hello: "world" }, delay: 5, // seconds retries: 3, }); // inside your route handler: const receiver = new Receiver({ currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY!, nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!, }); const ok = await receiver.verify({ signature, body, url });
import { Redis } from "@upstash/redis"; const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL!, token: process.env.UPSTASH_REDIS_REST_TOKEN!, }); await redis.set("user:1", { name: "Alice", role: "admin" }); const user = await redis.get("user:1"); await redis.lpush("queue", "task-1", "task-2"); const task = await redis.rpop("queue"); const pipe = redis.pipeline(); pipe.incr("counter"); pipe.get("counter"); const results = await pipe.exec();
# Publish a message that round-trips back to your dev server $ curl -X POST \ -H 'Authorization: Bearer dev' \ -H 'Content-Type: application/json' \ -d '{"hello":"world"}' \ 'http://localhost:8080/v2/publishJSON/http://localhost:3000/api/echo' # => {"messageId":"msg_2j9a...","url":"http://localhost:3000/api/echo"} # Single Redis command $ curl -X POST -H 'Authorization: Bearer dev' \ -d '["SET","mykey","myvalue"]' http://localhost:8080/ # Pipeline $ curl -X POST -H 'Authorization: Bearer dev' \ -d '[["SET","a","1"],["SET","b","2"],["MGET","a","b"]]' \ http://localhost:8080/pipeline
Two services. One sidekick.
downstash mirrors the Upstash wire shape closely — same headers, same JWT signing, same encoding flags. Your code can't tell the difference.
Signed message delivery, locally.
Publish to any URL — including localhost — with delay, retries, callbacks, and HMAC-SHA256 signed JWTs that the official Receiver verifies cleanly.
The commands you actually use, in-memory.
Strings, hashes, lists, sets, sorted sets, pipelines, transactions, TTLs, scans. The @upstash/redis SDK works without modification — same REST shape, same base64 encoding flag.
Real JWTs. Real verification.
downstash signs every outbound delivery with a real HMAC-SHA256 JWT. The official Upstash Receiver verifies it — no shortcuts, no test-only paths in your code.
Verified by the same SDK you'll ship to production.
Every claim matches Upstash's wire format. Pass the Upstash-Signature header into Receiver.verify() exactly as you would in prod — downstash returns a stable pair of keys so your .env.local never drifts between machines.
Small surface. Every flag has an env var.
downstashStart the server (port 8080)downstash serveExplicit serve subcommanddownstash resetTruncate the messages tabledownstash keysPrint signing keys + Redis config--port <n>env: DOWNSTASH_PORT--db <path>env: DOWNSTASH_DB--tick-ms <n>Delivery loop interval (default 250)--log-level <lvl>debug · info · warn · error--current-signing-keyOverride current QStash key--next-signing-keyOverride next QStash key--redis-token <s>Redis auth token (default "dev")--quietShorthand for --log-level=warn