Redis (or its open-source fork Valkey) is an in-memory key-value server. Vukem uses it as a shared backend: when your app runs on more than one server, the cache, sessions, queue, rate limits and locks must live somewhere every server can see. One Redis gives you all five. The step-by-step guide at the end starts from zero.
go get github.com/AlexGalhardo/vukem/drivers/redisrc, err := redis.Open(ctx, os.Getenv("REDIS_URL")) // redis://localhost:6379/0, rediss:// for TLS
app, err := vukem.New(
vukem.WithCacheStore(rc.CacheStore("cache:")), // also backs locks
vukem.WithSessionStore(rc.SessionStore("")), // "session:" keys with TTL
vukem.WithQueueDriver(rc.Queue(redis.QueueOptions{})),
// ...
)
app.AddHealthCheck("redis", rc.Ping)Reading REDIS_URL from the app configuration#
os.Getenv above runs before vukem.New loads .env, .env.local and the sources vukemtest passes. To read the URL from app.Source, use the lazy options: each function runs on the first app.Cache(), app.Sessions() or app.Queue() call, after configuration loaded, and its error is returned by that call (DefaultMiddleware builds sessions at boot, so a bad URL is still a boot error).
var (
once sync.Once
rc *redis.Client
rcErr error
)
client := func(app *vukem.App) (*redis.Client, error) {
once.Do(func() {
if rc, rcErr = redis.Open(context.Background(), app.Source.Get("REDIS_URL")); rcErr != nil {
return
}
app.AddHealthCheck("redis", rc.Ping)
app.OnShutdown(func(context.Context) error { return rc.Close() })
})
return rc, rcErr
}
app, err := vukem.New(
vukem.WithCacheStoreFunc(func(app *vukem.App) (cache.Store, error) {
c, err := client(app)
if err != nil {
return nil, err
}
return c.CacheStore("cache:"), nil
}),
vukem.WithSessionStoreFunc(func(app *vukem.App) (session.Store, error) {
c, err := client(app)
if err != nil {
return nil, err
}
return c.SessionStore(""), nil
}),
vukem.WithQueueDriverFunc(func(app *vukem.App) (queue.Driver, error) {
c, err := client(app)
if err != nil {
return nil, err
}
return c.Queue(redis.QueueOptions{}), nil
}),
)The last of WithCacheStore / WithCacheStoreFunc (and likewise for sessions and queues) wins; SetSessionStore from a provider still overrides both. A function that returns nil, nil is an error. A function must not call the accessor it feeds (app.Cache() inside WithCacheStoreFunc, app.Sessions() or DefaultMiddleware inside WithSessionStoreFunc, app.Queue() inside WithQueueDriverFunc): it runs inside that accessor's sync.Once and would deadlock.
| Feature | API |
|---|---|
| Cache store | rc.CacheStore(prefix) implements cache.Store; Flush deletes only the prefix (SCAN + DEL) and refuses an empty one; it declares cache.Sharer (Shared() is true), so OnOneServer() and RATE_LIMIT_STORE=cache accept it. Upgrade drivers/redis together with core: a driver release without Shared() is treated as not shared and refused |
| Sessions | rc.SessionStore(prefix) implements session.ListStore: RevokeOthers, UserSessions and RevokeSession work as on the database store. Keys hold SHA-256 of the session id, never the cookie value; sessions saved under the clear id by older versions move to the hashed key on their first load. Each session records its owner (<prefix>owner:<hash>) and each user a set of session hashes (<prefix>user:<owner>); every command touches one key (Redis Cluster works), so listing or revoking costs three round trips per session of the user; needs Redis 6.2+ |
| Queue | rc.Queue(redis.QueueOptions{Prefix, Grace}): sorted sets under {queue}: (one Cluster slot), atomic Lua reservation, failed jobs for queue:failed, queue:retry, queue:forget |
| Rate limiting | rc.RateLimiter("api:", 60, time.Minute) implements ratelimit.Limiter with GCRA, shared by every server |
| Locks | cache.New(rc.CacheStore("locks:"), "").NewLock(name, ttl) |
| Pub/sub | rc.Publish(ctx, channel, b), msgs, err := rc.Subscribe(ctx, channels...) |
- Works with Redis 7+ and Valkey 8+; CI runs the
cachetestandqueuetestconformance suites on both. Openpings the server, so the app does not boot while Redis is down; a malformed URL always fails.redis.OpenLazy(url)only parses the URL and connects on first use, so the process starts andrc.Pingreports the outage in a readiness check (app.AddHealthCheck). go-redis retries a failed command 3 times with backoff, so thatPingtakes seconds while Redis is down; for a fast readiness answer set?dial_timeout=1s&max_retries=-1on the URL (or use a separate client with those options for the check).redis.New(client)wraps an existing go-redis client (Cluster, Sentinel, custom options);rc.Redis()returns it.
Step by step: sessions and cache on Redis#
1. Start Redis locally
services:
redis:
image: redis:7.4
ports: ["6379:6379"]docker compose up -d redis2. Add the driver and the URL
go get github.com/AlexGalhardo/vukem/drivers/redisREDIS_URL=redis://localhost:6379/03. Plug Redis into the app
Use the lazy options shown in Reading REDIS_URL from the app configuration, so the URL comes from .env and from vukemtest sources. For a quick start, the eager form is enough:
rc, err := redis.Open(ctx, os.Getenv("REDIS_URL")) // fails when Redis is down
if err != nil {
return nil, err
}
app, err := vukem.New(
vukem.WithCacheStore(rc.CacheStore("myapp:cache:")),
vukem.WithSessionStore(rc.SessionStore("myapp:session:")),
// ...
)
if err != nil {
return nil, err
}
app.AddHealthCheck("redis", rc.Ping)
app.OnShutdown(func(context.Context) error { return rc.Close() })4. Share rate limits
RATE_LIMIT_STORE=cache # limiters count in app.Cache(), now RedisOr use the Redis GCRA limiter directly: ratelimit.Middleware(rc.RateLimiter("login:", 5, time.Minute), ratelimit.ByIP).
5. Check that it works
vukem serve
curl -s -c jar.txt http://localhost:3000/ > /dev/null # creates a session
docker compose exec redis redis-cli --scan --pattern 'myapp:*'
curl -s 'http://localhost:3000/up?checks=1' # "redis" is reportedPub/sub#
msgs, err := rc.Subscribe(ctx, "orders") // the channel closes when ctx ends
if err != nil {
return err
}
go func() {
for b := range msgs {
log.Printf("order event: %s", b)
}
}()
err = rc.Publish(ctx, "orders", []byte(`{"id":42}`))Pub/sub is fire-and-forget: subscribers that are offline miss messages. Use the queue for work that must not be lost.
Pitfalls#
- One Redis, several apps. Give every store its own prefix per app (
myapp:cache:,myapp:session:), or two apps read each other's keys. - Eviction policies. A Redis configured with
maxmemory-policy allkeys-lrumay evict sessions and queued jobs under memory pressure. Usenoeviction(orvolatile-lru) for a Redis that holds sessions or queues, or run a separate one for the cache. os.Getenvbefore.envloads. The eager form reads the process environment only; use the lazyWith...Funcoptions to readapp.Source.- Slow readiness checks while Redis is down. go-redis retries; add
?dial_timeout=1s&max_retries=-1to the URL used by the health check. - Persistence. A default Redis may lose recent writes on restart. Queues and sessions need AOF or RDB persistence enabled (managed Redis usually has it).
Security notes#
- Never expose Redis to the internet. Bind it to a private network, require a password (
redis://:password@host:6379/0) and userediss://(TLS) across networks. - Keep the password in an environment variable, never in code or in a committed
.env. - Sessions in Redis hold only session data; still, anyone with Redis access can read or forge sessions, so treat Redis credentials like database credentials.