From 207ae9e3c04a37298f358cf944312af234ce480a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ordon?= Date: Fri, 27 Feb 2026 02:02:42 +0000 Subject: [PATCH 1/4] feat: add REDIS_KEY_PREFIX env var for Redis ACL namespace isolation Adds optional REDIS_KEY_PREFIX env var that prefixes all Redis keys (BullMQ queues via `prefix` option, cache/lock/rate-limit keys via `redisKey()` helper). When unset, behavior is unchanged (BullMQ defaults to "bull:", cache keys are unprefixed). This enables self-hosters using Redis ACL multi-tenancy to restrict useSend to its own key namespace (e.g. `~usesend:*`). 16 files changed across env schema, Redis module, 9 BullMQ queue/worker files, and 5 direct Redis key operation sites. --- .../src/app/api/auth/[...nextauth]/route.ts | 4 +-- apps/web/src/env.js | 2 ++ apps/web/src/server/api/routers/waitlist.ts | 4 +-- .../src/server/jobs/campaign-scheduler-job.ts | 5 ++-- .../src/server/jobs/cleanup-email-bodies.ts | 4 ++- apps/web/src/server/jobs/usage-job.ts | 4 ++- .../src/server/jobs/webhook-cleanup-job.ts | 4 ++- apps/web/src/server/public-api/hono.ts | 4 +-- apps/web/src/server/redis.ts | 25 +++++++++++++++++-- .../src/server/service/campaign-service.ts | 5 ++-- .../server/service/contact-queue-service.ts | 4 ++- .../src/server/service/email-queue-service.ts | 5 ++-- .../src/server/service/idempotency-service.ts | 6 ++--- .../web/src/server/service/ses-hook-parser.ts | 4 ++- apps/web/src/server/service/team-service.ts | 8 +++--- .../web/src/server/service/webhook-service.ts | 6 +++-- 16 files changed, 66 insertions(+), 28 deletions(-) diff --git a/apps/web/src/app/api/auth/[...nextauth]/route.ts b/apps/web/src/app/api/auth/[...nextauth]/route.ts index 0d089e71..978665f8 100644 --- a/apps/web/src/app/api/auth/[...nextauth]/route.ts +++ b/apps/web/src/app/api/auth/[...nextauth]/route.ts @@ -2,7 +2,7 @@ import NextAuth from "next-auth"; import { authOptions } from "~/server/auth"; import { env } from "~/env"; -import { getRedis } from "~/server/redis"; +import { getRedis, redisKey } from "~/server/redis"; import { logger } from "~/server/logger/log"; const handler = NextAuth(authOptions); @@ -60,7 +60,7 @@ export async function POST(req: Request, ctx: any) { return handler(req, ctx); } const redis = getRedis(); - const key = `auth-rl:${ip}`; + const key = redisKey(`auth-rl:${ip}`); const ttl = 60; const count = await redis.incr(key); if (count === 1) await redis.expire(key, ttl); diff --git a/apps/web/src/env.js b/apps/web/src/env.js index 5cab4900..9c37cdde 100644 --- a/apps/web/src/env.js +++ b/apps/web/src/env.js @@ -53,6 +53,7 @@ export const env = createEnv({ FOUNDER_EMAIL: z.string().optional(), DISCORD_WEBHOOK_URL: z.string().optional(), REDIS_URL: z.string(), + REDIS_KEY_PREFIX: z.string().default(""), S3_COMPATIBLE_ACCESS_KEY: z.string().optional(), S3_COMPATIBLE_SECRET_KEY: z.string().optional(), S3_COMPATIBLE_API_URL: z.string().optional(), @@ -116,6 +117,7 @@ export const env = createEnv({ FOUNDER_EMAIL: process.env.FOUNDER_EMAIL, DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL, REDIS_URL: process.env.REDIS_URL, + REDIS_KEY_PREFIX: process.env.REDIS_KEY_PREFIX, FROM_EMAIL: process.env.FROM_EMAIL, S3_COMPATIBLE_ACCESS_KEY: process.env.S3_COMPATIBLE_ACCESS_KEY, S3_COMPATIBLE_SECRET_KEY: process.env.S3_COMPATIBLE_SECRET_KEY, diff --git a/apps/web/src/server/api/routers/waitlist.ts b/apps/web/src/server/api/routers/waitlist.ts index 36bed44d..d0bd55ba 100644 --- a/apps/web/src/server/api/routers/waitlist.ts +++ b/apps/web/src/server/api/routers/waitlist.ts @@ -4,7 +4,7 @@ import { env } from "~/env"; import { authedProcedure, createTRPCRouter } from "~/server/api/trpc"; import { logger } from "~/server/logger/log"; import { sendMail } from "~/server/mailer"; -import { getRedis } from "~/server/redis"; +import { getRedis, redisKey } from "~/server/redis"; import { WAITLIST_EMAIL_TYPES, waitlistSubmissionSchema, @@ -40,7 +40,7 @@ export const waitlistRouter = createTRPCRouter({ } const redis = getRedis(); - const rateKey = `waitlist:requests:${user.id}`; + const rateKey = redisKey(`waitlist:requests:${user.id}`); const currentCountRaw = await redis.get(rateKey); const currentCount = currentCountRaw ? Number(currentCountRaw) : 0; diff --git a/apps/web/src/server/jobs/campaign-scheduler-job.ts b/apps/web/src/server/jobs/campaign-scheduler-job.ts index e00d99d6..c566e396 100644 --- a/apps/web/src/server/jobs/campaign-scheduler-job.ts +++ b/apps/web/src/server/jobs/campaign-scheduler-job.ts @@ -4,7 +4,7 @@ import { CAMPAIGN_SCHEDULER_QUEUE, DEFAULT_QUEUE_OPTIONS, } from "../queue/queue-constants"; -import { getRedis } from "../redis"; +import { getRedis, BULL_PREFIX } from "../redis"; import { CampaignBatchService } from "../service/campaign-service"; import { db } from "../db"; import { logger } from "../logger/log"; @@ -18,6 +18,7 @@ export class CampaignSchedulerService { CAMPAIGN_SCHEDULER_QUEUE, { connection: getRedis(), + prefix: BULL_PREFIX, } ); @@ -82,7 +83,7 @@ export class CampaignSchedulerService { logger.error({ err }, "Campaign scheduler tick failed"); } }), - { connection: getRedis(), concurrency: 1 } + { connection: getRedis(), concurrency: 1, prefix: BULL_PREFIX } ); static async start() { diff --git a/apps/web/src/server/jobs/cleanup-email-bodies.ts b/apps/web/src/server/jobs/cleanup-email-bodies.ts index 5e221d64..ca40d4b5 100644 --- a/apps/web/src/server/jobs/cleanup-email-bodies.ts +++ b/apps/web/src/server/jobs/cleanup-email-bodies.ts @@ -1,6 +1,6 @@ import {Queue, Worker} from "bullmq"; import {db} from "~/server/db"; -import {getRedis} from "~/server/redis"; +import {getRedis, BULL_PREFIX} from "~/server/redis"; import {logger} from "../logger/log"; import {DEFAULT_QUEUE_OPTIONS} from "../queue/queue-constants"; import {env} from "~/env"; @@ -19,6 +19,7 @@ if (isSelfHosted() && isEmailCleanupEnabled()) { */ const cleanupQueue = new Queue(CLEANUP_QUEUE_NAME, { connection: getRedis(), + prefix: BULL_PREFIX, }); const worker = new Worker( @@ -47,6 +48,7 @@ if (isSelfHosted() && isEmailCleanupEnabled()) { }, { connection: getRedis(), + prefix: BULL_PREFIX, } ); diff --git a/apps/web/src/server/jobs/usage-job.ts b/apps/web/src/server/jobs/usage-job.ts index 140089f8..9f1078bf 100644 --- a/apps/web/src/server/jobs/usage-job.ts +++ b/apps/web/src/server/jobs/usage-job.ts @@ -3,7 +3,7 @@ import { db } from "~/server/db"; import { env } from "~/env"; import { getUsageDate, getUsageUnits } from "~/lib/usage"; import { sendUsageToStripe } from "~/server/billing/usage"; -import { getRedis } from "~/server/redis"; +import { getRedis, BULL_PREFIX } from "~/server/redis"; import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants"; import { logger } from "../logger/log"; @@ -11,6 +11,7 @@ const USAGE_QUEUE_NAME = "usage-reporting"; const usageQueue = new Queue(USAGE_QUEUE_NAME, { connection: getRedis(), + prefix: BULL_PREFIX, }); const worker = new Worker( @@ -69,6 +70,7 @@ const worker = new Worker( }, { connection: getRedis(), + prefix: BULL_PREFIX, }, ); diff --git a/apps/web/src/server/jobs/webhook-cleanup-job.ts b/apps/web/src/server/jobs/webhook-cleanup-job.ts index 35ae446c..b12dbfe6 100644 --- a/apps/web/src/server/jobs/webhook-cleanup-job.ts +++ b/apps/web/src/server/jobs/webhook-cleanup-job.ts @@ -1,7 +1,7 @@ import { Queue, Worker } from "bullmq"; import { subDays } from "date-fns"; import { db } from "~/server/db"; -import { getRedis } from "~/server/redis"; +import { getRedis, BULL_PREFIX } from "~/server/redis"; import { DEFAULT_QUEUE_OPTIONS, WEBHOOK_CLEANUP_QUEUE } from "../queue/queue-constants"; import { logger } from "../logger/log"; @@ -9,6 +9,7 @@ const WEBHOOK_RETENTION_DAYS = 30; const webhookCleanupQueue = new Queue(WEBHOOK_CLEANUP_QUEUE, { connection: getRedis(), + prefix: BULL_PREFIX, }); const worker = new Worker( @@ -30,6 +31,7 @@ const worker = new Worker( }, { connection: getRedis(), + prefix: BULL_PREFIX, } ); diff --git a/apps/web/src/server/public-api/hono.ts b/apps/web/src/server/public-api/hono.ts index 7e199360..28e1bbc4 100644 --- a/apps/web/src/server/public-api/hono.ts +++ b/apps/web/src/server/public-api/hono.ts @@ -3,7 +3,7 @@ import { swaggerUI } from "@hono/swagger-ui"; import { Context, Next } from "hono"; import { handleError } from "./api-error"; import { env } from "~/env"; -import { getRedis } from "~/server/redis"; +import { getRedis, redisKey } from "~/server/redis"; import { getTeamFromToken } from "~/server/public-api/auth"; import { isSelfHosted } from "~/utils/common"; import { UnsendApiError } from "./api-error"; @@ -66,7 +66,7 @@ export function getApp() { const team = c.var.team; const limit = team.apiRateLimit ?? 2; // Default limit from your previous setup - const key = `rl:${team.id}`; // Rate limit key for Redis + const key = redisKey(`rl:${team.id}`); // Rate limit key for Redis const redis = getRedis(); let currentRequests: number; diff --git a/apps/web/src/server/redis.ts b/apps/web/src/server/redis.ts index 43b2368c..5fe65996 100644 --- a/apps/web/src/server/redis.ts +++ b/apps/web/src/server/redis.ts @@ -3,6 +3,26 @@ import { env } from "~/env"; export let connection: IORedis | null = null; +/** + * Key prefix derived from REDIS_KEY_PREFIX env var. + * When set (e.g. "usesend"), all cache keys become "usesend:team:1", etc. + * When empty, keys are unprefixed (backwards compatible). + */ +export const REDIS_PREFIX = env.REDIS_KEY_PREFIX + ? `${env.REDIS_KEY_PREFIX}:` + : ""; + +/** + * BullMQ prefix (no trailing colon — BullMQ adds its own separator). + * When REDIS_KEY_PREFIX is empty, falls back to BullMQ's default "bull". + */ +export const BULL_PREFIX = env.REDIS_KEY_PREFIX || "bull"; + +/** Prefix a cache key with REDIS_KEY_PREFIX. */ +export function redisKey(key: string): string { + return `${REDIS_PREFIX}${key}`; +} + export const getRedis = () => { if (!connection || connection.status === "end") { connection = new IORedis(`${env.REDIS_URL}?family=0`, { @@ -24,9 +44,10 @@ export async function withCache( const { ttlSeconds = 120, disable = false } = options ?? {}; const redis = getRedis(); + const prefixedKey = redisKey(key); if (!disable) { - const cached = await redis.get(key); + const cached = await redis.get(prefixedKey); if (cached) { try { return JSON.parse(cached) as T; @@ -40,7 +61,7 @@ export async function withCache( if (!disable) { try { - await redis.setex(key, ttlSeconds, JSON.stringify(value)); + await redis.setex(prefixedKey, ttlSeconds, JSON.stringify(value)); } catch { // ignore cache set errors } diff --git a/apps/web/src/server/service/campaign-service.ts b/apps/web/src/server/service/campaign-service.ts index 5ce49bfe..368139be 100644 --- a/apps/web/src/server/service/campaign-service.ts +++ b/apps/web/src/server/service/campaign-service.ts @@ -10,7 +10,7 @@ import { } from "@prisma/client"; import { EmailQueueService } from "./email-queue-service"; import { Queue, Worker } from "bullmq"; -import { getRedis } from "../redis"; +import { getRedis, BULL_PREFIX } from "../redis"; import { CAMPAIGN_BATCH_QUEUE, DEFAULT_QUEUE_OPTIONS, @@ -928,6 +928,7 @@ export class CampaignBatchService { CAMPAIGN_BATCH_QUEUE, { connection: getRedis(), + prefix: BULL_PREFIX, } ); @@ -1028,7 +1029,7 @@ export class CampaignBatchService { data: { lastCursor: newCursor, lastSentAt: new Date() }, }); }), - { connection: getRedis(), concurrency: 20 } + { connection: getRedis(), concurrency: 20, prefix: BULL_PREFIX } ); static async queueBatch({ diff --git a/apps/web/src/server/service/contact-queue-service.ts b/apps/web/src/server/service/contact-queue-service.ts index a9093f8b..b0120591 100644 --- a/apps/web/src/server/service/contact-queue-service.ts +++ b/apps/web/src/server/service/contact-queue-service.ts @@ -1,5 +1,5 @@ import { Queue, Worker } from "bullmq"; -import { getRedis } from "../redis"; +import { getRedis, BULL_PREFIX } from "../redis"; import { DEFAULT_QUEUE_OPTIONS, CONTACT_BULK_ADD_QUEUE, @@ -19,6 +19,7 @@ type ContactJob = TeamJob; class ContactQueueService { public static queue = new Queue(CONTACT_BULK_ADD_QUEUE, { connection: getRedis(), + prefix: BULL_PREFIX, defaultJobOptions: DEFAULT_QUEUE_OPTIONS, }); @@ -27,6 +28,7 @@ class ContactQueueService { createWorkerHandler(processContactJob), { connection: getRedis(), + prefix: BULL_PREFIX, concurrency: 20, }, ); diff --git a/apps/web/src/server/service/email-queue-service.ts b/apps/web/src/server/service/email-queue-service.ts index 730eedbb..81abec97 100644 --- a/apps/web/src/server/service/email-queue-service.ts +++ b/apps/web/src/server/service/email-queue-service.ts @@ -5,7 +5,7 @@ import { convert as htmlToText } from "html-to-text"; import { getConfigurationSetName } from "~/utils/ses-utils"; import { db } from "../db"; import { sendRawEmail } from "../aws/ses"; -import { getRedis } from "../redis"; +import { getRedis, BULL_PREFIX } from "../redis"; import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants"; import { logger } from "../logger/log"; import { createWorkerHandler, TeamJob } from "../queue/bullmq-context"; @@ -25,12 +25,13 @@ function createQueueAndWorker(region: string, quota: number, suffix: string) { const queueName = `${region}-${suffix}`; - const queue = new Queue(queueName, { connection }); + const queue = new Queue(queueName, { connection, prefix: BULL_PREFIX }); // TODO: Add team context to job data when queueing const worker = new Worker(queueName, createWorkerHandler(executeEmail), { concurrency: quota, connection, + prefix: BULL_PREFIX, }); return { queue, worker }; diff --git a/apps/web/src/server/service/idempotency-service.ts b/apps/web/src/server/service/idempotency-service.ts index 2434e2a6..ec1d8479 100644 --- a/apps/web/src/server/service/idempotency-service.ts +++ b/apps/web/src/server/service/idempotency-service.ts @@ -1,4 +1,4 @@ -import { getRedis } from "~/server/redis"; +import { getRedis, redisKey } from "~/server/redis"; import { canonicalizePayload } from "~/server/utils/idempotency"; import { UnsendApiError } from "~/server/public-api/api-error"; import { logger } from "~/server/logger/log"; @@ -22,11 +22,11 @@ export type IdempotencyHandlerOptions = { }; function resultKey(teamId: number, key: string) { - return `idem:${teamId}:${key}`; + return redisKey(`idem:${teamId}:${key}`); } function lockKey(teamId: number, key: string) { - return `idemlock:${teamId}:${key}`; + return redisKey(`idemlock:${teamId}:${key}`); } export const IdempotencyService = { diff --git a/apps/web/src/server/service/ses-hook-parser.ts b/apps/web/src/server/service/ses-hook-parser.ts index 275c99d7..797789fa 100644 --- a/apps/web/src/server/service/ses-hook-parser.ts +++ b/apps/web/src/server/service/ses-hook-parser.ts @@ -21,7 +21,7 @@ import { updateCampaignAnalytics, } from "./campaign-service"; import { env } from "~/env"; -import { getRedis } from "../redis"; +import { getRedis, BULL_PREFIX } from "../redis"; import { Queue, Worker } from "bullmq"; import { DEFAULT_QUEUE_OPTIONS, @@ -619,6 +619,7 @@ function getEmailData(data: SesEvent) { export class SesHookParser { private static sesHookQueue = new Queue(SES_WEBHOOK_QUEUE, { connection: getRedis(), + prefix: BULL_PREFIX, }); private static worker = new Worker( @@ -635,6 +636,7 @@ export class SesHookParser { }, { connection: getRedis(), + prefix: BULL_PREFIX, concurrency: 50, }, ); diff --git a/apps/web/src/server/service/team-service.ts b/apps/web/src/server/service/team-service.ts index a4f97fb5..661018f3 100644 --- a/apps/web/src/server/service/team-service.ts +++ b/apps/web/src/server/service/team-service.ts @@ -5,7 +5,7 @@ import { sendMail, sendTeamInviteEmail } from "~/server/mailer"; import { logger } from "~/server/logger/log"; import type { Prisma, Team, TeamInvite } from "@prisma/client"; import { UnsendApiError } from "../public-api/api-error"; -import { getRedis } from "~/server/redis"; +import { getRedis, redisKey } from "~/server/redis"; import { LimitReason } from "~/lib/constants/plans"; import { LimitService } from "./limit-service"; import { renderUsageLimitReachedEmail } from "../email-templates/UsageLimitReachedEmail"; @@ -17,7 +17,7 @@ const TEAM_CACHE_TTL_SECONDS = 120; // 2 minutes export class TeamService { private static cacheKey(teamId: number) { - return `team:${teamId}`; + return redisKey(`team:${teamId}`); } static async refreshTeamCache(teamId: number): Promise { @@ -396,7 +396,7 @@ export class TeamService { } const redis = getRedis(); - const cacheKey = `limit:notify:${teamId}:${reason}`; + const cacheKey = redisKey(`limit:notify:${teamId}:${reason}`); // Atomic SET NX to prevent race conditions: only one concurrent caller // can acquire the cooldown key. TTL = 24 hours (one notification per day). const acquired = await redis.set(cacheKey, "1", "EX", 24 * 60 * 60, "NX"); @@ -493,7 +493,7 @@ export class TeamService { } const redis = getRedis(); - const cacheKey = `limit:warning:${teamId}:${reason}`; + const cacheKey = redisKey(`limit:warning:${teamId}:${reason}`); // Atomic SET NX to prevent race conditions: only one concurrent caller // can acquire the cooldown key. TTL = 24 hours (one notification per day). const acquired = await redis.set(cacheKey, "1", "EX", 24 * 60 * 60, "NX"); diff --git a/apps/web/src/server/service/webhook-service.ts b/apps/web/src/server/service/webhook-service.ts index 3888c7f3..5590a050 100644 --- a/apps/web/src/server/service/webhook-service.ts +++ b/apps/web/src/server/service/webhook-service.ts @@ -10,7 +10,7 @@ import { type WebhookEventType, } from "@usesend/lib/src/webhook/webhook-events"; import { db } from "../db"; -import { getRedis } from "../redis"; +import { getRedis, BULL_PREFIX, redisKey } from "../redis"; import { DEFAULT_QUEUE_OPTIONS, WEBHOOK_DISPATCH_QUEUE, @@ -42,6 +42,7 @@ type WebhookEventInput = export class WebhookQueueService { private static queue = new Queue(WEBHOOK_DISPATCH_QUEUE, { connection: getRedis(), + prefix: BULL_PREFIX, defaultJobOptions: { ...DEFAULT_QUEUE_OPTIONS, attempts: WEBHOOK_MAX_ATTEMPTS, @@ -57,6 +58,7 @@ export class WebhookQueueService { createWorkerHandler(processWebhookCall), { connection: getRedis(), + prefix: BULL_PREFIX, concurrency: WEBHOOK_DISPATCH_CONCURRENCY, }, ); @@ -446,7 +448,7 @@ async function processWebhookCall(job: WebhookCallJob) { }, }); - const lockKey = `webhook:lock:${call.webhookId}`; + const lockKey = redisKey(`webhook:lock:${call.webhookId}`); const redis = getRedis(); const lockValue = randomUUID(); From 48837475ba6c373a09e60c3476259148f8f5ef4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ordon?= Date: Fri, 27 Feb 2026 03:03:15 +0000 Subject: [PATCH 2/4] docs: add REDIS_KEY_PREFIX to self-host assets and fix docker run example Add REDIS_KEY_PREFIX env var to docker/prod/compose.yml, .env.example, .env.selfhost.example, and self-hosting docs. Fix missing trailing backslashes in standalone docker run example. --- .env.example | 3 +++ .env.selfhost.example | 3 +++ apps/docs/self-hosting/overview.mdx | 2 ++ docker/README.md | 18 +++++++++--------- docker/prod/compose.yml | 1 + 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index a2ab1826..79539fe0 100644 --- a/.env.example +++ b/.env.example @@ -22,4 +22,7 @@ FROM_EMAIL="hello@usesend.com" API_RATE_LIMIT=2 AUTH_EMAIL_RATE_LIMIT=5 +# Optional: prefix all Redis keys (useful for shared Redis with ACL isolation) +# REDIS_KEY_PREFIX="" + NEXT_PUBLIC_IS_CLOUD=true diff --git a/.env.selfhost.example b/.env.selfhost.example index b5cee461..d294bd34 100644 --- a/.env.selfhost.example +++ b/.env.selfhost.example @@ -36,5 +36,8 @@ DOCKER_OUTPUT=1 API_RATE_LIMIT=1 AUTH_EMAIL_RATE_LIMIT=5 +# Optional: prefix all Redis keys (useful for shared Redis with ACL isolation) +# REDIS_KEY_PREFIX="" + # used to send important error notification - optional DISCORD_WEBHOOK_URL="" diff --git a/apps/docs/self-hosting/overview.mdx b/apps/docs/self-hosting/overview.mdx index df246446..07b2413e 100644 --- a/apps/docs/self-hosting/overview.mdx +++ b/apps/docs/self-hosting/overview.mdx @@ -59,6 +59,8 @@ DATABASE_URL="postgres://:@:/" REDIS_URL="redis://:@:" ``` +If you're sharing a Redis instance across multiple apps and using Redis ACL for isolation, set `REDIS_KEY_PREFIX` to namespace all keys (e.g. `REDIS_KEY_PREFIX="usesend"` prefixes all keys with `usesend:`). + Url is the app url you're going to use and secret is random string. You can generate a random secret using this command. diff --git a/docker/README.md b/docker/README.md index 01a92e41..dfe16e4b 100644 --- a/docker/README.md +++ b/docker/README.md @@ -48,15 +48,15 @@ docker pull ghcr.io/usesend/usesend ``` docker run -d \ -p 3000:3000 \ - -e NEXTAUTH_URL="" - -e NEXTAUTH_SECRET="" - -e DATABASE_URL="" - -e REDIS_URL="" - -e AWS_ACCESS_KEY="" - -e AWS_SECRET_KEY="" - -e AWS_DEFAULT_REGION="" - -e GITHUB_ID="" - -e GITHUB_SECRET="" + -e NEXTAUTH_URL="" \ + -e NEXTAUTH_SECRET="" \ + -e DATABASE_URL="" \ + -e REDIS_URL="" \ + -e AWS_ACCESS_KEY="" \ + -e AWS_SECRET_KEY="" \ + -e AWS_DEFAULT_REGION="" \ + -e GITHUB_ID="" \ + -e GITHUB_SECRET="" \ usesend/usesend ``` diff --git a/docker/prod/compose.yml b/docker/prod/compose.yml index d6600579..89bba504 100644 --- a/docker/prod/compose.yml +++ b/docker/prod/compose.yml @@ -60,6 +60,7 @@ services: - GITHUB_ID=${GITHUB_ID:?err} - GITHUB_SECRET=${GITHUB_SECRET:?err} - REDIS_URL=${REDIS_URL:?err} + - REDIS_KEY_PREFIX=${REDIS_KEY_PREFIX:-} - NEXT_PUBLIC_IS_CLOUD=${NEXT_PUBLIC_IS_CLOUD:-false} - API_RATE_LIMIT=${API_RATE_LIMIT:-1} - SMTP_HOST=${SMTP_HOST:-smtp.usesend.com} From 5d3c146280a0198eb5b9909783141a49118cf9de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ordon?= Date: Fri, 27 Feb 2026 10:47:43 +0000 Subject: [PATCH 3/4] fix(redis): disable ioredis ready check and BullMQ version check Redis ACL blocks INFO command (in @dangerous category). ioredis uses INFO for ready check, BullMQ uses it for version detection. Without these flags, BullMQ workers fail to initialize and silently stop processing jobs. - Add enableReadyCheck: false to ioredis connection - Add skipVersionCheck: true to all 5 Queue + 5 Worker constructors --- apps/web/src/server/redis.ts | 1 + apps/web/src/server/service/campaign-service.ts | 3 ++- apps/web/src/server/service/contact-queue-service.ts | 2 ++ apps/web/src/server/service/email-queue-service.ts | 3 ++- apps/web/src/server/service/ses-hook-parser.ts | 2 ++ apps/web/src/server/service/webhook-service.ts | 2 ++ 6 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/web/src/server/redis.ts b/apps/web/src/server/redis.ts index 5fe65996..f21f3e9b 100644 --- a/apps/web/src/server/redis.ts +++ b/apps/web/src/server/redis.ts @@ -27,6 +27,7 @@ export const getRedis = () => { if (!connection || connection.status === "end") { connection = new IORedis(`${env.REDIS_URL}?family=0`, { maxRetriesPerRequest: null, + enableReadyCheck: false, }); } return connection; diff --git a/apps/web/src/server/service/campaign-service.ts b/apps/web/src/server/service/campaign-service.ts index 368139be..baa43008 100644 --- a/apps/web/src/server/service/campaign-service.ts +++ b/apps/web/src/server/service/campaign-service.ts @@ -929,6 +929,7 @@ export class CampaignBatchService { { connection: getRedis(), prefix: BULL_PREFIX, + skipVersionCheck: true, } ); @@ -1029,7 +1030,7 @@ export class CampaignBatchService { data: { lastCursor: newCursor, lastSentAt: new Date() }, }); }), - { connection: getRedis(), concurrency: 20, prefix: BULL_PREFIX } + { connection: getRedis(), concurrency: 20, prefix: BULL_PREFIX, skipVersionCheck: true } ); static async queueBatch({ diff --git a/apps/web/src/server/service/contact-queue-service.ts b/apps/web/src/server/service/contact-queue-service.ts index b0120591..eee6c36c 100644 --- a/apps/web/src/server/service/contact-queue-service.ts +++ b/apps/web/src/server/service/contact-queue-service.ts @@ -20,6 +20,7 @@ class ContactQueueService { public static queue = new Queue(CONTACT_BULK_ADD_QUEUE, { connection: getRedis(), prefix: BULL_PREFIX, + skipVersionCheck: true, defaultJobOptions: DEFAULT_QUEUE_OPTIONS, }); @@ -29,6 +30,7 @@ class ContactQueueService { { connection: getRedis(), prefix: BULL_PREFIX, + skipVersionCheck: true, concurrency: 20, }, ); diff --git a/apps/web/src/server/service/email-queue-service.ts b/apps/web/src/server/service/email-queue-service.ts index 81abec97..fef79422 100644 --- a/apps/web/src/server/service/email-queue-service.ts +++ b/apps/web/src/server/service/email-queue-service.ts @@ -25,13 +25,14 @@ function createQueueAndWorker(region: string, quota: number, suffix: string) { const queueName = `${region}-${suffix}`; - const queue = new Queue(queueName, { connection, prefix: BULL_PREFIX }); + const queue = new Queue(queueName, { connection, prefix: BULL_PREFIX, skipVersionCheck: true }); // TODO: Add team context to job data when queueing const worker = new Worker(queueName, createWorkerHandler(executeEmail), { concurrency: quota, connection, prefix: BULL_PREFIX, + skipVersionCheck: true, }); return { queue, worker }; diff --git a/apps/web/src/server/service/ses-hook-parser.ts b/apps/web/src/server/service/ses-hook-parser.ts index 797789fa..3ce5f179 100644 --- a/apps/web/src/server/service/ses-hook-parser.ts +++ b/apps/web/src/server/service/ses-hook-parser.ts @@ -620,6 +620,7 @@ export class SesHookParser { private static sesHookQueue = new Queue(SES_WEBHOOK_QUEUE, { connection: getRedis(), prefix: BULL_PREFIX, + skipVersionCheck: true, }); private static worker = new Worker( @@ -637,6 +638,7 @@ export class SesHookParser { { connection: getRedis(), prefix: BULL_PREFIX, + skipVersionCheck: true, concurrency: 50, }, ); diff --git a/apps/web/src/server/service/webhook-service.ts b/apps/web/src/server/service/webhook-service.ts index 5590a050..4e47f362 100644 --- a/apps/web/src/server/service/webhook-service.ts +++ b/apps/web/src/server/service/webhook-service.ts @@ -43,6 +43,7 @@ export class WebhookQueueService { private static queue = new Queue(WEBHOOK_DISPATCH_QUEUE, { connection: getRedis(), prefix: BULL_PREFIX, + skipVersionCheck: true, defaultJobOptions: { ...DEFAULT_QUEUE_OPTIONS, attempts: WEBHOOK_MAX_ATTEMPTS, @@ -59,6 +60,7 @@ export class WebhookQueueService { { connection: getRedis(), prefix: BULL_PREFIX, + skipVersionCheck: true, concurrency: WEBHOOK_DISPATCH_CONCURRENCY, }, ); From 94390419292909648106bfa0ad6f0ec80ff6c7d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ordon?= Date: Sun, 1 Mar 2026 03:11:39 +0000 Subject: [PATCH 4/4] fix(redis): add skipVersionCheck to remaining BullMQ job queues Add skipVersionCheck: true to Queue and Worker constructors in all 4 job files (campaign-scheduler, cleanup-email-bodies, usage-job, webhook-cleanup) to match the pattern already used in service files. This prevents BullMQ version mismatch errors when using REDIS_KEY_PREFIX with Redis ACL namespace isolation. --- apps/web/src/server/jobs/campaign-scheduler-job.ts | 3 ++- apps/web/src/server/jobs/cleanup-email-bodies.ts | 2 ++ apps/web/src/server/jobs/usage-job.ts | 2 ++ apps/web/src/server/jobs/webhook-cleanup-job.ts | 2 ++ 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/server/jobs/campaign-scheduler-job.ts b/apps/web/src/server/jobs/campaign-scheduler-job.ts index c566e396..f5c70e8c 100644 --- a/apps/web/src/server/jobs/campaign-scheduler-job.ts +++ b/apps/web/src/server/jobs/campaign-scheduler-job.ts @@ -19,6 +19,7 @@ export class CampaignSchedulerService { { connection: getRedis(), prefix: BULL_PREFIX, + skipVersionCheck: true, } ); @@ -83,7 +84,7 @@ export class CampaignSchedulerService { logger.error({ err }, "Campaign scheduler tick failed"); } }), - { connection: getRedis(), concurrency: 1, prefix: BULL_PREFIX } + { connection: getRedis(), concurrency: 1, prefix: BULL_PREFIX, skipVersionCheck: true } ); static async start() { diff --git a/apps/web/src/server/jobs/cleanup-email-bodies.ts b/apps/web/src/server/jobs/cleanup-email-bodies.ts index ca40d4b5..a8020c96 100644 --- a/apps/web/src/server/jobs/cleanup-email-bodies.ts +++ b/apps/web/src/server/jobs/cleanup-email-bodies.ts @@ -20,6 +20,7 @@ if (isSelfHosted() && isEmailCleanupEnabled()) { const cleanupQueue = new Queue(CLEANUP_QUEUE_NAME, { connection: getRedis(), prefix: BULL_PREFIX, + skipVersionCheck: true, }); const worker = new Worker( @@ -49,6 +50,7 @@ if (isSelfHosted() && isEmailCleanupEnabled()) { { connection: getRedis(), prefix: BULL_PREFIX, + skipVersionCheck: true, } ); diff --git a/apps/web/src/server/jobs/usage-job.ts b/apps/web/src/server/jobs/usage-job.ts index 9f1078bf..df1bf28a 100644 --- a/apps/web/src/server/jobs/usage-job.ts +++ b/apps/web/src/server/jobs/usage-job.ts @@ -12,6 +12,7 @@ const USAGE_QUEUE_NAME = "usage-reporting"; const usageQueue = new Queue(USAGE_QUEUE_NAME, { connection: getRedis(), prefix: BULL_PREFIX, + skipVersionCheck: true, }); const worker = new Worker( @@ -71,6 +72,7 @@ const worker = new Worker( { connection: getRedis(), prefix: BULL_PREFIX, + skipVersionCheck: true, }, ); diff --git a/apps/web/src/server/jobs/webhook-cleanup-job.ts b/apps/web/src/server/jobs/webhook-cleanup-job.ts index b12dbfe6..6eb64261 100644 --- a/apps/web/src/server/jobs/webhook-cleanup-job.ts +++ b/apps/web/src/server/jobs/webhook-cleanup-job.ts @@ -10,6 +10,7 @@ const WEBHOOK_RETENTION_DAYS = 30; const webhookCleanupQueue = new Queue(WEBHOOK_CLEANUP_QUEUE, { connection: getRedis(), prefix: BULL_PREFIX, + skipVersionCheck: true, }); const worker = new Worker( @@ -32,6 +33,7 @@ const worker = new Worker( { connection: getRedis(), prefix: BULL_PREFIX, + skipVersionCheck: true, } );