9.6 KiB
9.6 KiB
Redis Migration Guide
Overview
This guide covers migrating from in-memory KV storage to Redis for multi-instance SaaS deployments. The current in-memory session storage works well for single-instance deployments but requires centralized storage for horizontal scaling.
When to Migrate
Current Setup (Sufficient For):
- Single-instance deployments
- Development environments
- Small-scale production deployments
Redis Required For:
- Multiple server instances behind load balancer
- Container orchestration (Kubernetes, Docker Swarm)
- High-availability SaaS deployments
- Session persistence across server restarts
What Needs Migration
Critical Data (Must Migrate)
- Sessions (
sessionsKV
): Essential for authentication across instances - CSRF tokens: Stored within session objects
- Rate limiting data: Currently in-memory Map in
rate-limiter.ts
Optional Data (Can Remain File-based)
- Bookings, treatments, reviews, gallery photos
- Can stay in file-based KV for now
- Migrate later if performance becomes an issue
Redis Setup
Installation Options
Self-hosted Redis
# Docker
docker run -d -p 6379:6379 redis:alpine
# Ubuntu/Debian
sudo apt update
sudo apt install redis-server
# macOS (Homebrew)
brew install redis
brew services start redis
Managed Services
- Redis Cloud: Managed Redis service
- AWS ElastiCache: Redis-compatible cache
- Azure Cache for Redis: Microsoft's managed service
- DigitalOcean Managed Databases: Redis option available
Configuration
Add to .env
:
# Redis Configuration (required for multi-instance deployments)
REDIS_URL=redis://localhost:6379
REDIS_TLS_ENABLED=false # Set to true for production
REDIS_PASSWORD=your_redis_password # Optional
Code Changes
1. Install Redis Client
pnpm add ioredis
pnpm add -D @types/ioredis
2. Create Redis KV Adapter
Create src/server/lib/create-redis-kv.ts
:
import Redis from 'ioredis';
interface RedisKVStore<T> {
getItem(key: string): Promise<T | null>;
setItem(key: string, value: T): Promise<void>;
removeItem(key: string): Promise<void>;
getAllItems(): Promise<T[]>;
subscribe(): AsyncIterable<void>;
}
export function createRedisKV<T>(namespace: string): RedisKVStore<T> {
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
retryDelayOnFailover: 100,
enableReadyCheck: false,
maxRetriesPerRequest: null,
lazyConnect: true,
});
const prefix = `${namespace}:`;
return {
async getItem(key: string): Promise<T | null> {
const value = await redis.get(`${prefix}${key}`);
return value ? JSON.parse(value) : null;
},
async setItem(key: string, value: T): Promise<void> {
await redis.set(`${prefix}${key}`, JSON.stringify(value));
},
async removeItem(key: string): Promise<void> {
await redis.del(`${prefix}${key}`);
},
async getAllItems(): Promise<T[]> {
const keys = await redis.keys(`${prefix}*`);
if (keys.length === 0) return [];
const values = await redis.mget(...keys);
return values
.filter(v => v !== null)
.map(v => JSON.parse(v!));
},
async* subscribe(): AsyncIterable<void> {
const pubsub = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
await pubsub.subscribe(`${namespace}:changes`);
for await (const message of pubsub) {
if (message[0] === 'message' && message[1] === `${namespace}:changes`) {
yield;
}
}
}
};
}
// Helper to notify subscribers of changes
export async function notifyRedisChanges(namespace: string): Promise<void> {
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
await redis.publish(`${namespace}:changes`, 'update');
await redis.quit();
}
3. Update Session Storage
In src/server/lib/auth.ts
:
import { createRedisKV } from './create-redis-kv.js';
// Replace this line:
// export const sessionsKV = createKV<Session>("sessions");
// With this:
export const sessionsKV = createRedisKV<Session>("sessions");
4. Update Rate Limiter
In src/server/lib/rate-limiter.ts
:
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
retryDelayOnFailover: 100,
enableReadyCheck: false,
maxRetriesPerRequest: null,
lazyConnect: true,
});
export async function checkBookingRateLimit(
email?: string,
ip?: string
): Promise<{ allowed: boolean; resetTime?: number }> {
const now = Date.now();
const windowMs = 15 * 60 * 1000; // 15 minutes
const maxRequests = 3;
const results = await Promise.all([
email ? checkRateLimit(`booking:email:${email}`, maxRequests, windowMs) : { allowed: true },
ip ? checkRateLimit(`booking:ip:${ip}`, maxRequests, windowMs) : { allowed: true }
]);
return {
allowed: results.every(r => r.allowed),
resetTime: results.find(r => !r.allowed)?.resetTime
};
}
async function checkRateLimit(
key: string,
maxRequests: number,
windowMs: number
): Promise<{ allowed: boolean; resetTime?: number }> {
const now = Date.now();
const windowStart = now - windowMs;
// Use Redis sorted set for sliding window
const pipeline = redis.pipeline();
// Remove old entries
pipeline.zremrangebyscore(key, 0, windowStart);
// Count current entries
pipeline.zcard(key);
// Add current request
pipeline.zadd(key, now, `${now}-${Math.random()}`);
// Set expiry
pipeline.expire(key, Math.ceil(windowMs / 1000));
const results = await pipeline.exec();
const currentCount = results?.[1]?.[1] as number || 0;
return {
allowed: currentCount < maxRequests,
resetTime: currentCount >= maxRequests ? windowStart + windowMs : undefined
};
}
5. Environment Configuration
Update .env.example
:
# Redis Configuration (optional - required for multi-instance SaaS deployments)
# For single-instance deployments, the default in-memory storage is sufficient
# See docs/redis-migration.md for migration guide
REDIS_URL=redis://localhost:6379
REDIS_TLS_ENABLED=false # Enable for production
REDIS_PASSWORD=your_redis_password # Optional
Migration Strategy
Development Testing
-
Start Redis locally:
docker run -d -p 6379:6379 redis:alpine
-
Test session functionality:
- Login/logout
- Session validation
- CSRF token generation/validation
- Session rotation
-
Test rate limiting:
- Verify rate limit enforcement
- Check sliding window behavior
-
Simulate multi-instance:
- Run multiple server instances
- Verify session sharing works
Staging Deployment
- Deploy Redis in staging
- Run single instance first to verify functionality
- Scale to multiple instances and test session sharing
- Monitor Redis memory usage and connection pool
Production Rollout
Option A: Blue/Green Deployment
- Deploy new version with Redis to green environment
- Test thoroughly with production-like data
- Switch traffic to green environment
- Note: Existing sessions will be lost (users need to re-login)
Option B: Gradual Migration
- Deploy Redis alongside existing system
- Write to both stores temporarily (dual-write)
- Read from Redis first, fallback to in-memory
- After verification period, remove in-memory store
Monitoring & Maintenance
Key Metrics
- Redis memory usage: Monitor
used_memory
andused_memory_peak
- Connection pool: Track active connections and pool utilization
- Session operations: Monitor session creation/validation latency
- Rate limit hits: Track rate limit enforcement effectiveness
Backup Strategy
- Enable Redis persistence: Configure RDB snapshots or AOF logging
- Regular backups: Backup Redis data regularly
- Session data: Ephemeral, but rate limit data should be backed up
Scaling Considerations
- Redis Cluster: For horizontal scaling across multiple nodes
- Redis Sentinel: For high availability with automatic failover
- Connection pooling: Configure appropriate pool sizes
Rollback Plan
If issues arise, you can revert to in-memory storage:
- Remove Redis imports and revert to
createKV
- Remove Redis environment variables
- Redeploy with in-memory storage
- Note: All sessions will be lost (users need to re-login)
Cost Considerations
Redis Hosting Costs
- Self-hosted: Server costs + maintenance
- Managed services:
- Redis Cloud: ~$7/month for 30MB
- AWS ElastiCache: ~$15/month for t3.micro
- Azure Cache: ~$16/month for Basic tier
Memory Requirements
Session storage: Minimal
- Example: 1000 concurrent users × 1KB per session = ~1MB
- CSRF tokens add minimal overhead
Rate limiting: Negligible
- Sliding window data is automatically cleaned up
- Minimal memory footprint per IP/email
Alternative Solutions
Sticky Sessions
- Load balancer routes user to same instance
- Pros: Simpler implementation
- Cons: Less resilient, harder to scale
Database-backed Sessions
- Use PostgreSQL/MySQL instead of Redis
- Pros: No additional infrastructure
- Cons: Higher latency, more database load
JWT Tokens
- Stateless authentication tokens
- Pros: No server-side session storage needed
- Cons: No server-side session invalidation, different security model