diff --git a/client/index.html b/client/index.html
index 4f949edbb..5f48a1bd3 100644
--- a/client/index.html
+++ b/client/index.html
@@ -12,6 +12,7 @@
/>
Checkmate
+
diff --git a/client/public/config.js b/client/public/config.js
new file mode 100644
index 000000000..30686d2e0
--- /dev/null
+++ b/client/public/config.js
@@ -0,0 +1,4 @@
+// Fallback so /config.js always resolves (e.g. under the Vite dev server). In
+// the Docker image the server shadows this path and renders the real config
+// from its CLIENT_CONFIG_* environment variables.
+window.__CHECKMATE_CONFIG__ = {};
diff --git a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx
index b3e08fe67..d41bf3ca3 100644
--- a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx
+++ b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx
@@ -10,8 +10,9 @@ import { useInviteForm } from "@/Hooks/useInviteForm";
import type { InviteFormData } from "@/Validation/invite";
import { usePost } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants";
+import { runtimeConfig } from "@/Utils/runtimeConfig";
-const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST;
+const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST;
interface InviteResponse {
token: string;
diff --git a/client/src/Utils/ApiClient.ts b/client/src/Utils/ApiClient.ts
index d88dca6b1..c0468ed0e 100644
--- a/client/src/Utils/ApiClient.ts
+++ b/client/src/Utils/ApiClient.ts
@@ -1,8 +1,10 @@
import axios from "axios";
import type { AxiosError } from "axios";
import type { AxiosRequestConfig, AxiosResponse } from "axios";
+import { runtimeConfig } from "@/Utils/runtimeConfig";
-const BASE_URL = import.meta.env.VITE_APP_API_BASE_URL || "/api/v1";
+const BASE_URL =
+ runtimeConfig.apiBaseUrl || import.meta.env.VITE_APP_API_BASE_URL || "/api/v1";
const api = axios.create({
baseURL: BASE_URL,
diff --git a/client/src/Utils/logger.ts b/client/src/Utils/logger.ts
index 4a97dbecb..74be87918 100644
--- a/client/src/Utils/logger.ts
+++ b/client/src/Utils/logger.ts
@@ -1,4 +1,5 @@
import type { LogLevel } from "@/Types/Log";
+import { runtimeConfig } from "@/Utils/runtimeConfig";
interface LogContext {
[key: string]: any;
@@ -25,7 +26,8 @@ interface ErrorLogData {
userAgent: string;
}
-const configuredLevel: LogLevel = import.meta.env.VITE_APP_LOG_LEVEL || "error";
+const configuredLevel: LogLevel =
+ runtimeConfig.logLevel || import.meta.env.VITE_APP_LOG_LEVEL || "error";
const configuredPriority = LOG_LEVEL_PRIORITY[configuredLevel];
const shouldLog = (level: LogLevel): boolean => {
diff --git a/client/src/Utils/runtimeConfig.ts b/client/src/Utils/runtimeConfig.ts
new file mode 100644
index 000000000..2d5e4be94
--- /dev/null
+++ b/client/src/Utils/runtimeConfig.ts
@@ -0,0 +1,18 @@
+import type { LogLevel } from "@/Types/Log";
+
+// Served by GET /config.js (rendered from server env) or a static config.js
+// next to index.html; loaded before the bundle in index.html.
+export interface RuntimeConfig {
+ apiBaseUrl?: string;
+ clientHost?: string;
+ logLevel?: LogLevel;
+}
+
+declare global {
+ interface Window {
+ __CHECKMATE_CONFIG__?: RuntimeConfig;
+ }
+}
+
+export const runtimeConfig: RuntimeConfig =
+ (typeof window !== "undefined" && window.__CHECKMATE_CONFIG__) || {};
diff --git a/client/src/Utils/statusPageUrl.ts b/client/src/Utils/statusPageUrl.ts
index d5628d6bc..65109c198 100644
--- a/client/src/Utils/statusPageUrl.ts
+++ b/client/src/Utils/statusPageUrl.ts
@@ -1,6 +1,9 @@
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
+import { runtimeConfig } from "@/Utils/runtimeConfig";
-const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST as string | undefined;
+const CLIENT_HOST =
+ runtimeConfig.clientHost ||
+ (import.meta.env.VITE_APP_CLIENT_HOST as string | undefined);
const getClientOriginProtocol = (): string => {
const source =
diff --git a/server/src/app.ts b/server/src/app.ts
index 2ed1c2f01..c363db5d2 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -52,6 +52,13 @@ export const createApp = ({
app.use(createStatusPageDocumentCsp(allowedOrigin));
+ // Client runtime config; registered before express.static so it shadows the
+ // fallback config.js in the client build output
+ const clientConfigScript = `window.__CHECKMATE_CONFIG__ = ${JSON.stringify(envSettings.clientConfig).replace(/ {
+ res.set("Cache-Control", "no-cache").type("application/javascript").send(clientConfigScript);
+ });
+
app.use(express.static(frontendPath));
app.use(express.json());
diff --git a/server/src/config/envValidation.ts b/server/src/config/envValidation.ts
index 05471e690..0bfaa01ce 100644
--- a/server/src/config/envValidation.ts
+++ b/server/src/config/envValidation.ts
@@ -1,5 +1,5 @@
import { z } from "zod";
-import { DbTypes, QueueModes } from "@/domain/app-settings/app-settings.type.js";
+import { DbTypes, LogLevels, QueueModes } from "@/domain/app-settings/app-settings.type.js";
import { booleanCoercion } from "@/api/validation/shared.js";
import { ILogger } from "@/utils/logger.js";
@@ -9,7 +9,7 @@ const envSchema = z.object({
// Port for worker health checks
HEALTH_PORT: z.string().default("52346"),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
- LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("debug"),
+ LOG_LEVEL: z.enum(LogLevels).default("debug"),
// Database
DB_CONNECTION_STRING: z.string().min(1, "Database connection string is required"),
@@ -25,6 +25,12 @@ const envSchema = z.object({
// Client Configuration
CLIENT_HOST: z.string().url("CLIENT_HOST must be a valid URL"),
+ // Client runtime config overrides, rendered into GET /config.js; unset keys are
+ // omitted and the client uses its same-origin defaults
+ CLIENT_CONFIG_API_BASE_URL: z.string().optional(),
+ CLIENT_CONFIG_CLIENT_HOST: z.string().url("CLIENT_CONFIG_CLIENT_HOST must be a valid URL").optional(),
+ CLIENT_CONFIG_LOG_LEVEL: z.enum(LogLevels).optional(),
+
// Optional
ORIGIN: z.string().optional(),
diff --git a/server/src/domain/app-settings/app-settings.service.ts b/server/src/domain/app-settings/app-settings.service.ts
index 8beb65d84..92a54702e 100755
--- a/server/src/domain/app-settings/app-settings.service.ts
+++ b/server/src/domain/app-settings/app-settings.service.ts
@@ -1,5 +1,5 @@
import { ISettingsRepository } from "@/domain/app-settings/app-settings-repository.interface.js";
-import { Settings, SettingsUpdate, type DbType, type QueueMode } from "@/domain/app-settings/app-settings.type.js";
+import { Settings, SettingsUpdate, type ClientRuntimeConfig, type DbType, type QueueMode } from "@/domain/app-settings/app-settings.type.js";
import { AppError } from "@/utils/AppError.js";
import { ValidatedEnv } from "@/config/envValidation.js";
import type { StringValue } from "ms";
@@ -16,6 +16,7 @@ export type EnvConfig = {
queueMode: QueueMode;
queuePrimaryProcesses: boolean;
statusPageThemesEnabled: boolean;
+ clientConfig: ClientRuntimeConfig;
};
export interface ISettingsService {
@@ -44,6 +45,11 @@ export class SettingsService implements ISettingsService {
queueMode: env.QUEUE_MODE,
queuePrimaryProcesses: env.QUEUE_PRIMARY_PROCESSES,
statusPageThemesEnabled: env.STATUS_PAGE_THEMES_ENABLED,
+ clientConfig: {
+ ...(env.CLIENT_CONFIG_API_BASE_URL && { apiBaseUrl: env.CLIENT_CONFIG_API_BASE_URL }),
+ ...(env.CLIENT_CONFIG_CLIENT_HOST && { clientHost: env.CLIENT_CONFIG_CLIENT_HOST }),
+ ...(env.CLIENT_CONFIG_LOG_LEVEL && { logLevel: env.CLIENT_CONFIG_LOG_LEVEL }),
+ },
};
}
diff --git a/server/src/domain/app-settings/app-settings.type.ts b/server/src/domain/app-settings/app-settings.type.ts
index 8cb52e93d..8233ced63 100644
--- a/server/src/domain/app-settings/app-settings.type.ts
+++ b/server/src/domain/app-settings/app-settings.type.ts
@@ -4,6 +4,17 @@ export type DbType = (typeof DbTypes)[number];
export const QueueModes = ["primary", "worker"] as const;
export type QueueMode = (typeof QueueModes)[number];
+export const LogLevels = ["error", "warn", "info", "debug"] as const;
+export type LogLevel = (typeof LogLevels)[number];
+
+// Rendered into GET /config.js as window.__CHECKMATE_CONFIG__; keys left unset
+// fall back to the client's same-origin defaults.
+export type ClientRuntimeConfig = {
+ apiBaseUrl?: string;
+ clientHost?: string;
+ logLevel?: LogLevel;
+};
+
export interface SettingsThresholds {
cpu?: number;
memory?: number;
diff --git a/server/test/unit/services/settingsService.test.ts b/server/test/unit/services/settingsService.test.ts
index af58b5705..43ea7fe18 100644
--- a/server/test/unit/services/settingsService.test.ts
+++ b/server/test/unit/services/settingsService.test.ts
@@ -83,6 +83,19 @@ describe("SettingsService", () => {
dbConnectionString: "mongodb://localhost:27017/test_db",
dbType: "mongodb",
statusPageThemesEnabled: false,
+ clientConfig: {},
+ });
+ });
+
+ it("maps only set CLIENT_CONFIG_* env vars into clientConfig", () => {
+ const { service } = createService({
+ CLIENT_CONFIG_API_BASE_URL: "https://api.example.com/api/v1",
+ CLIENT_CONFIG_LOG_LEVEL: "warn",
+ });
+
+ expect(service.loadSettings().clientConfig).toEqual({
+ apiBaseUrl: "https://api.example.com/api/v1",
+ logLevel: "warn",
});
});