diff --git a/messages/en-US.json b/messages/en-US.json
index ea591febb..88302ecd7 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -793,6 +793,15 @@
"targetsDescription": "Set up targets to route traffic to backend services",
"targetStickySessions": "Enable Sticky Sessions",
"targetStickySessionsDescription": "Keep connections on the same backend target for their entire session.",
+ "loadBalancingStrategy": "Load Balancing Strategy",
+ "loadBalancingStrategy_wrr": "Round Robin",
+ "loadBalancingStrategy_wrr_description": "Distribute requests evenly across targets in rotation.",
+ "loadBalancingStrategy_p2c": "Power of Two Choices",
+ "loadBalancingStrategy_p2c_description": "Pick two random targets and send the request to the one with fewer active connections. Requires Traefik v3.4 or newer.",
+ "loadBalancingStrategy_hrw": "Client IP Hash",
+ "loadBalancingStrategy_hrw_description": "Hash the client IP so the same client is consistently routed to the same target. Requires Traefik v3.6 or newer.",
+ "loadBalancingStrategy_leasttime": "Least Response Time",
+ "loadBalancingStrategy_leasttime_description": "Send requests to the target with the fastest response time and fewest active connections. Requires Traefik v3.6 or newer.",
"methodSelect": "Select method",
"targetSubmit": "Add Target",
"targetNoOne": "This resource doesn't have any targets. Add a target to configure where to send requests to the backend.",
diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts
index 53b2db0d3..53f02b69e 100644
--- a/server/db/pg/schema/schema.ts
+++ b/server/db/pg/schema/schema.ts
@@ -181,6 +181,10 @@ export const resources = pgTable(
applyRules: boolean("applyRules"),
enabled: boolean("enabled").notNull().default(true),
stickySession: boolean("stickySession").notNull().default(false),
+ loadBalancingStrategy: varchar("loadBalancingStrategy", { length: 32 })
+ .$type<"wrr" | "p2c" | "hrw" | "leasttime">()
+ .notNull()
+ .default("wrr"), // traefik http loadBalancer.strategy
tlsServerName: varchar("tlsServerName"),
setHostHeader: varchar("setHostHeader"),
enableProxy: boolean("enableProxy").default(true),
diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts
index f9ea8fd6c..72b323158 100644
--- a/server/db/sqlite/schema/schema.ts
+++ b/server/db/sqlite/schema/schema.ts
@@ -197,6 +197,10 @@ export const resources = sqliteTable(
stickySession: integer("stickySession", { mode: "boolean" })
.notNull()
.default(false),
+ loadBalancingStrategy: text("loadBalancingStrategy")
+ .$type<"wrr" | "p2c" | "hrw" | "leasttime">()
+ .notNull()
+ .default("wrr"), // traefik http loadBalancer.strategy
tlsServerName: text("tlsServerName"),
setHostHeader: text("setHostHeader"),
enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
diff --git a/server/lib/blueprints/exportResourceBlueprint.ts b/server/lib/blueprints/exportResourceBlueprint.ts
index 5b825e74c..9f3f74169 100644
--- a/server/lib/blueprints/exportResourceBlueprint.ts
+++ b/server/lib/blueprints/exportResourceBlueprint.ts
@@ -410,6 +410,13 @@ export async function generatePublicResourceBlueprintYaml(
if (resource.tlsServerName) {
resourceConfig["tls-server-name"] = resource.tlsServerName;
}
+ if (
+ resource.loadBalancingStrategy &&
+ resource.loadBalancingStrategy !== "wrr"
+ ) {
+ resourceConfig["load-balancing-strategy"] =
+ resource.loadBalancingStrategy;
+ }
if (resource.requestHeaders) {
try {
resourceConfig.requestHeaders = JSON.parse(resource.requestHeaders);
diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts
index ffd4331df..6a52a9872 100644
--- a/server/lib/blueprints/publicResources.ts
+++ b/server/lib/blueprints/publicResources.ts
@@ -424,6 +424,9 @@ export async function updatePublicResources(
setHostHeader: resourceData["host-header"] || null,
tlsServerName:
resourceData["tls-server-name"] || null,
+ loadBalancingStrategy:
+ resourceData["load-balancing-strategy"] ??
+ "wrr",
emailWhitelistEnabled: resourceData.auth?.[
"whitelist-users"
]
@@ -628,6 +631,9 @@ export async function updatePublicResources(
setHostHeader: resourceData["host-header"] || null,
tlsServerName:
resourceData["tls-server-name"] || null,
+ loadBalancingStrategy:
+ resourceData["load-balancing-strategy"] ??
+ "wrr",
requestHeaders,
responseHeaders,
maintenanceModeEnabled:
@@ -1260,6 +1266,8 @@ export async function updatePublicResources(
enabled: resourceEnabled,
setHostHeader: resourceData["host-header"] || null,
tlsServerName: resourceData["tls-server-name"] || null,
+ loadBalancingStrategy:
+ resourceData["load-balancing-strategy"] ?? "wrr",
ssl: resourceSsl,
requestHeaders,
responseHeaders,
diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts
index eb50906ca..94b9d380b 100644
--- a/server/lib/blueprints/types.ts
+++ b/server/lib/blueprints/types.ts
@@ -327,6 +327,9 @@ export const PublicResourceSchema = z
auth: AuthSchema.optional(),
"host-header": z.string().optional(),
"tls-server-name": z.string().optional(),
+ "load-balancing-strategy": z
+ .enum(["wrr", "p2c", "hrw", "leasttime"])
+ .optional(),
headers: z.array(HeaderSchema).optional(), // deprecated alias for requestHeaders
requestHeaders: z.array(HeaderSchema).optional(),
responseHeaders: z.array(HeaderSchema).optional(),
diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts
index 47f665794..81a42ab4b 100644
--- a/server/lib/traefik/getTraefikConfig.ts
+++ b/server/lib/traefik/getTraefikConfig.ts
@@ -30,6 +30,7 @@ import { buildWildcardTls } from "./certResolver";
import { buildHostRule, appendPathMatch, computeRoutePriority } from "./rule";
import {
buildHttpLoadBalancerServers,
+ buildLoadBalancingStrategy,
buildStickySessionCookie,
buildTcpUdpLoadBalancerServers,
buildStickySessionIp
@@ -93,6 +94,7 @@ export async function getTraefikConfig(
domainId: resources.domainId,
enabled: resources.enabled,
stickySession: resources.stickySession,
+ loadBalancingStrategy: resources.loadBalancingStrategy,
tlsServerName: resources.tlsServerName,
setHostHeader: resources.setHostHeader,
enableProxy: resources.enableProxy,
@@ -222,6 +224,7 @@ export async function getTraefikConfig(
domainId: row.domainId,
enabled: row.enabled,
stickySession: row.stickySession,
+ loadBalancingStrategy: row.loadBalancingStrategy,
tlsServerName: row.tlsServerName,
setHostHeader: row.setHostHeader,
enableProxy: row.enableProxy,
@@ -458,6 +461,9 @@ export async function getTraefikConfig(
config_output.http.services![serviceName] = {
loadBalancer: {
servers: buildHttpLoadBalancerServers(targets),
+ ...buildLoadBalancingStrategy(
+ resource.loadBalancingStrategy
+ ),
...(resource.stickySession
? buildStickySessionCookie(resource.ssl)
: {})
diff --git a/server/lib/traefik/loadBalancer.test.ts b/server/lib/traefik/loadBalancer.test.ts
index 0ffd5201d..2adb5f040 100644
--- a/server/lib/traefik/loadBalancer.test.ts
+++ b/server/lib/traefik/loadBalancer.test.ts
@@ -2,6 +2,7 @@ import "@server/extendZod";
import { assertEqualsObj } from "@test/assert";
import {
buildHttpLoadBalancerServers,
+ buildLoadBalancingStrategy,
buildTcpUdpLoadBalancerServers
} from "./loadBalancer";
import type { TargetWithSite } from "./types";
@@ -75,6 +76,24 @@ function runTests() {
);
}
+ assertEqualsObj(
+ buildLoadBalancingStrategy("wrr"),
+ {},
+ "Default wrr strategy should be omitted"
+ );
+ assertEqualsObj(
+ buildLoadBalancingStrategy(null),
+ {},
+ "Missing strategy should be omitted"
+ );
+ for (const strategy of ["p2c", "hrw", "leasttime"]) {
+ assertEqualsObj(
+ buildLoadBalancingStrategy(strategy),
+ { strategy },
+ `${strategy} strategy should be emitted`
+ );
+ }
+
console.log("All load balancer endpoint tests passed!");
}
diff --git a/server/lib/traefik/loadBalancer.ts b/server/lib/traefik/loadBalancer.ts
index 538f6bf07..365756b37 100644
--- a/server/lib/traefik/loadBalancer.ts
+++ b/server/lib/traefik/loadBalancer.ts
@@ -63,6 +63,18 @@ export function buildHttpLoadBalancerServers(targets: TargetWithSite[]) {
);
}
+/**
+ * Build the loadBalancer.strategy for an HTTP-mode resource. Traefik defaults
+ * to wrr when strategy is omitted, so only emit it for the non-default
+ * strategies to keep the generated config unchanged for existing resources.
+ */
+export function buildLoadBalancingStrategy(strategy: string | null) {
+ if (!strategy || strategy === "wrr") {
+ return {};
+ }
+ return { strategy };
+}
+
export function buildStickySessionCookie(ssl: boolean | null) {
return {
sticky: {
diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts
index 4c2b87906..51a879a42 100644
--- a/server/private/lib/traefik/getTraefikConfig.ts
+++ b/server/private/lib/traefik/getTraefikConfig.ts
@@ -66,6 +66,7 @@ import {
} from "@server/lib/traefik/rule";
import {
buildHttpLoadBalancerServers,
+ buildLoadBalancingStrategy,
buildStickySessionCookie,
buildTcpUdpLoadBalancerServers,
buildStickySessionIp
@@ -131,6 +132,7 @@ export async function getTraefikConfig(
domainId: resources.domainId,
enabled: resources.enabled,
stickySession: resources.stickySession,
+ loadBalancingStrategy: resources.loadBalancingStrategy,
tlsServerName: resources.tlsServerName,
setHostHeader: resources.setHostHeader,
enableProxy: resources.enableProxy,
@@ -275,6 +277,7 @@ export async function getTraefikConfig(
domainId: row.domainId,
enabled: row.enabled,
stickySession: row.stickySession,
+ loadBalancingStrategy: row.loadBalancingStrategy,
tlsServerName: row.tlsServerName,
setHostHeader: row.setHostHeader,
enableProxy: row.enableProxy,
@@ -639,6 +642,9 @@ export async function getTraefikConfig(
config_output.http.services![serviceName] = {
loadBalancer: {
servers: buildHttpLoadBalancerServers(targets),
+ ...buildLoadBalancingStrategy(
+ resource.loadBalancingStrategy
+ ),
...(resource.stickySession
? buildStickySessionCookie(resource.ssl)
: {})
diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts
index 2716d8556..a366cc7a2 100644
--- a/server/routers/resource/updateResource.ts
+++ b/server/routers/resource/updateResource.ts
@@ -90,6 +90,12 @@ const updateHttpResourceBodySchema = z
domainId: z.string().optional(),
enabled: z.boolean().optional(),
stickySession: z.boolean().optional(),
+ loadBalancingStrategy: z
+ .enum(["wrr", "p2c", "hrw", "leasttime"])
+ .optional()
+ .describe(
+ "How requests are distributed across targets: wrr (weighted round robin, default), p2c (power of two choices), hrw (highest random weight / client IP hashing), leasttime (lowest response time)."
+ ),
tlsServerName: z.string().nullable().optional(),
setHostHeader: z.string().nullable().optional(),
skipToIdpId: z
diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx
index 40330e51b..a44165126 100644
--- a/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx
+++ b/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx
@@ -2,6 +2,13 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue
+} from "@app/components/ui/select";
import { HeadersInput } from "@app/components/HeadersInput";
import {
SettingsContainer,
@@ -45,6 +52,8 @@ import { useActionState, useState, startTransition } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
+const LOAD_BALANCING_STRATEGIES = ["wrr", "p2c", "hrw", "leasttime"] as const;
+
export default function ReverseProxyTargetsPage() {
const params = useParams();
const { resource, updateResource } = useResourceContext();
@@ -90,6 +99,7 @@ function ProxyResourceHttpForm({
const httpSettingsSchema = z.object({
stickySession: z.boolean(),
+ loadBalancingStrategy: z.enum(LOAD_BALANCING_STRATEGIES),
ssl: z.boolean(),
tlsServerName: z
.string()
@@ -131,6 +141,7 @@ function ProxyResourceHttpForm({
resolver: zodResolver(httpSettingsSchema),
defaultValues: {
stickySession: resource.stickySession,
+ loadBalancingStrategy: resource.loadBalancingStrategy,
ssl: resource.ssl,
tlsServerName: resource.tlsServerName || "",
setHostHeader: resource.setHostHeader || "",
@@ -164,6 +175,7 @@ function ProxyResourceHttpForm({
`/resource/${resource.resourceId}`,
{
stickySession: data.stickySession,
+ loadBalancingStrategy: data.loadBalancingStrategy,
ssl: data.ssl,
tlsServerName: data.tlsServerName || null,
setHostHeader: data.setHostHeader || null,
@@ -186,6 +198,7 @@ function ProxyResourceHttpForm({
updateResource({
...resource,
stickySession: data.stickySession,
+ loadBalancingStrategy: data.loadBalancingStrategy,
ssl: data.ssl,
tlsServerName: data.tlsServerName || null,
setHostHeader: data.setHostHeader || null,
@@ -305,6 +318,58 @@ function ProxyResourceHttpForm({
/>
+
+ (
+
+
+ {t(
+ "loadBalancingStrategy"
+ )}
+
+
+
+ {t(
+ `loadBalancingStrategy_${field.value}_description`
+ )}
+
+
+
+ )}
+ />
+
+