diff --git a/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts index 07fc42c33a..a537271218 100644 --- a/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts +++ b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts @@ -35,6 +35,7 @@ describe("Host rule format regression tests", () => { customEntrypoint: null, middlewares: null, forwardAuthEnabled: false, + skipDefaultNetwork: false, }; describe("Host rule format validation", () => { diff --git a/apps/dokploy/__test__/compose/domain/labels.test.ts b/apps/dokploy/__test__/compose/domain/labels.test.ts index e48b54452e..43780ecee3 100644 --- a/apps/dokploy/__test__/compose/domain/labels.test.ts +++ b/apps/dokploy/__test__/compose/domain/labels.test.ts @@ -24,6 +24,7 @@ describe("createDomainLabels", () => { stripPath: false, middlewares: null, forwardAuthEnabled: false, + skipDefaultNetwork: false, }; it("should create basic labels for web entrypoint", async () => { diff --git a/apps/dokploy/__test__/compose/domain/network-service.test.ts b/apps/dokploy/__test__/compose/domain/network-service.test.ts index 83fe8a166e..2326a6f372 100644 --- a/apps/dokploy/__test__/compose/domain/network-service.test.ts +++ b/apps/dokploy/__test__/compose/domain/network-service.test.ts @@ -30,4 +30,34 @@ describe("addDokployNetworkToService", () => { const result = addDokployNetworkToService(["default", "dokploy-network"]); expect(result).toEqual(["default", "dokploy-network"]); }); + + describe("skipDefaultNetwork option", () => { + it("should still add dokploy-network but skip default when true (array)", () => { + const result = addDokployNetworkToService(["dokploy-network"], true); + expect(result).toEqual(["dokploy-network"]); + }); + + it("should skip default on an empty array when true", () => { + const result = addDokployNetworkToService([], true); + expect(result).toEqual(["dokploy-network"]); + }); + + it("should preserve other existing networks while skipping default", () => { + const result = addDokployNetworkToService(["other-network"], true); + expect(result).toEqual(["other-network", "dokploy-network"]); + }); + + it("should still add dokploy-network but skip default when true (object)", () => { + const result = addDokployNetworkToService({ "other-network": {} }, true); + expect(result).toEqual({ + "other-network": {}, + "dokploy-network": {}, + }); + }); + + it("should default to false and preserve existing behavior when omitted", () => { + const result = addDokployNetworkToService(["dokploy-network"]); + expect(result).toEqual(["dokploy-network", "default"]); + }); + }); }); diff --git a/apps/dokploy/__test__/traefik/forward-auth.test.ts b/apps/dokploy/__test__/traefik/forward-auth.test.ts index 9bc9a1f9ed..5c59cb7298 100644 --- a/apps/dokploy/__test__/traefik/forward-auth.test.ts +++ b/apps/dokploy/__test__/traefik/forward-auth.test.ts @@ -35,6 +35,7 @@ const baseDomain: Domain = { stripPath: false, middlewares: null, forwardAuthEnabled: false, + skipDefaultNetwork: false, }; describe("forwardAuthMiddlewareName", () => { diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 68758ca2d9..5172549af3 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -149,6 +149,7 @@ const baseDomain: Domain = { stripPath: false, middlewares: null, forwardAuthEnabled: false, + skipDefaultNetwork: false, }; const baseRedirect: Redirect = { diff --git a/apps/dokploy/drizzle/0175_skip_default_network.sql b/apps/dokploy/drizzle/0175_skip_default_network.sql new file mode 100644 index 0000000000..b2fe37d7f5 --- /dev/null +++ b/apps/dokploy/drizzle/0175_skip_default_network.sql @@ -0,0 +1 @@ +ALTER TABLE "domain" ADD COLUMN "skipDefaultNetwork" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/apps/dokploy/drizzle/meta/_journal.json b/apps/dokploy/drizzle/meta/_journal.json index cb54c899b0..be52394567 100644 --- a/apps/dokploy/drizzle/meta/_journal.json +++ b/apps/dokploy/drizzle/meta/_journal.json @@ -1226,6 +1226,13 @@ "when": 1783674181297, "tag": "0174_great_naoko", "breakpoints": true + }, + { + "idx": 175, + "version": "7", + "when": 1783860223772, + "tag": "0175_skip_default_network", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/server/src/db/schema/domain.ts b/packages/server/src/db/schema/domain.ts index 092275fde2..4fa49e4812 100644 --- a/packages/server/src/db/schema/domain.ts +++ b/packages/server/src/db/schema/domain.ts @@ -56,6 +56,16 @@ export const domains = pgTable("domain", { stripPath: boolean("stripPath").notNull().default(false), middlewares: text("middlewares").array().default(sql`ARRAY[]::text[]`), forwardAuthEnabled: boolean("forwardAuthEnabled").notNull().default(false), + // When a domain is attached to a compose service, Dokploy always attaches + // that service to BOTH "dokploy-network" and the project's implicit + // "default" network (see addDokployNetworkToService) so it keeps talking + // to sibling services (fix for #3562). For single-service compose apps + // (or any service that doesn't need default-network connectivity), being + // on two networks can make Traefik's Docker provider pick the wrong + // network's IP for routing, causing persistent 502s that don't self-heal. + // Opt-in escape hatch, defaults to false so existing behavior/tests are + // unchanged. + skipDefaultNetwork: boolean("skipDefaultNetwork").notNull().default(false), }); export const domainsRelations = relations(domains, ({ one }) => ({ @@ -96,6 +106,7 @@ export const apiCreateDomain = createSchema.pick({ stripPath: true, middlewares: true, forwardAuthEnabled: true, + skipDefaultNetwork: true, }); export const apiFindDomain = z.object({ @@ -129,5 +140,6 @@ export const apiUpdateDomain = createSchema stripPath: true, middlewares: true, forwardAuthEnabled: true, + skipDefaultNetwork: true, }) .merge(createSchema.pick({ domainId: true }).required()); diff --git a/packages/server/src/db/validations/domain.ts b/packages/server/src/db/validations/domain.ts index 059e3cae25..5e4d1a02f0 100644 --- a/packages/server/src/db/validations/domain.ts +++ b/packages/server/src/db/validations/domain.ts @@ -95,6 +95,7 @@ export const domainCompose = z customCertResolver: z.string(), serviceName: z.string().min(1, { message: "Service name is required" }), middlewares: z.array(z.string()).optional(), + skipDefaultNetwork: z.boolean().optional(), }) .superRefine((input, ctx) => { if (input.https && !input.certificateType) { diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index 8094f1df2a..70f5e4fff3 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -224,6 +224,7 @@ export const addDomainToCompose = async ( // Add the dokploy-network to the service result.services[serviceName].networks = addDokployNetworkToService( result.services[serviceName].networks, + domain.skipDefaultNetwork ?? false, ); } } @@ -349,6 +350,15 @@ export const createDomainLabels = ( export const addDokployNetworkToService = ( networkService: DefinitionsService["networks"], + // Opt-in escape hatch for #: attaching a domain-linked + // service to both dokploy-network AND the implicit "default" network + // (added in #3562 so sibling services stay reachable) can make Traefik's + // Docker provider pick the wrong network's IP for routing, causing 502s + // that don't self-heal on restart. Services that don't rely on the + // default network for inter-service connectivity (e.g. single-service + // compose apps) can set this to skip the "default" attachment. Defaults + // to false so existing behavior is unchanged. + skipDefaultNetwork = false, ) => { let networks = networkService; const network = "dokploy-network"; @@ -361,14 +371,14 @@ export const addDokployNetworkToService = ( if (!networks.includes(network)) { networks.push(network); } - if (!networks.includes(defaultNetwork)) { + if (!skipDefaultNetwork && !networks.includes(defaultNetwork)) { networks.push(defaultNetwork); } } else if (networks && typeof networks === "object") { if (!(network in networks)) { networks[network] = {}; } - if (!(defaultNetwork in networks)) { + if (!skipDefaultNetwork && !(defaultNetwork in networks)) { networks[defaultNetwork] = {}; } }