Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions apps/dokploy/__test__/compose/domain/host-rule-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,51 @@ describe("Host rule format regression tests", () => {
});
});

describe("Wildcard domain support (Traefik v3)", () => {
/**
* Traefik v3 requires HostRegexp for wildcard domains.
* @see https://doc.traefik.io/traefik/v3.0/routing/routers/
*/
it("should generate HostRegexp rule for wildcard domain", async () => {
const labels = await createDomainLabels(
"test-app",
{ ...baseDomain, host: "*.example.com" },
"web",
);
const ruleLabel = labels.find((l) => l.includes(".rule="));

expect(ruleLabel).toBeDefined();
// Should use HostRegexp instead of Host for wildcards
expect(ruleLabel).toContain("HostRegexp(`^.+\\.example\\.com$`)");
expect(ruleLabel).not.toContain("Host(`*.example.com`)");
});

it("should generate HostRegexp rule for nested wildcard domain", async () => {
const labels = await createDomainLabels(
"test-app",
{ ...baseDomain, host: "*.app.example.com" },
"web",
);
const ruleLabel = labels.find((l) => l.includes(".rule="));

expect(ruleLabel).toBeDefined();
expect(ruleLabel).toContain("HostRegexp(`^.+\\.app\\.example\\.com$`)");
});

it("should combine HostRegexp with PathPrefix for wildcard domain", async () => {
const labels = await createDomainLabels(
"test-app",
{ ...baseDomain, host: "*.example.com", path: "/api" },
"web",
);
const ruleLabel = labels.find((l) => l.includes(".rule="));

expect(ruleLabel).toBeDefined();
expect(ruleLabel).toContain("HostRegexp(`^.+\\.example\\.com$`)");
expect(ruleLabel).toContain("PathPrefix(`/api`)");
});
});

