Self-hosted Upstash Redis-compatible HTTP proxy backed by any standard Redis 6+ server.
Drop-in replacement for @upstash/redis — point the SDK at your own server instead of Upstash's cloud. Modern TypeScript rewrite of SRH (serverless-redis-http), sibling project to up-vector.
git clone https://github.com/Coriou/up-redis.git
cd up-redis
cp .env.example .env
# Edit .env — set UPREDIS_TOKEN to a secret of your choice
# Local development — exposes port 8080 to the host
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -dThe API is now available at http://localhost:8080.
Note: The base
docker-compose.ymluses Dockerexposerather thanportsto keep the service internal-only — this is intentional for deployment behind a reverse proxy (Coolify, Traefik, nginx, etc.). Use the dev overlay above to publish the port to localhost. For a production deployment behind your own reverse proxy, justdocker compose up -d.
Just swap the URL and token — everything else stays the same:
import { Redis } from "@upstash/redis"
const redis = new Redis({
url: "http://localhost:8080", // ← your up-redis instance
token: "your-token-here",
})
// Works exactly like Upstash
await redis.set("greeting", "Hello, World!")
const value = await redis.get("greeting") // "Hello, World!"
await redis.hset("user:1", { name: "Ben", role: "admin" })
const user = await redis.hgetall("user:1") // { name: "Ben", role: "admin" }
// Pipelines
const pipe = redis.pipeline()
pipe.set("a", 1)
pipe.incr("a")
pipe.get("a")
const results = await pipe.exec() // ["OK", 2, 2]
// Transactions
const tx = redis.multi()
tx.set("counter", 0)
tx.incr("counter")
const txResults = await tx.exec() // ["OK", 1]
// PubSub
const sub = redis.subscribe(["my-channel"])
sub.on("message", ({ channel, message }) => {
console.log(`${channel}: ${message}`)
})
await redis.publish("my-channel", "hello") // 1 (subscriber count)
await sub.unsubscribe()Works with any language — just send HTTP requests:
# Single command
curl -X POST http://localhost:8080/ \
-H "Authorization: Bearer your-token" \
-H "Content-Type: application/json" \
-d '["SET", "mykey", "myvalue"]'
# → {"result":"OK"}
# Path-style command
curl http://localhost:8080/set/mykey/myvalue \
-H "Authorization: Bearer your-token"
# → {"result":"OK"}
# Pipeline (batch)
curl -X POST http://localhost:8080/pipeline \
-H "Authorization: Bearer your-token" \
-H "Content-Type: application/json" \
-d '[["SET","k1","v1"],["GET","k1"],["DEL","k1"]]'
# → [{"result":"OK"},{"result":"v1"},{"result":1}]
# Transaction (atomic)
curl -X POST http://localhost:8080/multi-exec \
-H "Authorization: Bearer your-token" \
-H "Content-Type: application/json" \
-d '[["SET","k1","v1"],["INCR","counter"]]'
# → [{"result":"OK"},{"result":1}]
# Subscribe to channel (SSE stream — stays open)
curl -N http://localhost:8080/subscribe/my-channel \
-H "Authorization: Bearer your-token"
# → data: subscribe,my-channel,1
# → data: message,my-channel,hello (when someone publishes)
# Pattern subscribe (SSE stream — stays open)
curl -N 'http://localhost:8080/psubscribe/news:*' \
-H "Authorization: Bearer your-token"
# → data: psubscribe,news:*,1
# → data: pmessage,news:*,news:1,"hello" (when a matching channel publishes)
# Publish to channel (from another terminal)
curl -X POST http://localhost:8080/publish/my-channel/hello \
-H "Authorization: Bearer your-token"
# → {"result":1}
# Or publish with a raw Redis command
curl -X POST http://localhost:8080/ \
-H "Authorization: Bearer your-token" \
-H "Content-Type: application/json" \
-d '["PUBLISH", "my-channel", "hello"]'
# → {"result":1}Implements the Upstash Redis REST API, validated by 486 tests including 97 using the real @upstash/redis SDK.
| Endpoint | Status |
|---|---|
POST / |
Supported — single command |
GET|POST|PUT /:command/:arg... |
Supported — path-style REST commands |
POST /pipeline |
Supported — batch execution |
POST /multi-exec |
Supported — atomic transactions |
GET|POST /subscribe/:channel |
Supported — PubSub over SSE |
GET|POST /psubscribe/:pattern |
Supported — pattern PubSub over SSE |
POST /publish/:channel/:message |
Supported — PubSub publish shortcut |
GET / |
Supported — health check (welcome message) |
GET /health |
Supported — rich health with Redis probe (readiness) |
GET /livez |
Supported — liveness probe (does NOT check Redis) |
GET /readyz |
Supported — Kubernetes-style readiness alias for /health |
GET /metrics |
Supported — Prometheus (opt-in) |
Authentication accepts Authorization: Bearer <token> and Upstash's _token=<token> query parameter. If both are present, the Authorization header takes precedence. Query-param auth can be disabled with UPREDIS_ALLOW_TOKEN_QUERY_PARAM=false to avoid leaking the token into reverse-proxy access logs (see Security).
All Redis commands are forwarded transparently. up-redis is a proxy — it doesn't interpret commands, so any command your Redis server supports will work, with these exceptions blocked at the proxy layer to protect the shared connection:
- Connection-state-changing:
SUBSCRIBE/PSUBSCRIBE/SSUBSCRIBE(use/subscribe/:channel),MONITOR,MULTI/EXEC/DISCARD/WATCH/UNWATCH(use/multi-exec),SELECT,QUIT,RESET - Blocking commands:
BLPOP,BRPOP,BRPOPLPUSH,BLMOVE,BLMPOP,BZPOPMIN,BZPOPMAX,BZMPOP,WAIT,WAITAOF,XREAD BLOCK,XREADGROUP BLOCK— these would hold the shared connection and starve every other request - Server admin / DoS vectors:
SHUTDOWN,REPLICAOF/SLAVEOF,FAILOVER,DEBUG,MONITOR,CLIENT KILL/PAUSE/UNPAUSE/REPLY/NO-EVICT/NO-TOUCH/SETNAME/SETINFO/TRACKING,CLUSTER FAILOVER/RESET/MEET/FORGET/REPLICATE/ADDSLOTS/DELSLOTS/SETSLOT - Dangerous by default (configurable):
KEYS(O(N) — scans the whole keyspace on the shared connection),FLUSHALL,FLUSHDB,SWAPDBare blocked by default. SetUPREDIS_ALLOW_DANGEROUS_COMMANDS=trueto permit them. Add your own withUPREDIS_BLOCKED_COMMANDS.
Requests are also rejected with 400 if a command argument isn't a string or number (e.g. an object or null, which would otherwise be silently coerced to garbage like "[object Object]"), or if the Upstash-Response-Format: resp2 header is sent (up-redis only speaks the JSON envelope).
Read-only CLIENT subcommands like CLIENT INFO, CLIENT GETNAME, CLIENT ID, CLIENT LIST remain available, as do read-only CLUSTER subcommands like CLUSTER INFO, CLUSTER NODES, CLUSTER MYID, CLUSTER SLOTS, CLUSTER SHARDS.
| Aspect | SRH (Elixir) | up-redis (Bun/Hono) |
|---|---|---|
| Language | Elixir | TypeScript — same as your app |
| Health checks | None | Rich /health with Redis probe + shutdown state |
| Logging | None | Structured JSON/text logging with levels |
| Metrics | None | Prometheus counters + histograms |
| Graceful shutdown | None | Request draining, configurable timeout |
| Request timeout | None | Per-request timeout middleware |
| Concurrent MULTI/EXEC | Broken (#25) | Correct — dedicated connection per transaction |
| PubSub (SUBSCRIBE) | Not supported | SSE streaming, Upstash-compatible |
| Tests | External | 486 built-in (unit + integration + SDK compat) |
| Aspect | Upstash | up-redis |
|---|---|---|
| Read-your-writes | Multi-region sync tokens | Stable token echoed; single-region consistency comes from Redis |
| UNLINK with 0 keys | Silently succeeds | Redis returns error |
| ZRANGE LIMIT | Works without BYSCORE/BYLEX | Redis requires BYSCORE/BYLEX |
| RedisJSON | Custom response format | Standard Redis Stack format |
| PUNSUBSCRIBE/UNSUBSCRIBE REST endpoints | Stream command endpoints | SDK unsubscribe() aborts the SSE stream; direct endpoints are not exposed |
| MONITOR SSE | POST /monitor |
Not supported; use redis-cli monitor directly |
| RESP2 response format | Upstash-Response-Format: resp2 |
Rejected with 400 — up-redis only speaks the JSON envelope (the SDK's default) |
| Rate limiting | Built-in | Use reverse proxy (nginx, Caddy) |
| Multi-region | Built-in | Single-region by design |
up-redis tracks the current @upstash/redis SDK (pinned to 1.38.x in this repo) and the documented Upstash REST contract. A weekly CI job runs the full SDK compatibility suite against @upstash/redis@latest and opens an issue automatically on any drift, so incompatibilities surface quickly. Any standard Redis 6+ server works as the backend; module commands (RedisJSON JSON.*, Search FT.*) are passed through transparently but only work if your Redis has the corresponding module loaded.
Good fit if you:
- Want a self-hosted Redis REST proxy with zero vendor lock-in
- Use the
@upstash/redisSDK and want to develop/test locally - Need production infrastructure (health checks, logging, metrics, graceful shutdown)
- Want correct MULTI/EXEC under concurrent load (SRH's bug #25)
Use Upstash Cloud instead if you need:
- Multi-region replication with read-your-writes consistency
- Built-in rate limiting and access control
- Managed infrastructure with zero ops
All environment variables are prefixed UPREDIS_:
| Variable | Default | Description |
|---|---|---|
UPREDIS_TOKEN |
— | Required. Bearer token for API authentication |
UPREDIS_REDIS_URL |
redis://localhost:6379 |
Redis connection URL — redis://, rediss:// (TLS), valkey://, valkeys:// (any Redis 6+, Valkey, KeyDB) |
UPREDIS_PORT |
8080 |
HTTP listen port |
UPREDIS_HOST |
0.0.0.0 |
HTTP listen host |
UPREDIS_LOG_LEVEL |
info |
Log level: debug, info, warn, error |
UPREDIS_LOG_FORMAT |
json |
Log format: json (structured) or text (human-readable) |
UPREDIS_SHUTDOWN_TIMEOUT |
30000 |
Max milliseconds to wait for request drain on shutdown (min 1000) |
UPREDIS_REQUEST_TIMEOUT |
30000 |
Per-request timeout in milliseconds (0 = disabled) |
UPREDIS_MAX_BODY_SIZE |
10485760 |
Max request body size in bytes (10MB) |
UPREDIS_MAX_PIPELINE_COMMANDS |
1000 |
Max commands per /pipeline or /multi-exec request |
UPREDIS_MAX_SUBSCRIPTIONS |
10000 |
Max concurrent SSE /subscribe/:channel connections (each holds a dedicated Redis connection) |
UPREDIS_METRICS |
false |
Enable Prometheus metrics at GET /metrics |
UPREDIS_ALLOW_DANGEROUS_COMMANDS |
false |
Permit KEYS, FLUSHALL, FLUSHDB, SWAPDB (blocked by default — they can block the shared connection or destroy data) |
UPREDIS_BLOCKED_COMMANDS |
— | Extra commands to block, comma-separated and case-insensitive (e.g. DEBUG,KEYS) |
UPREDIS_ALLOW_TOKEN_QUERY_PARAM |
true |
Allow ?_token= query-param auth. Set false to require the Authorization header (avoids leaking the token into proxy logs) |
up-redis is designed to run behind a reverse proxy (Coolify, Traefik, nginx, Caddy) on a private network:
- Transport: up-redis serves plain HTTP. Terminate TLS at your reverse proxy — don't expose the port directly to the internet. The production
docker-compose.ymluses Dockerexpose(network-internal) rather thanportsfor exactly this reason. - Authentication: the bearer token is compared in constant time (SHA-256 +
timingSafeEqual, which also hides the token length). A startup warning fires ifUPREDIS_TOKENis short, low-entropy, or a known placeholder — use a long random secret, e.g.openssl rand -hex 32. - Token in URLs: the
?_token=query parameter (Upstash compat) is convenient but leaks the secret into reverse-proxy/access logs and browser history. Prefer theAuthorizationheader, and setUPREDIS_ALLOW_TOKEN_QUERY_PARAM=falseto reject query-param auth entirely. /metricsis unauthenticated (so Prometheus can scrape it). Only enable it (UPREDIS_METRICS=true) when the port is reachable solely by your monitoring stack, or restrict/metricsat the reverse proxy.- Command surface: destructive/DoS-prone commands (
KEYS,FLUSHALL,FLUSHDB,SWAPDB) are blocked by default; harden further withUPREDIS_BLOCKED_COMMANDS. For defense in depth, also restrict commands with Redis ACLs on the backing server. - Resource limits: request body size (
UPREDIS_MAX_BODY_SIZE), pipeline/transaction length (UPREDIS_MAX_PIPELINE_COMMANDS), and concurrent SSE subscriptions (UPREDIS_MAX_SUBSCRIPTIONS) are all bounded to limit abuse of the shared connection.
Health check — no auth required:
# Lightweight probe (used by Docker HEALTHCHECK, SRH-compatible)
curl http://localhost:8080/
# → 200 "Welcome to up-redis" or 503 "Shutting Down"
# Rich readiness endpoint — checks Redis connectivity
curl http://localhost:8080/health
# → {"status":"ok","redis":"connected"}
# → {"status":"degraded","redis":"disconnected"} (503)
# → {"status":"shutting_down","redis":"..."} (503)
# Liveness probe — does NOT check Redis (use this for Kubernetes livenessProbe)
curl http://localhost:8080/livez
# → {"status":"ok"} or {"status":"shutting_down"} (503)
# Kubernetes-style readiness alias for /health
curl http://localhost:8080/readyz
# → {"status":"ready","redis":"connected"} or {"status":"not_ready",...} (503)Liveness vs readiness: /livez returns 200 as long as the process can respond. /health and /readyz return 503 when Redis is unreachable. Configure Kubernetes livenessProbe against /livez and readinessProbe against /health so a transient Redis outage doesn't cause unnecessary pod restarts:
livenessProbe:
httpGet:
path: /livez
port: 8080
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 10Prometheus metrics — enable with UPREDIS_METRICS=true:
curl http://localhost:8080/metricsExposes http_requests_total{method,status}, http_request_duration_seconds histogram, and upredis_info gauge.
Structured logging — JSON by default (set UPREDIS_LOG_FORMAT=text for dev). Includes request IDs (X-Request-ID), method, path, status, and duration.
@upstash/redis SDK ──HTTP POST──▶ up-redis (Hono/Bun) ──RESP3──▶ Redis 6+
- Runtime: Bun — native TypeScript, fastest JS runtime
- HTTP: Hono v4 — lightweight, fast
- Redis: Bun.redis (native, zero-dep) — RESP3, auto-pipelining
- Validation: Zod v4 — config validation
Key design decisions: single shared connection with auto-pipelining for commands/pipelines, dedicated connection per MULTI/EXEC transaction (prevents interleaving), dedicated connection per PubSub subscription (SSE streaming), RESP3-to-RESP2 translation layer (Maps→flat arrays, Booleans→0/1), recursive base64 encoding with literal OK preserved per Upstash REST docs.
See PLAN.md for full architecture details.
bun install # Install dependencies
bun run dev # Dev server with --watch
bun run build # Bundle to dist/index.js
bun run lint # Biome check
bun run lint:fix # Biome auto-fix
bun run typecheck # TypeScript check486 tests across three tiers:
| Tier | Tests | Purpose |
|---|---|---|
| Unit | 218 | RESP3 normalization (incl. ±inf/nan), base64 encoding, SSE event ordering, blocked/dangerous command checks, arg validation, token strength |
| Integration | 171 | Full HTTP roundtrips against real Redis (commands, pipelines, transactions, PubSub, auth, health, blocked commands) |
| SDK Compatibility | 97 | Real @upstash/redis SDK against up-redis (including Subscriber class, sync-token handling, and SDK 1.38 auto-pipeline behavior) |
bun test # All tests
bun test tests/unit # Unit only (no Redis needed)
bun test tests/integration # Integration (needs Redis + server running)
bun test tests/compatibility # SDK compat (needs Redis + server running)The compatibility tests use the actual @upstash/redis TypeScript SDK, exercising the exact HTTP paths and response formats that production apps use. A weekly CI job also tests against the latest SDK version to catch incompatibilities early.
cp .env.example .env # Set UPREDIS_TOKEN
docker compose up -d # Starts up-redis + RedisBoth services can share the same Redis Stack instance — up-redis handles standard Redis commands, up-vector handles vector search:
services:
redis-stack:
image: redis/redis-stack-server:latest
up-redis:
image: ghcr.io/coriou/up-redis:latest
environment:
UPREDIS_TOKEN: ${UPREDIS_TOKEN}
UPREDIS_REDIS_URL: redis://redis-stack:6379
up-vector:
image: ghcr.io/coriou/up-vector:latest
environment:
UPVECTOR_TOKEN: ${UPVECTOR_TOKEN}
UPVECTOR_REDIS_URL: redis://redis-stack:6379up-redis is backend-agnostic: it forwards Redis commands transparently and speaks the
RESP-version-independent JSON envelope, so it runs against any Redis 6+ server, Valkey, or
KeyDB. The bundled backend in docker-compose.yml defaults to redis:8-alpine; select a
different one by setting UPREDIS_REDIS_IMAGE (in .env locally, or Coolify's env UI).
UPREDIS_REDIS_IMAGE |
Backend | Notes |
|---|---|---|
redis:8-alpine (default) |
Redis 8 | Core + Vector Sets only (no JSON/FT/TS/Bloom) |
redis:7-alpine |
Redis 7 | Previous default; the CI baseline gate |
valkey/valkey:9-alpine |
Valkey 9 | BSD-licensed; no AGPL; no JSON/FT in base |
redis/redis-stack-server |
Redis + modules | Debian, large image; JSON/FT/TS/Bloom |
valkey/valkey-bundle |
Valkey + modules | BSD; JSON/FT etc. |
The minimum supported backend is still Redis 6+ — bumping the default to Redis 8 is a convenience, not a floor change. Redis 7 remains a first-class, CI-gated option.
CI runs the integration + SDK-compatibility suites against Redis 7, Redis 8, and Valkey 9 (all
required to merge). Module backends (redis/redis-stack-server, valkey/valkey-bundle) are
not CI-tested — the core variants stay module-free.
The default bundled backend moved from redis:7-alpine to redis:8-alpine. This changes the
live data path on an existing production deploy, so upgrade deliberately:
- In-place upgrade is safe. Redis 8 loads Redis 7.x RDB/AOF (forward-compatible), so the
existing
redis-datanamed volume upgrades in place — no dump/restore needed. - Snapshot first anyway. Take a backup before pulling the new image (e.g.
BGSAVE, copy the RDB file, or snapshot the volume) — standard upgrade hygiene. - It is fully reversible. To roll back to Redis 7, set
UPREDIS_REDIS_IMAGE=redis:7-alpinein Coolify's env UI (or.env) and redeploy. Reversibility holds because Redis 7 → 8 is forward-compatible at the persistence layer for ordinary keyspaces; an operator who has begun using Redis-8-only features should not expect a clean downgrade.
The floor is unchanged: Redis 6+ is still supported.
UPREDIS_REDIS_IMAGEandUPREDIS_REDIS_URLare runtime (not Build) variables; they auto-surface in Coolify's env UI pre-filled with their defaults.- For URLs containing
$(e.g. some managed-provider passwords), flag the "Is Literal?" toggle so Coolify does not interpolate them. - Compose
profilesare not usable on Coolify (bug #6395), and multi--foverrides are not supported for app deploys. On Coolify, the external-backend path is to setUPREDIS_REDIS_URLto your managed endpoint in the env UI — not to applydocker-compose.external.yml.
To point up-redis at an external or managed Redis/Valkey instead of the bundled container, use the external override locally:
docker compose -f docker-compose.yml -f docker-compose.external.yml up -d
# or, equivalently:
COMPOSE_FILE=docker-compose.yml:docker-compose.external.yml docker compose up -dand set UPREDIS_REDIS_URL to your endpoint (the override requires it and fails fast if unset).
TLS: bare rediss:// / valkeys:// verifies out of the box against Bun's bundled CAs for
Upstash, ElastiCache, MemoryDB, Azure Cache, Redis Cloud, and current Aiven. rediss:// requires
Bun ≥ 1.2.22 (up-redis ships 1.3.6). Known limitation: private-CA backends (e.g. GCP
Memorystore, a self-hosted private CA) are not yet supported — a UPREDIS_REDIS_CA_FILE option is
a planned follow-up.
Managed-provider foot-guns (the real failure modes):
- Non-standard ports (the #1 foot-gun): Azure Cache
6380(TLS) /10000, GCP6378, and Redis Cloud / Aiven assign per-instance high ports. (GCP uses6378only when in-transit encryption is on;6379otherwise.) The port is part ofUPREDIS_REDIS_URL— get it from the provider console; don't assume6379. - Cluster endpoints (MemoryDB, ElastiCache cluster-mode) and IAM / Entra auth cannot be expressed in a static URL on a non-cluster client — these are unsupported in this configuration.
up-redis is and remains MIT. Redis 8 is AGPLv3, but there is no license contamination:
up-redis is a separate process that talks to Redis as a network client over a socket (per the FSF
GPL FAQ on aggregation / separate programs), so running up-redis against an AGPL Redis does not
make up-redis AGPL. For organizations that blanket-ban AGPL regardless, Valkey
(valkey/valkey:9-alpine, BSD-3-Clause) is the drop-in escape hatch — set
UPREDIS_REDIS_IMAGE=valkey/valkey:9-alpine.
Modules: JSON.* / FT.* / TS.* / Bloom commands only work if your backend has the
corresponding module loaded. The official redis:8-alpine bundles only Vector Sets
(VADD/VSIM) in core — not JSON / Search / TimeSeries / Bloom. For those, use
redis/redis-stack-server (Debian, large) or valkey/valkey-bundle via UPREDIS_REDIS_IMAGE.
Module backends are not CI-tested.
MIT