From 032e80411472d5a8b31ad61ba1176b94744b5803 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 27 Aug 2026 09:37:14 -0600 Subject: [PATCH] fix: remove empty routers/services traefik config instead of writing invalid yaml Attaching Basic Auth or a Redirect to an application with no domains wrote an empty routers/services block to the app's dynamic config file. Traefik's file provider rejects that as invalid and aborts its watcher, blocking config updates for every other application. Fixes #5189 --- .../traefik/write-app-traefik-config.test.ts | 104 ++++++++++++++++++ .../server/src/utils/traefik/application.ts | 20 ++++ packages/server/src/utils/traefik/redirect.ts | 8 +- packages/server/src/utils/traefik/security.ts | 15 ++- 4 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts diff --git a/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts b/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts new file mode 100644 index 000000000..2f80e811b --- /dev/null +++ b/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts @@ -0,0 +1,104 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { writeAppTraefikConfig } from "@dokploy/server/utils/traefik/application"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + execAsyncRemote: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + execAsyncRemote: mocks.execAsyncRemote, + }; +}); + +describe("writeAppTraefikConfig", () => { + let cwd: string; + let dynamicPath: string; + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-traefik-")); + dynamicPath = path.join(cwd, ".docker", "traefik", "dynamic"); + fs.mkdirSync(dynamicPath, { recursive: true }); + vi.spyOn(process, "cwd").mockReturnValue(cwd); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + // Regression test for #5189: Traefik's file provider rejects a standalone + // `routers: {}` / `services: {}` map and aborts its watcher for every + // dynamic config once it hits one, so an app with no domains must never + // get an on-disk config file at all. + it("removes the file instead of writing empty routers/services", async () => { + const appName = "no-domain-app"; + const configPath = path.join(dynamicPath, `${appName}.yml`); + fs.writeFileSync(configPath, "stale content", "utf8"); + + await writeAppTraefikConfig( + { http: { routers: {}, services: {} } }, + appName, + ); + + expect(fs.existsSync(configPath)).toBe(false); + }); + + it("writes the file when routers/services are present", async () => { + const appName = "with-domain-app"; + const configPath = path.join(dynamicPath, `${appName}.yml`); + + await writeAppTraefikConfig( + { + http: { + routers: { [`${appName}-router-1`]: { rule: "Host(`x`)" } }, + services: {}, + }, + }, + appName, + ); + + expect(fs.existsSync(configPath)).toBe(true); + }); + + it("removes the remote file instead of writing empty routers/services", async () => { + mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); + + await writeAppTraefikConfig( + { http: { routers: {}, services: {} } }, + "no-domain-app", + "server-id", + ); + + expect(mocks.execAsyncRemote).toHaveBeenCalledOnce(); + const [, command] = mocks.execAsyncRemote.mock.calls[0]; + expect(command).toMatch(/^rm -f /); + expect(command).toContain("no-domain-app.yml"); + }); + + it("writes the remote file when routers/services are present", async () => { + mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); + + await writeAppTraefikConfig( + { + http: { + routers: { "with-domain-app-router-1": { rule: "Host(`x`)" } }, + services: {}, + }, + }, + "with-domain-app", + "server-id", + ); + + expect(mocks.execAsyncRemote).toHaveBeenCalledOnce(); + const [, command] = mocks.execAsyncRemote.mock.calls[0]; + expect(command).toMatch(/^echo /); + }); +}); diff --git a/packages/server/src/utils/traefik/application.ts b/packages/server/src/utils/traefik/application.ts index 3da847f59..fb77e5ece 100644 --- a/packages/server/src/utils/traefik/application.ts +++ b/packages/server/src/utils/traefik/application.ts @@ -292,6 +292,26 @@ export const writeTraefikConfigRemote = async ( } }; +const isEmptyHttpRoutersAndServices = (traefikConfig: FileConfig) => + Object.keys(traefikConfig.http?.routers || {}).length === 0 && + Object.keys(traefikConfig.http?.services || {}).length === 0; + +export const writeAppTraefikConfig = async ( + traefikConfig: FileConfig, + appName: string, + serverId?: string | null, +) => { + if (isEmptyHttpRoutersAndServices(traefikConfig)) { + await removeTraefikConfig(appName, serverId); + return; + } + if (serverId) { + await writeTraefikConfigRemote(traefikConfig, appName, serverId); + } else { + writeTraefikConfig(traefikConfig, appName); + } +}; + export const createServiceConfig = ( appName: string, domain: Domain, diff --git a/packages/server/src/utils/traefik/redirect.ts b/packages/server/src/utils/traefik/redirect.ts index e9b5a94a8..c8b39f33e 100644 --- a/packages/server/src/utils/traefik/redirect.ts +++ b/packages/server/src/utils/traefik/redirect.ts @@ -3,7 +3,7 @@ import type { ApplicationNested } from "../builders"; import { loadOrCreateConfig, loadOrCreateConfigRemote, - writeTraefikConfig, + writeAppTraefikConfig, writeTraefikConfigRemote, } from "./application"; import type { FileConfig } from "./file-types"; @@ -89,11 +89,10 @@ export const createRedirectMiddleware = async ( if (serverId) { await writeTraefikConfigRemote(config, "middlewares", serverId); - await writeTraefikConfigRemote(appConfig, appName, serverId); } else { writeMiddleware(config); - writeTraefikConfig(appConfig, appName); } + await writeAppTraefikConfig(appConfig, appName, serverId); }; export const removeRedirectMiddleware = async ( @@ -124,9 +123,8 @@ export const removeRedirectMiddleware = async ( if (serverId) { await writeTraefikConfigRemote(config, "middlewares", serverId); - await writeTraefikConfigRemote(appConfig, appName, serverId); } else { - writeTraefikConfig(appConfig, appName); writeMiddleware(config); } + await writeAppTraefikConfig(appConfig, appName, serverId); }; diff --git a/packages/server/src/utils/traefik/security.ts b/packages/server/src/utils/traefik/security.ts index 2ded82356..2ca34906e 100644 --- a/packages/server/src/utils/traefik/security.ts +++ b/packages/server/src/utils/traefik/security.ts @@ -4,7 +4,7 @@ import type { ApplicationNested } from "../builders"; import { loadOrCreateConfig, loadOrCreateConfigRemote, - writeTraefikConfig, + writeAppTraefikConfig, writeTraefikConfigRemote, } from "./application"; import type { @@ -62,11 +62,10 @@ export const createSecurityMiddleware = async ( addMiddleware(appConfig, middlewareName); if (serverId) { await writeTraefikConfigRemote(config, "middlewares", serverId); - await writeTraefikConfigRemote(appConfig, appName, serverId); } else { - writeTraefikConfig(appConfig, appName); writeMiddleware(config); } + await writeAppTraefikConfig(appConfig, appName, serverId); }; export const removeSecurityMiddleware = async ( @@ -89,6 +88,7 @@ export const removeSecurityMiddleware = async ( appConfig = loadOrCreateConfig(appName); } const middlewareName = `auth-${appName}`; + let removedLastUser = false; if (config.http?.middlewares) { const currentMiddleware = config.http.middlewares[middlewareName]; @@ -106,11 +106,7 @@ export const removeSecurityMiddleware = async ( delete config.http.middlewares[middlewareName]; } deleteMiddleware(appConfig, middlewareName); - if (serverId) { - await writeTraefikConfigRemote(appConfig, appName, serverId); - } else { - writeTraefikConfig(appConfig, appName); - } + removedLastUser = true; } } } @@ -120,6 +116,9 @@ export const removeSecurityMiddleware = async ( } else { writeMiddleware(config); } + if (removedLastUser) { + await writeAppTraefikConfig(appConfig, appName, serverId); + } }; const isBasicAuthMiddleware = (