describe("Edge cases for domain names", () => {
const domainCases = [
{ name: "simple domain", host: "example.com" },
Expand Down
106 changes: 106 additions & 0 deletions apps/dokploy/__test__/traefik/host-rule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {
generateTraefikHostRule,
isWildcardDomain,
} from "@dokploy/server/utils/traefik/host-rule";
import { describe, expect, it } from "vitest";

/**
* Tests for Traefik host rule generation.
*
* Traefik v3 changed the syntax for wildcard routing:
* - Regular domains use: Host(`domain.com`)
* - Wildcard domains use: HostRegexp(`^.+\.domain\.com$`)
*
* @see https://doc.traefik.io/traefik/v3.0/routing/routers/
* @see https://community.traefik.io/t/how-to-create-a-router-rule-for-a-wildcard/19850
*/
describe("generateTraefikHostRule", () => {
describe("regular domains", () => {
it("should generate Host rule for simple domain", () => {
const rule = generateTraefikHostRule("example.com");
expect(rule).toBe("Host(`example.com`)");
});

it("should generate Host rule for subdomain", () => {
const rule = generateTraefikHostRule("app.example.com");
expect(rule).toBe("Host(`app.example.com`)");
});

it("should generate Host rule for deep subdomain", () => {
const rule = generateTraefikHostRule("api.v1.example.com");
expect(rule).toBe("Host(`api.v1.example.com`)");
});

it("should generate Host rule for localhost", () => {
const rule = generateTraefikHostRule("localhost");
expect(rule).toBe("Host(`localhost`)");
});

it("should generate Host rule for IP address", () => {
const rule = generateTraefikHostRule("192.168.1.100");
expect(rule).toBe("Host(`192.168.1.100`)");
});

it("should generate Host rule for hyphenated domain", () => {
const rule = generateTraefikHostRule("my-app.example-host.com");
expect(rule).toBe("Host(`my-app.example-host.com`)");
});
});

describe("wildcard domains", () => {
it("should generate HostRegexp rule for wildcard domain", () => {
const rule = generateTraefikHostRule("*.example.com");
expect(rule).toBe("HostRegexp(`^.+\\.example\\.com$`)");
});

it("should generate HostRegexp rule for wildcard subdomain", () => {
const rule = generateTraefikHostRule("*.app.example.com");
expect(rule).toBe("HostRegexp(`^.+\\.app\\.example\\.com$`)");
});

it("should escape dots in base domain for regex", () => {
const rule = generateTraefikHostRule("*.my.multi.level.domain.com");
expect(rule).toBe("HostRegexp(`^.+\\.my\\.multi\\.level\\.domain\\.com$`)");
});

it("should handle hyphenated wildcard domain", () => {
const rule = generateTraefikHostRule("*.my-app.example.com");
expect(rule).toBe("HostRegexp(`^.+\\.my-app\\.example\\.com$`)");
});
});

describe("edge cases", () => {
it("should not treat domain with asterisk in the middle as wildcard", () => {
// Only *.domain.com is a wildcard, not a*b.domain.com
const rule = generateTraefikHostRule("test*.example.com");
expect(rule).toBe("Host(`test*.example.com`)");
});

it("should not treat domain ending with asterisk as wildcard", () => {
const rule = generateTraefikHostRule("example.com*");
expect(rule).toBe("Host(`example.com*`)");
});
});
});

describe("isWildcardDomain", () => {
it("should return true for wildcard domain", () => {
expect(isWildcardDomain("*.example.com")).toBe(true);
});

it("should return true for nested wildcard", () => {
expect(isWildcardDomain("*.app.example.com")).toBe(true);
});

it("should return false for regular domain", () => {
expect(isWildcardDomain("example.com")).toBe(false);
});

it("should return false for subdomain", () => {
expect(isWildcardDomain("app.example.com")).toBe(false);
});

it("should return false for domain with asterisk in middle", () => {
expect(isWildcardDomain("a*b.example.com")).toBe(false);
});
});
1 change: 1 addition & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export * from "./utils/tracking/hubspot";
export * from "./utils/traefik/application";
export * from "./utils/traefik/domain";
export * from "./utils/traefik/file-types";
export * from "./utils/traefik/host-rule";
export * from "./utils/traefik/middleware";
export * from "./utils/traefik/redirect";
export * from "./utils/traefik/security";
Expand Down
4 changes: 3 additions & 1 deletion packages/server/src/utils/docker/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { cloneGiteaRepository } from "../providers/gitea";
import { cloneGithubRepository } from "../providers/github";
import { cloneGitlabRepository } from "../providers/gitlab";
import { getCreateComposeFileCommand } from "../providers/raw";
import { generateTraefikHostRule } from "../traefik/host-rule";
import { randomizeDeployableSpecificationFile } from "./collision";
import { randomizeSpecificationFile } from "./compose";
import type {
Expand Down Expand Up @@ -263,8 +264,9 @@ export const createDomainLabels = (
internalPath,
} = domain;
const routerName = `${appName}-${uniqueConfigKey}-${entrypoint}`;
const hostRule = generateTraefikHostRule(host);
const labels = [
`traefik.http.routers.${routerName}.rule=Host(\`${host}\`)${path && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`,
`traefik.http.routers.${routerName}.rule=${hostRule}${path && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`,
`traefik.http.routers.${routerName}.entrypoints=${entrypoint}`,
`traefik.http.services.${routerName}.loadbalancer.server.port=${port}`,
`traefik.http.routers.${routerName}.service=${routerName}`,
Expand Down
4 changes: 3 additions & 1 deletion packages/server/src/utils/traefik/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
writeTraefikConfigRemote,
} from "./application";
import type { FileConfig, HttpRouter } from "./file-types";
import { generateTraefikHostRule } from "./host-rule";
import { createPathMiddlewares, removePathMiddlewares } from "./middleware";

export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
Expand Down Expand Up @@ -114,8 +115,9 @@ export const createRouterConfig = async (

const { host, path, https, uniqueConfigKey, internalPath, stripPath } =
domain;
const hostRule = generateTraefikHostRule(host);
const routerConfig: HttpRouter = {
rule: `Host(\`${host}\`)${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`,
rule: `${hostRule}${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`,
service: `${appName}-service-${uniqueConfigKey}`,
middlewares: [],
entryPoints: [entryPoint],
Expand Down
32 changes: 32 additions & 0 deletions packages/server/src/utils/traefik/host-rule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Generates a Traefik routing rule for the given host.
*
* For regular domains: uses Host(`domain.com`)
* For wildcard domains (*.domain.com): uses HostRegexp(`.+\.domain\.com`)
*
* Traefik v3 changed the syntax for wildcard routing:
* - v2 used: HostRegexp({subdomain:[a-z]+}.domain.com)
* - v3 uses: HostRegexp(`.+\.domain\.com`)
*
* @see https://doc.traefik.io/traefik/v3.0/routing/routers/
* @see https://community.traefik.io/t/how-to-create-a-router-rule-for-a-wildcard/19850
*/
export const generateTraefikHostRule = (host: string): string => {
if (host.startsWith("*.")) {
// Wildcard domain: *.example.com -> HostRegexp(`.+\.example\.com`)
const baseDomain = host.slice(2); // Remove "*."
// Escape dots in the domain for regex
const escapedDomain = baseDomain.replace(/\./g, "\\.");
return `HostRegexp(\`^.+\\.${escapedDomain}$\`)`;
}

// Regular domain: use Host() matcher
return `Host(\`${host}\`)`;
};

/**
* Checks if the given host is a wildcard domain (starts with *.)
*/
export const isWildcardDomain = (host: string): boolean => {
return host.startsWith("*.");
};