From 657314ac70bd4b752227f975be3e3277031f26c7 Mon Sep 17 00:00:00 2001 From: klixsoft Date: Tue, 28 Jul 2026 10:08:45 +0545 Subject: [PATCH] fix(traefik): remove stale acme.json certificates on domain/app deletion Deleting a domain, application, or preview deployment removed the Traefik routing config but left the Let's Encrypt certificate entry in acme.json, causing Traefik to retry ACME renewal indefinitely for domains that no longer exist. Fixes #4920 --- .../__test__/traefik/acme-certificate.test.ts | 74 ++++++++++++++++++ .../dokploy/server/api/routers/application.ts | 8 ++ apps/dokploy/server/api/routers/domain.ts | 4 + .../server/src/services/preview-deployment.ts | 8 +- packages/server/src/utils/traefik/domain.ts | 78 +++++++++++++++++++ 5 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 apps/dokploy/__test__/traefik/acme-certificate.test.ts diff --git a/apps/dokploy/__test__/traefik/acme-certificate.test.ts b/apps/dokploy/__test__/traefik/acme-certificate.test.ts new file mode 100644 index 0000000000..2089084201 --- /dev/null +++ b/apps/dokploy/__test__/traefik/acme-certificate.test.ts @@ -0,0 +1,74 @@ +import { fs, vol } from "memfs"; + +vi.mock("node:fs", () => ({ + ...fs, + default: fs, +})); + +import path from "node:path"; +import { paths, removeAcmeCertificate } from "@dokploy/server"; +import { beforeEach, expect, test, vi } from "vitest"; + +const { DYNAMIC_TRAEFIK_PATH } = paths(); +const acmeJsonPath = path.join(DYNAMIC_TRAEFIK_PATH, "acme.json"); + +const acmeStore = { + letsencrypt: { + Account: { Email: "test@localhost.com" }, + Certificates: [ + { domain: { main: "example.com" }, certificate: "cert-1", key: "key-1" }, + { + domain: { main: "app.example.com", sans: ["www.app.example.com"] }, + certificate: "cert-2", + key: "key-2", + }, + ], + }, +}; + +beforeEach(() => { + vol.reset(); + vol.mkdirSync(DYNAMIC_TRAEFIK_PATH, { recursive: true }); + vol.writeFileSync(acmeJsonPath, JSON.stringify(acmeStore)); +}); + +test("removes the certificate matching the deleted host", async () => { + await removeAcmeCertificate("example.com"); + + const updated = JSON.parse(fs.readFileSync(acmeJsonPath, "utf8") as string); + expect(updated.letsencrypt.Certificates).toHaveLength(1); + expect( + updated.letsencrypt.Certificates.some( + (cert: { domain: { main: string } }) => + cert.domain.main === "example.com", + ), + ).toBe(false); +}); + +test("removes the certificate matching a SAN entry", async () => { + await removeAcmeCertificate("www.app.example.com"); + + const updated = JSON.parse(fs.readFileSync(acmeJsonPath, "utf8") as string); + expect(updated.letsencrypt.Certificates).toHaveLength(1); + expect(updated.letsencrypt.Certificates[0].domain.main).toBe("example.com"); +}); + +test("is case-insensitive when matching the host", async () => { + await removeAcmeCertificate("EXAMPLE.com"); + + const updated = JSON.parse(fs.readFileSync(acmeJsonPath, "utf8") as string); + expect(updated.letsencrypt.Certificates).toHaveLength(1); +}); + +test("is a no-op when the host has no matching certificate", async () => { + await removeAcmeCertificate("unrelated.com"); + + const updated = JSON.parse(fs.readFileSync(acmeJsonPath, "utf8") as string); + expect(updated.letsencrypt.Certificates).toHaveLength(2); +}); + +test("does not throw when acme.json does not exist", async () => { + vol.reset(); + + await expect(removeAcmeCertificate("example.com")).resolves.not.toThrow(); +}); diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index e1bbedcb9e..c511db1a97 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -13,6 +13,7 @@ import { mechanizeDockerContainer, readConfig, readRemoteConfig, + removeAcmeCertificate, removeDeployments, removeDirectoryCode, removeMonitoringDirectory, @@ -257,6 +258,13 @@ export const applicationRouter = createTRPCRouter({ await removeTraefikConfig(application.appName, application.serverId), async () => await removeService(application?.appName, application.serverId), + async () => { + for (const domain of application.domains) { + if (domain.certificateType === "letsencrypt") { + await removeAcmeCertificate(domain.host, application.serverId); + } + } + }, ]; for (const operation of cleanupOperations) { diff --git a/apps/dokploy/server/api/routers/domain.ts b/apps/dokploy/server/api/routers/domain.ts index 8210fcf8a5..14b74b6d19 100644 --- a/apps/dokploy/server/api/routers/domain.ts +++ b/apps/dokploy/server/api/routers/domain.ts @@ -9,6 +9,7 @@ import { generateTraefikMeDomain, getWebServerSettings, manageDomain, + removeAcmeCertificate, removeDomain, removeDomainById, updateDomainById, @@ -187,6 +188,9 @@ export const domainRouter = createTRPCRouter({ if (domain.applicationId) { const application = await findApplicationById(domain.applicationId); await removeDomain(application, domain.uniqueConfigKey); + if (domain.certificateType === "letsencrypt") { + await removeAcmeCertificate(domain.host, application.serverId); + } } return result; diff --git a/packages/server/src/services/preview-deployment.ts b/packages/server/src/services/preview-deployment.ts index 20f64259bc..7c65ed8273 100644 --- a/packages/server/src/services/preview-deployment.ts +++ b/packages/server/src/services/preview-deployment.ts @@ -13,7 +13,7 @@ import { removeService } from "../utils/docker/utils"; import { removeDirectoryCode } from "../utils/filesystem/directory"; import { authGithub } from "../utils/providers/github"; import { removeTraefikConfig } from "../utils/traefik/application"; -import { manageDomain } from "../utils/traefik/domain"; +import { manageDomain, removeAcmeCertificate } from "../utils/traefik/domain"; import { findApplicationById } from "./application"; import { removeDeploymentsByPreviewDeploymentId } from "./deployment"; import { createDomain } from "./domain"; @@ -67,6 +67,12 @@ export const removePreviewDeployment = async (previewDeploymentId: string) => { await removeDirectoryCode(application?.appName, application?.serverId), async () => await removeTraefikConfig(application?.appName, application?.serverId), + async () => { + const domain = previewDeployment.domain; + if (domain?.certificateType === "letsencrypt") { + await removeAcmeCertificate(domain.host, application?.serverId); + } + }, async () => await db .delete(previewDeployments) diff --git a/packages/server/src/utils/traefik/domain.ts b/packages/server/src/utils/traefik/domain.ts index d35473a543..c3c7d7064a 100644 --- a/packages/server/src/utils/traefik/domain.ts +++ b/packages/server/src/utils/traefik/domain.ts @@ -1,5 +1,11 @@ +import fs from "node:fs"; +import path from "node:path"; +import { paths } from "@dokploy/server/constants"; import type { Domain } from "@dokploy/server/services/domain"; +import { quote } from "shell-quote"; import type { ApplicationNested } from "../builders"; +import { encodeBase64 } from "../docker/utils"; +import { execAsyncRemote } from "../process/execAsync"; import { createServiceConfig, loadOrCreateConfig, @@ -17,6 +23,78 @@ import { } from "./forward-auth"; import { createPathMiddlewares, removePathMiddlewares } from "./middleware"; +const ACME_CERT_RESOLVER = "letsencrypt"; + +interface AcmeCertificate { + domain?: { main?: string; sans?: string[] }; + [key: string]: unknown; +} + +interface AcmeStore { + [resolverName: string]: { + Certificates?: AcmeCertificate[]; + [key: string]: unknown; + }; +} + +// Prunes the stale certificate entry left in Traefik's acme.json after a +// domain/app is deleted. Without this Traefik keeps retrying ACME renewal +// for a domain that no longer resolves anywhere, forever. +export const removeAcmeCertificate = async ( + host: string, + serverId?: string | null, +) => { + try { + const { DYNAMIC_TRAEFIK_PATH } = paths(!!serverId); + const acmeJsonPath = path.join(DYNAMIC_TRAEFIK_PATH, "acme.json"); + + const raw = serverId + ? (await execAsyncRemote(serverId, `cat ${quote([acmeJsonPath])}`)).stdout + : fs.existsSync(acmeJsonPath) + ? fs.readFileSync(acmeJsonPath, "utf8") + : ""; + + if (!raw?.trim()) { + return; + } + + const store = JSON.parse(raw) as AcmeStore; + const resolver = store[ACME_CERT_RESOLVER]; + + if (!resolver?.Certificates?.length) { + return; + } + + const normalizedHost = host.toLowerCase(); + const nextCertificates = resolver.Certificates.filter((cert) => { + const main = cert.domain?.main?.toLowerCase(); + const sans = cert.domain?.sans?.map((san) => san.toLowerCase()) || []; + return main !== normalizedHost && !sans.includes(normalizedHost); + }); + + if (nextCertificates.length === resolver.Certificates.length) { + return; + } + + resolver.Certificates = nextCertificates; + const updated = JSON.stringify(store); + const tmpPath = `${acmeJsonPath}.tmp`; + + if (serverId) { + const encoded = encodeBase64(updated); + await execAsyncRemote( + serverId, + `echo "${encoded}" | base64 -d > ${quote([tmpPath])} && mv ${quote([tmpPath])} ${quote([acmeJsonPath])}`, + ); + } else { + fs.writeFileSync(tmpPath, updated, "utf8"); + fs.renameSync(tmpPath, acmeJsonPath); + } + } catch (error) { + console.error(`Error removing acme certificate for ${host}:`, error); + } +}; + export const manageDomain = async (app: ApplicationNested, domain: Domain) => { const { appName } = app; let config: FileConfig;