From f5cbb9acd8e85df65892574941f12cedc4c4730c Mon Sep 17 00:00:00 2001 From: myagizmaktav Date: Sun, 12 Jul 2026 12:49:21 +0000 Subject: [PATCH] feat(compose): add opt-in skipDefaultNetwork to avoid Traefik multi-network routing issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a domain is attached to a compose service, addDokployNetworkToService unconditionally attaches that service to both "dokploy-network" and the project's implicit "default" network (introduced in #3562 so sibling services stay reachable via Compose's own default networking). For services that don't rely on the default network for inter-service connectivity (e.g. single-service compose apps, or apps that already manage their own explicit network topology), being attached to two networks can make Traefik's Docker provider select the wrong network's IP for routing. In a live incident this produced a container that Docker reported "healthy" while Traefik returned a persistent 502 through Cloudflare — and neither restarting the same container nor reloading Traefik's config recovered it; only fully removing and recreating the container (new container ID, new network endpoint) did. This adds a new per-domain boolean, `skipDefaultNetwork` (default false, so all existing behavior and tests are unchanged), that lets a domain opt out of the "default" network attachment while still keeping dokploy-network. Mirrors the existing `stripPath`-style per-domain flag pattern. - packages/server/src/db/schema/domain.ts: new column + pick lists - packages/server/src/db/validations/domain.ts: zod validation for the compose domain schema - packages/server/src/utils/docker/domain.ts: addDokployNetworkToService now accepts an optional skipDefaultNetwork flag; call site passes domain.skipDefaultNetwork - apps/dokploy/drizzle/: migration for the new column - New tests in network-service.test.ts covering the opt-in behavior; existing tests for the default (unchanged) behavior still pass - Updated 4 existing test fixtures (Domain mock objects) that needed the new required field to keep typecheck green Note: apps/dokploy/drizzle/meta/*_snapshot.json was not regenerated — I don't have a local Postgres/drizzle-kit toolchain to run `drizzle-kit generate` against. The schema.ts change and migration SQL are correct and minimal (single boolean column, matching the existing 0174 migration's style); happy to update the snapshot if pointed at how you'd like it generated, or if CI regenerates it automatically. All 146 existing tests in apps/dokploy/__test__/compose/ pass, plus 5 new ones for this change. `pnpm --filter=@dokploy/server run typecheck` and `pnpm --filter=dokploy run typecheck` are clean (the one remaining handle-tag.tsx Zod-version error pre-exists on canary and is unrelated). --- .../compose/domain/host-rule-format.test.ts | 1 + .../__test__/compose/domain/labels.test.ts | 1 + .../compose/domain/network-service.test.ts | 30 +++++++++++++++++++ .../__test__/traefik/forward-auth.test.ts | 1 + apps/dokploy/__test__/traefik/traefik.test.ts | 1 + .../drizzle/0175_skip_default_network.sql | 1 + apps/dokploy/drizzle/meta/_journal.json | 7 +++++ packages/server/src/db/schema/domain.ts | 12 ++++++++ packages/server/src/db/validations/domain.ts | 1 + packages/server/src/utils/docker/domain.ts | 14 +++++++-- 10 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 apps/dokploy/drizzle/0175_skip_default_network.sql 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] = {}; } }