Skip to content
Open
152 changes: 152 additions & 0 deletions apps/dokploy/__test__/queues/redis-connection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";

// Mock fs for readSecret tests
vi.mock("node:fs");

// Mock pullImage to avoid actual docker commands in unit tests
vi.mock("@dokploy/server/utils/docker/utils", () => ({
Comment thread
satoukouga marked this conversation as resolved.
pullImage: vi.fn().mockResolvedValue({}),
}));

// Mock dockerode to verify service settings
const mockCreateService = vi.fn().mockResolvedValue({});
const mockGetService = vi.fn().mockReturnValue({
inspect: vi.fn().mockRejectedValue(new Error("Not found")),
update: vi.fn().mockResolvedValue({}),
});

vi.mock("dockerode", () => {
return {
default: vi.fn().mockImplementation(function () {
return {
createService: mockCreateService,
getService: mockGetService,
pull: vi.fn().mockResolvedValue({}),
};
}),
};
});

describe("redis-connection", () => {
afterEach(() => {
vi.resetModules();
vi.unstubAllEnvs();
vi.clearAllMocks();
});

it("should use REDIS_URL if provided", async () => {
vi.stubEnv("REDIS_URL", "redis://user:pass@remote-host:6379/1");

const { redisConfig } = await import("../../server/queues/redis-connection");

expect(redisConfig).toEqual({
url: "redis://user:pass@remote-host:6379/1",
});
}, 30000);

it("should use individual env vars if REDIS_URL is not provided", async () => {
vi.stubEnv("REDIS_HOST", "custom-host");
vi.stubEnv("REDIS_PORT", "1234");
vi.stubEnv("REDIS_DB_INDEX", "2");
vi.stubEnv("REDIS_PASSWORD", "secret");
vi.stubEnv("REDIS_USERNAME", "admin");

const { redisConfig } = await import("../../server/queues/redis-connection");

expect(redisConfig).toEqual({
host: "custom-host",
port: 1234,
db: 2,
password: "secret",
username: "admin",
});
});

it("should read password from REDIS_PASSWORD_FILE if provided", async () => {
vi.stubEnv("REDIS_PASSWORD_FILE", "/tmp/password.txt");
vi.mocked(fs.readFileSync).mockReturnValue("file-secret\n");

const { redisConfig } = await import("../../server/queues/redis-connection");

expect((redisConfig as any).password).toBe("file-secret");
expect(fs.readFileSync).toHaveBeenCalledWith("/tmp/password.txt", "utf8");
});

it("should fallback to defaults in development", async () => {
vi.stubEnv("NODE_ENV", "development");

const { redisConfig } = await import("../../server/queues/redis-connection");

expect(redisConfig).toEqual({
host: "127.0.0.1",
port: 6379,
db: 0,
});
});

it("should fallback to production defaults", async () => {
vi.stubEnv("NODE_ENV", "production");

const { redisConfig } = await import("../../server/queues/redis-connection");

expect(redisConfig).toEqual({
host: "dokploy-redis",
port: 6379,
db: 0,
});
});

it("should fallback to defaults on non-numeric env vars", async () => {
vi.stubEnv("REDIS_PORT", "invalid");
vi.stubEnv("REDIS_DB_INDEX", "not-a-number");

const { redisConfig } = await import("../../server/queues/redis-connection");

expect((redisConfig as any).port).toBe(6379);
expect((redisConfig as any).db).toBe(0);
});

it("should verify initializeRedis creates service with correct Args (no Command override) when password is set", async () => {
vi.stubEnv("REDIS_PASSWORD", "test-pass");
vi.stubEnv("NODE_ENV", "production");

const { initializeRedis } = await import("@dokploy/server/setup/redis-setup");

await initializeRedis();

expect(mockCreateService).toHaveBeenCalledWith(
expect.objectContaining({
TaskTemplate: expect.objectContaining({
ContainerSpec: expect.objectContaining({
Args: ["redis-server", "--requirepass", "test-pass"],
}),
}),
}),
);

// Ensure Command is NOT set to avoid overriding ENTRYPOINT
const lastCall = mockCreateService.mock.calls[0][0];
expect(lastCall.TaskTemplate.ContainerSpec.Command).toBeUndefined();
}, 30000);

it("should skip initializeRedis if an external REDIS_URL is provided", async () => {
vi.stubEnv("REDIS_URL", "redis://remote-redis:6379");
const { initializeRedis } = await import("@dokploy/server/setup/redis-setup");

await initializeRedis();

expect(mockCreateService).not.toHaveBeenCalled();
expect(mockGetService).not.toHaveBeenCalled();
});

it("should skip initializeRedis if an external REDIS_HOST is provided", async () => {
vi.stubEnv("REDIS_HOST", "external-redis.com");
const { initializeRedis } = await import("@dokploy/server/setup/redis-setup");

await initializeRedis();

expect(mockCreateService).not.toHaveBeenCalled();
expect(mockGetService).not.toHaveBeenCalled();
});
});
17 changes: 11 additions & 6 deletions apps/dokploy/server/queues/redis-connection.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { redisConfig as sharedRedisConfig } from "@dokploy/server/setup/redis-constants";
import type { ConnectionOptions } from "bullmq";

