
Valkey
- 86 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
valkey is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- valkey
- AI & Agent Building
- AI-coding skill
Valkey by the numbers
- 86 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,032 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill valkeyAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 86 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Valkey
Open-source, Redis-compatible in-memory data store maintained by the Linux Foundation. Forked from Redis OSS 7.2.4 (BSD 3-Clause license). Drop-in replacement for Redis OSS 2.x through 7.2.x — same protocol, commands, and data formats.
When to use: Caching, session storage, rate limiting, pub/sub messaging, task queues, leaderboards, distributed locks, real-time counters, or any workload requiring sub-millisecond key-value operations.
When NOT to use: Primary relational data store, large object storage (>512 MB values), workloads requiring strong ACID transactions across multiple keys without Lua scripting.
Quick Reference
| Task | Approach | Key Point |
|---|---|---|
| Cache-aside | GET -> miss -> DB read -> SET key val EX ttl | Always set a TTL, even a long one |
| Session storage | HSET session:{id} field val + EXPIRE | Sliding TTL on each request |
| Rate limiting | INCR + EXPIRE (fixed window) or sorted set (sliding) | Sorted set for precision |
| Distributed lock | SET lock:{res} token NX PX 30000 | Always set expiry to prevent deadlocks |
| Queue (simple) | LPUSH + BRPOP | Blocking pop with timeout |
| Queue (reliable) | Streams + XGROUP + XACK | Consumer groups for at-least-once |
| Pub/Sub | SUBSCRIBE / PUBLISH | Fire-and-forget, no persistence |
| Streams | XADD + XREADGROUP | Persistent, replayable, consumer groups |
| Leaderboard | Sorted set: ZADD / ZREVRANGE | O(log N) rank operations |
| Unique count | PFADD + PFCOUNT (HyperLogLog) | ~12 KB memory, 0.81% error |
| Eviction policy | maxmemory-policy allkeys-lru | Best default for most workloads |
| Docker setup | valkey/valkey:8.1-alpine | Health check: valkey-cli ping |
| Persistence | AOF (appendonly yes) + RDB snapshots | AOF for durability, RDB for backups |
| Client library | ioredis or iovalkey (official fork) | All Redis clients work unchanged |
| Migrate from Redis | Swap binary, keep data files | RDB/AOF compatible through Redis 7.2 |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
Using KEYS * in production | Use SCAN with cursor for iteration |
| No TTL on cache keys | Always set TTL — unbounded growth causes OOM |
DEL on large keys blocking server | Use UNLINK for async deletion |
| Pub/Sub for durable messaging | Use Streams with consumer groups for persistence |
| Same TTL on all keys (thundering herd) | Add jitter: EX (base + random(0, spread)) |
No maxmemory set in production | Set maxmemory + eviction policy explicitly |
Using MULTI/EXEC for locking | Use SET ... NX PX for distributed locks |
| Storing large blobs (>1 MB values) | Store references; keep values small |
| No health check in Docker Compose | Add valkey-cli ping health check |
Ignoring requirepass in production | Always set authentication + ACLs |
Delegation
- Discover caching patterns and data model review: Use
Exploreagent - Plan migration strategy from Redis to Valkey: Use
Planagent - Implement full caching layer with tests: Use
Taskagent
If the docker skill is available, delegate Compose networking and multi-stage build patterns to it.If the performance-optimizer skill is available, delegate application-level caching strategy to it.If the database-security skill is available, delegate ACL and TLS configuration review to it.References
- Data structures and commands -- Strings, hashes, lists, sets, sorted sets, streams, HyperLogLog, bitmaps, geospatial
- Caching patterns -- Cache-aside, write-through, TTL strategies, eviction policies, client-side caching, invalidation
- Common patterns -- Rate limiting, distributed locks, queues, session storage, pub/sub, streams, leaderboards
- Docker and deployment -- Compose setup, persistence, replication, Sentinel, Cluster, security, migration from Redis
Cache-Aside (Lazy Loading)
The application checks the cache first. On miss, reads from the database, then populates the cache. Most common pattern.
async function getUser(id: string): Promise<User> {
const cached = await valkey.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await db.user.findUnique({ where: { id } });
if (user) {
await valkey.set(`user:${id}`, JSON.stringify(user), 'EX', 3600);
}
return user;
}Pros: Only caches what is actually requested. Cache failures do not break reads. Cons: Cache miss adds latency (DB read + cache write). Data can go stale.
Write-Through
Write to both cache and database on every write. Cache is always current.
async function updateUser(id: string, data: Partial<User>): Promise<User> {
const user = await db.user.update({ where: { id }, data });
await valkey.set(`user:${id}`, JSON.stringify(user), 'EX', 3600);
return user;
}Pros: Cache always reflects latest writes. No stale data for known keys. Cons: Write latency increases. Caches data that may never be read.
Write-Behind (Write-Back)
Write to cache immediately, flush to database asynchronously. Higher throughput but risk of data loss on crash.
Best implemented with Streams as a write buffer:
async function updateCounter(id: string, amount: number): Promise<void> {
await valkey.incrby(`counter:${id}`, amount);
await valkey.xadd('flush:counters', '*', 'id', id, 'amount', String(amount));
}A background worker consumes the stream and persists to the database in batches.
TTL Strategies
| Strategy | Command | Use Case |
|---|---|---|
| Fixed TTL | SET key val EX 3600 | General caching |
| Sliding TTL | EXPIRE key 1800 on each access | Session storage |
| Staggered TTL | EX (base + random(0, spread)) | Prevent thundering herd |
| Short TTL | EX 60 | Frequently changing data |
| Long safety net | EX 86400 | Rarely changes but should eventually expire |
Thundering Herd Prevention
When many keys expire at the same time, all cache misses hit the database simultaneously:
function ttlWithJitter(baseTtl: number, jitterPercent = 10): number {
const jitter = Math.floor(Math.random() * baseTtl * (jitterPercent / 100));
return baseTtl + jitter;
}
await valkey.set(key, value, 'EX', ttlWithJitter(3600));Stale-While-Revalidate
Serve stale data immediately, refresh in the background:
async function getWithSWR(
key: string,
fetchFn: () => Promise<string>,
): Promise<string> {
const ttl = await valkey.ttl(key);
const value = await valkey.get(key);
if (value && ttl < 300) {
// Less than 5 minutes left — refresh in background
fetchFn().then((fresh) => valkey.set(key, fresh, 'EX', 3600));
}
if (value) return value;
const fresh = await fetchFn();
await valkey.set(key, fresh, 'EX', 3600);
return fresh;
}Eviction Policies
Set with maxmemory and maxmemory-policy in configuration.
| Policy | Scope | Algorithm |
|---|---|---|
noeviction | N/A | Returns error on writes when full |
allkeys-lru | All keys | Least Recently Used |
allkeys-lfu | All keys | Least Frequently Used |
allkeys-random | All keys | Random eviction |
volatile-lru | Keys with TTL | LRU among expiring keys |
volatile-lfu | Keys with TTL | LFU among expiring keys |
volatile-random | Keys with TTL | Random among expiring keys |
volatile-ttl | Keys with TTL | Shortest remaining TTL first |
Choosing a Policy
- `allkeys-lru` — best default for most workloads (power-law access patterns)
- `allkeys-lfu` — when frequency matters more than recency (popular items stay cached)
- `volatile-ttl` — when TTL hints are set intentionally and should drive eviction
- `noeviction` — when data loss is unacceptable (session store, queues)
Tuning
maxmemory 256mb
maxmemory-policy allkeys-lru
maxmemory-samples 10 # Higher = closer to true LRU (default 5)
# LFU tuning (only relevant with *-lfu policies)
lfu-log-factor 10 # Higher = more hits needed to saturate counter
lfu-decay-time 1 # Minutes before counter halvesMonitor eviction with INFO stats — check evicted_keys.
Client-Side Caching
Server-assisted caching where Valkey pushes invalidation messages when cached keys change.
Tracking Mode (Default)
Server tracks which keys each client reads. When a key changes, the server sends an invalidation to clients that cached it.
CLIENT TRACKING ON REDIRECT 42 # 42 = client ID for invalidation channelBroadcasting Mode
Clients subscribe to key prefixes. Less server memory but more invalidation messages.
CLIENT TRACKING ON BCAST PREFIX user: PREFIX product:OPTIN Mode
Clients explicitly opt-in per key:
CLIENT CACHING YES
GET user:123 # Server tracks this key for this clientNOLOOP
Prevents receiving invalidation for keys you modified yourself:
CLIENT TRACKING ON NOLOOPCache Invalidation
| Method | Command | Use Case |
|---|---|---|
| TTL-based | EXPIRE key ttl | Natural expiration |
| Active delete | DEL key or UNLINK key | On write/update |
| Pattern delete | SCAN + UNLINK | Invalidate by prefix |
| Keyspace notifications | CONFIG SET notify-keyspace-events KEA | React to key changes |
| Client tracking | CLIENT TRACKING ON | Server-pushed invalidation |
Pattern Invalidation with SCAN
Never use KEYS in production. Use SCAN for safe iteration:
async function invalidatePattern(pattern: string): Promise<number> {
let cursor = '0';
let deleted = 0;
do {
const [next, keys] = await valkey.scan(
cursor,
'MATCH',
pattern,
'COUNT',
100,
);
cursor = next;
if (keys.length > 0) {
deleted += await valkey.unlink(...keys);
}
} while (cursor !== '0');
return deleted;
}
await invalidatePattern('user:123:*');Memory Monitoring
INFO memory # Memory usage summary
MEMORY USAGE key # Bytes for a specific key
MEMORY DOCTOR # Diagnostic suggestions
DBSIZE # Total key count
INFO keyspace # Keys per database with TTL statsRate Limiting
Fixed Window
Simple counter per time window. Allows burst at window boundaries.
async function isRateLimited(
userId: string,
limit: number,
windowSec: number,
): Promise<boolean> {
const key = `rate:${userId}:${Math.floor(Date.now() / 1000 / windowSec)}`;
const count = await valkey.incr(key);
if (count === 1) await valkey.expire(key, windowSec);
return count > limit;
}Sliding Window (Sorted Set)
More precise — no boundary burst. Uses sorted set with timestamps.
async function isRateLimited(
userId: string,
limit: number,
windowMs: number,
): Promise<boolean> {
const key = `rate:${userId}`;
const now = Date.now();
const windowStart = now - windowMs;
const pipeline = valkey.pipeline();
pipeline.zremrangebyscore(key, 0, windowStart);
pipeline.zadd(key, now, `${now}:${Math.random()}`);
pipeline.zcard(key);
pipeline.expire(key, Math.ceil(windowMs / 1000));
const results = await pipeline.exec();
const count = results[2][1] as number;
return count > limit;
}Distributed Locks
Single-Instance Lock
async function acquireLock(
resource: string,
ttlMs: number,
): Promise<string | null> {
const token = crypto.randomUUID();
const result = await valkey.set(`lock:${resource}`, token, 'NX', 'PX', ttlMs);
return result === 'OK' ? token : null;
}Safe Unlock (Lua Script)
Ensures only the lock holder can release:
const UNLOCK_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
async function releaseLock(resource: string, token: string): Promise<boolean> {
const result = await valkey.eval(UNLOCK_SCRIPT, 1, `lock:${resource}`, token);
return result === 1;
}Valkey 9.0+ has DELIFEQ — delete if value equals, replacing the Lua script:
DELIFEQ lock:resource "token-value"Lock with Retry
async function withLock<T>(
resource: string,
ttlMs: number,
fn: () => Promise<T>,
retries = 3,
retryDelayMs = 200,
): Promise<T> {
for (let i = 0; i < retries; i++) {
const token = await acquireLock(resource, ttlMs);
if (token) {
try {
return await fn();
} finally {
await releaseLock(resource, token);
}
}
await new Promise((r) => setTimeout(r, retryDelayMs * (i + 1)));
}
throw new Error(`Failed to acquire lock on ${resource}`);
}Redlock (Multi-Instance)
For fault tolerance, acquire locks on N/2+1 out of N independent Valkey instances within a time budget. Use the redlock npm package for the algorithm implementation.
Queues
Simple Queue (List)
// Producer
await valkey.lpush(
'queue:emails',
JSON.stringify({ to: 'user@example.com', subject: 'Welcome' }),
);
// Consumer (blocking)
const [, message] = await valkey.brpop('queue:emails', 30); // 30s timeout
if (message) {
const job = JSON.parse(message);
await sendEmail(job);
}Reliable Queue (LMOVE)
Move to a processing list before handling. If the consumer crashes, the message is not lost.
LMOVE queue:emails queue:emails:processing RIGHT LEFT
# Process the message...
LREM queue:emails:processing 1 "message"Stream-Based Queue (Recommended)
Consumer groups provide at-least-once delivery, acknowledgment, and multiple consumers.
// Create consumer group (once)
await valkey.xgroup('CREATE', 'stream:tasks', 'workers', '$', 'MKSTREAM');
// Producer
await valkey.xadd(
'stream:tasks',
'*',
'type',
'process',
'payload',
JSON.stringify(data),
);
// Consumer
const entries = await valkey.xreadgroup(
'GROUP',
'workers',
'consumer1',
'COUNT',
1,
'BLOCK',
5000,
'STREAMS',
'stream:tasks',
'>',
);
if (entries) {
const [, messages] = entries[0];
for (const [id, fields] of messages) {
await processTask(fields);
await valkey.xack('stream:tasks', 'workers', id);
}
}Session Storage
Hash-Based Sessions
async function createSession(
sessionId: string,
data: Record<string, string>,
): Promise<void> {
const key = `session:${sessionId}`;
await valkey.hset(key, data);
await valkey.expire(key, 1800); // 30 minutes
}
async function getSession(
sessionId: string,
): Promise<Record<string, string> | null> {
const key = `session:${sessionId}`;
const data = await valkey.hgetall(key);
if (Object.keys(data).length === 0) return null;
// Sliding expiration
await valkey.expire(key, 1800);
return data;
}
async function destroySession(sessionId: string): Promise<void> {
await valkey.unlink(`session:${sessionId}`);
}String-Based Sessions (JSON)
Simpler but cannot update individual fields without read-modify-write:
await valkey.set(`session:${id}`, JSON.stringify(sessionData), 'EX', 1800);
const session = JSON.parse(await valkey.get(`session:${id}`));Hash-based is preferred when individual fields are read or updated independently.
Pub/Sub
Fire-and-forget messaging. Messages are NOT persisted — if no subscriber is listening, the message is lost.
// Subscriber
const sub = valkey.duplicate(); // Dedicated connection for subscriptions
await sub.subscribe('notifications:user:123');
sub.on('message', (channel, message) => {
const event = JSON.parse(message);
handleNotification(event);
});
// Publisher (from any connection)
await valkey.publish(
'notifications:user:123',
JSON.stringify({ type: 'new_message', from: 'Alice' }),
);Pattern Subscriptions
await sub.psubscribe('notifications:*');
sub.on('pmessage', (pattern, channel, message) => {
// pattern = 'notifications:*'
// channel = 'notifications:user:123'
});Use Pub/Sub for: real-time notifications, cache invalidation broadcasts, live dashboards. Use Streams instead when: you need message persistence, replay, or at-least-once delivery.
Leaderboards
Sorted sets provide O(log N) rank operations — ideal for leaderboards at any scale.
// Add or update score
await valkey.zadd('leaderboard:weekly', score, `player:${userId}`);
// Increment score
await valkey.zincrby('leaderboard:weekly', pointsEarned, `player:${userId}`);
// Top 10 with scores
const top10 = await valkey.zrevrange('leaderboard:weekly', 0, 9, 'WITHSCORES');
// Player rank (0-based)
const rank = await valkey.zrevrank('leaderboard:weekly', `player:${userId}`);
// Players in score range
const tier = await valkey.zrangebyscore(
'leaderboard:weekly',
1000,
2000,
'WITHSCORES',
);Counters
Atomic increment/decrement operations. No race conditions.
// Page views
await valkey.incr('page:views:home');
// Credits
await valkey.incrby(`user:credits:${userId}`, 50);
// Hash field counters
await valkey.hincrby('stats:daily', 'signups', 1);
await valkey.hincrby('stats:daily', 'api_calls', 1);
// Decrement
await valkey.decrby(`user:credits:${userId}`, 10);Pipelining
Send multiple commands without waiting for individual responses. Reduces round-trip overhead.
const pipeline = valkey.pipeline();
pipeline.set('key1', 'val1', 'EX', 3600);
pipeline.set('key2', 'val2', 'EX', 3600);
pipeline.get('key3');
pipeline.incr('counter');
const results = await pipeline.exec();
// results = [[null, 'OK'], [null, 'OK'], [null, 'val3'], [null, 42]]Pipeline in batches of ~1,000-10,000 commands to balance throughput and memory.
Strings
Binary-safe sequences up to 512 MB. The most basic type — used for caching, counters, and flags.
SET user:123:name "Alice" EX 3600 # Set with 1-hour TTL
GET user:123:name # Retrieve value
MSET k1 "v1" k2 "v2" # Set multiple atomically
MGET k1 k2 # Get multiple
INCR page:views:home # Atomic increment (returns new value)
INCRBY user:credits:123 50 # Increment by amount
SETNX lock:resource "token" # Set only if not existsKey flags for SET:
| Flag | Meaning |
|---|---|
EX seconds | TTL in seconds |
PX milliseconds | TTL in milliseconds |
NX | Only set if key does not exist |
XX | Only set if key already exists |
GET | Return old value before setting |
Hashes
Field-value maps attached to a single key. Ideal for objects and structured data.
HSET user:123 name "Alice" email "alice@example.com" role "admin"
HGET user:123 name # Single field
HMGET user:123 name email # Multiple fields
HGETALL user:123 # All fields and values
HDEL user:123 role # Remove field
HINCRBY user:123 login_count 1 # Increment numeric field
HEXISTS user:123 email # Check field existence
HKEYS user:123 # All field names
HLEN user:123 # Field countValkey 9.0+ supports hash field expiration — expire individual fields without destroying the whole key:
HGETEX user:123 FIELDS 1 temp_token EX 300 # Get field, set 5-min TTL on itLists
Ordered collections (insertion order). Implemented as linked lists — O(1) push/pop, O(N) index access.
RPUSH queue:emails "msg1" "msg2" # Append to tail
LPUSH queue:emails "msg0" # Prepend to head
RPOP queue:emails # Pop from tail
LPOP queue:emails # Pop from head
LRANGE queue:emails 0 -1 # Get all elements
LLEN queue:emails # List length
BRPOP queue:emails 30 # Blocking pop (30s timeout)
LMOVE src dest RIGHT LEFT # Atomic move between listsSets
Unordered collections of unique strings. O(1) membership check.
SADD tags:post:1 "typescript" "react" "testing"
SREM tags:post:1 "testing" # Remove member
SISMEMBER tags:post:1 "react" # Check membership (O(1))
SMEMBERS tags:post:1 # All members
SCARD tags:post:1 # Count
SINTER tags:post:1 tags:post:2 # Intersection
SUNION tags:post:1 tags:post:2 # Union
SDIFF tags:post:1 tags:post:2 # Difference
SRANDMEMBER tags:post:1 2 # 2 random membersSorted Sets
Unique strings ordered by floating-point score. O(log N) for most operations. The backbone of leaderboards, priority queues, and time-series indexes.
ZADD leaderboard 1500 "player:1" 1200 "player:2" 1800 "player:3"
ZREVRANGE leaderboard 0 9 WITHSCORES # Top 10 (highest first)
ZRANGE leaderboard 0 9 WITHSCORES # Bottom 10 (lowest first)
ZRANK leaderboard "player:1" # Rank (0-based, ascending)
ZREVRANK leaderboard "player:1" # Rank (0-based, descending)
ZSCORE leaderboard "player:1" # Get score
ZINCRBY leaderboard 10 "player:1" # Increment score
ZRANGEBYSCORE leaderboard 1000 2000 # Members in score range
ZREM leaderboard "player:2" # Remove member
ZCARD leaderboard # CountStreams
Append-only log for event sourcing and message queuing. Persistent, replayable, with consumer groups.
# Produce
XADD events:orders * action "created" order_id "ord_123" total "4999"
# Consume (simple)
XREAD COUNT 10 BLOCK 5000 STREAMS events:orders 0
XRANGE events:orders - + # All entries
XLEN events:orders # Stream length
# Consumer groups (at-least-once delivery)
XGROUP CREATE events:orders workers $ MKSTREAM
XREADGROUP GROUP workers consumer1 COUNT 1 BLOCK 5000 STREAMS events:orders >
XACK events:orders workers 1234567890-0 # Acknowledge processing
XPENDING events:orders workers # View unacknowledged
XTRIM events:orders MAXLEN ~ 10000 # Trim to ~10K entriesStreams vs Pub/Sub
| Feature | Pub/Sub | Streams |
|---|---|---|
| Persistence | No | Yes |
| Replay | No | Yes (XRANGE) |
| Consumer groups | No | Yes (XGROUP) |
| Acknowledgment | No | Yes (XACK) |
| Delivery | At-most-once | At-most-once or at-least-once |
| Use case | Real-time notifications | Event sourcing, task queues |
HyperLogLog
Probabilistic cardinality estimation using ~12 KB regardless of set size. 0.81% standard error.
PFADD unique:visitors:2025-02 "user:1" "user:2" "user:3"
PFCOUNT unique:visitors:2025-02 # Approximate unique count
PFMERGE unique:visitors:q1 unique:visitors:2025-01 unique:visitors:2025-02Use for: unique visitor counts, distinct event tracking, cardinality where exact precision is not required.
Bitmaps
Bit-level operations on strings. Memory-efficient for boolean flags across large ID spaces.
SETBIT feature:dark-mode 1001 1 # User 1001 enabled dark mode
GETBIT feature:dark-mode 1001 # Check flag
BITCOUNT feature:dark-mode # Count enabled users
BITOP AND both:features feature:dark-mode feature:beta
BITPOS feature:dark-mode 1 # First user with flag setUse for: feature flags, daily active user tracking, presence indicators.
Geospatial
Store and query geographic coordinates using sorted sets internally.
GEOADD stores -122.4194 37.7749 "sf-downtown" -73.9857 40.7484 "nyc-midtown"
GEOPOS stores "sf-downtown" # Get coordinates
GEODIST stores "sf-downtown" "nyc-midtown" km # Distance
GEOSEARCH stores FROMLONLAT -122.4 37.8 BYRADIUS 10 km ASC COUNT 5Key Management
Commands that apply across all data types:
EXISTS key # Check existence (returns 0 or 1)
TYPE key # Get data type
TTL key # Remaining TTL in seconds (-1 = no TTL, -2 = expired)
EXPIRE key 3600 # Set TTL
PERSIST key # Remove TTL
RENAME key newkey # Rename
UNLINK key # Async delete (non-blocking)
SCAN 0 MATCH "user:*" COUNT 100 # Iterate keys (cursor-based)
OBJECT ENCODING key # Internal encoding (ziplist, hashtable, etc.)
MEMORY USAGE key # Bytes used by keyDocker Compose
Development Setup
services:
valkey:
image: valkey/valkey:8.1-alpine
ports:
- '6379:6379'
volumes:
- valkey-data:/data
command: valkey-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
healthcheck:
test: ['CMD', 'valkey-cli', 'ping']
interval: 10s
timeout: 5s
retries: 3
restart: unless-stopped
volumes:
valkey-data:Production Setup
services:
valkey:
image: valkey/valkey:8.1-alpine
ports:
- '6379:6379'
volumes:
- valkey-data:/data
command: >
valkey-server
--appendonly yes
--appendfsync everysec
--maxmemory 1gb
--maxmemory-policy allkeys-lru
--requirepass ${VALKEY_PASSWORD}
--bind 0.0.0.0
--protected-mode yes
--save 900 1
--save 300 10
--save 60 10000
healthcheck:
test: ['CMD', 'valkey-cli', '-a', '${VALKEY_PASSWORD}', 'ping']
interval: 10s
timeout: 5s
retries: 3
deploy:
resources:
limits:
memory: 1280m
restart: unless-stopped
volumes:
valkey-data:With Application Service
services:
app:
build: .
environment:
VALKEY_URL: valkey://valkey:6379
depends_on:
valkey:
condition: service_healthy
valkey:
image: valkey/valkey:8.1-alpine
volumes:
- valkey-data:/data
command: valkey-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
healthcheck:
test: ['CMD', 'valkey-cli', 'ping']
interval: 10s
timeout: 5s
retries: 3
volumes:
valkey-data:Valkey Bundle (With Modules)
Includes JSON, Bloom, and Search modules:
services:
valkey:
image: valkey/valkey-bundle:latest
ports:
- '6379:6379'
volumes:
- valkey-data:/dataPersistence
RDB (Point-in-Time Snapshots)
save 900 1 # Snapshot if 1+ keys changed in 900 seconds
save 300 10 # Snapshot if 10+ keys changed in 300 seconds
save 60 10000 # Snapshot if 10000+ keys changed in 60 seconds
dbfilename dump.rdb
dir /dataGood for backups. Some data loss on crash (up to the last snapshot interval).
AOF (Append-Only File)
appendonly yes
appendfsync everysec # Options: always, everysec, no
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mbappendfsync | Durability | Performance |
|---|---|---|
always | No data loss | Slowest |
everysec | Up to 1 second loss | Balanced (recommended) |
no | OS-dependent | Fastest |
RDB + AOF (Recommended)
Enable both. On restart, AOF is used for recovery (more complete). RDB provides efficient backups.
Replication
Asynchronous primary-replica replication for read scaling and high availability.
# On replica (valkey.conf)
replicaof primary-host 6379
replica-read-only yesDocker Compose with replication:
services:
valkey-primary:
image: valkey/valkey:8.1-alpine
command: valkey-server --appendonly yes
volumes:
- primary-data:/data
valkey-replica:
image: valkey/valkey:8.1-alpine
command: valkey-server --replicaof valkey-primary 6379 --replica-read-only yes
depends_on:
- valkey-primary
volumes:
primary-data:Sentinel (High Availability)
Automatic failover for primary-replica deployments. Run 3+ Sentinel instances for quorum.
# sentinel.conf
sentinel monitor myvalkey valkey-primary 6379 2 # 2 = quorum
sentinel down-after-milliseconds myvalkey 5000
sentinel failover-timeout myvalkey 60000
sentinel parallel-syncs myvalkey 1Clients connect to Sentinel to discover the current primary:
import Valkey from 'iovalkey';
const valkey = new Valkey({
sentinels: [
{ host: 'sentinel-1', port: 26379 },
{ host: 'sentinel-2', port: 26379 },
{ host: 'sentinel-3', port: 26379 },
],
name: 'myvalkey',
});Cluster Mode
Data partitioned across nodes using 16,384 hash slots. Horizontal scaling.
# valkey.conf (each node)
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000Client connection:
import Valkey from 'iovalkey';
const cluster = new Valkey.Cluster([
{ host: 'node-1', port: 6379 },
{ host: 'node-2', port: 6379 },
{ host: 'node-3', port: 6379 },
]);Valkey 9.0 improvements: atomic slot migration, numbered databases in cluster mode, scaling to 2,000+ nodes.
Security
Authentication
# Single password (legacy)
requirepass your_strong_password
# ACL (recommended)
user default off
user appuser on >password ~app:* +@read +@write -@admin
user readonly on >readpass ~* +@readTLS
tls-port 6380
tls-cert-file /path/to/valkey.crt
tls-key-file /path/to/valkey.key
tls-ca-cert-file /path/to/ca.crt
tls-auth-clients yesHardening Checklist
- Set
requirepassor configure ACLs - Bind to specific interfaces:
bind 127.0.0.1 - Enable
protected-mode yeswhen no auth is set - Disable dangerous commands via ACLs or
rename-command FLUSHALL "" - Run as unprivileged user (default in Docker image)
- Never expose port 6379 directly to the internet
Client Libraries
Node.js / TypeScript Options
| Package | Description | Recommendation |
|---|---|---|
ioredis | Popular Redis client, works with Valkey unchanged | Existing projects |
iovalkey | Official Valkey fork of ioredis, identical API | New projects (official maintenance) |
redis (node-redis) | Official Redis client, works with Valkey unchanged | If already using |
@valkey/valkey-glide | Rust-core client with Node.js bindings | Maximum performance |
Connection Example (iovalkey / ioredis)
import Valkey from 'iovalkey';
const valkey = new Valkey({
host: 'localhost',
port: 6379,
password: process.env.VALKEY_PASSWORD,
maxRetriesPerRequest: 3,
retryStrategy(times) {
return Math.min(times * 50, 2000);
},
});
valkey.on('error', (err) => console.error('Valkey connection error:', err));
valkey.on('connect', () => console.log('Connected to Valkey'));Connection URL
const valkey = new Valkey('redis://user:password@host:6379/0');The redis:// scheme works — Valkey is protocol-compatible.
Migration from Redis
What Changes
| Aspect | Redis | Valkey |
|---|---|---|
| Binary | redis-server / redis-cli | valkey-server / valkey-cli |
| Config file | redis.conf | valkey.conf |
| License | RSALv2 / SSPLv1 | BSD 3-Clause |
| Docker image | redis | valkey/valkey |
What Stays the Same
- All commands and data structures
- Wire protocol (RESP2 / RESP3)
- RDB and AOF file formats (through Redis 7.2)
- Client libraries (no code changes needed)
- Cluster, Sentinel, and replication protocols
- Lua scripting and module API
- Default port (6379)
Migration Steps
Minimal downtime (replication):
1. Start Valkey instance 2. Configure Valkey as replica of Redis: replicaof redis-host 6379 3. Wait for sync to complete: INFO replication shows master_link_status:up 4. Stop writes to Redis 5. Promote Valkey: replicaof no one 6. Update application connection strings 7. Resume writes
Zero downtime (DNS swap):
1. Run Valkey as replica until caught up 2. Promote Valkey to primary 3. Update DNS or load balancer to point to Valkey 4. Decommission Redis
Physical (requires downtime):
1. Stop Redis 2. Copy RDB file to Valkey data directory 3. Start Valkey
Redis CE 7.4+ data files are NOT compatible with Valkey. Migrate from 7.4+ using replication or export/import.