export const redisConfig: ConnectionOptions = {
host:
process.env.NODE_ENV === "production"
? process.env.REDIS_HOST || "dokploy-redis"
: "127.0.0.1",
};
export const redisConfig: ConnectionOptions =
"url" in sharedRedisConfig
? { url: sharedRedisConfig.url as string }
: {
host: sharedRedisConfig.host as string,
port: sharedRedisConfig.port as number,
db: sharedRedisConfig.db as number,
password: sharedRedisConfig.password as string | undefined,
username: sharedRedisConfig.username as string | undefined,
};
Comment thread
satoukouga marked this conversation as resolved.
Outdated
5 changes: 2 additions & 3 deletions apps/schedules/src/queue.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { Queue, type RepeatableJob } from "bullmq";
import { redisConfig } from "@dokploy/server/setup/redis-constants";
import { logger } from "./logger.js";
import type { QueueJob } from "./schema.js";

export const jobQueue = new Queue("backupQueue", {
connection: {
url: process.env.REDIS_URL!,
},
connection: redisConfig,
Comment thread
satoukouga marked this conversation as resolved.
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
Expand Down
13 changes: 4 additions & 9 deletions apps/schedules/src/workers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type Job, Worker } from "bullmq";
import { redisConfig } from "@dokploy/server/setup/redis-constants";
import { logger } from "./logger.js";
import type { QueueJob } from "./schema.js";
import { runJobs } from "./utils.js";
Expand All @@ -11,9 +12,7 @@ export const firstWorker = new Worker(
},
{
concurrency: 100,
connection: {
url: process.env.REDIS_URL!,
},
connection: redisConfig,
},
);
export const secondWorker = new Worker(
Expand All @@ -24,9 +23,7 @@ export const secondWorker = new Worker(
},
{
concurrency: 100,
connection: {
url: process.env.REDIS_URL!,
},
connection: redisConfig,
},
);

Expand All @@ -38,8 +35,6 @@ export const thirdWorker = new Worker(
},
{
concurrency: 100,
connection: {
url: process.env.REDIS_URL!,
},
connection: redisConfig,
},
);
51 changes: 51 additions & 0 deletions packages/server/src/setup/redis-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import fs from "node:fs";

export const {
REDIS_URL,
REDIS_HOST,
REDIS_PORT,
REDIS_PASSWORD,
REDIS_PASSWORD_FILE,
REDIS_DB_INDEX,
REDIS_USERNAME,
} = process.env;

export function readSecret(path: string): string {
try {
return fs.readFileSync(path, "utf8").trim();
} catch (error) {
throw new Error(
`Cannot read secret at ${path}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

export const getRedisPassword = (): string | undefined => {
return REDIS_PASSWORD_FILE
? readSecret(REDIS_PASSWORD_FILE)
: REDIS_PASSWORD;
};

const parseNumeric = (
value: string | undefined,
defaultValue: number,
): number => {
if (!value) return defaultValue;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : defaultValue;
};

/**
* Common Redis configuration shape.
*/
export const redisConfig = REDIS_URL
? { url: REDIS_URL }
: {
host:
REDIS_HOST ||
(process.env.NODE_ENV === "production" ? "dokploy-redis" : "127.0.0.1"),
port: parseNumeric(REDIS_PORT, 6379),
db: parseNumeric(REDIS_DB_INDEX, 0),
password: getRedisPassword(),
username: REDIS_USERNAME,
};
23 changes: 23 additions & 0 deletions packages/server/src/setup/redis-setup.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,31 @@
import type { CreateServiceOptions } from "dockerode";
import { docker } from "../constants";
import {
REDIS_HOST,
REDIS_URL,
getRedisPassword,
} from "./redis-constants";
import { pullImage } from "../utils/docker/utils";
Comment thread
satoukouga marked this conversation as resolved.

export const initializeRedis = async () => {
// Skip provisioning local Redis if an external one is configured
const isExternalRedis =
REDIS_URL ||
(REDIS_HOST &&
REDIS_HOST !== "dokploy-redis" &&
REDIS_HOST !== "127.0.0.1" &&
REDIS_HOST !== "localhost");

if (isExternalRedis) {
console.log("External Redis detected: Skipping local Redis setup. ✅");
return;
}

const imageName = "redis:7";
const containerName = "dokploy-redis";

const redisPassword = getRedisPassword();
Comment thread
satoukouga marked this conversation as resolved.

const settings: CreateServiceOptions = {
Name: containerName,
TaskTemplate: {
Expand All @@ -18,6 +38,9 @@ export const initializeRedis = async () => {
Target: "/data",
},
],
...(redisPassword && {
Args: ["redis-server", "--requirepass", redisPassword],
}),
},
Networks: [{ Target: "dokploy-network" }],
Placement: {
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/templates/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export function processValue(
if (params.length === 1 && params[0] && params[0].match(/^\d{1,3}$/)) {
return generateJwt({ length: Number.parseInt(params[0], 10) });
}
let [secret, payload] = params;
let [secret, payload]: [string, any] = params as [string, any];
Comment thread
satoukouga marked this conversation as resolved.
Outdated
Comment thread
satoukouga marked this conversation as resolved.
Outdated
Comment thread
satoukouga marked this conversation as resolved.
Outdated
if (typeof payload === "string" && variables[payload]) {
Comment thread
satoukouga marked this conversation as resolved.
payload = variables[payload];
}
Expand Down
4 changes: 2 additions & 2 deletions packages/server/src/utils/databases/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,10 @@ export const buildRedis = async (redis: RedisNested) => {
}),
}
: {
Command: ["/bin/sh"],
Args: ["-c", `redis-server --requirepass ${databasePassword}`],
Args: ["redis-server", "--requirepass", databasePassword],
}),
...(Ulimits && { Ulimits }),

Labels,
},
Networks,
Expand Down
Loading