diff --git a/Dockerfile b/Dockerfile
index 4cab0e8f36..6886e32178 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -19,6 +19,7 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
ENV NODE_ENV=production
RUN pnpm --filter=@dokploy/server build
RUN pnpm --filter=./apps/dokploy run build
+RUN test -f /usr/src/app/apps/dokploy/dist/caddy-migration-rollback.mjs
RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy
@@ -43,7 +44,8 @@ COPY --from=build /prod/dokploy/drizzle ./drizzle
COPY .env.production ./.env
COPY --from=build /prod/dokploy/components.json ./components.json
COPY --from=build /prod/dokploy/node_modules ./node_modules
-
+RUN test -f /app/dist/caddy-migration-rollback.mjs \
+ && node -r dotenv/config /app/dist/caddy-migration-rollback.mjs --help | grep -q "Usage: caddy-migration-rollback"
# Install docker
RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --version 28.5.2 && rm get-docker.sh && curl https://rclone.org/install.sh | bash
diff --git a/apps/dokploy/__test__/caddy/application-web-server-config-cache.test.ts b/apps/dokploy/__test__/caddy/application-web-server-config-cache.test.ts
new file mode 100644
index 0000000000..36f1b926c0
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/application-web-server-config-cache.test.ts
@@ -0,0 +1,96 @@
+import { readFileSync } from "node:fs";
+import { expect, test, vi } from "vitest";
+import { invalidateApplicationWebServerConfig } from "@/components/dashboard/application/web-server-config-cache";
+
+const createUtils = () => ({
+ application: {
+ readTraefikConfig: {
+ invalidate: vi.fn(),
+ },
+ readWebServerConfig: {
+ invalidate: vi.fn(),
+ },
+ },
+});
+
+test("invalidates legacy Traefik and provider-aware application web-server config caches", async () => {
+ const utils = createUtils();
+
+ await invalidateApplicationWebServerConfig(utils, "app-1");
+
+ expect(utils.application.readTraefikConfig.invalidate).toHaveBeenCalledWith({
+ applicationId: "app-1",
+ });
+ expect(utils.application.readWebServerConfig.invalidate).toHaveBeenCalledWith(
+ { applicationId: "app-1" },
+ );
+});
+
+test("awaits both application web-server config cache invalidations", async () => {
+ const calls: string[] = [];
+ const utils = createUtils();
+ utils.application.readTraefikConfig.invalidate.mockImplementation(
+ async () => {
+ await Promise.resolve();
+ calls.push("traefik");
+ },
+ );
+ utils.application.readWebServerConfig.invalidate.mockImplementation(
+ async () => {
+ await Promise.resolve();
+ calls.push("web-server");
+ },
+ );
+
+ await invalidateApplicationWebServerConfig(utils, "app-1");
+
+ expect(calls).toEqual(["traefik", "web-server"]);
+});
+
+test("propagates application web-server config cache invalidation failures", async () => {
+ const utils = createUtils();
+ utils.application.readWebServerConfig.invalidate.mockRejectedValueOnce(
+ new Error("provider-aware cache failed"),
+ );
+
+ await expect(
+ invalidateApplicationWebServerConfig(utils, "app-1"),
+ ).rejects.toThrow("provider-aware cache failed");
+ expect(utils.application.readTraefikConfig.invalidate).toHaveBeenCalledWith({
+ applicationId: "app-1",
+ });
+});
+
+const applicationMutationHandlers = [
+ [
+ "domain handler",
+ "../../components/dashboard/application/domains/handle-domain.tsx",
+ ],
+ [
+ "redirect form handler",
+ "../../components/dashboard/application/advanced/redirects/handle-redirect.tsx",
+ ],
+ [
+ "redirect delete handler",
+ "../../components/dashboard/application/advanced/redirects/show-redirects.tsx",
+ ],
+ [
+ "security form handler",
+ "../../components/dashboard/application/advanced/security/handle-security.tsx",
+ ],
+ [
+ "security delete handler",
+ "../../components/dashboard/application/advanced/security/show-security.tsx",
+ ],
+] as const;
+
+test.each(applicationMutationHandlers)(
+ "%s uses the shared application web-server cache invalidation helper",
+ (_name, filePath) => {
+ const source = readFileSync(new URL(filePath, import.meta.url), "utf8");
+
+ expect(source).toContain("invalidateApplicationWebServerConfig");
+ expect(source).not.toMatch(/readTraefikConfig\.invalidate/);
+ expect(source).not.toMatch(/readWebServerConfig\.invalidate/);
+ },
+);
diff --git a/apps/dokploy/__test__/caddy/application/domain-service.test.ts b/apps/dokploy/__test__/caddy/application/domain-service.test.ts
new file mode 100644
index 0000000000..3820047466
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/application/domain-service.test.ts
@@ -0,0 +1,335 @@
+import { beforeEach, expect, test, vi } from "vitest";
+
+const txInsertMock = vi.hoisted(() => vi.fn());
+const txValuesMock = vi.hoisted(() => vi.fn());
+const transactionMock = vi.hoisted(() => vi.fn());
+const dbDeleteMock = vi.hoisted(() => vi.fn());
+const domainsFindFirstMock = vi.hoisted(() => vi.fn());
+const domainsFindManyMock = vi.hoisted(() => vi.fn());
+
+vi.mock("@dokploy/server/db", () => ({
+ db: {
+ transaction: transactionMock,
+ query: {
+ domains: {
+ findFirst: domainsFindFirstMock,
+ findMany: domainsFindManyMock,
+ },
+ },
+ delete: dbDeleteMock,
+ },
+}));
+
+vi.mock("@dokploy/server/services/application", () => ({
+ findApplicationById: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/utils/web-server/domain", () => ({
+ manageWebServerDomain: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/utils/docker/domain", () => ({
+ getCaddyComposeRouteTargetsForWebServer: vi.fn(),
+ writeCaddyComposeRoutesForTargets: vi.fn(),
+}));
+
+import { findApplicationById } from "@dokploy/server/services/application";
+import {
+ createComposeDomain,
+ createDomain,
+ removeComposeDomainsForWebServer,
+} from "@dokploy/server/services/domain";
+import {
+ getCaddyComposeRouteTargetsForWebServer,
+ writeCaddyComposeRoutesForTargets,
+} from "@dokploy/server/utils/docker/domain";
+import { manageWebServerDomain } from "@dokploy/server/utils/web-server/domain";
+
+const domain = {
+ domainId: "domain-1",
+ applicationId: "app-1",
+ composeId: null,
+ previewDeploymentId: null,
+ host: "example.com",
+ uniqueConfigKey: 7,
+};
+
+const composeDomain = {
+ ...domain,
+ applicationId: null,
+ composeId: "compose-1",
+ domainType: "compose",
+};
+
+const retainedComposeDomain = {
+ ...composeDomain,
+ domainId: "domain-2",
+ host: "retained.example.com",
+ uniqueConfigKey: 8,
+};
+
+const application = {
+ applicationId: "app-1",
+ appName: "my-app",
+ serverId: null,
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ txInsertMock.mockReturnValue({
+ values: txValuesMock.mockReturnValue({
+ returning: vi.fn().mockResolvedValue([domain]),
+ }),
+ });
+ transactionMock.mockImplementation(async (callback) => {
+ const tx = { delete: dbDeleteMock, insert: txInsertMock };
+ return callback(tx);
+ });
+ domainsFindFirstMock.mockResolvedValue(domain);
+ domainsFindManyMock.mockResolvedValue([composeDomain]);
+ dbDeleteMock.mockReturnValue({
+ where: vi.fn().mockReturnValue({
+ returning: vi.fn().mockResolvedValue([composeDomain]),
+ }),
+ });
+ vi.mocked(getCaddyComposeRouteTargetsForWebServer).mockResolvedValue([
+ {},
+ ] as never);
+ vi.mocked(writeCaddyComposeRoutesForTargets).mockResolvedValue(
+ undefined as never,
+ );
+ vi.mocked(findApplicationById).mockResolvedValue(application as never);
+});
+
+test("creates copied new-service application domains through the active web server provider", async () => {
+ vi.mocked(manageWebServerDomain).mockResolvedValue(undefined as never);
+
+ const created = await createDomain({
+ host: " example.com ",
+ applicationId: "app-1",
+ domainType: "application",
+ } as never);
+
+ expect(created).toBe(domain);
+ expect(txInsertMock).toHaveBeenCalled();
+ expect(manageWebServerDomain).toHaveBeenCalledWith(application, domain);
+});
+
+test("uses the existing application domain port when a new application domain omits port", async () => {
+ const existingDomain = {
+ ...domain,
+ domainId: "domain-existing",
+ host: "current.example.com",
+ port: 80,
+ uniqueConfigKey: 1,
+ };
+ const createdDomain = {
+ ...domain,
+ host: "new.example.com",
+ port: 80,
+ uniqueConfigKey: 9,
+ };
+ domainsFindManyMock.mockResolvedValueOnce([existingDomain]);
+ txValuesMock.mockReturnValueOnce({
+ returning: vi.fn().mockResolvedValue([createdDomain]),
+ });
+ vi.mocked(manageWebServerDomain).mockResolvedValue(undefined as never);
+
+ const created = await createDomain({
+ host: " new.example.com ",
+ applicationId: "app-1",
+ domainType: "application",
+ } as never);
+
+ expect(created).toBe(createdDomain);
+ expect(txValuesMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ host: "new.example.com",
+ port: 80,
+ }),
+ );
+ expect(manageWebServerDomain).toHaveBeenCalledWith(
+ application,
+ createdDomain,
+ );
+});
+
+test("preserves an explicit application domain port", async () => {
+ const existingDomain = {
+ ...domain,
+ domainId: "domain-existing",
+ host: "current.example.com",
+ port: 80,
+ uniqueConfigKey: 1,
+ };
+ const createdDomain = {
+ ...domain,
+ host: "api.example.com",
+ port: 8080,
+ uniqueConfigKey: 9,
+ };
+ domainsFindManyMock.mockResolvedValueOnce([existingDomain]);
+ txValuesMock.mockReturnValueOnce({
+ returning: vi.fn().mockResolvedValue([createdDomain]),
+ });
+ vi.mocked(manageWebServerDomain).mockResolvedValue(undefined as never);
+
+ await createDomain({
+ host: "api.example.com",
+ applicationId: "app-1",
+ domainType: "application",
+ port: 8080,
+ } as never);
+
+ expect(txValuesMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ host: "api.example.com",
+ port: 8080,
+ }),
+ );
+});
+
+test("does not infer application domain ports for compose domains", async () => {
+ txValuesMock.mockReturnValueOnce({
+ returning: vi.fn().mockResolvedValue([composeDomain]),
+ });
+ vi.mocked(manageWebServerDomain).mockResolvedValue(undefined as never);
+
+ await createDomain({
+ host: "compose.example.com",
+ composeId: "compose-1",
+ domainType: "compose",
+ } as never);
+
+ expect(txValuesMock).toHaveBeenCalledWith(
+ expect.not.objectContaining({
+ port: 80,
+ }),
+ );
+});
+
+test("removes application domain rows when provider route creation fails", async () => {
+ vi.mocked(manageWebServerDomain).mockRejectedValueOnce(
+ new Error("caddy reload failed") as never,
+ );
+
+ await expect(
+ createDomain({
+ host: "example.com",
+ applicationId: "app-1",
+ domainType: "application",
+ } as never),
+ ).rejects.toThrow("caddy reload failed");
+
+ expect(manageWebServerDomain).toHaveBeenCalledWith(application, domain);
+ expect(dbDeleteMock).toHaveBeenCalled();
+});
+
+test("removes compose domain rows when Caddy compose route refresh fails after creation", async () => {
+ txInsertMock.mockReturnValueOnce({
+ values: vi.fn().mockReturnValue({
+ returning: vi.fn().mockResolvedValue([composeDomain]),
+ }),
+ });
+ vi.mocked(writeCaddyComposeRoutesForTargets)
+ .mockRejectedValueOnce(new Error("caddy refresh failed") as never)
+ .mockResolvedValueOnce(undefined as never);
+
+ await expect(
+ createComposeDomain(
+ {
+ composeId: "compose-1",
+ appName: "my-compose",
+ serverId: null,
+ } as never,
+ {
+ host: "example.com",
+ composeId: "compose-1",
+ domainType: "compose",
+ } as never,
+ "caddy",
+ ),
+ ).rejects.toThrow("caddy refresh failed");
+
+ expect(dbDeleteMock).toHaveBeenCalled();
+ expect(writeCaddyComposeRoutesForTargets).toHaveBeenCalledTimes(2);
+});
+
+test("refreshes Caddy compose routes with zero remaining domains before deleting imported template domains", async () => {
+ domainsFindManyMock.mockResolvedValueOnce([composeDomain]);
+
+ const removed = await removeComposeDomainsForWebServer(
+ {
+ composeId: "compose-1",
+ appName: "my-compose",
+ serverId: null,
+ } as never,
+ [composeDomain] as never,
+ "caddy",
+ );
+
+ expect(removed).toEqual([composeDomain]);
+ expect(getCaddyComposeRouteTargetsForWebServer).toHaveBeenCalledWith(
+ expect.objectContaining({ composeId: "compose-1" }),
+ [],
+ "caddy",
+ );
+ expect(dbDeleteMock).toHaveBeenCalled();
+ expect(writeCaddyComposeRoutesForTargets).toHaveBeenCalledTimes(1);
+});
+
+test("restores Caddy compose routes if imported template domain deletion fails", async () => {
+ domainsFindManyMock.mockResolvedValueOnce([
+ composeDomain,
+ retainedComposeDomain,
+ ]);
+ dbDeleteMock.mockReturnValueOnce({
+ where: vi.fn().mockReturnValue({
+ returning: vi.fn().mockRejectedValue(new Error("db delete failed")),
+ }),
+ });
+
+ await expect(
+ removeComposeDomainsForWebServer(
+ {
+ composeId: "compose-1",
+ appName: "my-compose",
+ serverId: null,
+ } as never,
+ [composeDomain] as never,
+ "caddy",
+ ),
+ ).rejects.toThrow("db delete failed");
+
+ expect(getCaddyComposeRouteTargetsForWebServer).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({ composeId: "compose-1" }),
+ [retainedComposeDomain],
+ "caddy",
+ );
+ expect(getCaddyComposeRouteTargetsForWebServer).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({ composeId: "compose-1" }),
+ [composeDomain, retainedComposeDomain],
+ "caddy",
+ );
+ expect(writeCaddyComposeRoutesForTargets).toHaveBeenCalledTimes(2);
+});
+
+test("deletes imported template compose domains without Caddy refresh under Traefik", async () => {
+ domainsFindManyMock.mockResolvedValueOnce([composeDomain]);
+
+ await removeComposeDomainsForWebServer(
+ {
+ composeId: "compose-1",
+ appName: "my-compose",
+ serverId: null,
+ } as never,
+ [composeDomain] as never,
+ "traefik",
+ );
+
+ expect(getCaddyComposeRouteTargetsForWebServer).not.toHaveBeenCalled();
+ expect(writeCaddyComposeRoutesForTargets).not.toHaveBeenCalled();
+ expect(dbDeleteMock).toHaveBeenCalled();
+});
diff --git a/apps/dokploy/__test__/caddy/application/domain.test.ts b/apps/dokploy/__test__/caddy/application/domain.test.ts
new file mode 100644
index 0000000000..9b9569e743
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/application/domain.test.ts
@@ -0,0 +1,165 @@
+import type { ApplicationNested, Domain } from "@dokploy/server";
+import {
+ compileCaddyConfig,
+ createCaddyApplicationRouteFragment,
+ getCaddyApplicationFragmentId,
+ paths,
+} from "@dokploy/server";
+import { expect, test } from "vitest";
+
+const app = (overrides: Partial = {}) =>
+ ({
+ appName: "my-app",
+ serverId: null,
+ ...overrides,
+ }) as ApplicationNested;
+
+const domain = (overrides: Partial = {}) =>
+ ({
+ domainId: "domain-1",
+ applicationId: "app-1",
+ composeId: null,
+ previewDeploymentId: null,
+ domainType: "application",
+ host: "example.com",
+ path: null,
+ internalPath: "/",
+ stripPath: false,
+ https: false,
+ certificateType: "none",
+ customCertResolver: null,
+ customEntrypoint: null,
+ middlewares: null,
+ port: null,
+ serviceName: null,
+ uniqueConfigKey: 7,
+ createdAt: "",
+ ...overrides,
+ }) as Domain;
+
+const getServers = (config: ReturnType) => {
+ const apps = config.apps as Record;
+ return apps.http.servers as Record;
+};
+
+test("creates deterministic Caddy application fragments", () => {
+ const fragment = createCaddyApplicationRouteFragment(app(), domain());
+
+ expect(fragment.id).toBe(getCaddyApplicationFragmentId("my-app", 7));
+ expect(fragment.source).toBe("dokploy-application");
+ expect(fragment.routes[0]).toMatchObject({
+ id: "my-app-route-7",
+ hosts: ["example.com"],
+ https: false,
+ upstreams: ["http://my-app:80"],
+ });
+});
+
+test("renders path rewrites and custom upstream port", () => {
+ const fragment = createCaddyApplicationRouteFragment(
+ app(),
+ domain({
+ path: "/public",
+ stripPath: true,
+ internalPath: "/internal",
+ port: 3000,
+ }),
+ );
+ const config = compileCaddyConfig({ fragments: [fragment] });
+ const handlers = getServers(config).http.routes[0].handle;
+
+ expect(getServers(config).http.routes[0].match[0]).toMatchObject({
+ host: ["example.com"],
+ path: ["/public*"],
+ });
+ expect(handlers[0]).toMatchObject({
+ handler: "rewrite",
+ strip_path_prefix: "/public",
+ });
+ expect(handlers[1]).toMatchObject({
+ handler: "rewrite",
+ uri: "/internal{http.request.uri.path}",
+ });
+ expect(handlers[2]).toMatchObject({
+ handler: "reverse_proxy",
+ upstreams: [{ dial: "my-app:3000" }],
+ });
+});
+
+test("HTTPS application routes redirect HTTP and proxy on HTTPS", () => {
+ const fragment = createCaddyApplicationRouteFragment(
+ app(),
+ domain({ https: true, certificateType: "letsencrypt" }),
+ );
+ const config = compileCaddyConfig({ fragments: [fragment] });
+ const servers = getServers(config);
+
+ expect(servers.http.routes[0].handle[0]).toMatchObject({
+ handler: "static_response",
+ status_code: 308,
+ });
+ expect(servers.https.routes[0].handle.at(-1)).toMatchObject({
+ handler: "reverse_proxy",
+ upstreams: [{ dial: "my-app:80" }],
+ });
+});
+
+test("loads uploaded certificates for custom Caddy HTTPS routes", () => {
+ const fragment = createCaddyApplicationRouteFragment(
+ app(),
+ domain({
+ https: true,
+ certificateType: "custom",
+ customCertResolver: "certificate-uploaded",
+ }),
+ );
+ const config = compileCaddyConfig({ fragments: [fragment] });
+ const tls = (config.apps as any).tls;
+ const certificatePath = `${paths(false).CERTIFICATES_PATH}/certificate-uploaded`;
+
+ expect(tls.certificates.load_files).toEqual([
+ {
+ certificate: `${certificatePath}/chain.crt`,
+ key: `${certificatePath}/privkey.key`,
+ },
+ ]);
+ expect(getServers(config).https.routes[0].handle.at(-1)).toMatchObject({
+ handler: "reverse_proxy",
+ upstreams: [{ dial: "my-app:80" }],
+ });
+});
+
+test("punycodes internationalized hosts for Caddy routes", () => {
+ const fragment = createCaddyApplicationRouteFragment(
+ app(),
+ domain({ host: "тест.рф" }),
+ );
+
+ expect(fragment.routes[0]?.hosts).toEqual(["xn--e1aybc.xn--p1ai"]);
+});
+
+test("rejects Traefik-only domain features for Caddy routes", () => {
+ expect(() =>
+ createCaddyApplicationRouteFragment(
+ app(),
+ domain({
+ customEntrypoint: "admin",
+ customCertResolver: "internal",
+ middlewares: ["auth@file"],
+ }),
+ ),
+ ).toThrow("unsupported Caddy fields");
+});
+
+test("rejects Caddy custom certificate routes without an uploaded certificate", () => {
+ expect(() =>
+ createCaddyApplicationRouteFragment(
+ app(),
+ domain({
+ https: true,
+ certificateType: "custom",
+ customCertResolver: null,
+ }),
+ ),
+ ).toThrow("custom certificates require an uploaded certificate");
+});
diff --git a/apps/dokploy/__test__/caddy/certificate-guard.test.ts b/apps/dokploy/__test__/caddy/certificate-guard.test.ts
new file mode 100644
index 0000000000..454a3cd97e
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/certificate-guard.test.ts
@@ -0,0 +1,146 @@
+import { fs, vol } from "memfs";
+import { beforeEach, expect, test, vi } from "vitest";
+
+const certificatesFindFirstMock = vi.hoisted(() => vi.fn());
+
+vi.mock("node:fs", () => ({
+ ...fs,
+ default: fs,
+}));
+
+vi.mock("@dokploy/server/db", () => ({
+ db: {
+ query: {
+ certificates: {
+ findFirst: certificatesFindFirstMock,
+ },
+ },
+ },
+}));
+
+import type { Domain } from "@dokploy/server";
+import { paths } from "@dokploy/server/constants";
+import { assertCaddyDomainCertificateAvailable } from "@dokploy/server/utils/caddy/domain";
+
+const writeCertificateFiles = (certificatePath: string) => {
+ const certDir = `${paths().CERTIFICATES_PATH}/${certificatePath}`;
+ vol.mkdirSync(certDir, { recursive: true });
+ vol.writeFileSync(`${certDir}/chain.crt`, "cert");
+ vol.writeFileSync(`${certDir}/privkey.key`, "key");
+};
+
+const domain = (overrides: Partial = {}) =>
+ ({
+ domainId: "domain-1",
+ applicationId: "app-1",
+ composeId: null,
+ previewDeploymentId: null,
+ domainType: "application",
+ host: "example.com",
+ path: "/",
+ internalPath: "/",
+ stripPath: false,
+ https: true,
+ certificateType: "custom",
+ customCertResolver: "certificate-uploaded",
+ customEntrypoint: null,
+ middlewares: null,
+ port: 3000,
+ serviceName: null,
+ uniqueConfigKey: 7,
+ createdAt: "",
+ ...overrides,
+ }) as Domain;
+
+beforeEach(() => {
+ vol.reset();
+ vi.clearAllMocks();
+});
+
+test("allows uploaded Caddy certificates assigned to the same server and organization", async () => {
+ writeCertificateFiles("certificate-uploaded");
+ certificatesFindFirstMock.mockResolvedValue({
+ certificatePath: "certificate-uploaded",
+ serverId: null,
+ organizationId: "org-1",
+ });
+
+ await expect(
+ assertCaddyDomainCertificateAvailable(null, domain(), "org-1"),
+ ).resolves.toBeUndefined();
+});
+
+test("requires organization context for uploaded Caddy certificate validation", async () => {
+ writeCertificateFiles("certificate-uploaded");
+ certificatesFindFirstMock.mockResolvedValue({
+ certificatePath: "certificate-uploaded",
+ serverId: null,
+ organizationId: "org-1",
+ });
+
+ await expect(
+ assertCaddyDomainCertificateAvailable(null, domain(), null),
+ ).rejects.toThrow(
+ "Caddy custom certificate validation requires organization context",
+ );
+ expect(certificatesFindFirstMock).not.toHaveBeenCalled();
+});
+
+test("rejects missing, cross-server, or cross-organization Caddy certificate paths", async () => {
+ certificatesFindFirstMock.mockResolvedValueOnce(null);
+ await expect(
+ assertCaddyDomainCertificateAvailable(null, domain(), "org-1"),
+ ).rejects.toThrow("is not available for this server and organization");
+
+ certificatesFindFirstMock.mockResolvedValueOnce({
+ certificatePath: "certificate-uploaded",
+ serverId: "server-2",
+ organizationId: "org-1",
+ });
+ await expect(
+ assertCaddyDomainCertificateAvailable("server-1", domain(), "org-1"),
+ ).rejects.toThrow("is not available for this server and organization");
+
+ certificatesFindFirstMock.mockResolvedValueOnce({
+ certificatePath: "certificate-uploaded",
+ serverId: null,
+ organizationId: "org-2",
+ });
+ await expect(
+ assertCaddyDomainCertificateAvailable(null, domain(), "org-1"),
+ ).rejects.toThrow("is not available for this server and organization");
+});
+
+test("rejects uploaded Caddy certificate rows when files are missing", async () => {
+ certificatesFindFirstMock.mockResolvedValue({
+ certificatePath: "certificate-uploaded",
+ serverId: null,
+ organizationId: "org-1",
+ });
+
+ await expect(
+ assertCaddyDomainCertificateAvailable(null, domain(), "org-1"),
+ ).rejects.toThrow("missing readable chain.crt or privkey.key");
+});
+
+test("ignores stale custom certificate fields when HTTPS is disabled", async () => {
+ await expect(
+ assertCaddyDomainCertificateAvailable(
+ null,
+ domain({ https: false }),
+ "org-1",
+ ),
+ ).resolves.toBeUndefined();
+ expect(certificatesFindFirstMock).not.toHaveBeenCalled();
+});
+
+test("does not require organization context for non-custom Caddy certificates", async () => {
+ await expect(
+ assertCaddyDomainCertificateAvailable(
+ null,
+ domain({ certificateType: "letsencrypt", customCertResolver: null }),
+ null,
+ ),
+ ).resolves.toBeUndefined();
+ expect(certificatesFindFirstMock).not.toHaveBeenCalled();
+});
diff --git a/apps/dokploy/__test__/caddy/certificate-lifecycle.test.ts b/apps/dokploy/__test__/caddy/certificate-lifecycle.test.ts
new file mode 100644
index 0000000000..b49ddf616c
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/certificate-lifecycle.test.ts
@@ -0,0 +1,93 @@
+import { beforeEach, expect, test, vi } from "vitest";
+
+const dbMock = vi.hoisted(() => ({
+ query: {
+ certificates: { findFirst: vi.fn() },
+ domains: { findFirst: vi.fn() },
+ },
+ delete: vi.fn(),
+ update: vi.fn(),
+}));
+
+const resolveWebServerProviderMock = vi.hoisted(() => vi.fn());
+const removeDirectoryIfExistsContentMock = vi.hoisted(() => vi.fn());
+
+vi.mock("@dokploy/server/db", () => ({
+ db: dbMock,
+}));
+
+vi.mock("@dokploy/server/services/web-server-settings", () => ({
+ getCaddyCompileSettings: vi.fn().mockResolvedValue({}),
+ resolveWebServerProvider: resolveWebServerProviderMock,
+}));
+
+vi.mock("@dokploy/server/utils/filesystem/directory", () => ({
+ removeDirectoryIfExistsContent: removeDirectoryIfExistsContentMock,
+}));
+
+vi.mock("@dokploy/server/utils/process/execAsync", () => ({
+ execAsyncRemote: vi.fn(),
+}));
+
+import {
+ removeCertificateById,
+ updateCertificate,
+} from "@dokploy/server/services/certificate";
+
+const certificate = {
+ certificateId: "cert-1",
+ name: "Shared cert",
+ certificatePath: "certificate-uploaded",
+ certificateData: "cert",
+ privateKey: "key",
+ autoRenew: null,
+ organizationId: "org-1",
+ serverId: null,
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ dbMock.query.certificates.findFirst.mockResolvedValue(certificate);
+ dbMock.query.domains.findFirst.mockResolvedValue(null);
+ dbMock.delete.mockReturnValue({
+ where: vi.fn().mockReturnValue({
+ returning: vi.fn().mockResolvedValue([certificate]),
+ }),
+ });
+ dbMock.update.mockReturnValue({
+ set: vi.fn().mockReturnValue({
+ where: vi.fn().mockReturnValue({
+ returning: vi.fn().mockResolvedValue([certificate]),
+ }),
+ }),
+ });
+ resolveWebServerProviderMock.mockResolvedValue("caddy");
+ removeDirectoryIfExistsContentMock.mockResolvedValue(undefined);
+});
+
+test("blocks deleting uploaded certificates used by active Caddy domains", async () => {
+ dbMock.query.domains.findFirst.mockResolvedValueOnce({
+ host: "example.com",
+ });
+
+ await expect(removeCertificateById("cert-1")).rejects.toThrow(
+ 'Cannot delete certificate "Shared cert" because active Caddy domain "example.com" uses it',
+ );
+
+ expect(removeDirectoryIfExistsContentMock).not.toHaveBeenCalled();
+ expect(dbMock.delete).not.toHaveBeenCalled();
+});
+
+test("blocks replacing uploaded certificate files used by active Caddy domains", async () => {
+ dbMock.query.domains.findFirst.mockResolvedValueOnce({
+ host: "example.com",
+ });
+
+ await expect(
+ updateCertificate("cert-1", { certificateData: "new cert" }),
+ ).rejects.toThrow(
+ 'Cannot update certificate "Shared cert" because active Caddy domain "example.com" uses it',
+ );
+
+ expect(dbMock.update).not.toHaveBeenCalled();
+});
diff --git a/apps/dokploy/__test__/caddy/compose-domain-call-sites.test.ts b/apps/dokploy/__test__/caddy/compose-domain-call-sites.test.ts
new file mode 100644
index 0000000000..b6f4f98647
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/compose-domain-call-sites.test.ts
@@ -0,0 +1,68 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, test } from "vitest";
+
+const readSource = (relativePath: string) =>
+ readFileSync(new URL(relativePath, import.meta.url), "utf8");
+
+const expectNoRawComposeCreateDomain = (source: string) => {
+ expect(source).not.toMatch(
+ /createDomain\(\s*\{[\s\S]{0,500}domainType:\s*"compose"/,
+ );
+};
+
+describe("compose domain creation call-site contract", () => {
+ test("routes template compose domains through shared provider-aware helpers", () => {
+ const source = readSource("../../server/api/routers/compose.ts");
+
+ expect(source).toContain("createComposeDomain(");
+ expect(source).toContain("removeComposeDomainsForWebServer(");
+ expect(source).toContain("ctx.session.activeOrganizationId");
+ expectNoRawComposeCreateDomain(source);
+ });
+
+ test("routes AI-generated compose domains through shared provider-aware helpers", () => {
+ const source = readSource("../../server/api/routers/ai.ts");
+
+ expect(source).toContain("createComposeDomain(");
+ expect(source).toContain("ctx.session.activeOrganizationId");
+ expectNoRawComposeCreateDomain(source);
+ });
+
+ test("routes project duplication domains through application and compose helpers", () => {
+ const source = readSource("../../server/api/routers/project.ts");
+
+ expect(source).toContain("await createDomain({");
+ expect(source).toContain("applicationId: newApplication.applicationId");
+ expect(source).toContain("createComposeDomain(");
+ expect(source).toContain("ctx.session.activeOrganizationId");
+ expectNoRawComposeCreateDomain(source);
+ });
+
+ test("routes domain-router compose domains through shared provider-aware helpers", () => {
+ const source = readSource("../../server/api/routers/domain.ts");
+
+ expect(source).toContain("createComposeDomain(");
+ expect(source).toContain("refreshCaddyComposeRoutes(");
+ expect(source).toContain("ctx.session.activeOrganizationId");
+ expectNoRawComposeCreateDomain(source);
+ });
+
+ test("keeps compose deploy route refreshes tied to eagerly loaded project org context", () => {
+ const source = readSource(
+ "../../../../packages/server/src/services/compose.ts",
+ );
+
+ expect(source).toMatch(
+ /export const findComposeById[\s\S]*environment:\s*{\s*with:\s*{\s*project:\s*true/,
+ );
+
+ const routeRefreshCalls =
+ source.match(/writeCaddyComposeRoutesForTargets\([\s\S]*?\);/g) ?? [];
+ expect(routeRefreshCalls).toHaveLength(2);
+ for (const call of routeRefreshCalls) {
+ expect(call).toContain(
+ "organizationId: compose.environment.project.organizationId",
+ );
+ }
+ });
+});
diff --git a/apps/dokploy/__test__/caddy/compose/domain.test.ts b/apps/dokploy/__test__/caddy/compose/domain.test.ts
new file mode 100644
index 0000000000..35208b5651
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/compose/domain.test.ts
@@ -0,0 +1,348 @@
+import path from "node:path";
+import type { Compose, Domain } from "@dokploy/server";
+import { fs, vol } from "memfs";
+import { parse, stringify } from "yaml";
+
+vi.mock("node:fs", () => ({
+ ...fs,
+ default: fs,
+}));
+
+const execAsyncMock = vi.hoisted(() => vi.fn());
+const execAsyncRemoteMock = vi.hoisted(() => vi.fn());
+
+vi.mock("@dokploy/server/utils/process/execAsync", () => ({
+ execAsync: execAsyncMock,
+ execAsyncRemote: execAsyncRemoteMock,
+}));
+
+import {
+ addDomainToCompose,
+ addDomainToComposeForWebServer,
+ compileCaddyConfig,
+ createCaddyComposeRouteFragment,
+ createDomainLabels,
+ isDokployGeneratedTraefikLabel,
+ paths,
+ readCaddyRouteFragments,
+ refreshCaddyComposeRoutes,
+} from "@dokploy/server";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+const compose = (overrides: Partial = {}) =>
+ ({
+ appName: "my-compose",
+ serverId: null,
+ sourceType: "raw",
+ composePath: "docker-compose.yml",
+ composeType: "docker-compose",
+ isolatedDeployment: false,
+ isolatedDeploymentsVolume: false,
+ randomize: false,
+ suffix: null,
+ ...overrides,
+ }) as Compose;
+
+const domain = (overrides: Partial = {}) =>
+ ({
+ domainId: "domain-1",
+ applicationId: null,
+ composeId: "compose-1",
+ previewDeploymentId: null,
+ domainType: "compose",
+ host: "example.com",
+ path: "/",
+ internalPath: "/",
+ stripPath: false,
+ https: false,
+ certificateType: "none",
+ customCertResolver: null,
+ customEntrypoint: null,
+ middlewares: null,
+ port: 8080,
+ serviceName: "web",
+ uniqueConfigKey: 3,
+ createdAt: "",
+ ...overrides,
+ }) as Domain;
+
+const writeComposeFile = (
+ composeInput: Compose,
+ content: Record,
+) => {
+ const filePath = path.join(
+ paths(false).COMPOSE_PATH,
+ composeInput.appName,
+ "code",
+ "docker-compose.yml",
+ );
+ vol.mkdirSync(path.dirname(filePath), { recursive: true });
+ vol.writeFileSync(filePath, stringify(content));
+};
+
+const getServers = (config: ReturnType) => {
+ const apps = config.apps as Record;
+ return apps.http.servers as Record;
+};
+
+beforeEach(() => {
+ vol.reset();
+ execAsyncMock.mockReset();
+ execAsyncRemoteMock.mockReset();
+ execAsyncMock.mockResolvedValue({ stdout: "dokploy-caddy\n", stderr: "" });
+ execAsyncRemoteMock.mockResolvedValue({
+ stdout: "dokploy-caddy\n",
+ stderr: "",
+ });
+});
+
+describe("Caddy compose route generation", () => {
+ test("creates route fragments with compose upstream names", () => {
+ const fragment = createCaddyComposeRouteFragment(
+ compose(),
+ domain(),
+ "web",
+ );
+ const config = compileCaddyConfig({ fragments: [fragment] });
+
+ expect(fragment).toMatchObject({
+ id: "compose.my-compose.3",
+ source: "dokploy-compose",
+ });
+ expect(getServers(config).http.routes[0].handle.at(-1)).toMatchObject({
+ handler: "reverse_proxy",
+ upstreams: [{ dial: "my-compose-web:8080" }],
+ });
+ });
+
+ test("uses swarm stack service names for Caddy upstreams", () => {
+ const fragment = createCaddyComposeRouteFragment(
+ compose({ composeType: "stack" }),
+ domain(),
+ "web-blue",
+ );
+ const config = compileCaddyConfig({ fragments: [fragment] });
+
+ expect(getServers(config).http.routes[0].handle.at(-1)).toMatchObject({
+ handler: "reverse_proxy",
+ upstreams: [{ dial: "my-compose_web-blue:8080" }],
+ });
+ });
+
+ test("refreshes Caddy compose route fragments for domains created outside the domain router", async () => {
+ const composeInput = compose();
+ const routeDomain = domain({ https: true });
+ writeComposeFile(composeInput, {
+ services: {
+ web: { image: "nginx" },
+ },
+ });
+
+ await refreshCaddyComposeRoutes(composeInput, [routeDomain], "caddy");
+
+ const fragments = await readCaddyRouteFragments();
+ expect(fragments).toHaveLength(1);
+ expect(fragments[0]).toMatchObject({
+ id: "compose.my-compose.3",
+ source: "dokploy-compose",
+ });
+ const config = compileCaddyConfig({ fragments });
+ expect(getServers(config).https.routes[0].handle.at(-1)).toMatchObject({
+ handler: "reverse_proxy",
+ upstreams: [{ dial: "my-compose-web:8080" }],
+ });
+ });
+
+ test("loads uploaded certificates for custom Caddy compose HTTPS routes", () => {
+ const fragment = createCaddyComposeRouteFragment(
+ compose(),
+ domain({
+ https: true,
+ certificateType: "custom",
+ customCertResolver: "certificate-uploaded",
+ }),
+ "web",
+ );
+ const config = compileCaddyConfig({ fragments: [fragment] });
+ const tls = (config.apps as any).tls;
+ const certificatePath = `${paths(false).CERTIFICATES_PATH}/certificate-uploaded`;
+
+ expect(tls.certificates.load_files).toEqual([
+ {
+ certificate: `${certificatePath}/chain.crt`,
+ key: `${certificatePath}/privkey.key`,
+ },
+ ]);
+ expect(getServers(config).https.routes[0].handle.at(-1)).toMatchObject({
+ handler: "reverse_proxy",
+ upstreams: [{ dial: "my-compose-web:8080" }],
+ });
+ });
+
+ test("skips compose route refresh for Traefik provider", async () => {
+ const composeInput = compose();
+ writeComposeFile(composeInput, {
+ services: {
+ web: { image: "nginx" },
+ },
+ });
+
+ await refreshCaddyComposeRoutes(composeInput, [domain()], "traefik");
+
+ expect(await readCaddyRouteFragments()).toEqual([]);
+ expect(execAsyncMock).not.toHaveBeenCalled();
+ });
+
+ test("Traefik provider conversion delegates to the existing Traefik label path", async () => {
+ const composeInput = compose();
+ writeComposeFile(composeInput, {
+ services: {
+ web: { image: "nginx" },
+ },
+ });
+
+ const existingTraefikOutput = await addDomainToCompose(composeInput, [
+ domain(),
+ ]);
+ const providerOutput = await addDomainToComposeForWebServer(
+ composeInput,
+ [domain()],
+ "traefik",
+ );
+
+ expect(providerOutput).toEqual(existingTraefikOutput);
+ });
+
+ test("Caddy compose conversion strips only Dokploy Traefik labels and attaches shared network", async () => {
+ const composeInput = compose();
+ const routeDomain = domain({ https: true, path: "/app", stripPath: true });
+ const dokployLabels = [
+ "traefik.docker.network=dokploy-network",
+ "traefik.enable=true",
+ ...createDomainLabels(composeInput.appName, routeDomain, "web"),
+ ...createDomainLabels(composeInput.appName, routeDomain, "websecure"),
+ ];
+ writeComposeFile(composeInput, {
+ services: {
+ web: {
+ image: "nginx",
+ labels: [
+ "com.example.keep=true",
+ "traefik.http.routers.manual.rule=Host(`manual.example.com`)",
+ "traefik.http.routers.my-compose-custom-web.rule=Host(`custom.example.com`)",
+ ...dokployLabels,
+ ],
+ },
+ },
+ });
+
+ const converted = await addDomainToComposeForWebServer(
+ composeInput,
+ [routeDomain],
+ "caddy",
+ );
+
+ const webService = converted?.services?.web;
+ expect(webService).toBeDefined();
+ const labels = webService?.labels as string[];
+ expect(labels).toEqual([
+ "com.example.keep=true",
+ "traefik.http.routers.manual.rule=Host(`manual.example.com`)",
+ "traefik.http.routers.my-compose-custom-web.rule=Host(`custom.example.com`)",
+ ]);
+ expect(webService?.networks).toEqual({
+ "dokploy-network": { aliases: ["my-compose-web"] },
+ default: {},
+ });
+ expect(converted?.networks?.["dokploy-network"]).toEqual({
+ external: true,
+ });
+ });
+
+ test("Caddy compose conversion rejects unsupported domain fields before mutating compose labels", async () => {
+ const composeInput = compose();
+ writeComposeFile(composeInput, {
+ services: {
+ web: {
+ image: "nginx",
+ labels: [
+ "traefik.enable=true",
+ "traefik.http.routers.my-compose-3-web.rule=Host(`example.com`)",
+ ],
+ },
+ },
+ });
+
+ await expect(
+ addDomainToComposeForWebServer(
+ composeInput,
+ [domain({ middlewares: ["auth@file"] })],
+ "caddy",
+ ),
+ ).rejects.toThrow("unsupported Caddy fields");
+
+ const stored = parse(
+ vol
+ .readFileSync(
+ path.join(
+ paths(false).COMPOSE_PATH,
+ composeInput.appName,
+ "code",
+ "docker-compose.yml",
+ ),
+ "utf8",
+ )
+ .toString(),
+ ) as any;
+ expect(stored.services.web.labels).toEqual([
+ "traefik.enable=true",
+ "traefik.http.routers.my-compose-3-web.rule=Host(`example.com`)",
+ ]);
+ });
+
+ test("Caddy compose conversion uses finalized randomized service names", async () => {
+ const composeInput = compose({ randomize: true, suffix: "blue" });
+ writeComposeFile(composeInput, {
+ services: {
+ web: { image: "nginx" },
+ worker: { image: "busybox", depends_on: ["web"] },
+ },
+ });
+
+ const converted = await addDomainToComposeForWebServer(
+ composeInput,
+ [domain()],
+ "caddy",
+ );
+
+ expect(converted?.services?.web).toBeUndefined();
+ expect(converted?.services?.["web-blue"]?.networks).toEqual({
+ "dokploy-network": { aliases: ["my-compose-web-blue"] },
+ default: {},
+ });
+ expect(converted?.services?.["worker-blue"]?.depends_on).toEqual([
+ "web-blue",
+ ]);
+ });
+
+ test("classifier identifies current Dokploy-generated Traefik labels without matching unrelated labels", () => {
+ const routeDomain = domain({ https: true });
+ expect(
+ isDokployGeneratedTraefikLabel(
+ "traefik.http.routers.my-compose-3-web.rule=Host(`example.com`)",
+ { appName: "my-compose", domains: [routeDomain] },
+ ),
+ ).toBe(true);
+ expect(
+ isDokployGeneratedTraefikLabel("traefik.enable=true", {
+ includeGenericLabels: true,
+ }),
+ ).toBe(true);
+ expect(
+ isDokployGeneratedTraefikLabel(
+ "traefik.http.routers.manual.rule=Host(`manual.example.com`)",
+ { appName: "my-compose", domains: [routeDomain] },
+ ),
+ ).toBe(false);
+ });
+});
diff --git a/apps/dokploy/__test__/caddy/config-lock.test.ts b/apps/dokploy/__test__/caddy/config-lock.test.ts
new file mode 100644
index 0000000000..2ffa274a1a
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/config-lock.test.ts
@@ -0,0 +1,84 @@
+import { fs } from "memfs";
+
+vi.mock("node:fs", () => ({
+ ...fs,
+ default: fs,
+}));
+
+const execAsyncMock = vi.hoisted(() => vi.fn());
+const execAsyncRemoteMock = vi.hoisted(() => vi.fn());
+
+vi.mock("@dokploy/server/utils/process/execAsync", () => ({
+ execAsync: execAsyncMock,
+ execAsyncRemote: execAsyncRemoteMock,
+}));
+
+import { withCaddyConfigLock } from "@dokploy/server";
+import { expect, test, vi } from "vitest";
+
+const deferred = () => {
+ let resolve!: () => void;
+ const promise = new Promise((res) => {
+ resolve = res;
+ });
+ return { promise, resolve };
+};
+
+test("serializes tasks for the same server", async () => {
+ const order: string[] = [];
+ const firstGate = deferred();
+
+ const first = withCaddyConfigLock(undefined, async () => {
+ order.push("first:start");
+ await firstGate.promise;
+ order.push("first:end");
+ });
+ const second = withCaddyConfigLock(undefined, async () => {
+ order.push("second:start");
+ });
+
+ // Let the second task start if the lock (incorrectly) allowed it.
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(order).toEqual(["first:start"]);
+
+ firstGate.resolve();
+ await Promise.all([first, second]);
+ expect(order).toEqual(["first:start", "first:end", "second:start"]);
+});
+
+test("runs tasks for different servers concurrently", async () => {
+ const order: string[] = [];
+ const localGate = deferred();
+
+ const local = withCaddyConfigLock(undefined, async () => {
+ order.push("local:start");
+ await localGate.promise;
+ order.push("local:end");
+ });
+ const remote = withCaddyConfigLock("server-1", async () => {
+ order.push("remote:start");
+ });
+
+ await remote;
+ expect(order).toEqual(["local:start", "remote:start"]);
+
+ localGate.resolve();
+ await local;
+ expect(order).toEqual(["local:start", "remote:start", "local:end"]);
+});
+
+test("releases the lock after a failed task and propagates the error", async () => {
+ const failing = withCaddyConfigLock(undefined, async () => {
+ throw new Error("compile failed");
+ });
+ await expect(failing).rejects.toThrow("compile failed");
+
+ const result = await withCaddyConfigLock(undefined, async () => "recovered");
+ expect(result).toBe("recovered");
+});
+
+test("returns the task result", async () => {
+ await expect(
+ withCaddyConfigLock("server-2", async () => 42),
+ ).resolves.toBe(42);
+});
diff --git a/apps/dokploy/__test__/caddy/config.test.ts b/apps/dokploy/__test__/caddy/config.test.ts
new file mode 100644
index 0000000000..d8787d6fe4
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/config.test.ts
@@ -0,0 +1,829 @@
+import { fs, vol } from "memfs";
+
+vi.mock("node:fs", () => ({
+ ...fs,
+ default: fs,
+}));
+
+const execAsyncMock = vi.hoisted(() => vi.fn());
+const execAsyncRemoteMock = vi.hoisted(() => vi.fn());
+
+vi.mock("@dokploy/server/utils/process/execAsync", () => ({
+ execAsync: execAsyncMock,
+ execAsyncRemote: execAsyncRemoteMock,
+}));
+
+import {
+ type ApplicationNested,
+ type CaddyRouteFragment,
+ type CaddyRouteIntent,
+ CLOUDFLARE_TRUSTED_PROXY_RANGES,
+ caddyTrustedProxySettingsToConfig,
+ compileAndWriteCaddyConfig,
+ compileCaddyConfig,
+ compileWriteAndReloadCaddyConfigSafely,
+ compileWriteAndValidateCaddyConfigSafely,
+ createCaddyDashboardRouteFragment,
+ type Domain,
+ getCaddyMigrationArtifactPaths,
+ manageCaddyDomain,
+ normalizeCaddyTrustedProxySettings,
+ paths,
+ readCaddyRouteFragments,
+ removeCaddyDomain,
+ updateServerCaddy,
+ validateCaddyConfigFileWithImage,
+ writeCaddyRouteFragment,
+} from "@dokploy/server";
+import type { webServerSettings } from "@dokploy/server/db/schema";
+import { beforeEach, expect, test, vi } from "vitest";
+
+const route = (
+ overrides: Partial = {},
+): CaddyRouteIntent => ({
+ id: "app-route",
+ source: "dokploy-application",
+ hosts: ["example.com"],
+ https: false,
+ upstreams: ["http://app:3000"],
+ ...overrides,
+});
+
+const getServers = (config: ReturnType) => {
+ const apps = config.apps as Record;
+ return apps.http.servers as Record;
+};
+
+type WebServerSettings = typeof webServerSettings.$inferSelect;
+
+const settings = (overrides: Partial = {}) =>
+ ({
+ id: "settings-1",
+ webServerProvider: "caddy",
+ caddyTrustedProxyConfig: null,
+ requestLogsEnabled: false,
+ https: false,
+ certificateType: "none",
+ host: null,
+ serverIp: null,
+ letsEncryptEmail: null,
+ sshPrivateKey: null,
+ enableDockerCleanup: false,
+ logCleanupCron: null,
+ metricsConfig: {} as WebServerSettings["metricsConfig"],
+ whitelabelingConfig: null,
+ remoteServersOnly: false,
+ enforceSSO: false,
+ cleanupCacheApplications: false,
+ cleanupCacheOnCompose: false,
+ cleanupCacheOnPreviews: false,
+ createdAt: null,
+ updatedAt: new Date(),
+ ...overrides,
+ }) as WebServerSettings;
+
+beforeEach(() => {
+ vol.reset();
+ execAsyncMock.mockReset();
+ execAsyncRemoteMock.mockReset();
+ execAsyncMock.mockResolvedValue({ stdout: "dokploy-caddy\n", stderr: "" });
+ execAsyncRemoteMock.mockResolvedValue({
+ stdout: "dokploy-caddy\n",
+ stderr: "",
+ });
+});
+
+test("compiles explicit http and https servers with managed HTTPS redirect", () => {
+ const config = compileCaddyConfig({
+ letsEncryptEmail: "ops@example.com",
+ routes: [route({ https: true })],
+ });
+
+ const servers = getServers(config);
+ expect(servers.http.listen).toEqual([":80"]);
+ expect(servers.https.listen).toEqual([":443"]);
+ expect(servers.metrics).toEqual({
+ listen: [":2020"],
+ routes: [
+ {
+ handle: [{ handler: "metrics" }],
+ terminal: true,
+ },
+ ],
+ });
+ expect(servers.http.routes[0].handle[0].handler).toBe("static_response");
+ expect(servers.http.routes[0].handle[0].headers.Location).toEqual([
+ "https://{http.request.host}{http.request.uri}",
+ ]);
+ expect(servers.https.routes[0].handle.at(-1)).toMatchObject({
+ handler: "reverse_proxy",
+ upstreams: [{ dial: "app:3000" }],
+ });
+ expect((config.apps as any).tls.automation.policies[0].issuers[0].email).toBe(
+ "ops@example.com",
+ );
+ expect(servers.http.trusted_proxies).toBeUndefined();
+ expect(servers.https.trusted_proxies).toBeUndefined();
+ expect(servers.http.client_ip_headers).toBeUndefined();
+ expect(servers.https.client_ip_headers).toBeUndefined();
+ expect((config as any).logging).toBeUndefined();
+ expect(servers.http.logs).toBeUndefined();
+ expect(servers.https.logs).toBeUndefined();
+});
+
+test("compiles Caddy access-log output when request analytics are enabled", () => {
+ const config = compileCaddyConfig({
+ routes: [route({ https: true })],
+ accessLogs: { enabled: true },
+ });
+
+ const servers = getServers(config);
+ expect(servers.http.logs).toEqual({});
+ expect(servers.https.logs).toEqual({});
+ expect((config as any).logging.logs["dokploy-requests"]).toEqual({
+ writer: {
+ output: "file",
+ filename: "/etc/caddy/access.log",
+ },
+ encoder: {
+ format: "json",
+ },
+ include: ["http.log.access"],
+ });
+});
+
+test("dashboard Caddy updates preserve enabled request access logs", async () => {
+ await updateServerCaddy(
+ settings({
+ requestLogsEnabled: true,
+ https: true,
+ letsEncryptEmail: "ops@example.com",
+ caddyTrustedProxyConfig: {
+ mode: "static",
+ ranges: ["192.0.2.0/24"],
+ clientIpHeaders: ["X-Forwarded-For"],
+ strict: true,
+ },
+ }),
+ "dashboard.example.com",
+ );
+
+ const config = JSON.parse(
+ vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8") as string,
+ );
+ const servers = (config.apps as Record).http.servers;
+
+ expect(servers.http.logs).toEqual({});
+ expect(servers.http.trusted_proxies).toEqual({
+ source: "static",
+ ranges: ["192.0.2.0/24"],
+ });
+ expect(servers.http.client_ip_headers).toEqual(["X-Forwarded-For"]);
+ expect(servers.https.trusted_proxies).toEqual(servers.http.trusted_proxies);
+ expect((config.apps as any).tls.automation.policies[0].issuers[0].email).toBe(
+ "ops@example.com",
+ );
+ expect((config as any).logging.logs["dokploy-requests"]).toEqual({
+ writer: {
+ output: "file",
+ filename: "/etc/caddy/access.log",
+ },
+ encoder: {
+ format: "json",
+ },
+ include: ["http.log.access"],
+ });
+});
+
+test("restores dashboard fragments when Caddy dashboard reload fails", async () => {
+ const existingFragment = createCaddyDashboardRouteFragment(
+ settings(),
+ "old-dashboard.example.com",
+ );
+ const concurrentFragment: CaddyRouteFragment = {
+ version: 1,
+ id: "application.concurrent",
+ source: "dokploy-application",
+ routes: [route({ id: "concurrent", hosts: ["concurrent.example.com"] })],
+ };
+ await writeCaddyRouteFragment(existingFragment);
+ const previousConfig = `${JSON.stringify(
+ compileCaddyConfig({ fragments: [existingFragment] }),
+ null,
+ 2,
+ )}\n`;
+ vol.mkdirSync(paths().MAIN_CADDY_PATH, { recursive: true });
+ vol.writeFileSync(paths().CADDY_CONFIG_PATH, previousConfig);
+ let concurrentFragmentWritten = false;
+ execAsyncMock.mockImplementation(async (command: string) => {
+ if (command.includes("caddy validate")) {
+ if (!concurrentFragmentWritten) {
+ concurrentFragmentWritten = true;
+ await writeCaddyRouteFragment(concurrentFragment);
+ }
+ throw new Error("validation failed");
+ }
+ return { stdout: "dokploy-caddy\n", stderr: "" };
+ });
+
+ await expect(
+ updateServerCaddy(settings(), "new-dashboard.example.com"),
+ ).rejects.toThrow("validation failed");
+
+ expect(await readCaddyRouteFragments()).toEqual([
+ concurrentFragment,
+ existingFragment,
+ ]);
+ expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe(
+ previousConfig,
+ );
+});
+
+test("does not overwrite a concurrent dashboard fragment update when dashboard reload fails", async () => {
+ const existingFragment = createCaddyDashboardRouteFragment(
+ settings(),
+ "old-dashboard.example.com",
+ );
+ const concurrentDashboardFragment = createCaddyDashboardRouteFragment(
+ settings(),
+ "concurrent-dashboard.example.com",
+ );
+ await writeCaddyRouteFragment(existingFragment);
+ const previousConfig = `${JSON.stringify(
+ compileCaddyConfig({ fragments: [existingFragment] }),
+ null,
+ 2,
+ )}\n`;
+ const concurrentConfig = `${JSON.stringify(
+ compileCaddyConfig({ fragments: [concurrentDashboardFragment] }),
+ null,
+ 2,
+ )}\n`;
+ vol.mkdirSync(paths().MAIN_CADDY_PATH, { recursive: true });
+ vol.writeFileSync(paths().CADDY_CONFIG_PATH, previousConfig);
+ let failedInitialValidation = false;
+ execAsyncMock.mockImplementation(async (command: string) => {
+ if (command.includes("caddy validate") && !failedInitialValidation) {
+ failedInitialValidation = true;
+ await writeCaddyRouteFragment(concurrentDashboardFragment);
+ throw new Error("validation failed");
+ }
+ return { stdout: "dokploy-caddy\n", stderr: "" };
+ });
+
+ await expect(
+ updateServerCaddy(settings(), "new-dashboard.example.com"),
+ ).rejects.toThrow("validation failed");
+
+ expect(await readCaddyRouteFragments()).toEqual([
+ concurrentDashboardFragment,
+ ]);
+ expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe(
+ concurrentConfig,
+ );
+});
+
+test("restores removed dashboard fragments when Caddy dashboard removal reload fails", async () => {
+ const existingFragment = createCaddyDashboardRouteFragment(
+ settings(),
+ "old-dashboard.example.com",
+ );
+ await writeCaddyRouteFragment(existingFragment);
+ const previousConfig = `${JSON.stringify(
+ compileCaddyConfig({ fragments: [existingFragment] }),
+ null,
+ 2,
+ )}\n`;
+ vol.mkdirSync(paths().MAIN_CADDY_PATH, { recursive: true });
+ vol.writeFileSync(paths().CADDY_CONFIG_PATH, previousConfig);
+ execAsyncMock.mockImplementation(async (command: string) => {
+ if (command.includes("caddy validate")) {
+ throw new Error("validation failed");
+ }
+ return { stdout: "dokploy-caddy\n", stderr: "" };
+ });
+
+ await expect(updateServerCaddy(settings(), null)).rejects.toThrow(
+ "validation failed",
+ );
+
+ expect(await readCaddyRouteFragments()).toEqual([existingFragment]);
+ expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe(
+ previousConfig,
+ );
+});
+
+test("compiles Cloudflare trusted proxy settings with safe client IP headers", () => {
+ const config = compileCaddyConfig({
+ routes: [route({ https: true })],
+ trustedProxies: {
+ source: "cloudflare",
+ },
+ });
+
+ const servers = getServers(config);
+ for (const server of [servers.http, servers.https]) {
+ expect(server.trusted_proxies).toEqual({
+ source: "static",
+ ranges: [...CLOUDFLARE_TRUSTED_PROXY_RANGES],
+ });
+ expect(server.client_ip_headers).toEqual([
+ "CF-Connecting-IP",
+ "X-Forwarded-For",
+ ]);
+ // Caddy's trusted_proxies_strict is an int field; booleans fail
+ // `caddy validate`.
+ expect(server.trusted_proxies_strict).toBe(1);
+ }
+});
+
+test("compiles custom static trusted proxy ranges", () => {
+ const config = compileCaddyConfig({
+ routes: [route()],
+ trustedProxies: {
+ source: "static",
+ ranges: ["192.0.2.0/24", "2001:db8::/32"],
+ clientIpHeaders: ["X-Forwarded-For"],
+ strict: false,
+ },
+ });
+
+ const servers = getServers(config);
+ expect(servers.http).toMatchObject({
+ trusted_proxies: {
+ source: "static",
+ ranges: ["192.0.2.0/24", "2001:db8::/32"],
+ },
+ client_ip_headers: ["X-Forwarded-For"],
+ });
+ expect(servers.http.trusted_proxies_strict).toBeUndefined();
+ expect(servers.https.trusted_proxies).toEqual(servers.http.trusted_proxies);
+});
+
+test("normalizes persisted Caddy trusted proxy settings", () => {
+ const settings = normalizeCaddyTrustedProxySettings({
+ mode: "static",
+ ranges: [" 192.0.2.0/24 ", "192.0.2.0/24"],
+ clientIpHeaders: [" X-Forwarded-For ", ""],
+ strict: null,
+ });
+
+ expect(settings).toEqual({
+ mode: "static",
+ ranges: ["192.0.2.0/24"],
+ clientIpHeaders: ["X-Forwarded-For"],
+ strict: true,
+ });
+ expect(caddyTrustedProxySettingsToConfig(settings)).toEqual({
+ source: "static",
+ ranges: ["192.0.2.0/24"],
+ clientIpHeaders: ["X-Forwarded-For"],
+ strict: true,
+ });
+ expect(normalizeCaddyTrustedProxySettings({ mode: "disabled" })).toBeNull();
+});
+
+test("rejects invalid trusted proxy settings before writing Caddy config", () => {
+ expect(() =>
+ compileCaddyConfig({
+ trustedProxies: { source: "static", ranges: ["192.0.2.0/33"] },
+ }),
+ ).toThrow("Invalid Caddy trusted proxy prefix");
+
+ expect(() =>
+ compileCaddyConfig({
+ trustedProxies: {
+ source: "static",
+ ranges: ["192.0.2.0/24"],
+ clientIpHeaders: ["X-Forwarded-For", "x-forwarded-for"],
+ },
+ }),
+ ).toThrow("Duplicate Caddy trusted proxy client IP header");
+});
+
+test("sorts routes by priority before path specificity and stable id", () => {
+ const config = compileCaddyConfig({
+ routes: [
+ route({ id: "low-specific", priority: 1, pathPrefix: "/very/specific" }),
+ route({ id: "high-priority", priority: 10, pathPrefix: "/api" }),
+ route({ id: "same-priority-longer", priority: 1, pathPrefix: "/longer" }),
+ route({
+ id: "same-tiebreak-b",
+ priority: 1,
+ pathPrefix: "/same",
+ upstreams: ["http://b:3000"],
+ }),
+ route({
+ id: "same-tiebreak-a",
+ priority: 1,
+ pathPrefix: "/same",
+ upstreams: ["http://a:3000"],
+ }),
+ ],
+ });
+
+ const [first, second, third, fourth, fifth] = getServers(config).http.routes;
+ expect(first.match[0].path).toEqual(["/api*"]);
+ expect(second.match[0].path).toEqual(["/very/specific*"]);
+ expect(third.match[0].path).toEqual(["/longer*"]);
+ expect(fourth.handle.at(-1).upstreams).toEqual([{ dial: "a:3000" }]);
+ expect(fifth.handle.at(-1).upstreams).toEqual([{ dial: "b:3000" }]);
+});
+
+test("orders migrated manual and Traefik routes before DB fallbacks for identical catch-all matches", () => {
+ const config = compileCaddyConfig({
+ routes: [
+ route({
+ id: "db-compose-fallback",
+ source: "dokploy-compose",
+ https: true,
+ upstreams: ["http://cms:3000"],
+ }),
+ route({
+ id: "manual-waf",
+ source: "manual",
+ https: true,
+ upstreams: ["http://waf:8080"],
+ }),
+ route({
+ id: "dynamic-blog",
+ source: "traefik-dynamic-file",
+ https: true,
+ upstreams: ["http://blog:8080"],
+ }),
+ route({
+ id: "label-cms",
+ source: "traefik-compose-label",
+ https: true,
+ upstreams: ["http://cms:80"],
+ }),
+ ],
+ });
+
+ const upstreamOrder = getServers(config).https.routes.map(
+ (compiledRoute: any) => compiledRoute.handle.at(-1).upstreams[0].dial,
+ );
+ expect(upstreamOrder).toEqual([
+ "waf:8080",
+ "cms:80",
+ "blog:8080",
+ "cms:3000",
+ ]);
+});
+
+test("renders transforms, headers, and HTTPS upstream transport", () => {
+ const config = compileCaddyConfig({
+ routes: [
+ route({
+ upstreams: ["https://upstream.example.com:443"],
+ transforms: {
+ stripPrefix: "/public",
+ addPrefix: "/internal",
+ responseHeaders: {
+ "Cache-Control": "no-store",
+ },
+ },
+ }),
+ ],
+ });
+
+ const handlers = getServers(config).http.routes[0].handle;
+ expect(handlers[0]).toMatchObject({
+ handler: "headers",
+ response: { set: { "Cache-Control": ["no-store"] } },
+ });
+ expect(handlers[1]).toMatchObject({
+ handler: "rewrite",
+ strip_path_prefix: "/public",
+ });
+ expect(handlers[2]).toMatchObject({
+ handler: "rewrite",
+ uri: "/internal{http.request.uri.path}",
+ });
+ expect(handlers[3]).toMatchObject({
+ handler: "reverse_proxy",
+ upstreams: [{ dial: "upstream.example.com:443" }],
+ transport: { protocol: "http", tls: {} },
+ });
+});
+
+test("rejects proxy upstreams without explicit valid ports", () => {
+ for (const upstream of [
+ "http://admin",
+ "https://external.example.com",
+ "http://app:0",
+ "http://app:65536",
+ "http://app:abc",
+ "app",
+ "app:0",
+ ]) {
+ expect(() =>
+ compileCaddyConfig({
+ routes: [route({ upstreams: [upstream] })],
+ }),
+ ).toThrow(`invalid upstream "${upstream}"`);
+ }
+});
+
+test("allows redirect-only routes without upstreams", () => {
+ const config = compileCaddyConfig({
+ routes: [
+ route({
+ upstreams: [],
+ redirectScheme: { scheme: "https", permanent: true },
+ }),
+ ],
+ });
+
+ expect(getServers(config).http.routes[0].handle[0]).toMatchObject({
+ handler: "static_response",
+ status_code: 308,
+ });
+});
+
+test("allows static response routes without upstreams", () => {
+ const config = compileCaddyConfig({
+ routes: [
+ route({
+ source: "manual",
+ https: true,
+ upstreams: [],
+ staticResponse: {
+ statusCode: 404,
+ headers: {
+ "Cache-Control": "no-store",
+ },
+ },
+ }),
+ ],
+ });
+
+ const servers = getServers(config);
+ expect(servers.http.routes[0].handle[0]).toMatchObject({
+ handler: "static_response",
+ status_code: 308,
+ });
+ expect(servers.https.routes[0].handle).toEqual([
+ {
+ handler: "static_response",
+ status_code: 404,
+ headers: {
+ "Cache-Control": ["no-store"],
+ },
+ },
+ ]);
+});
+
+test("stores fragments and compiles them into the active config", async () => {
+ const fragment: CaddyRouteFragment = {
+ version: 1,
+ id: "app.example",
+ source: "dokploy-application",
+ routes: [route({ id: "stored", pathPrefix: "/stored" })],
+ };
+
+ await writeCaddyRouteFragment(fragment);
+ const fragments = await readCaddyRouteFragments();
+ const config = await compileAndWriteCaddyConfig();
+
+ expect(fragments).toEqual([fragment]);
+ expect(getServers(config).http.routes[0].match[0].path).toEqual(["/stored*"]);
+});
+
+test("rejects invalid fragment ids before touching the store", async () => {
+ for (const id of ["../bad", "..", ".", "bad..segment"]) {
+ await expect(
+ writeCaddyRouteFragment({
+ version: 1,
+ id,
+ source: "manual",
+ routes: [route()],
+ }),
+ ).rejects.toThrow("Invalid Caddy fragment id");
+ }
+ expect(vol.existsSync(paths().CADDY_FRAGMENTS_PATH)).toBe(false);
+});
+
+test("rejects unsafe Caddy migration ids", () => {
+ for (const id of ["..", ".", "bad..segment"]) {
+ expect(() => getCaddyMigrationArtifactPaths(id)).toThrow(
+ "Invalid Caddy migration id",
+ );
+ }
+});
+
+test("validates a config file with the Caddy binary in an isolated runtime container", async () => {
+ await validateCaddyConfigFileWithImage(
+ "/etc/dokploy/caddy/migrations/test/caddy.json",
+ );
+
+ const validateCommand = execAsyncMock.mock.calls
+ .map(([command]) => command as string)
+ .find((command) => command.includes("docker run"));
+ expect(validateCommand).toContain("docker run --rm --network none");
+ expect(validateCommand).toContain("caddy\\:2.11.4");
+ expect(validateCommand).toContain(
+ "/etc/dokploy/caddy/migrations/test/caddy.json\\:/etc/caddy/caddy.json\\:ro",
+ );
+ expect(validateCommand).toContain(
+ "/etc/dokploy/caddy/migrations/test/.validate-runtime/config\\:/config",
+ );
+ expect(validateCommand).toContain(
+ `${paths().CERTIFICATES_PATH}\\:${paths().CERTIFICATES_PATH}\\:ro`,
+ );
+ expect(validateCommand).toContain(" caddy validate --config");
+ expect(validateCommand).not.toContain("caddy\\:2.11.4 validate --config");
+});
+
+test("restores the previous Caddy config when safe validation fails without reloading", async () => {
+ const previousConfig = `${JSON.stringify(
+ compileCaddyConfig({ routes: [route({ hosts: ["old.example.com"] })] }),
+ null,
+ 2,
+ )}\n`;
+ vol.mkdirSync(paths().MAIN_CADDY_PATH, { recursive: true });
+ vol.mkdirSync(paths().CADDY_FRAGMENTS_PATH, { recursive: true });
+ vol.writeFileSync(paths().CADDY_CONFIG_PATH, previousConfig);
+ execAsyncMock.mockImplementation(async (command: string) => {
+ if (command.includes("caddy validate")) {
+ throw new Error("validation failed");
+ }
+ return { stdout: "dokploy-caddy\n", stderr: "" };
+ });
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => undefined);
+
+ await expect(
+ compileWriteAndValidateCaddyConfigSafely({
+ accessLogs: { enabled: true },
+ }),
+ ).rejects.toThrow("validation failed");
+
+ expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe(
+ previousConfig,
+ );
+ expect(
+ execAsyncMock.mock.calls.some(([command]) =>
+ (command as string).includes("caddy reload"),
+ ),
+ ).toBe(false);
+ consoleError.mockRestore();
+});
+
+test("reloads the restored Caddy config when safe reload fails", async () => {
+ const previousConfig = `${JSON.stringify(
+ compileCaddyConfig({ routes: [route({ hosts: ["old.example.com"] })] }),
+ null,
+ 2,
+ )}\n`;
+ vol.mkdirSync(paths().MAIN_CADDY_PATH, { recursive: true });
+ vol.mkdirSync(paths().CADDY_FRAGMENTS_PATH, { recursive: true });
+ vol.writeFileSync(paths().CADDY_CONFIG_PATH, previousConfig);
+ let reloads = 0;
+ execAsyncMock.mockImplementation(async (command: string) => {
+ if (command.includes("caddy reload")) {
+ reloads += 1;
+ if (reloads === 1) {
+ throw new Error("reload failed");
+ }
+ }
+ return { stdout: "dokploy-caddy\n", stderr: "" };
+ });
+
+ await expect(
+ compileWriteAndReloadCaddyConfigSafely({
+ trustedProxies: {
+ source: "static",
+ ranges: ["192.0.2.0/24"],
+ },
+ }),
+ ).rejects.toThrow("reload failed");
+
+ expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe(
+ previousConfig,
+ );
+ expect(reloads).toBe(2);
+});
+
+test("preserves the original safe reload error when the restored Caddy reload also fails", async () => {
+ const previousConfig = `${JSON.stringify(
+ compileCaddyConfig({ routes: [route({ hosts: ["old.example.com"] })] }),
+ null,
+ 2,
+ )}\n`;
+ vol.mkdirSync(paths().MAIN_CADDY_PATH, { recursive: true });
+ vol.mkdirSync(paths().CADDY_FRAGMENTS_PATH, { recursive: true });
+ vol.writeFileSync(paths().CADDY_CONFIG_PATH, previousConfig);
+ const restoreError = new Error("restored reload failed");
+ let reloads = 0;
+ execAsyncMock.mockImplementation(async (command: string) => {
+ if (command.includes("caddy reload")) {
+ reloads += 1;
+ if (reloads === 1) {
+ throw new Error("new config reload failed");
+ }
+ throw restoreError;
+ }
+ return { stdout: "dokploy-caddy\n", stderr: "" };
+ });
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => undefined);
+
+ let thrownError: Error | undefined;
+ try {
+ await compileWriteAndReloadCaddyConfigSafely({
+ trustedProxies: {
+ source: "static",
+ ranges: ["192.0.2.0/24"],
+ },
+ });
+ } catch (error) {
+ thrownError = error as Error;
+ }
+
+ expect(thrownError).toBeInstanceOf(Error);
+ expect(thrownError?.message).toBe("new config reload failed");
+ expect((thrownError as Error & { restoreError?: unknown }).restoreError).toBe(
+ restoreError,
+ );
+ expect(consoleError).toHaveBeenCalledWith(
+ "Failed to restore Caddy config:",
+ restoreError,
+ );
+ consoleError.mockRestore();
+ expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe(
+ previousConfig,
+ );
+ expect(reloads).toBe(2);
+});
+
+test("restores previous fragments when Caddy domain reload fails", async () => {
+ const existingFragment: CaddyRouteFragment = {
+ version: 1,
+ id: "app.existing",
+ source: "dokploy-application",
+ routes: [route({ id: "existing", hosts: ["old.example.com"] })],
+ };
+ await writeCaddyRouteFragment(existingFragment);
+ execAsyncMock.mockImplementation(async (command: string) => {
+ if (command.includes("caddy validate")) {
+ throw new Error("validation failed");
+ }
+ return { stdout: "dokploy-caddy\n", stderr: "" };
+ });
+
+ await expect(
+ manageCaddyDomain(
+ { appName: "my-app", serverId: null } as ApplicationNested,
+ {
+ domainId: "domain-1",
+ applicationId: "app-1",
+ composeId: null,
+ previewDeploymentId: null,
+ domainType: "application",
+ host: "example.com",
+ path: "/",
+ internalPath: "/",
+ stripPath: false,
+ https: false,
+ certificateType: "none",
+ customCertResolver: null,
+ customEntrypoint: null,
+ middlewares: null,
+ port: 3000,
+ serviceName: null,
+ uniqueConfigKey: 7,
+ createdAt: "",
+ } as Domain,
+ ),
+ ).rejects.toThrow("validation failed");
+
+ expect(await readCaddyRouteFragments()).toEqual([existingFragment]);
+});
+
+test("restores removed fragments when Caddy domain removal reload fails", async () => {
+ const existingFragment: CaddyRouteFragment = {
+ version: 1,
+ id: "application.my-app.7",
+ source: "dokploy-application",
+ routes: [route({ id: "existing", hosts: ["old.example.com"] })],
+ };
+ await writeCaddyRouteFragment(existingFragment);
+ execAsyncMock.mockImplementation(async (command: string) => {
+ if (command.includes("caddy validate")) {
+ throw new Error("validation failed");
+ }
+ return { stdout: "dokploy-caddy\n", stderr: "" };
+ });
+
+ await expect(
+ removeCaddyDomain(
+ { appName: "my-app", serverId: null } as ApplicationNested,
+ 7,
+ ),
+ ).rejects.toThrow("validation failed");
+
+ expect(await readCaddyRouteFragments()).toEqual([existingFragment]);
+});
diff --git a/apps/dokploy/__test__/caddy/dashboard-route.test.ts b/apps/dokploy/__test__/caddy/dashboard-route.test.ts
new file mode 100644
index 0000000000..7ed02b2096
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/dashboard-route.test.ts
@@ -0,0 +1,88 @@
+import {
+ compileCaddyConfig,
+ createCaddyDashboardRouteFragment,
+} from "@dokploy/server";
+import type { webServerSettings } from "@dokploy/server/db/schema";
+import { expect, test } from "vitest";
+
+type WebServerSettings = typeof webServerSettings.$inferSelect;
+
+const settings = (overrides: Partial = {}) =>
+ ({
+ id: "settings-1",
+ webServerProvider: "caddy",
+ caddyTrustedProxyConfig: null,
+ https: false,
+ certificateType: "none",
+ host: null,
+ serverIp: null,
+ letsEncryptEmail: null,
+ sshPrivateKey: null,
+ enableDockerCleanup: false,
+ logCleanupCron: null,
+ metricsConfig: {} as WebServerSettings["metricsConfig"],
+ whitelabelingConfig: null,
+ cleanupCacheApplications: false,
+ cleanupCacheOnCompose: false,
+ cleanupCacheOnPreviews: false,
+ createdAt: null,
+ updatedAt: new Date(),
+ ...overrides,
+ }) as WebServerSettings;
+
+const getServers = (config: ReturnType) => {
+ const apps = config.apps as Record;
+ return apps.http.servers as Record;
+};
+
+test("keeps the Caddy admin API bound to localhost", () => {
+ const config = compileCaddyConfig();
+
+ expect(config.admin.listen).toBe("localhost:2019");
+});
+
+test("creates a Caddy dashboard route to the Dokploy container", () => {
+ const fragment = createCaddyDashboardRouteFragment(
+ settings(),
+ "dashboard.example.com",
+ );
+ const config = compileCaddyConfig({ fragments: [fragment] });
+ const route = getServers(config).http.routes[0];
+
+ expect(fragment).toMatchObject({
+ id: "dashboard.dokploy",
+ source: "dokploy-dashboard",
+ });
+ expect(route.match[0]).toMatchObject({
+ host: ["dashboard.example.com"],
+ });
+ expect(route.match[0].path).toBeUndefined();
+ expect(route.handle.at(-1)).toMatchObject({
+ handler: "reverse_proxy",
+ upstreams: [{ dial: "dokploy:3000" }],
+ });
+});
+
+test("dashboard HTTPS route uses global ACME email when compiled", () => {
+ const fragment = createCaddyDashboardRouteFragment(
+ settings({ https: true, letsEncryptEmail: "ops@example.com" }),
+ "панель.example.com",
+ );
+ const config = compileCaddyConfig({
+ fragments: [fragment],
+ letsEncryptEmail: "ops@example.com",
+ });
+ const servers = getServers(config);
+
+ expect(fragment.routes[0]?.hosts).toEqual(["xn--80aksgi6f.example.com"]);
+ expect(servers.http.routes[0].handle[0]).toMatchObject({
+ handler: "static_response",
+ status_code: 308,
+ });
+ expect(servers.https.routes[0].handle.at(-1)).toMatchObject({
+ handler: "reverse_proxy",
+ });
+ expect((config.apps as any).tls.automation.policies[0].issuers[0].email).toBe(
+ "ops@example.com",
+ );
+});
diff --git a/apps/dokploy/__test__/caddy/domain-modal-ui-contract.test.ts b/apps/dokploy/__test__/caddy/domain-modal-ui-contract.test.ts
new file mode 100644
index 0000000000..15292a69f3
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/domain-modal-ui-contract.test.ts
@@ -0,0 +1,110 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, test } from "vitest";
+
+const readSource = (relativePath: string) =>
+ readFileSync(new URL(relativePath, import.meta.url), "utf8");
+
+const compact = (source: string) => source.replace(/\s+/g, " ");
+
+describe("Caddy domain modal UI contract", () => {
+ test("wires application domain tab and edit actions through AddDomain", () => {
+ const applicationPage = compact(
+ readSource(
+ "../../pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx",
+ ),
+ );
+ const showDomainsSource = readSource(
+ "../../components/dashboard/application/domains/show-domains.tsx",
+ );
+ const showDomains = compact(showDomainsSource);
+ const columns = compact(
+ readSource("../../components/dashboard/application/domains/columns.tsx"),
+ );
+
+ expect(applicationPage).toContain(
+ 'import { ShowDomains } from "@/components/dashboard/application/domains/show-domains";',
+ );
+ expect(applicationPage).toContain(' ',
+ );
+
+ expect(showDomains).toContain(
+ 'import { AddDomain } from "./handle-domain";',
+ );
+ expect(showDomains).toContain("");
+ expect(showDomainsSource).toMatch(
+ /",
+ );
+ });
+
+ test("resolves the active Caddy provider and scopes uploaded certificates", () => {
+ const source = readSource(
+ "../../components/dashboard/application/domains/handle-domain.tsx",
+ );
+ const normalized = compact(source);
+
+ expect(source).toMatch(
+ /api\.settings\.getActiveWebServerProvider\.useQuery\(\s*\{\s*serverId:\s*application\?\.serverId \|\| undefined\s*\},\s*\{\s*enabled:\s*!!application\s*\}/,
+ );
+ expect(normalized).toContain(
+ 'const isCaddyProvider = activeProvider === "caddy";',
+ );
+ expect(source).toMatch(
+ /api\.certificates\.all\.useQuery\(undefined,\s*\{\s*enabled:\s*isOpen && isCaddyProvider,\s*\}\)/,
+ );
+ expect(normalized).toContain(
+ "certificate.serverId === application.serverId",
+ );
+ expect(normalized).toContain(": !certificate.serverId");
+ });
+
+ test("renders Caddy certificate copy and submits uploaded certificate paths", () => {
+ const source = readSource(
+ "../../components/dashboard/application/domains/handle-domain.tsx",
+ );
+ const normalized = compact(source);
+
+ for (const expectedCopy of [
+ "This server uses Caddy",
+ "Caddy route fragments",
+ "Caddy can manage HTTPS",
+ "Let Caddy manage HTTPS automatically for this host.",
+ "Caddy-managed HTTPS (ACME)",
+ "Uploaded certificate",
+ "Uploaded Certificate",
+ "Select an uploaded certificate",
+ "Add an uploaded certificate for this server",
+ ]) {
+ expect(source).toContain(expectedCopy);
+ }
+
+ expect(normalized).toContain('name="customCertResolver"');
+ expect(normalized).toContain("key={certificate.certificateId}");
+ expect(normalized).toContain("value={certificate.certificatePath}");
+ });
+
+ test("keeps Traefik-only domain controls hidden when Caddy is active", () => {
+ const source = readSource(
+ "../../components/dashboard/application/domains/handle-domain.tsx",
+ );
+
+ expect(source).toMatch(
+ /\{!isCaddyProvider && \(\s* ({
+ assertCaddyDomainSupported: vi.fn(),
+ createComposeDomain: vi.fn(),
+ createDomain: vi.fn(),
+ findApplicationById: vi.fn(),
+ findComposeById: vi.fn(),
+ findDomainById: vi.fn(),
+ findDomainsByApplicationId: vi.fn(),
+ findDomainsByComposeId: vi.fn(),
+ findPreviewDeploymentById: vi.fn(),
+ findServerById: vi.fn(),
+ generateTraefikMeDomain: vi.fn(),
+ getWebServerSettings: vi.fn(),
+ manageWebServerDomain: vi.fn(),
+ refreshCaddyComposeRoutes: vi.fn(),
+ removeDomainById: vi.fn(),
+ removeWebServerDomain: vi.fn(),
+ resolveWebServerProvider: vi.fn(),
+ updateDomainById: vi.fn(),
+ validateDomain: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/services/permission", () => ({
+ checkServicePermissionAndAccess: vi.fn(),
+}));
+
+vi.mock("@/server/api/utils/audit", () => ({
+ audit: vi.fn(),
+}));
+
+import {
+ findApplicationById,
+ findComposeById,
+ findDomainById,
+ findDomainsByComposeId,
+ findPreviewDeploymentById,
+ manageWebServerDomain,
+ refreshCaddyComposeRoutes,
+ removeDomainById,
+ removeWebServerDomain,
+ resolveWebServerProvider,
+ updateDomainById,
+} from "@dokploy/server";
+import { domainRouter } from "@/server/api/routers/domain";
+
+const application = {
+ applicationId: "app-1",
+ appName: "my-app",
+ serverId: null,
+};
+
+const currentDomain = {
+ domainId: "domain-1",
+ applicationId: "app-1",
+ composeId: null,
+ previewDeploymentId: null,
+ domainType: "application",
+ host: "old.example.com",
+ path: "/",
+ internalPath: "/",
+ stripPath: false,
+ https: true,
+ certificateType: "letsencrypt",
+ customCertResolver: null,
+ customEntrypoint: null,
+ middlewares: null,
+ port: 3000,
+ serviceName: null,
+ uniqueConfigKey: 7,
+ createdAt: "",
+};
+
+const compose = {
+ composeId: "compose-1",
+ appName: "my-compose",
+ serverId: null,
+};
+
+const currentComposeDomain = {
+ ...currentDomain,
+ applicationId: null,
+ composeId: "compose-1",
+ domainType: "compose" as const,
+ serviceName: "web",
+};
+
+const siblingComposeDomain = {
+ ...currentComposeDomain,
+ domainId: "domain-2",
+ host: "sibling.example.com",
+ uniqueConfigKey: 8,
+};
+
+const previewDeployment = {
+ previewDeploymentId: "preview-1",
+ applicationId: "app-1",
+ appName: "my-app-pr-42",
+};
+
+const currentPreviewDomain = {
+ ...currentDomain,
+ applicationId: null,
+ previewDeploymentId: "preview-1",
+ domainType: "preview" as const,
+ host: "preview.example.com",
+};
+
+const updateInput = {
+ domainId: "domain-1",
+ domainType: "application" as const,
+ host: "new.example.com",
+ path: "/",
+ internalPath: "/",
+ stripPath: false,
+ https: true,
+ certificateType: "letsencrypt" as const,
+ customCertResolver: null,
+ customEntrypoint: null,
+ middlewares: null,
+ port: 3000,
+ serviceName: null,
+};
+
+const caller = domainRouter.createCaller({
+ session: {
+ userId: "user-1",
+ activeOrganizationId: "org-1",
+ },
+ user: {
+ id: "user-1",
+ role: "owner",
+ ownerId: "user-1",
+ email: "owner@example.com",
+ enableEnterpriseFeatures: true,
+ isValidEnterpriseLicense: true,
+ },
+ req: { headers: {} },
+ res: {},
+} as never);
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(findDomainById).mockResolvedValue(currentDomain as never);
+ vi.mocked(findApplicationById).mockResolvedValue(application as never);
+ vi.mocked(findComposeById).mockResolvedValue(compose as never);
+ vi.mocked(findPreviewDeploymentById).mockResolvedValue(
+ previewDeployment as never,
+ );
+ vi.mocked(findDomainsByComposeId).mockResolvedValue([
+ currentComposeDomain,
+ siblingComposeDomain,
+ ] as never);
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy");
+ vi.mocked(manageWebServerDomain).mockResolvedValue(undefined as never);
+ vi.mocked(removeWebServerDomain).mockResolvedValue(undefined as never);
+ vi.mocked(refreshCaddyComposeRoutes).mockResolvedValue(undefined as never);
+});
+
+test("restores the previous Caddy application route when domain update persistence fails", async () => {
+ vi.mocked(updateDomainById).mockRejectedValueOnce(
+ new Error("db update failed") as never,
+ );
+
+ await expect(caller.update(updateInput)).rejects.toThrow("db update failed");
+
+ expect(manageWebServerDomain).toHaveBeenNthCalledWith(
+ 1,
+ application,
+ expect.objectContaining({
+ domainId: "domain-1",
+ host: "new.example.com",
+ }),
+ );
+ expect(manageWebServerDomain).toHaveBeenNthCalledWith(
+ 2,
+ application,
+ currentDomain,
+ );
+});
+
+test("preserves application domain rows when Caddy route removal fails before delete", async () => {
+ vi.mocked(removeWebServerDomain).mockRejectedValueOnce(
+ new Error("caddy route removal failed") as never,
+ );
+
+ await expect(caller.delete({ domainId: "domain-1" })).rejects.toThrow(
+ "caddy route removal failed",
+ );
+
+ expect(removeWebServerDomain).toHaveBeenCalledWith(application, 7);
+ expect(removeDomainById).not.toHaveBeenCalled();
+ expect(manageWebServerDomain).not.toHaveBeenCalled();
+});
+
+test("restores the removed Caddy application route when domain delete persistence fails", async () => {
+ vi.mocked(removeDomainById).mockRejectedValueOnce(
+ new Error("db delete failed") as never,
+ );
+
+ await expect(caller.delete({ domainId: "domain-1" })).rejects.toThrow(
+ "db delete failed",
+ );
+
+ expect(removeWebServerDomain).toHaveBeenCalledWith(application, 7);
+ expect(manageWebServerDomain).toHaveBeenCalledWith(
+ application,
+ currentDomain,
+ );
+});
+
+test("preserves preview domain rows when Caddy route removal fails before delete", async () => {
+ vi.mocked(findDomainById).mockResolvedValueOnce(
+ currentPreviewDomain as never,
+ );
+ vi.mocked(removeWebServerDomain).mockRejectedValueOnce(
+ new Error("preview caddy route removal failed") as never,
+ );
+
+ await expect(caller.delete({ domainId: "domain-1" })).rejects.toThrow(
+ "preview caddy route removal failed",
+ );
+
+ expect(findPreviewDeploymentById).toHaveBeenCalledWith("preview-1");
+ expect(removeWebServerDomain).toHaveBeenCalledWith(
+ expect.objectContaining({ appName: "my-app-pr-42" }),
+ 7,
+ );
+ expect(removeDomainById).not.toHaveBeenCalled();
+});
+
+test("restores previous compose domain fields when Caddy route refresh fails after update", async () => {
+ const updatedDomain = {
+ ...currentComposeDomain,
+ host: "new.example.com",
+ };
+ vi.mocked(findDomainById).mockResolvedValueOnce(
+ currentComposeDomain as never,
+ );
+ vi.mocked(updateDomainById)
+ .mockResolvedValueOnce(updatedDomain as never)
+ .mockResolvedValueOnce(currentComposeDomain as never);
+ vi.mocked(refreshCaddyComposeRoutes)
+ .mockRejectedValueOnce(new Error("caddy refresh failed") as never)
+ .mockResolvedValueOnce(undefined as never);
+
+ await expect(
+ caller.update({
+ ...updateInput,
+ domainType: "compose",
+ serviceName: "web",
+ }),
+ ).rejects.toThrow("caddy refresh failed");
+
+ expect(updateDomainById).toHaveBeenNthCalledWith(
+ 1,
+ "domain-1",
+ expect.objectContaining({
+ host: "new.example.com",
+ domainType: "compose",
+ serviceName: "web",
+ }),
+ );
+ expect(updateDomainById).toHaveBeenNthCalledWith(
+ 2,
+ "domain-1",
+ expect.objectContaining({
+ host: "old.example.com",
+ domainType: "compose",
+ serviceName: "web",
+ }),
+ );
+ expect(refreshCaddyComposeRoutes).toHaveBeenNthCalledWith(
+ 2,
+ compose,
+ undefined,
+ "caddy",
+ "org-1",
+ );
+});
+
+test("preserves compose domain rows when Caddy route refresh fails before delete", async () => {
+ vi.mocked(findDomainById).mockResolvedValueOnce(
+ currentComposeDomain as never,
+ );
+ vi.mocked(refreshCaddyComposeRoutes)
+ .mockRejectedValueOnce(new Error("caddy refresh failed") as never)
+ .mockResolvedValueOnce(undefined as never);
+
+ await expect(caller.delete({ domainId: "domain-1" })).rejects.toThrow(
+ "caddy refresh failed",
+ );
+
+ expect(refreshCaddyComposeRoutes).toHaveBeenNthCalledWith(
+ 1,
+ compose,
+ [siblingComposeDomain],
+ "caddy",
+ "org-1",
+ );
+ expect(refreshCaddyComposeRoutes).toHaveBeenNthCalledWith(
+ 2,
+ compose,
+ undefined,
+ "caddy",
+ "org-1",
+ );
+ expect(removeDomainById).not.toHaveBeenCalled();
+});
+
+test("restores all compose routes when compose domain delete persistence fails", async () => {
+ vi.mocked(findDomainById).mockResolvedValueOnce(
+ currentComposeDomain as never,
+ );
+ vi.mocked(removeDomainById).mockRejectedValueOnce(
+ new Error("db delete failed") as never,
+ );
+
+ await expect(caller.delete({ domainId: "domain-1" })).rejects.toThrow(
+ "db delete failed",
+ );
+
+ expect(refreshCaddyComposeRoutes).toHaveBeenNthCalledWith(
+ 1,
+ compose,
+ [siblingComposeDomain],
+ "caddy",
+ "org-1",
+ );
+ expect(refreshCaddyComposeRoutes).toHaveBeenNthCalledWith(
+ 2,
+ compose,
+ undefined,
+ "caddy",
+ "org-1",
+ );
+});
diff --git a/apps/dokploy/__test__/caddy/domain-validation.test.ts b/apps/dokploy/__test__/caddy/domain-validation.test.ts
new file mode 100644
index 0000000000..1d314014c0
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/domain-validation.test.ts
@@ -0,0 +1,169 @@
+import {
+ apiCreateDomain,
+ apiUpdateDomain,
+} from "@dokploy/server/db/schema/domain";
+import { domain, domainCompose } from "@dokploy/server/db/validations/domain";
+import { describe, expect, test } from "vitest";
+
+describe("domain validation", () => {
+ test("does not require a custom certificate resolver when HTTPS is disabled", () => {
+ expect(
+ domain.safeParse({
+ host: "example.com",
+ https: false,
+ certificateType: "none",
+ }).success,
+ ).toBe(true);
+
+ expect(
+ domainCompose.safeParse({
+ host: "example.com",
+ https: false,
+ certificateType: "none",
+ serviceName: "web",
+ }).success,
+ ).toBe(true);
+ });
+
+ test("requires a custom certificate resolver for HTTPS custom certificates", () => {
+ const result = domain.safeParse({
+ host: "example.com",
+ https: true,
+ certificateType: "custom",
+ });
+
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ expect(result.error.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ path: ["customCertResolver"],
+ message: "Required when certificate type is custom",
+ }),
+ ]),
+ );
+ }
+ });
+
+ const baseApplicationDomain = {
+ applicationId: "app-1",
+ domainType: "application" as const,
+ host: "app.example.com",
+ path: "/",
+ internalPath: "/",
+ stripPath: false,
+ port: 3000,
+ middlewares: [] as string[],
+ };
+
+ test("accepts application domain create payloads used by the Caddy modal", () => {
+ expect(
+ apiCreateDomain.parse({
+ ...baseApplicationDomain,
+ https: true,
+ certificateType: "letsencrypt",
+ }),
+ ).toMatchObject({
+ applicationId: "app-1",
+ domainType: "application",
+ https: true,
+ certificateType: "letsencrypt",
+ });
+
+ expect(
+ apiCreateDomain.parse({
+ ...baseApplicationDomain,
+ https: true,
+ certificateType: "custom",
+ customCertResolver: "local-uploaded-cert-path",
+ }),
+ ).toMatchObject({
+ customCertResolver: "local-uploaded-cert-path",
+ certificateType: "custom",
+ });
+
+ expect(
+ apiCreateDomain.parse({
+ ...baseApplicationDomain,
+ https: false,
+ certificateType: "none",
+ }),
+ ).toMatchObject({
+ https: false,
+ certificateType: "none",
+ });
+ });
+
+ test("accepts application domain update payloads used by the Caddy modal", () => {
+ const baseUpdate = {
+ ...baseApplicationDomain,
+ domainId: "domain-1",
+ };
+
+ expect(
+ apiUpdateDomain.parse({
+ ...baseUpdate,
+ https: true,
+ certificateType: "letsencrypt",
+ }),
+ ).toMatchObject({
+ domainId: "domain-1",
+ domainType: "application",
+ https: true,
+ certificateType: "letsencrypt",
+ });
+
+ expect(
+ apiUpdateDomain.parse({
+ ...baseUpdate,
+ https: true,
+ certificateType: "custom",
+ customCertResolver: "local-uploaded-cert-path",
+ }),
+ ).toMatchObject({
+ domainId: "domain-1",
+ customCertResolver: "local-uploaded-cert-path",
+ certificateType: "custom",
+ });
+
+ expect(
+ apiUpdateDomain.parse({
+ ...baseUpdate,
+ https: false,
+ certificateType: "none",
+ }),
+ ).toMatchObject({
+ domainId: "domain-1",
+ https: false,
+ certificateType: "none",
+ });
+ });
+
+ test("rejects Caddy custom certificate payloads without an uploaded certificate reference", () => {
+ const createResult = apiCreateDomain.safeParse({
+ ...baseApplicationDomain,
+ https: true,
+ certificateType: "custom",
+ });
+ const updateResult = apiUpdateDomain.safeParse({
+ ...baseApplicationDomain,
+ domainId: "domain-1",
+ https: true,
+ certificateType: "custom",
+ });
+
+ for (const result of [createResult, updateResult]) {
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ expect(result.error.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ path: ["customCertResolver"],
+ message: "Required when certificate type is custom",
+ }),
+ ]),
+ );
+ }
+ }
+ });
+});
diff --git a/apps/dokploy/__test__/caddy/migration/apply-rollback.test.ts b/apps/dokploy/__test__/caddy/migration/apply-rollback.test.ts
new file mode 100644
index 0000000000..987560b826
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/migration/apply-rollback.test.ts
@@ -0,0 +1,701 @@
+import { fs, vol } from "memfs";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+vi.mock("node:fs", () => ({
+ ...fs,
+ default: fs,
+}));
+
+vi.mock("@dokploy/server/services/settings", () => ({
+ getDockerResourceSnapshot: vi.fn(),
+ readEnvironmentVariables: vi.fn(),
+ readPorts: vi.fn(),
+ stopDockerResource: vi.fn(),
+ startDockerResourceFromSnapshot: vi.fn(),
+ ensureTraefikRunningFromSnapshot: vi.fn(),
+ waitForDockerResourceRunning: vi.fn(),
+ writeCaddySetup: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/services/web-server-settings", () => ({
+ getCaddyCompileSettings: vi.fn(),
+ resolveWebServerProvider: vi.fn(),
+ updateLocalWebServerProvider: vi.fn(),
+ updateRemoteWebServerProvider: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/utils/caddy/config", () => ({
+ CADDY_METRICS_PORT: 2020,
+ reloadCaddyAfterValidation: vi.fn(),
+ validateCaddyConfigFileWithImage: vi.fn(),
+ validateCaddyConfigWithContainer: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/utils/caddy/migration/upstream-preflight", () => ({
+ runCaddyMigrationUpstreamPreflight: vi.fn(),
+}));
+
+import { paths } from "@dokploy/server/constants";
+import * as settingsService from "@dokploy/server/services/settings";
+import * as providerService from "@dokploy/server/services/web-server-settings";
+import * as caddyConfig from "@dokploy/server/utils/caddy/config";
+import { applyCaddyMigration } from "@dokploy/server/utils/caddy/migration/apply";
+import { getCaddyMigrationArtifactPaths } from "@dokploy/server/utils/caddy/migration/files";
+import { rollbackCaddyMigration } from "@dokploy/server/utils/caddy/migration/rollback";
+import type { CaddyMigrationReport } from "@dokploy/server/utils/caddy/migration/types";
+import * as upstreamPreflight from "@dokploy/server/utils/caddy/migration/upstream-preflight";
+
+const createReport = (migrationId: string): CaddyMigrationReport => {
+ const artifactPaths = getCaddyMigrationArtifactPaths(migrationId);
+ return {
+ migrationId,
+ serverId: null,
+ createdAt: "2026-05-22T00:00:00.000Z",
+ updatedAt: "2026-05-22T00:00:00.000Z",
+ status: "prepared",
+ sourceProvider: "traefik",
+ targetProvider: "caddy",
+ artifactPaths,
+ inputs: {
+ traefikStaticConfigPath: `${paths().MAIN_TRAEFIK_PATH}/traefik.yml`,
+ traefikStaticConfigFound: true,
+ dynamicFiles: [],
+ dbApplicationDomains: 0,
+ dbComposeDomains: 0,
+ composeFilesScanned: [],
+ composeFilesSkipped: [],
+ },
+ summary: {
+ fragments: 1,
+ routes: 1,
+ warnings: 0,
+ blockingWarnings: 0,
+ },
+ validation: { status: "passed", message: "ok" },
+ compileSettings: {
+ letsEncryptEmail: null,
+ trustedProxies: null,
+ },
+ warnings: [],
+ events: [],
+ };
+};
+
+const seedMigration = (migrationId: string) => {
+ const report = createReport(migrationId);
+ const currentPaths = paths();
+ vol.mkdirSync(report.artifactPaths.fragmentsDir, { recursive: true });
+ vol.writeFileSync(
+ `${report.artifactPaths.fragmentsDir}/app.json`,
+ JSON.stringify({ version: 1, id: "app", source: "manual", routes: [] }),
+ );
+ vol.writeFileSync(report.artifactPaths.caddyJson, '{"apps":{"http":{}}}\n');
+ vol.writeFileSync(
+ report.artifactPaths.reportJson,
+ `${JSON.stringify(report, null, 2)}\n`,
+ );
+ vol.mkdirSync(currentPaths.MAIN_TRAEFIK_PATH, { recursive: true });
+ vol.writeFileSync(
+ `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`,
+ "entryPoints: {}\n",
+ );
+ vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true });
+ vol.writeFileSync(
+ `${currentPaths.DYNAMIC_TRAEFIK_PATH}/app.yml`,
+ "http: {}\n",
+ );
+ vol.mkdirSync(currentPaths.CADDY_FRAGMENTS_PATH, { recursive: true });
+ vol.writeFileSync(`${currentPaths.CADDY_FRAGMENTS_PATH}/old.json`, "{}\n");
+ vol.writeFileSync(currentPaths.CADDY_CONFIG_PATH, '{"old":true}\n');
+ return report;
+};
+
+describe("applyCaddyMigration", () => {
+ beforeEach(() => {
+ vol.reset();
+ vi.clearAllMocks();
+ vi.mocked(providerService.resolveWebServerProvider).mockResolvedValue(
+ "traefik",
+ );
+ vi.mocked(providerService.getCaddyCompileSettings).mockResolvedValue({
+ trustedProxies: null,
+ });
+ vi.mocked(settingsService.getDockerResourceSnapshot).mockImplementation(
+ async (resourceName: string) => ({
+ resourceName,
+ resourceType:
+ resourceName === "dokploy-traefik" ? "standalone" : "unknown",
+ running: resourceName === "dokploy-traefik",
+ }),
+ );
+ vi.mocked(settingsService.readEnvironmentVariables).mockResolvedValue("");
+ vi.mocked(settingsService.readPorts).mockResolvedValue([]);
+ vi.mocked(settingsService.waitForDockerResourceRunning).mockResolvedValue({
+ resourceName: "dokploy-caddy",
+ resourceType: "standalone",
+ running: true,
+ });
+ vi.mocked(
+ settingsService.ensureTraefikRunningFromSnapshot,
+ ).mockResolvedValue(undefined);
+ vi.mocked(caddyConfig.reloadCaddyAfterValidation).mockResolvedValue(
+ {} as any,
+ );
+ vi.mocked(caddyConfig.validateCaddyConfigFileWithImage).mockResolvedValue(
+ {} as any,
+ );
+ vi.mocked(caddyConfig.validateCaddyConfigWithContainer).mockResolvedValue(
+ {} as any,
+ );
+ vi.mocked(
+ upstreamPreflight.runCaddyMigrationUpstreamPreflight,
+ ).mockResolvedValue({
+ status: "passed",
+ checkedAt: "2026-05-22T00:00:00.000Z",
+ network: "dokploy-network",
+ probeImage: "busybox:1.36",
+ checks: [],
+ });
+ });
+
+ test("rejects a concurrent migration operation while a lock exists", async () => {
+ const report = seedMigration("caddy-apply-locked");
+ vol.mkdirSync(`${report.artifactPaths.root}/.operation.lock`);
+
+ await expect(
+ applyCaddyMigration({ migrationId: report.migrationId }),
+ ).rejects.toThrow("already has an apply or rollback operation in progress");
+ expect(
+ upstreamPreflight.runCaddyMigrationUpstreamPreflight,
+ ).not.toHaveBeenCalled();
+ });
+
+ test("writes approved artifacts, starts Caddy, validates, then updates provider", async () => {
+ const report = seedMigration("caddy-apply-success");
+ vi.mocked(settingsService.readPorts).mockImplementation(
+ async (resourceName: string) =>
+ resourceName === "dokploy-traefik"
+ ? [
+ { targetPort: 8080, publishedPort: 8080, protocol: "tcp" },
+ { targetPort: 8082, publishedPort: 8082, protocol: "tcp" },
+ { targetPort: 2019, publishedPort: 2019, protocol: "tcp" },
+ { targetPort: 9000, publishedPort: 9000, protocol: "tcp" },
+ ]
+ : [],
+ );
+
+ const applied = await applyCaddyMigration({
+ migrationId: report.migrationId,
+ });
+
+ expect(applied.status).toBe("applied");
+ expect(settingsService.stopDockerResource).toHaveBeenCalledWith(
+ "dokploy-traefik",
+ undefined,
+ );
+ expect(
+ vi.mocked(upstreamPreflight.runCaddyMigrationUpstreamPreflight).mock
+ .invocationCallOrder[0],
+ ).toBeLessThan(
+ vi.mocked(settingsService.stopDockerResource).mock
+ .invocationCallOrder[0] ?? 0,
+ );
+ expect(caddyConfig.validateCaddyConfigFileWithImage).toHaveBeenCalledWith(
+ report.artifactPaths.caddyJson,
+ undefined,
+ );
+ expect(
+ vi.mocked(caddyConfig.validateCaddyConfigFileWithImage).mock
+ .invocationCallOrder[0],
+ ).toBeLessThan(
+ vi.mocked(settingsService.stopDockerResource).mock
+ .invocationCallOrder[0] ?? 0,
+ );
+ expect(settingsService.writeCaddySetup).toHaveBeenCalledWith(
+ expect.objectContaining({
+ additionalPorts: [
+ { targetPort: 9000, publishedPort: 9000, protocol: "tcp" },
+ ],
+ }),
+ );
+ expect(caddyConfig.validateCaddyConfigWithContainer).toHaveBeenCalled();
+ expect(providerService.updateLocalWebServerProvider).toHaveBeenCalledWith(
+ "caddy",
+ );
+ expect(
+ vi.mocked(caddyConfig.validateCaddyConfigWithContainer).mock
+ .invocationCallOrder[0],
+ ).toBeLessThan(
+ vi.mocked(providerService.updateLocalWebServerProvider).mock
+ .invocationCallOrder[0] ?? 0,
+ );
+ expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe(
+ '{"apps":{"http":{}}}\n',
+ );
+ expect(vol.existsSync(`${paths().CADDY_FRAGMENTS_PATH}/app.json`)).toBe(
+ true,
+ );
+ });
+
+ test("applies and rolls back approved Caddy custom certificate artifacts", async () => {
+ const report = seedMigration("caddy-custom-cert-apply-rollback");
+ const certificatePath = `${paths().CERTIFICATES_PATH}/certificate-uploaded`;
+ const loadFiles = [
+ {
+ certificate: `${certificatePath}/chain.crt`,
+ key: `${certificatePath}/privkey.key`,
+ },
+ ];
+ vol.writeFileSync(
+ report.artifactPaths.caddyJson,
+ `${JSON.stringify(
+ {
+ apps: {
+ http: {},
+ tls: {
+ certificates: {
+ load_files: loadFiles,
+ },
+ },
+ },
+ },
+ null,
+ 2,
+ )}\n`,
+ );
+ vi.mocked(
+ caddyConfig.validateCaddyConfigFileWithImage,
+ ).mockImplementationOnce(async (filePath: string) => {
+ const validatedConfig = JSON.parse(
+ vol.readFileSync(filePath, "utf8") as string,
+ );
+ expect(validatedConfig.apps.tls.certificates.load_files).toEqual(
+ loadFiles,
+ );
+ return {} as any;
+ });
+
+ const applied = await applyCaddyMigration({
+ migrationId: report.migrationId,
+ });
+
+ expect(applied.status).toBe("applied");
+ expect(caddyConfig.validateCaddyConfigFileWithImage).toHaveBeenCalledWith(
+ report.artifactPaths.caddyJson,
+ undefined,
+ );
+ const activeConfig = JSON.parse(
+ vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8") as string,
+ );
+ expect(activeConfig.apps.tls.certificates.load_files).toEqual(loadFiles);
+
+ const rolledBack = await rollbackCaddyMigration({
+ migrationId: report.migrationId,
+ });
+
+ expect(rolledBack.status).toBe("rolled_back");
+ expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe(
+ '{"old":true}\n',
+ );
+ expect(vol.existsSync(`${paths().CADDY_FRAGMENTS_PATH}/old.json`)).toBe(
+ true,
+ );
+ expect(providerService.updateLocalWebServerProvider).toHaveBeenCalledWith(
+ "traefik",
+ );
+ });
+
+ test("rejects apply when Caddy compile settings changed after prepare", async () => {
+ const report = seedMigration("caddy-apply-stale-settings");
+ vi.mocked(providerService.getCaddyCompileSettings).mockResolvedValueOnce({
+ trustedProxies: {
+ source: "cloudflare",
+ },
+ });
+
+ await expect(
+ applyCaddyMigration({ migrationId: report.migrationId }),
+ ).rejects.toThrow("Caddy compile settings changed after prepare");
+
+ expect(
+ upstreamPreflight.runCaddyMigrationUpstreamPreflight,
+ ).not.toHaveBeenCalled();
+ expect(settingsService.stopDockerResource).not.toHaveBeenCalled();
+ expect(settingsService.writeCaddySetup).not.toHaveBeenCalled();
+ const failedReport = JSON.parse(
+ vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string,
+ ) as CaddyMigrationReport;
+ expect(failedReport.status).toBe("failed");
+ expect(failedReport.warnings).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: "apply-failed",
+ message: expect.stringContaining("Caddy compile settings changed"),
+ }),
+ ]),
+ );
+ });
+
+ test("rejects apply when Caddy request-log compile settings changed after prepare", async () => {
+ const report = seedMigration("caddy-apply-stale-request-logs");
+ vi.mocked(providerService.getCaddyCompileSettings).mockResolvedValueOnce({
+ trustedProxies: null,
+ accessLogs: { enabled: true },
+ });
+
+ await expect(
+ applyCaddyMigration({ migrationId: report.migrationId }),
+ ).rejects.toThrow("Caddy compile settings changed after prepare");
+
+ expect(
+ upstreamPreflight.runCaddyMigrationUpstreamPreflight,
+ ).not.toHaveBeenCalled();
+ expect(settingsService.stopDockerResource).not.toHaveBeenCalled();
+ expect(settingsService.writeCaddySetup).not.toHaveBeenCalled();
+ const failedReport = JSON.parse(
+ vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string,
+ ) as CaddyMigrationReport;
+ expect(failedReport.status).toBe("failed");
+ expect(failedReport.warnings).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: "apply-failed",
+ message: expect.stringContaining("Caddy compile settings changed"),
+ }),
+ ]),
+ );
+ });
+
+ test("rewrites the mounted Caddy config file in place after setup", async () => {
+ const report = seedMigration("caddy-apply-preserves-config-inode");
+ let mountedConfigFd: number | undefined;
+ vi.mocked(settingsService.writeCaddySetup).mockImplementationOnce(
+ async () => {
+ vol.writeFileSync(
+ paths().CADDY_CONFIG_PATH,
+ '{"generatedBySetup":true}\n',
+ );
+ mountedConfigFd = fs.openSync(paths().CADDY_CONFIG_PATH, "r");
+ },
+ );
+
+ await applyCaddyMigration({
+ migrationId: report.migrationId,
+ });
+
+ expect(mountedConfigFd).toBeDefined();
+ const mountedContent = Buffer.alloc(1024);
+ const bytesRead = fs.readSync(
+ mountedConfigFd as number,
+ mountedContent,
+ 0,
+ mountedContent.length,
+ 0,
+ );
+ fs.closeSync(mountedConfigFd as number);
+ expect(mountedContent.toString("utf8", 0, bytesRead)).toBe(
+ '{"apps":{"http":{}}}\n',
+ );
+ expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe(
+ '{"apps":{"http":{}}}\n',
+ );
+ });
+
+ test("runtime upstream preflight failure stops apply before Traefik is stopped", async () => {
+ const report = seedMigration("caddy-preflight-fails");
+ vi.mocked(
+ upstreamPreflight.runCaddyMigrationUpstreamPreflight,
+ ).mockResolvedValueOnce({
+ status: "failed",
+ checkedAt: "2026-05-22T00:00:00.000Z",
+ network: "dokploy-network",
+ probeImage: "busybox:1.36",
+ checks: [
+ {
+ dial: "missing:3000",
+ host: "missing",
+ port: 3000,
+ network: "dokploy-network",
+ status: "failed",
+ reason: "DNS resolution failed",
+ routes: [],
+ },
+ ],
+ });
+
+ await expect(
+ applyCaddyMigration({ migrationId: report.migrationId }),
+ ).rejects.toThrow("Runtime upstream preflight failed");
+
+ expect(settingsService.stopDockerResource).not.toHaveBeenCalledWith(
+ "dokploy-traefik",
+ undefined,
+ );
+ expect(settingsService.writeCaddySetup).not.toHaveBeenCalled();
+ expect(
+ settingsService.ensureTraefikRunningFromSnapshot,
+ ).not.toHaveBeenCalled();
+ const finalReport = JSON.parse(
+ vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string,
+ ) as CaddyMigrationReport;
+ expect(finalReport.status).toBe("failed");
+ expect(finalReport.runtimePreflight?.status).toBe("failed");
+ });
+
+ test("rolls back to Traefik and keeps provider Traefik when Caddy setup fails", async () => {
+ const report = seedMigration("caddy-apply-fails");
+ vi.mocked(settingsService.writeCaddySetup).mockRejectedValueOnce(
+ new Error("caddy failed"),
+ );
+
+ await expect(
+ applyCaddyMigration({ migrationId: report.migrationId }),
+ ).rejects.toThrow("caddy failed");
+
+ expect(
+ providerService.updateLocalWebServerProvider,
+ ).not.toHaveBeenCalledWith("caddy");
+ expect(providerService.updateLocalWebServerProvider).toHaveBeenCalledWith(
+ "traefik",
+ );
+ expect(settingsService.stopDockerResource).toHaveBeenCalledWith(
+ "dokploy-caddy",
+ undefined,
+ );
+ expect(
+ settingsService.ensureTraefikRunningFromSnapshot,
+ ).toHaveBeenCalledWith(
+ expect.objectContaining({ resourceName: "dokploy-traefik" }),
+ undefined,
+ );
+ const finalReport = JSON.parse(
+ vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string,
+ ) as CaddyMigrationReport;
+ expect(finalReport.status).toBe("rolled_back");
+ });
+
+ test("uses restore-only unredacted resource snapshot during rollback", async () => {
+ const report = seedMigration("caddy-restore-snapshot");
+ vi.mocked(settingsService.getDockerResourceSnapshot).mockImplementation(
+ async (resourceName: string) => ({
+ resourceName,
+ resourceType:
+ resourceName === "dokploy-traefik" ? "standalone" : "unknown",
+ running: resourceName === "dokploy-traefik",
+ env: resourceName === "dokploy-traefik" ? "SECRET=value" : undefined,
+ image:
+ resourceName === "dokploy-traefik"
+ ? "private.registry.example/traefik:latest"
+ : undefined,
+ binds:
+ resourceName === "dokploy-traefik"
+ ? ["/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json"]
+ : undefined,
+ labels:
+ resourceName === "dokploy-traefik"
+ ? { "secret.label": "private" }
+ : undefined,
+ }),
+ );
+ vi.mocked(settingsService.writeCaddySetup).mockRejectedValueOnce(
+ new Error("caddy failed"),
+ );
+
+ await expect(
+ applyCaddyMigration({ migrationId: report.migrationId }),
+ ).rejects.toThrow("caddy failed");
+
+ const finalReport = JSON.parse(
+ vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string,
+ ) as CaddyMigrationReport;
+ expect(finalReport.backup?.traefikResource?.env).toBeUndefined();
+ expect(finalReport.backup?.traefikResource?.binds).toBeUndefined();
+ expect(finalReport.backup?.traefikResource?.labels).toBeUndefined();
+ expect(finalReport.backup?.traefikResource?.image).toBeUndefined();
+ expect(finalReport.backup?.traefikResource).toMatchObject({
+ resourceName: "dokploy-traefik",
+ resourceType: "standalone",
+ running: true,
+ });
+ expect(finalReport.backup?.restoreSnapshotPath).toBeTruthy();
+ const restoreSnapshots = JSON.parse(
+ vol.readFileSync(
+ finalReport.backup?.restoreSnapshotPath ?? "",
+ "utf8",
+ ) as string,
+ ) as { traefikResource: { env?: string; binds?: string[] } };
+ expect(restoreSnapshots.traefikResource.env).toBe("SECRET=value");
+ expect(restoreSnapshots.traefikResource.binds).toEqual([
+ "/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json",
+ ]);
+ expect(
+ settingsService.ensureTraefikRunningFromSnapshot,
+ ).toHaveBeenCalledWith(
+ expect.objectContaining({
+ env: "SECRET=value",
+ binds: ["/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json"],
+ }),
+ undefined,
+ );
+ });
+
+ test("rollback failure leaves provider unchanged and writes failed report", async () => {
+ const report = seedMigration("caddy-rollback-traefik-fails");
+ vi.mocked(settingsService.writeCaddySetup).mockRejectedValueOnce(
+ new Error("caddy failed"),
+ );
+ vi.mocked(
+ settingsService.ensureTraefikRunningFromSnapshot,
+ ).mockRejectedValueOnce(new Error("traefik missing"));
+
+ await expect(
+ applyCaddyMigration({ migrationId: report.migrationId }),
+ ).rejects.toThrow("caddy failed");
+
+ expect(
+ providerService.updateLocalWebServerProvider,
+ ).not.toHaveBeenCalledWith("traefik");
+ const finalReport = JSON.parse(
+ vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string,
+ ) as CaddyMigrationReport;
+ expect(finalReport.status).toBe("failed");
+ expect(finalReport.warnings).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: "rollback-failed",
+ message: "traefik missing",
+ blocking: true,
+ }),
+ ]),
+ );
+ });
+
+ test("restores backed-up Caddy files when post-start validation fails", async () => {
+ const report = seedMigration("caddy-validation-fails");
+ vi.mocked(
+ caddyConfig.validateCaddyConfigWithContainer,
+ ).mockRejectedValueOnce(new Error("validation failed"));
+
+ await expect(
+ applyCaddyMigration({ migrationId: report.migrationId }),
+ ).rejects.toThrow("validation failed");
+
+ expect(
+ providerService.updateLocalWebServerProvider,
+ ).not.toHaveBeenCalledWith("caddy");
+ expect(providerService.updateLocalWebServerProvider).toHaveBeenCalledWith(
+ "traefik",
+ );
+ expect(vol.readFileSync(paths().CADDY_CONFIG_PATH, "utf8")).toBe(
+ '{"old":true}\n',
+ );
+ expect(vol.existsSync(`${paths().CADDY_FRAGMENTS_PATH}/old.json`)).toBe(
+ true,
+ );
+ expect(vol.existsSync(`${paths().CADDY_FRAGMENTS_PATH}/app.json`)).toBe(
+ false,
+ );
+
+ const finalReport = JSON.parse(
+ vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string,
+ ) as CaddyMigrationReport;
+ expect(finalReport.status).toBe("rolled_back");
+ });
+
+ test("fails closed when an expected rollback backup path is missing", async () => {
+ const report = seedMigration("caddy-missing-traefik-backup");
+ const traefikConfigPath = `${paths().MAIN_TRAEFIK_PATH}/traefik.yml`;
+ vol.writeFileSync(
+ report.artifactPaths.reportJson,
+ `${JSON.stringify(
+ {
+ ...report,
+ status: "applied",
+ backup: {
+ createdAt: "2026-05-22T00:00:00.000Z",
+ traefikResource: {
+ resourceName: "dokploy-traefik",
+ resourceType: "standalone",
+ running: true,
+ },
+ files: [
+ {
+ label: "traefik-static",
+ source: traefikConfigPath,
+ backupPath: `${report.artifactPaths.backupsDir}/missing-traefik.yml`,
+ existed: true,
+ },
+ ],
+ },
+ },
+ null,
+ 2,
+ )}\n`,
+ );
+
+ await expect(
+ rollbackCaddyMigration({ migrationId: report.migrationId }),
+ ).rejects.toThrow("backup path is missing");
+
+ expect(vol.readFileSync(traefikConfigPath, "utf8")).toBe(
+ "entryPoints: {}\n",
+ );
+ expect(
+ settingsService.ensureTraefikRunningFromSnapshot,
+ ).not.toHaveBeenCalled();
+ expect(
+ providerService.updateLocalWebServerProvider,
+ ).not.toHaveBeenCalledWith("traefik");
+
+ const finalReport = JSON.parse(
+ vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string,
+ ) as CaddyMigrationReport;
+ expect(finalReport.status).toBe("failed");
+ expect(finalReport.warnings).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: "rollback-failed",
+ blocking: true,
+ }),
+ ]),
+ );
+ });
+
+ test("rolls back older reports that have backup metadata without file entries", async () => {
+ const report = seedMigration("caddy-old-backup-report");
+ vol.writeFileSync(
+ report.artifactPaths.reportJson,
+ `${JSON.stringify(
+ {
+ ...report,
+ status: "applied",
+ backup: {
+ createdAt: "2026-05-22T00:00:00.000Z",
+ traefikResource: {
+ resourceName: "dokploy-traefik",
+ resourceType: "standalone",
+ running: true,
+ },
+ },
+ },
+ null,
+ 2,
+ )}\n`,
+ );
+
+ const rolledBack = await rollbackCaddyMigration({
+ migrationId: report.migrationId,
+ });
+
+ expect(rolledBack.status).toBe("rolled_back");
+ expect(
+ settingsService.ensureTraefikRunningFromSnapshot,
+ ).toHaveBeenCalledWith(
+ expect.objectContaining({ resourceName: "dokploy-traefik" }),
+ undefined,
+ );
+ expect(providerService.updateLocalWebServerProvider).toHaveBeenCalledWith(
+ "traefik",
+ );
+ });
+});
diff --git a/apps/dokploy/__test__/caddy/migration/caddy-migration-rollback-cli.test.ts b/apps/dokploy/__test__/caddy/migration/caddy-migration-rollback-cli.test.ts
new file mode 100644
index 0000000000..49a301ab44
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/migration/caddy-migration-rollback-cli.test.ts
@@ -0,0 +1,95 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+const rollbackCaddyMigrationMock = vi.hoisted(() => vi.fn());
+
+vi.mock("@dokploy/server", () => ({
+ rollbackCaddyMigration: rollbackCaddyMigrationMock,
+}));
+
+import {
+ parseCaddyRollbackArgs,
+ runCaddyMigrationRollbackCli,
+} from "../../../scripts/caddy-migration-rollback";
+
+const createIo = () => ({
+ stdout: { write: vi.fn() },
+ stderr: { write: vi.fn() },
+});
+
+describe("caddy migration rollback CLI", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test("requires a migration id", async () => {
+ expect(() => parseCaddyRollbackArgs([])).toThrow("Missing --migration-id");
+
+ const io = createIo();
+ const code = await runCaddyMigrationRollbackCli([], io);
+
+ expect(code).toBe(1);
+ expect(rollbackCaddyMigrationMock).not.toHaveBeenCalled();
+ expect(io.stderr.write).toHaveBeenCalledWith(
+ expect.stringContaining("Missing --migration-id"),
+ );
+ });
+
+ test("prints help and exits zero without invoking rollback", async () => {
+ const io = createIo();
+
+ const code = await runCaddyMigrationRollbackCli(["--help"], io);
+
+ expect(code).toBe(0);
+ expect(rollbackCaddyMigrationMock).not.toHaveBeenCalled();
+ expect(io.stdout.write).toHaveBeenCalledWith(
+ expect.stringContaining("Usage: caddy-migration-rollback"),
+ );
+ expect(io.stderr.write).not.toHaveBeenCalled();
+ });
+
+ test("awaits rollback and exits zero for rolled_back reports", async () => {
+ rollbackCaddyMigrationMock.mockResolvedValueOnce({
+ migrationId: "caddy-123",
+ status: "rolled_back",
+ warnings: [],
+ summary: { warnings: 0, blockingWarnings: 0, fragments: 1, routes: 1 },
+ artifactPaths: { reportJson: "/tmp/report.json" },
+ });
+ const io = createIo();
+
+ const code = await runCaddyMigrationRollbackCli(
+ ["--migration-id", "caddy-123", "--server-id", "server-1"],
+ io,
+ );
+
+ expect(code).toBe(0);
+ expect(rollbackCaddyMigrationMock).toHaveBeenCalledWith({
+ migrationId: "caddy-123",
+ serverId: "server-1",
+ });
+ expect(io.stdout.write).toHaveBeenCalledWith(
+ expect.stringContaining('"status": "rolled_back"'),
+ );
+ });
+
+ test("exits non-zero for non-rolled-back terminal reports", async () => {
+ rollbackCaddyMigrationMock.mockResolvedValueOnce({
+ migrationId: "caddy-123",
+ status: "failed",
+ warnings: [],
+ summary: { warnings: 1, blockingWarnings: 1, fragments: 1, routes: 1 },
+ artifactPaths: { reportJson: "/tmp/report.json" },
+ });
+ const io = createIo();
+
+ const code = await runCaddyMigrationRollbackCli(
+ ["--migration-id", "caddy-123"],
+ io,
+ );
+
+ expect(code).toBe(1);
+ expect(io.stdout.write).toHaveBeenCalledWith(
+ expect.stringContaining('"status": "failed"'),
+ );
+ });
+});
diff --git a/apps/dokploy/__test__/caddy/migration/compose-label-translator.test.ts b/apps/dokploy/__test__/caddy/migration/compose-label-translator.test.ts
new file mode 100644
index 0000000000..4360b8e6d5
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/migration/compose-label-translator.test.ts
@@ -0,0 +1,329 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import {
+ compileCaddyConfig,
+ translateTraefikComposeLabelsToCaddyFragment,
+} from "@dokploy/server";
+import { describe, expect, test } from "vitest";
+import { parse } from "yaml";
+
+const fixture = (name: string) =>
+ parse(readFileSync(path.join(__dirname, "fixtures", name), "utf8")) as any;
+
+const getServers = (config: ReturnType) => {
+ const apps = config.apps as Record;
+ return apps.http.servers as Record;
+};
+
+const composeLabels = (serviceName: string) =>
+ fixture("generic-compose-labels.yml").services[serviceName].deploy?.labels ??
+ fixture("generic-compose-labels.yml").services[serviceName].labels;
+
+describe("Traefik compose labels to Caddy migration", () => {
+ test("translates representative Dokploy-generated HTTP/HTTPS label pairs", () => {
+ const result = translateTraefikComposeLabelsToCaddyFragment(
+ composeLabels("frontend"),
+ {
+ sourceFile: "compose-sample-stack/docker-compose.yml",
+ appName: "compose-sample-stack",
+ serviceName: "frontend",
+ upstreamServiceName: "frontend",
+ },
+ );
+ const config = compileCaddyConfig({ fragments: [result.fragment] });
+ const servers = getServers(config);
+
+ expect(result.warnings).toEqual([]);
+ expect(result.classifications.some((item) => item.dokployGenerated)).toBe(
+ true,
+ );
+
+ const secureRoute = result.routes.find((route) =>
+ route.id.includes("websecure"),
+ );
+ expect(secureRoute).toMatchObject({
+ source: "traefik-compose-label",
+ hosts: ["app.example.com"],
+ https: true,
+ upstreams: ["http://frontend:3000"],
+ });
+
+ const redirectRoute = result.routes.find((route) =>
+ route.id.endsWith("-web"),
+ );
+ expect(redirectRoute).toMatchObject({
+ redirectScheme: { scheme: "https", permanent: true },
+ upstreams: [],
+ });
+ expect(servers.https.routes[0].handle.at(-1)).toMatchObject({
+ handler: "reverse_proxy",
+ upstreams: [{ dial: "frontend:3000" }],
+ });
+ });
+
+ test("translates manual compose labels with OR host rules and file middleware definitions", () => {
+ const result = translateTraefikComposeLabelsToCaddyFragment(
+ composeLabels("cms"),
+ {
+ sourceFile: "cms-site/docker-compose.yml",
+ serviceName: "cms",
+ upstreamServiceName: "cms",
+ fileMiddlewares: {
+ "cms-security-headers": {
+ headers: {
+ stsSeconds: 31536000,
+ stsIncludeSubdomains: true,
+ stsPreload: true,
+ referrerPolicy: "strict-origin-when-cross-origin",
+ contentTypeNosniff: true,
+ customFrameOptionsValue: "SAMEORIGIN",
+ },
+ },
+ },
+ },
+ );
+
+ const prodRoute = result.routes.find((route) =>
+ route.id.includes("cms-prod"),
+ );
+ expect(result.warnings).toEqual([]);
+ expect(prodRoute).toMatchObject({
+ hosts: ["example.com", "www.example.com"],
+ https: true,
+ upstreams: ["http://cms:8080"],
+ transforms: {
+ responseHeaders: {
+ "Strict-Transport-Security":
+ "max-age=31536000; includeSubDomains; preload",
+ "Referrer-Policy": "strict-origin-when-cross-origin",
+ "X-Content-Type-Options": "nosniff",
+ "X-Frame-Options": "SAMEORIGIN",
+ },
+ },
+ });
+ });
+
+ test("translates path-specific manual labels with priority and dynamic file middleware mapping", () => {
+ const result = translateTraefikComposeLabelsToCaddyFragment(
+ composeLabels("admin"),
+ {
+ sourceFile: "admin-console/docker-compose.yml",
+ serviceName: "admin",
+ upstreamServiceName: "admin",
+ fileMiddlewares: {
+ "admin-no-store": {
+ headers: {
+ customResponseHeaders: {
+ "Cache-Control": "private, no-store",
+ },
+ },
+ },
+ },
+ },
+ );
+
+ expect(result.warnings).toEqual([]);
+ const adminRoutes = result.routes.filter((route) =>
+ route.id.includes("admin-console"),
+ );
+ expect(adminRoutes).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ priority: 100,
+ pathPrefix: "/admin",
+ transforms: expect.objectContaining({
+ responseHeaders: expect.objectContaining({
+ "Cache-Control": "private, no-store",
+ }),
+ }),
+ }),
+ expect.objectContaining({
+ priority: 100,
+ pathExact: "/login",
+ }),
+ ]),
+ );
+ });
+
+ test("treats tls=true router labels as HTTPS intent", () => {
+ const result = translateTraefikComposeLabelsToCaddyFragment(
+ [
+ "traefik.http.routers.app.rule=Host(`tls.example.com`)",
+ "traefik.http.routers.app.entrypoints=websecure",
+ "traefik.http.routers.app.tls=true",
+ "traefik.http.routers.app.service=app",
+ "traefik.http.services.app.loadbalancer.server.port=3000",
+ ],
+ {
+ sourceFile: "tls/docker-compose.yml",
+ serviceName: "app",
+ upstreamServiceName: "app",
+ },
+ );
+
+ expect(result.warnings).toEqual([]);
+ expect(result.routes[0]).toMatchObject({
+ hosts: ["tls.example.com"],
+ https: true,
+ upstreams: ["http://app:3000"],
+ });
+ });
+
+ test("translates compose-label routes that reference ipAllowList file middleware", () => {
+ const result = translateTraefikComposeLabelsToCaddyFragment(
+ [
+ "traefik.http.routers.dash.rule=Host(`dash.example.com`)",
+ "traefik.http.routers.dash.entrypoints=websecure",
+ "traefik.http.routers.dash.tls.certresolver=letsencrypt",
+ "traefik.http.routers.dash.service=dash",
+ "traefik.http.routers.dash.middlewares=internal-allowlist@file",
+ "traefik.http.services.dash.loadbalancer.server.port=8000",
+ ],
+ {
+ sourceFile: "dash/docker-compose.yml",
+ serviceName: "dash",
+ upstreamServiceName: "dash",
+ fileMiddlewares: {
+ "internal-allowlist": {
+ ipAllowList: {
+ sourceRange: ["192.0.2.0/24"],
+ },
+ } as any,
+ },
+ },
+ );
+
+ expect(result.warnings).toEqual([]);
+ expect(result.routes[0]).toMatchObject({
+ allowedRemoteIps: ["192.0.2.0/24"],
+ });
+ });
+
+ test("drops generated labels when manual labels are present after security middleware is migratable", () => {
+ const result = translateTraefikComposeLabelsToCaddyFragment(
+ [
+ "traefik.enable=true",
+ "traefik.http.routers.sample-app-42-websecure.rule=Host(`dashboard.example.com`)",
+ "traefik.http.routers.sample-app-42-websecure.entrypoints=websecure",
+ "traefik.http.routers.sample-app-42-websecure.tls.certresolver=letsencrypt",
+ "traefik.http.routers.sample-app-42-websecure.middlewares=internal-allowlist@file",
+ "traefik.http.routers.sample-app-42-websecure.service=sample-app-42-websecure",
+ "traefik.http.services.sample-app-42-websecure.loadbalancer.server.port=8000",
+ "traefik.http.routers.custom.rule=Host(`custom.example.com`)",
+ "traefik.http.routers.custom.entrypoints=websecure",
+ "traefik.http.routers.custom.tls.certresolver=letsencrypt",
+ "traefik.http.routers.custom.service=custom",
+ "traefik.http.services.custom.loadbalancer.server.port=3000",
+ ],
+ {
+ sourceFile: "sample-app/dashboard-api/labels",
+ appName: "sample-app",
+ domains: [
+ {
+ host: "dashboard.example.com",
+ uniqueConfigKey: 42,
+ https: true,
+ } as any,
+ ],
+ serviceName: "dashboard-api",
+ upstreamServiceName: "dashboard-api",
+ fileMiddlewares: {
+ "internal-allowlist": {
+ ipAllowList: {
+ sourceRange: ["192.0.2.0/24"],
+ },
+ } as any,
+ },
+ },
+ );
+
+ expect(result.routes).toEqual([
+ expect.objectContaining({
+ hosts: ["custom.example.com"],
+ upstreams: ["http://dashboard-api:3000"],
+ }),
+ ]);
+ expect(result.warnings).toEqual([]);
+ });
+
+ test("parses inline ipWhiteList labels as Caddy remote IP restrictions", () => {
+ const result = translateTraefikComposeLabelsToCaddyFragment(
+ [
+ "traefik.http.routers.admin.rule=Host(`admin.example.com`)",
+ "traefik.http.routers.admin.entrypoints=websecure",
+ "traefik.http.routers.admin.tls.certresolver=letsencrypt",
+ "traefik.http.routers.admin.service=admin",
+ "traefik.http.routers.admin.middlewares=admin-allow",
+ "traefik.http.services.admin.loadbalancer.server.port=8080",
+ "traefik.http.middlewares.admin-allow.ipWhiteList.sourceRange=192.0.2.0/24,127.0.0.1/32",
+ ],
+ {
+ sourceFile: "admin/docker-compose.yml",
+ serviceName: "admin",
+ upstreamServiceName: "admin",
+ },
+ );
+
+ expect(result.warnings).toEqual([]);
+ expect(result.routes[0]).toMatchObject({
+ allowedRemoteIps: ["192.0.2.0/24", "127.0.0.1/32"],
+ });
+ });
+
+ test("does not classify app-name-shaped labels as generated when DB domains are provided", () => {
+ const result = translateTraefikComposeLabelsToCaddyFragment(
+ [
+ "traefik.http.routers.my-compose-custom-web.rule=Host(`custom.example.com`)",
+ "traefik.http.routers.my-compose-custom-web.entrypoints=web",
+ "traefik.http.services.my-compose-custom-web.loadbalancer.server.port=8080",
+ ],
+ {
+ sourceFile: "my-compose/docker-compose.yml",
+ appName: "my-compose",
+ domains: [],
+ serviceName: "web",
+ upstreamServiceName: "web",
+ },
+ );
+
+ expect(result.classifications).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ label:
+ "traefik.http.routers.my-compose-custom-web.rule=Host(`custom.example.com`)",
+ dokployGenerated: false,
+ }),
+ ]),
+ );
+ expect(result.routes[0]).toMatchObject({
+ hosts: ["custom.example.com"],
+ upstreams: ["http://web:8080"],
+ });
+ });
+
+ test("returns blocking warnings for unsupported label rules and middleware constructs", () => {
+ const result = translateTraefikComposeLabelsToCaddyFragment(
+ composeLabels("unsupported"),
+ {
+ sourceFile: "unsupported/docker-compose.yml",
+ serviceName: "unsupported",
+ },
+ );
+
+ expect(result.routes).toHaveLength(0);
+ expect(result.warnings).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: "unsupported-matcher",
+ routerName: "unsupported",
+ blocking: true,
+ }),
+ expect.objectContaining({
+ code: "unsupported-middleware",
+ middlewareName: "plugin-only",
+ blocking: true,
+ }),
+ ]),
+ );
+ });
+});
diff --git a/apps/dokploy/__test__/caddy/migration/dynamic-file-translator.test.ts b/apps/dokploy/__test__/caddy/migration/dynamic-file-translator.test.ts
new file mode 100644
index 0000000000..a04a43c439
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/migration/dynamic-file-translator.test.ts
@@ -0,0 +1,266 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import {
+ compileCaddyConfig,
+ translateTraefikDynamicConfigToCaddyFragment,
+} from "@dokploy/server";
+import { describe, expect, test } from "vitest";
+
+const fixture = (name: string) =>
+ readFileSync(path.join(__dirname, "fixtures", name), "utf8");
+
+const getServers = (config: ReturnType) => {
+ const apps = config.apps as Record;
+ return apps.http.servers as Record;
+};
+
+describe("Traefik dynamic file to Caddy migration", () => {
+ test("translates priority dynamic-file routers, headers, redirects, and external upstreams", () => {
+ const result = translateTraefikDynamicConfigToCaddyFragment(
+ fixture("priority-dynamic.yml"),
+ { sourceFile: "priority-dynamic.yml" },
+ );
+ const config = compileCaddyConfig({ fragments: [result.fragment] });
+ const servers = getServers(config);
+
+ expect(result.warnings).toEqual([]);
+ expect(result.fragment).toMatchObject({
+ id: "migration.traefik-dynamic.priority-dynamic",
+ source: "traefik-dynamic-file",
+ });
+
+ const activityRoutes = result.routes.filter((route) =>
+ route.id.includes("activity-feed"),
+ );
+ expect(activityRoutes).toHaveLength(3);
+ expect(activityRoutes.map((route) => route.priority)).toEqual([
+ 10000, 10000, 10000,
+ ]);
+ expect(activityRoutes).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ pathPrefix: "/activity",
+ upstreams: ["https://activity.example.net:443"],
+ }),
+ expect.objectContaining({ pathExact: "/.well-known/webfinger" }),
+ expect.objectContaining({ pathExact: "/.well-known/nodeinfo" }),
+ ]),
+ );
+
+ const adminRoute = result.routes.find((route) =>
+ route.id.includes("admin-console"),
+ );
+ expect(adminRoute).toMatchObject({
+ hosts: ["example.com", "www.example.com"],
+ pathPrefix: "/admin",
+ priority: 9000,
+ transforms: {
+ responseHeaders: {
+ "Cache-Control":
+ "private, no-store, no-cache, must-revalidate, max-age=0",
+ Pragma: "no-cache",
+ Expires: "0",
+ },
+ },
+ });
+
+ const redirectRoute = result.routes.find((route) =>
+ route.id.includes("site-redirect"),
+ );
+ expect(redirectRoute).toMatchObject({
+ redirectScheme: { scheme: "https", permanent: true },
+ upstreams: [],
+ });
+
+ const activityProxy = servers.https.routes.find((route: any) =>
+ route.match[0].path?.includes("/activity*"),
+ );
+ expect(activityProxy.handle.at(-1)).toMatchObject({
+ handler: "reverse_proxy",
+ upstreams: [{ dial: "activity.example.net:443" }],
+ transport: { protocol: "http", tls: {} },
+ });
+ });
+
+ test("normalizes Traefik service URLs with default scheme ports", () => {
+ const result = translateTraefikDynamicConfigToCaddyFragment(
+ [
+ "http:",
+ " routers:",
+ " admin:",
+ " rule: Host(`admin.example.com`)",
+ " entryPoints: [websecure]",
+ " service: admin",
+ " tls:",
+ " certResolver: letsencrypt",
+ " services:",
+ " admin:",
+ " loadBalancer:",
+ " servers:",
+ " - url: http://admin",
+ ].join("\n"),
+ { sourceFile: "admin.yml" },
+ );
+
+ expect(result.routes[0]).toMatchObject({
+ upstreams: ["http://admin:80"],
+ });
+ expect(() =>
+ compileCaddyConfig({ fragments: [result.fragment] }),
+ ).not.toThrow();
+ });
+
+ test("skips Traefik api@internal dashboard routers as non-migratable internals", () => {
+ const result = translateTraefikDynamicConfigToCaddyFragment(
+ [
+ "http:",
+ " routers:",
+ " traefik-dashboard:",
+ " rule: PathPrefix(`/dashboard`) || PathPrefix(`/api`)",
+ " entryPoints: [traefik]",
+ " service: api@internal",
+ " middlewares: [internal-allowlist]",
+ ].join("\n"),
+ { sourceFile: "routers.yml" },
+ );
+
+ expect(result.routes).toEqual([]);
+ expect(result.warnings).toEqual([
+ expect.objectContaining({
+ code: "unsupported-router",
+ routerName: "traefik-dashboard",
+ serviceName: "api@internal",
+ blocking: false,
+ message: expect.stringContaining("Skipped Traefik internal router"),
+ }),
+ ]);
+ });
+
+ test("translates active routes that use ipAllowList file middleware", () => {
+ const result = translateTraefikDynamicConfigToCaddyFragment(
+ [
+ "http:",
+ " routers:",
+ " dash:",
+ " rule: Host(`dash.example.com`)",
+ " entryPoints: [websecure]",
+ " service: dash",
+ " tls:",
+ " certResolver: letsencrypt",
+ " middlewares: [internal-allowlist]",
+ " services:",
+ " dash:",
+ " loadBalancer:",
+ " servers:",
+ " - url: http://dash:8000",
+ " middlewares:",
+ " internal-allowlist:",
+ " ipAllowList:",
+ " sourceRange:",
+ " - 192.0.2.0/24",
+ ].join("\n"),
+ { sourceFile: "dash.yml" },
+ );
+
+ expect(result.warnings).toEqual([]);
+ expect(result.routes[0]).toMatchObject({
+ allowedRemoteIps: ["192.0.2.0/24"],
+ });
+ const config = compileCaddyConfig({ fragments: [result.fragment] });
+ const servers = getServers(config);
+ expect(servers.https.routes).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ match: [
+ expect.objectContaining({
+ remote_ip: { ranges: ["192.0.2.0/24"] },
+ }),
+ ],
+ }),
+ expect.objectContaining({
+ handle: [
+ expect.objectContaining({
+ handler: "static_response",
+ status_code: 403,
+ }),
+ ],
+ }),
+ ]),
+ );
+ });
+
+ test("translates strip/add prefix, basicAuth, chain, and blocks unsupported constructs", () => {
+ const result = translateTraefikDynamicConfigToCaddyFragment(
+ fixture("middleware-coverage.yml"),
+ { sourceFile: "middleware-coverage.yml" },
+ );
+
+ const toolRoute = result.routes.find((route) => route.id.endsWith("-tool"));
+ expect(toolRoute).toMatchObject({
+ transforms: {
+ stripPrefix: "/public",
+ addPrefix: "/internal",
+ requestHeaders: { "X-Forwarded-Proto": "https" },
+ responseHeaders: { "X-Test": "true" },
+ },
+ basicAuth: [{ username: "admin", hash: "$2y$05$abcdef" }],
+ });
+
+ expect(result.warnings).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: "unsupported-service",
+ serviceName: "tool",
+ blocking: true,
+ }),
+ expect.objectContaining({
+ code: "unsupported-middleware",
+ middlewareName: "plugin-auth",
+ blocking: true,
+ }),
+ expect.objectContaining({
+ code: "unsupported-router",
+ routerName: "unsupported-plugin",
+ message: expect.stringContaining("Custom certResolver"),
+ blocking: true,
+ }),
+ ]),
+ );
+ });
+
+ test("blocks basicAuth hashes that Caddy cannot safely consume", () => {
+ const result = translateTraefikDynamicConfigToCaddyFragment(
+ [
+ "http:",
+ " routers:",
+ " secure:",
+ " rule: Host(`secure.example.com`)",
+ " service: secure",
+ " middlewares: [legacy-auth]",
+ " services:",
+ " secure:",
+ " loadBalancer:",
+ " servers:",
+ " - url: http://secure:3000",
+ " middlewares:",
+ " legacy-auth:",
+ " basicAuth:",
+ " users:",
+ " - admin:$apr1$legacy-hash",
+ ].join("\n"),
+ { sourceFile: "legacy-auth.yml" },
+ );
+
+ expect(result.routes[0]?.basicAuth).toBeNull();
+ expect(result.warnings).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: "unsupported-security-middleware",
+ middlewareName: "legacy-auth",
+ blocking: true,
+ message: expect.stringContaining("hash format"),
+ }),
+ ]),
+ );
+ });
+});
diff --git a/apps/dokploy/__test__/caddy/migration/fixtures/generic-compose-labels.yml b/apps/dokploy/__test__/caddy/migration/fixtures/generic-compose-labels.yml
new file mode 100644
index 0000000000..2500c4eb69
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/migration/fixtures/generic-compose-labels.yml
@@ -0,0 +1,58 @@
+# Representative Traefik compose labels for Caddy migration tests.
+services:
+ frontend:
+ deploy:
+ labels:
+ - traefik.enable=true
+ - traefik.http.routers.compose-sample-stack-frontend-web.rule=Host(`app.example.com`)
+ - traefik.http.routers.compose-sample-stack-frontend-web.entrypoints=web
+ - traefik.http.services.compose-sample-stack-frontend-web.loadbalancer.server.port=3000
+ - traefik.http.routers.compose-sample-stack-frontend-web.service=compose-sample-stack-frontend-web
+ - traefik.http.routers.compose-sample-stack-frontend-web.middlewares=redirect-to-https@file
+ - traefik.http.routers.compose-sample-stack-frontend-websecure.rule=Host(`app.example.com`)
+ - traefik.http.routers.compose-sample-stack-frontend-websecure.entrypoints=websecure
+ - traefik.http.services.compose-sample-stack-frontend-websecure.loadbalancer.server.port=3000
+ - traefik.http.routers.compose-sample-stack-frontend-websecure.service=compose-sample-stack-frontend-websecure
+ - traefik.http.routers.compose-sample-stack-frontend-websecure.tls.certresolver=letsencrypt
+ - traefik.http.routers.compose-sample-stack-frontend-websecure.middlewares=security-headers@file
+ - traefik.docker.network=dokploy-network
+ cms:
+ deploy:
+ labels:
+ - traefik.enable=true
+ - traefik.docker.network=dokploy-network
+ - traefik.http.routers.cms-prod.rule=Host(`example.com`) || Host(`www.example.com`)
+ - traefik.http.routers.cms-prod.entrypoints=websecure
+ - traefik.http.routers.cms-prod.tls.certresolver=letsencrypt
+ - traefik.http.routers.cms-prod.middlewares=cms-security-headers@file
+ - traefik.http.routers.cms-prod.service=cms-prod
+ - traefik.http.routers.cms-preview.rule=Host(`cms.test.example.com`) || Host(`preview.test.example.com`)
+ - traefik.http.routers.cms-preview.entrypoints=websecure
+ - traefik.http.routers.cms-preview.tls.certresolver=letsencrypt
+ - traefik.http.routers.cms-preview.middlewares=cms-security-headers@file
+ - traefik.http.routers.cms-preview.service=cms-prod
+ - traefik.http.services.cms-prod.loadbalancer.server.port=8080
+ admin:
+ deploy:
+ labels:
+ - traefik.enable=true
+ - traefik.http.routers.admin.rule=Host(`admin.example.com`)
+ - traefik.http.routers.admin.entrypoints=websecure
+ - traefik.http.routers.admin.service=admin
+ - traefik.http.routers.admin.tls.certresolver=letsencrypt
+ - traefik.http.routers.admin.middlewares=security-headers@file
+ - traefik.http.routers.admin-console.rule=Host(`admin.example.com`) && (PathPrefix(`/admin`) || Path(`/login`))
+ - traefik.http.routers.admin-console.entrypoints=websecure
+ - traefik.http.routers.admin-console.service=admin
+ - traefik.http.routers.admin-console.tls.certresolver=letsencrypt
+ - traefik.http.routers.admin-console.middlewares=security-headers@file,admin-no-store@file
+ - traefik.http.routers.admin-console.priority=100
+ - traefik.http.services.admin.loadbalancer.server.port=80
+ unsupported:
+ labels:
+ - traefik.http.routers.unsupported.rule=HostRegexp(`{subdomain:[a-z]+}.example.com`)
+ - traefik.http.routers.unsupported.entrypoints=websecure
+ - traefik.http.routers.unsupported.service=unsupported
+ - traefik.http.services.unsupported.loadbalancer.server.port=9000
+ - traefik.http.routers.unsupported.middlewares=plugin-only
+ - traefik.http.middlewares.plugin-only.plugin.demo.enabled=true
diff --git a/apps/dokploy/__test__/caddy/migration/fixtures/middleware-coverage.yml b/apps/dokploy/__test__/caddy/migration/fixtures/middleware-coverage.yml
new file mode 100644
index 0000000000..964c992f75
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/migration/fixtures/middleware-coverage.yml
@@ -0,0 +1,54 @@
+http:
+ routers:
+ tool:
+ rule: "Host(`tools.example.com`) && PathPrefix(`/public`)"
+ entryPoints:
+ - websecure
+ service: tool
+ tls:
+ certResolver: letsencrypt
+ middlewares:
+ - tool-chain
+ unsupported-plugin:
+ rule: "Host(`blocked.example.com`)"
+ entryPoints:
+ - websecure
+ service: tool
+ tls:
+ certResolver: customresolver
+ middlewares:
+ - plugin-auth
+ services:
+ tool:
+ loadBalancer:
+ servers:
+ - url: http://tool:8080
+ passHostHeader: false
+ middlewares:
+ tool-chain:
+ chain:
+ middlewares:
+ - strip-public
+ - add-internal
+ - basic-users
+ - response-headers
+ strip-public:
+ stripPrefix:
+ prefixes:
+ - /public
+ add-internal:
+ addPrefix:
+ prefix: /internal
+ basic-users:
+ basicAuth:
+ users:
+ - admin:$2y$05$abcdef
+ response-headers:
+ headers:
+ customRequestHeaders:
+ X-Forwarded-Proto: "https"
+ customResponseHeaders:
+ X-Test: "true"
+ plugin-auth:
+ plugin:
+ unsupported: {}
diff --git a/apps/dokploy/__test__/caddy/migration/fixtures/priority-dynamic.yml b/apps/dokploy/__test__/caddy/migration/fixtures/priority-dynamic.yml
new file mode 100644
index 0000000000..ace2ca7a5c
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/migration/fixtures/priority-dynamic.yml
@@ -0,0 +1,58 @@
+# Representative Traefik dynamic-file routes for Caddy migration tests.
+http:
+ routers:
+ activity-feed:
+ rule: "Host(`example.com`) && (PathPrefix(`/activity`) || Path(`/.well-known/webfinger`) || Path(`/.well-known/nodeinfo`))"
+ entryPoints:
+ - websecure
+ service: hosted-activity
+ tls:
+ certResolver: letsencrypt
+ middlewares:
+ - security-headers
+ priority: 10000
+ admin-console:
+ rule: "(Host(`example.com`) || Host(`www.example.com`)) && PathPrefix(`/admin`)"
+ entryPoints:
+ - websecure
+ service: site
+ tls:
+ certResolver: letsencrypt
+ middlewares:
+ - security-headers
+ - admin-no-store
+ priority: 9000
+ site:
+ rule: "Host(`example.com`) || Host(`www.example.com`)"
+ entryPoints:
+ - websecure
+ service: site
+ tls:
+ certResolver: letsencrypt
+ middlewares:
+ - security-headers
+ site-redirect:
+ rule: "Host(`example.com`) || Host(`www.example.com`)"
+ entryPoints:
+ - web
+ service: site
+ middlewares:
+ - redirect-to-https
+ services:
+ site:
+ loadBalancer:
+ servers:
+ - url: http://site:8080
+ passHostHeader: true
+ hosted-activity:
+ loadBalancer:
+ servers:
+ - url: https://activity.example.net:443
+ passHostHeader: true
+ middlewares:
+ admin-no-store:
+ headers:
+ customResponseHeaders:
+ Cache-Control: "private, no-store, no-cache, must-revalidate, max-age=0"
+ Pragma: "no-cache"
+ Expires: "0"
diff --git a/apps/dokploy/__test__/caddy/migration/prepare.test.ts b/apps/dokploy/__test__/caddy/migration/prepare.test.ts
new file mode 100644
index 0000000000..41702311dc
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/migration/prepare.test.ts
@@ -0,0 +1,724 @@
+import { fs, vol } from "memfs";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+vi.mock("node:fs", () => ({
+ ...fs,
+ default: fs,
+}));
+
+vi.mock("@dokploy/server/db", () => ({
+ db: {
+ query: {
+ applications: { findMany: vi.fn() },
+ certificates: { findFirst: vi.fn() },
+ compose: { findMany: vi.fn() },
+ },
+ },
+}));
+
+vi.mock("@dokploy/server/services/web-server-settings", () => ({
+ getCaddyCompileSettings: vi.fn().mockResolvedValue({
+ letsEncryptEmail: "ops@example.com",
+ trustedProxies: null,
+ }),
+ getWebServerSettings: vi.fn().mockResolvedValue({
+ letsEncryptEmail: "ops@example.com",
+ }),
+}));
+
+vi.mock("@dokploy/server/utils/docker/domain", async (importOriginal) => {
+ const actual =
+ await importOriginal<
+ typeof import("@dokploy/server/utils/docker/domain")
+ >();
+ return {
+ ...actual,
+ loadDockerCompose: vi.fn().mockResolvedValue(null),
+ loadDockerComposeRemote: vi.fn().mockResolvedValue(null),
+ };
+});
+
+const remoteDockerMock = vi.hoisted(() => ({
+ listServices: vi.fn().mockResolvedValue([]),
+ listContainers: vi.fn().mockResolvedValue([]),
+ listTasks: vi.fn().mockResolvedValue([]),
+ getContainer: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
+ getRemoteDocker: vi.fn().mockResolvedValue(remoteDockerMock),
+}));
+
+import { paths } from "@dokploy/server/constants";
+import { db } from "@dokploy/server/db";
+import { prepareCaddyMigration } from "@dokploy/server/utils/caddy/migration/prepare";
+
+const domain = {
+ domainId: "domain-1",
+ host: "app.example.com",
+ https: true,
+ port: 3000,
+ customEntrypoint: null,
+ path: "/",
+ serviceName: null,
+ domainType: "application",
+ uniqueConfigKey: 1,
+ createdAt: new Date().toISOString(),
+ composeId: null,
+ customCertResolver: null,
+ applicationId: "app-1",
+ previewDeploymentId: null,
+ certificateType: "letsencrypt",
+ internalPath: "/",
+ stripPath: false,
+ middlewares: [],
+};
+
+const genericComposeFixture = [
+ "services:",
+ " cms:",
+ " deploy:",
+ " labels:",
+ " - traefik.enable=true",
+ " - traefik.docker.network=dokploy-network",
+ " - traefik.http.routers.cms-prod.rule=Host(`example.com`) || Host(`www.example.com`)",
+ " - traefik.http.routers.cms-prod.entrypoints=websecure",
+ " - traefik.http.routers.cms-prod.tls.certresolver=letsencrypt",
+ " - traefik.http.routers.cms-prod.middlewares=cms-security-headers@file",
+ " - traefik.http.routers.cms-prod.service=cms-prod",
+ " - traefik.http.services.cms-prod.loadbalancer.server.port=8080",
+ " unsupported:",
+ " labels:",
+ " - traefik.http.routers.unsupported.rule=HostRegexp(`{subdomain:[a-z]+}.example.com`)",
+ " - traefik.http.routers.unsupported.entrypoints=websecure",
+ " - traefik.http.routers.unsupported.service=unsupported",
+ " - traefik.http.services.unsupported.loadbalancer.server.port=9000",
+ " - traefik.http.routers.unsupported.middlewares=plugin-only",
+ " - traefik.http.middlewares.plugin-only.plugin.demo.enabled=true",
+].join("\n");
+
+const writeCertificateFiles = (certificatePath: string) => {
+ const certDir = `${paths().CERTIFICATES_PATH}/${certificatePath}`;
+ vol.mkdirSync(certDir, { recursive: true });
+ vol.writeFileSync(`${certDir}/chain.crt`, "cert");
+ vol.writeFileSync(`${certDir}/privkey.key`, "key");
+};
+
+describe("prepareCaddyMigration", () => {
+ beforeEach(() => {
+ vol.reset();
+ vi.clearAllMocks();
+ remoteDockerMock.listServices.mockResolvedValue([]);
+ remoteDockerMock.listContainers.mockResolvedValue([]);
+ remoteDockerMock.listTasks.mockResolvedValue([]);
+ remoteDockerMock.getContainer.mockReturnValue({
+ inspect: vi.fn().mockResolvedValue({}),
+ });
+ vi.mocked(db.query.applications.findMany).mockResolvedValue([
+ {
+ applicationId: "app-1",
+ appName: "test-app",
+ serverId: null,
+ environment: { project: { organizationId: "org-1" } },
+ domains: [domain],
+ } as any,
+ ]);
+ vi.mocked(db.query.compose.findMany).mockResolvedValue([]);
+ vi.mocked(db.query.certificates.findFirst).mockResolvedValue(undefined);
+ });
+
+ test("writes reviewable dry-run artifacts without touching live Caddy config", async () => {
+ const currentPaths = paths();
+ vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true });
+ vol.writeFileSync(
+ `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`,
+ "entryPoints:\n web:\n address: ':80'\n",
+ );
+ vol.writeFileSync(
+ `${currentPaths.DYNAMIC_TRAEFIK_PATH}/manual.yml`,
+ [
+ "http:",
+ " routers:",
+ " manual:",
+ " rule: Host(`manual.example.com`)",
+ " entryPoints: [websecure]",
+ " service: manual",
+ " tls:",
+ " certResolver: letsencrypt",
+ " services:",
+ " manual:",
+ " loadBalancer:",
+ " servers:",
+ " - url: http://manual:8080",
+ ].join("\n"),
+ );
+
+ const report = await prepareCaddyMigration();
+
+ expect(report.status).toBe("prepared");
+ expect(report.summary.blockingWarnings).toBe(0);
+ expect(report.summary.fragments).toBe(2);
+ expect(report.inputs.dynamicFiles).toHaveLength(1);
+ expect(vol.existsSync(report.artifactPaths.reportJson)).toBe(true);
+ expect(vol.existsSync(report.artifactPaths.reportMd)).toBe(true);
+ expect(vol.existsSync(report.artifactPaths.caddyJson)).toBe(true);
+ expect(vol.existsSync(currentPaths.CADDY_CONFIG_PATH)).toBe(false);
+
+ const draft = JSON.parse(
+ vol.readFileSync(report.artifactPaths.caddyJson, "utf8") as string,
+ ) as any;
+ expect(draft.apps.tls.automation.policies[0].issuers[0].email).toBe(
+ "ops@example.com",
+ );
+ const fragmentFiles = vol.readdirSync(report.artifactPaths.fragmentsDir);
+ expect(fragmentFiles).toEqual(
+ expect.arrayContaining([
+ "application.test-app.1.json",
+ "migration.traefik-dynamic.manual.json",
+ ]),
+ );
+ });
+
+ test("blocks DB fallback routes with missing uploaded custom certificates", async () => {
+ vi.mocked(db.query.applications.findMany).mockResolvedValue([
+ {
+ applicationId: "app-1",
+ appName: "custom-cert-app",
+ serverId: null,
+ environment: { project: { organizationId: "org-1" } },
+ domains: [
+ {
+ ...domain,
+ certificateType: "custom",
+ customCertResolver: "legacy-traefik-resolver",
+ },
+ ],
+ } as any,
+ ]);
+
+ const report = await prepareCaddyMigration();
+
+ expect(report.summary.blockingWarnings).toBe(1);
+ expect(report.warnings).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ blocking: true,
+ code: "missing-certificate",
+ source: "custom-cert-app",
+ message: expect.stringContaining("legacy-traefik-resolver"),
+ }),
+ ]),
+ );
+ expect(report.summary.fragments).toBe(0);
+ });
+
+ test("blocks DB fallback routes with cross-organization uploaded certificates", async () => {
+ vi.mocked(db.query.applications.findMany).mockResolvedValue([
+ {
+ applicationId: "app-1",
+ appName: "custom-cert-app",
+ serverId: null,
+ environment: { project: { organizationId: "org-1" } },
+ domains: [
+ {
+ ...domain,
+ certificateType: "custom",
+ customCertResolver: "certificate-uploaded",
+ },
+ ],
+ } as any,
+ ]);
+ vi.mocked(db.query.certificates.findFirst).mockResolvedValue({
+ certificatePath: "certificate-uploaded",
+ serverId: null,
+ organizationId: "org-2",
+ } as any);
+
+ const report = await prepareCaddyMigration();
+
+ expect(report.summary.blockingWarnings).toBe(1);
+ expect(report.warnings).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ blocking: true,
+ code: "missing-certificate",
+ source: "custom-cert-app",
+ message: expect.stringContaining("server and organization"),
+ }),
+ ]),
+ );
+ expect(report.summary.fragments).toBe(0);
+ });
+
+ test("keeps DB fallback routes with readable uploaded custom certificates", async () => {
+ writeCertificateFiles("certificate-uploaded");
+ vi.mocked(db.query.applications.findMany).mockResolvedValue([
+ {
+ applicationId: "app-1",
+ appName: "custom-cert-app",
+ serverId: null,
+ environment: { project: { organizationId: "org-1" } },
+ domains: [
+ {
+ ...domain,
+ certificateType: "custom",
+ customCertResolver: "certificate-uploaded",
+ },
+ ],
+ } as any,
+ ]);
+ vi.mocked(db.query.certificates.findFirst).mockResolvedValue({
+ certificatePath: "certificate-uploaded",
+ serverId: null,
+ organizationId: "org-1",
+ } as any);
+
+ const report = await prepareCaddyMigration();
+ const draft = JSON.parse(
+ vol.readFileSync(report.artifactPaths.caddyJson, "utf8") as string,
+ ) as any;
+ const certificatePath = `${paths().CERTIFICATES_PATH}/certificate-uploaded`;
+
+ expect(report.summary.blockingWarnings).toBe(0);
+ expect(report.warnings).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ code: "missing-certificate" }),
+ ]),
+ );
+ expect(report.summary.fragments).toBe(1);
+ expect(draft.apps.tls.certificates.load_files).toEqual([
+ {
+ certificate: `${certificatePath}/chain.crt`,
+ key: `${certificatePath}/privkey.key`,
+ },
+ ]);
+ });
+
+ test("carries existing manual Caddy fragments into the migration draft", async () => {
+ const currentPaths = paths();
+ vol.mkdirSync(currentPaths.CADDY_FRAGMENTS_PATH, { recursive: true });
+ vol.writeFileSync(
+ `${currentPaths.CADDY_FRAGMENTS_PATH}/manual.archive.json`,
+ JSON.stringify(
+ {
+ version: 1,
+ id: "manual.archive",
+ source: "manual",
+ routes: [
+ {
+ id: "archive-404",
+ source: "manual",
+ hosts: ["archive.example.com"],
+ https: true,
+ upstreams: [],
+ staticResponse: { statusCode: 404 },
+ },
+ ],
+ },
+ null,
+ 2,
+ ),
+ );
+
+ const report = await prepareCaddyMigration();
+
+ expect(report.summary.blockingWarnings).toBe(0);
+ expect(
+ vol.existsSync(
+ `${report.artifactPaths.fragmentsDir}/manual.archive.json`,
+ ),
+ ).toBe(true);
+ const draft = JSON.parse(
+ vol.readFileSync(report.artifactPaths.caddyJson, "utf8") as string,
+ ) as any;
+ const archiveRoute = draft.apps.http.servers.https.routes.find(
+ (item: any) => JSON.stringify(item.match).includes("archive.example.com"),
+ );
+ expect(archiveRoute.handle[0]).toMatchObject({
+ handler: "static_response",
+ status_code: 404,
+ });
+ });
+
+ test("blocks carried manual Caddy fragments that overlap generated migration routes", async () => {
+ const currentPaths = paths();
+ vol.mkdirSync(currentPaths.CADDY_FRAGMENTS_PATH, { recursive: true });
+ vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true });
+ vol.writeFileSync(
+ `${currentPaths.CADDY_FRAGMENTS_PATH}/manual.conflict.json`,
+ JSON.stringify(
+ {
+ version: 1,
+ id: "manual.conflict",
+ source: "manual",
+ routes: [
+ {
+ id: "manual-conflict",
+ source: "manual",
+ hosts: ["manual.example.com"],
+ https: true,
+ upstreams: [],
+ staticResponse: { statusCode: 404 },
+ },
+ ],
+ },
+ null,
+ 2,
+ ),
+ );
+ vol.writeFileSync(
+ `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`,
+ "entryPoints:\n web:\n address: ':80'\n",
+ );
+ vol.writeFileSync(
+ `${currentPaths.DYNAMIC_TRAEFIK_PATH}/manual.yml`,
+ [
+ "http:",
+ " routers:",
+ " manual:",
+ " rule: Host(`manual.example.com`)",
+ " entryPoints: [websecure]",
+ " service: manual",
+ " tls:",
+ " certResolver: letsencrypt",
+ " services:",
+ " manual:",
+ " loadBalancer:",
+ " servers:",
+ " - url: http://manual:8080",
+ ].join("\n"),
+ );
+ vi.mocked(db.query.applications.findMany).mockResolvedValue([]);
+
+ const report = await prepareCaddyMigration();
+
+ expect(report.summary.blockingWarnings).toBe(1);
+ expect(report.warnings).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: "conflicting-manual-fragment",
+ source: "manual.conflict",
+ blocking: true,
+ }),
+ ]),
+ );
+ expect(vol.readFileSync(report.artifactPaths.reportMd, "utf8")).toContain(
+ "conflicting-manual-fragment",
+ );
+ });
+
+ test("reports compose-label warnings with source references during dry run", async () => {
+ const currentPaths = paths();
+ vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true });
+ vol.writeFileSync(
+ `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`,
+ "entryPoints:\n web:\n address: ':80'\n",
+ );
+ vol.writeFileSync(
+ `${currentPaths.DYNAMIC_TRAEFIK_PATH}/middlewares.yml`,
+ [
+ "http:",
+ " middlewares:",
+ " cms-security-headers:",
+ " headers:",
+ " stsSeconds: 31536000",
+ " stsIncludeSubdomains: true",
+ " stsPreload: true",
+ " referrerPolicy: strict-origin-when-cross-origin",
+ " contentTypeNosniff: true",
+ ].join("\n"),
+ );
+ vi.mocked(db.query.applications.findMany).mockResolvedValue([]);
+ vi.mocked(db.query.compose.findMany).mockResolvedValue([
+ {
+ appName: "generic-fixture",
+ serverId: null,
+ composeFile: genericComposeFixture,
+ composeType: "docker-compose",
+ randomize: false,
+ isolatedDeployment: false,
+ suffix: null,
+ domains: [],
+ } as any,
+ ]);
+
+ const report = await prepareCaddyMigration();
+
+ expect(report.inputs.composeFilesScanned).toEqual(["generic-fixture"]);
+ expect(report.summary.fragments).toBe(1);
+ expect(report.summary.blockingWarnings).toBeGreaterThan(0);
+ expect(report.warnings).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: "unresolved-middleware",
+ source: "generic-fixture/cms/deploy.labels",
+ middlewareName: "cms-security-headers@file",
+ }),
+ ]),
+ );
+ expect(report.warnings).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: "unsupported-matcher",
+ source: "generic-fixture/unsupported/labels",
+ routerName: "unsupported",
+ blocking: true,
+ }),
+ ]),
+ );
+
+ const reportJson = JSON.parse(
+ vol.readFileSync(report.artifactPaths.reportJson, "utf8") as string,
+ );
+ expect(reportJson.inputs.composeFilesScanned).toEqual(["generic-fixture"]);
+ const reportMd = vol.readFileSync(
+ report.artifactPaths.reportMd,
+ "utf8",
+ ) as string;
+ expect(reportMd).toContain("Blocking warnings");
+ expect(reportMd).not.toContain("cms-security-headers@file");
+ expect(reportMd).toContain("generic-fixture/unsupported/labels");
+ });
+
+ test("normalizes dynamic-file upstream URLs with default scheme ports", async () => {
+ const currentPaths = paths();
+ vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true });
+ vol.writeFileSync(
+ `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`,
+ "entryPoints:\n web:\n address: ':80'\n",
+ );
+ vol.writeFileSync(
+ `${currentPaths.DYNAMIC_TRAEFIK_PATH}/admin.yml`,
+ [
+ "http:",
+ " routers:",
+ " admin:",
+ " rule: Host(`admin.example.com`)",
+ " entryPoints: [websecure]",
+ " service: admin",
+ " tls:",
+ " certResolver: letsencrypt",
+ " services:",
+ " admin:",
+ " loadBalancer:",
+ " servers:",
+ " - url: http://admin",
+ ].join("\n"),
+ );
+ vi.mocked(db.query.applications.findMany).mockResolvedValue([]);
+
+ const report = await prepareCaddyMigration();
+
+ expect(report.validation.status).toBe("passed");
+ expect(report.summary.blockingWarnings).toBe(0);
+ expect(report.warnings).toEqual([]);
+ expect(vol.readFileSync(report.artifactPaths.caddyJson, "utf8")).toContain(
+ '"dial": "admin:80"',
+ );
+ });
+
+ test("preserves generated-only compose label IP restrictions before discarding generated labels", async () => {
+ const currentPaths = paths();
+ vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true });
+ vol.writeFileSync(
+ `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`,
+ "entryPoints:\n web:\n address: ':80'\n",
+ );
+ vol.writeFileSync(
+ `${currentPaths.DYNAMIC_TRAEFIK_PATH}/middlewares.yml`,
+ [
+ "http:",
+ " middlewares:",
+ " internal-allowlist:",
+ " ipAllowList:",
+ " sourceRange:",
+ " - 192.0.2.0/24",
+ ].join("\n"),
+ );
+ vi.mocked(db.query.applications.findMany).mockResolvedValue([]);
+ vi.mocked(db.query.compose.findMany).mockResolvedValue([
+ {
+ appName: "sample-dashboard",
+ serverId: null,
+ composeFile: [
+ "services:",
+ " dash-api:",
+ " image: dash",
+ " labels:",
+ " - traefik.enable=true",
+ " - traefik.docker.network=dokploy-network",
+ " - traefik.http.routers.sample-dashboard-42-websecure.rule=Host(`dashboard.example.com`)",
+ " - traefik.http.routers.sample-dashboard-42-websecure.entrypoints=websecure",
+ " - traefik.http.routers.sample-dashboard-42-websecure.tls.certresolver=letsencrypt",
+ " - traefik.http.routers.sample-dashboard-42-websecure.middlewares=internal-allowlist@file",
+ " - traefik.http.routers.sample-dashboard-42-websecure.service=sample-dashboard-42-websecure",
+ " - traefik.http.services.sample-dashboard-42-websecure.loadbalancer.server.port=8000",
+ ].join("\n"),
+ composeType: "docker-compose",
+ randomize: false,
+ isolatedDeployment: false,
+ suffix: null,
+ domains: [
+ {
+ ...domain,
+ domainId: "dash-domain",
+ applicationId: null,
+ composeId: "dash-compose",
+ domainType: "compose",
+ host: "dashboard.example.com",
+ port: 8000,
+ serviceName: "dash-api",
+ uniqueConfigKey: 42,
+ },
+ ],
+ } as any,
+ ]);
+
+ const report = await prepareCaddyMigration();
+
+ expect(report.summary.blockingWarnings).toBe(0);
+ expect(report.warnings).toEqual([
+ expect.objectContaining({
+ code: "shadowed-route",
+ blocking: false,
+ }),
+ ]);
+ expect(report.summary.fragments).toBe(1);
+ const caddyJson = vol.readFileSync(report.artifactPaths.caddyJson, "utf8");
+ expect(caddyJson).toContain('"remote_ip"');
+ expect(caddyJson).toContain('"status_code": 403');
+ });
+
+ test("skips live Docker Traefik label services with no running tasks", async () => {
+ const currentPaths = paths();
+ vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true });
+ vol.writeFileSync(
+ `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`,
+ "entryPoints:\n web:\n address: ':80'\n",
+ );
+ vi.mocked(db.query.applications.findMany).mockResolvedValue([]);
+ vi.mocked(db.query.compose.findMany).mockResolvedValue([]);
+ remoteDockerMock.listServices.mockResolvedValue([
+ {
+ ID: "stopped-service-id",
+ Spec: {
+ Name: "stopped-stack_web",
+ Mode: { Replicated: { Replicas: 1 } },
+ Labels: {
+ "traefik.http.routers.stopped.rule": "Host(`stopped.example.com`)",
+ "traefik.http.routers.stopped.entrypoints": "websecure",
+ "traefik.http.routers.stopped.service": "stopped",
+ "traefik.http.routers.stopped.tls.certresolver": "letsencrypt",
+ "traefik.http.services.stopped.loadbalancer.server.port": "8080",
+ },
+ },
+ },
+ {
+ ID: "running-service-id",
+ Spec: {
+ Name: "running-stack_web",
+ Mode: { Replicated: { Replicas: 1 } },
+ Labels: {
+ "traefik.http.routers.running.rule": "Host(`running.example.com`)",
+ "traefik.http.routers.running.entrypoints": "websecure",
+ "traefik.http.routers.running.service": "running",
+ "traefik.http.routers.running.tls.certresolver": "letsencrypt",
+ "traefik.http.services.running.loadbalancer.server.port": "8080",
+ },
+ },
+ },
+ ]);
+ remoteDockerMock.listTasks.mockResolvedValue([
+ {
+ ServiceID: "running-service-id",
+ DesiredState: "running",
+ Status: { State: "running" },
+ },
+ ]);
+
+ const report = await prepareCaddyMigration();
+ const caddyJson = vol.readFileSync(report.artifactPaths.caddyJson, "utf8");
+
+ expect(report.summary.blockingWarnings).toBe(0);
+ expect(caddyJson).toContain("running.example.com");
+ expect(caddyJson).toContain("running-stack_web:8080");
+ expect(caddyJson).not.toContain("stopped.example.com");
+ expect(caddyJson).not.toContain("stopped-stack_web:8080");
+ });
+
+ test("uses inspected container network aliases for reachability checks", async () => {
+ const currentPaths = paths();
+ vol.mkdirSync(currentPaths.DYNAMIC_TRAEFIK_PATH, { recursive: true });
+ vol.writeFileSync(
+ `${currentPaths.MAIN_TRAEFIK_PATH}/traefik.yml`,
+ "entryPoints:\n web:\n address: ':80'\n",
+ );
+ vol.writeFileSync(
+ `${currentPaths.DYNAMIC_TRAEFIK_PATH}/admin.yml`,
+ [
+ "http:",
+ " routers:",
+ " admin:",
+ " rule: Host(`admin.example.com`)",
+ " entryPoints: [websecure]",
+ " service: admin",
+ " tls:",
+ " certResolver: letsencrypt",
+ " services:",
+ " admin:",
+ " loadBalancer:",
+ " servers:",
+ " - url: http://admin:80",
+ ].join("\n"),
+ );
+ vi.mocked(db.query.applications.findMany).mockResolvedValue([]);
+ vi.mocked(db.query.compose.findMany).mockResolvedValue([]);
+ remoteDockerMock.listContainers.mockResolvedValue([
+ {
+ Id: "traefik-container",
+ Names: ["/dokploy-traefik"],
+ NetworkSettings: { Networks: {} },
+ Labels: {},
+ },
+ {
+ Id: "admin-container",
+ Names: ["/admin-console-admin-1"],
+ NetworkSettings: { Networks: {} },
+ Labels: {},
+ },
+ ]);
+ remoteDockerMock.getContainer.mockImplementation((id: string) => ({
+ inspect: vi.fn().mockResolvedValue(
+ id === "admin-container"
+ ? {
+ NetworkSettings: {
+ Networks: {
+ "dokploy-network": {
+ Aliases: ["admin-console-admin-1", "admin"],
+ },
+ },
+ },
+ }
+ : {
+ NetworkSettings: {
+ Networks: {
+ "dokploy-network": { Aliases: ["dokploy-traefik"] },
+ },
+ },
+ },
+ ),
+ }));
+
+ const report = await prepareCaddyMigration();
+
+ expect(report.summary.blockingWarnings).toBe(0);
+ expect(report.warnings).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ code: "unreachable-upstream" }),
+ ]),
+ );
+ expect(vol.readFileSync(report.artifactPaths.caddyJson, "utf8")).toContain(
+ '"dial": "admin:80"',
+ );
+ });
+});
diff --git a/apps/dokploy/__test__/caddy/migration/traefik-recovery.test.ts b/apps/dokploy/__test__/caddy/migration/traefik-recovery.test.ts
new file mode 100644
index 0000000000..56720c50ae
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/migration/traefik-recovery.test.ts
@@ -0,0 +1,290 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+const execAsyncMock = vi.hoisted(() => vi.fn());
+const execAsyncRemoteMock = vi.hoisted(() => vi.fn());
+const initializeTraefikServiceMock = vi.hoisted(() => vi.fn());
+const initializeStandaloneTraefikMock = vi.hoisted(() => vi.fn());
+
+vi.mock("@dokploy/server/utils/process/execAsync", () => ({
+ execAsync: execAsyncMock,
+ execAsyncRemote: execAsyncRemoteMock,
+}));
+
+vi.mock("@dokploy/server/db", () => ({
+ db: {
+ query: {
+ compose: { findMany: vi.fn().mockResolvedValue([]) },
+ },
+ },
+}));
+
+vi.mock("@dokploy/server/setup/caddy-setup", () => ({
+ initializeCaddyService: vi.fn(),
+ initializeStandaloneCaddy: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/setup/traefik-setup", () => ({
+ initializeTraefikService: initializeTraefikServiceMock,
+ initializeStandaloneTraefik: initializeStandaloneTraefikMock,
+}));
+
+import {
+ ensureTraefikRunningFromSnapshot,
+ getDockerResourceSnapshot,
+} from "@dokploy/server/services/settings";
+
+describe("Traefik rollback recovery", () => {
+ let resourceTypes: Record;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ resourceTypes = {
+ "dokploy-traefik": "unknown",
+ dokploy: "service",
+ };
+ initializeTraefikServiceMock.mockImplementation(async () => {
+ resourceTypes["dokploy-traefik"] = "service";
+ });
+ initializeStandaloneTraefikMock.mockImplementation(async () => {
+ resourceTypes["dokploy-traefik"] = "standalone";
+ });
+ execAsyncMock.mockImplementation(async (command: string) => {
+ const resourceName = command.match(/RESOURCE_NAME="([^"]+)"/)?.[1];
+ if (resourceName) {
+ return {
+ stdout: `${resourceTypes[resourceName] ?? "unknown"}\n`,
+ stderr: "",
+ };
+ }
+ if (command.includes("docker start dokploy-traefik")) {
+ throw new Error("No such container: dokploy-traefik");
+ }
+ if (command.includes("docker service inspect dokploy-traefik")) {
+ return {
+ stdout: JSON.stringify({ Replicated: { Replicas: 1 } }),
+ stderr: "",
+ };
+ }
+ if (command.includes("docker service ps dokploy-traefik")) {
+ return { stdout: "Running 1 second ago\n", stderr: "" };
+ }
+ if (
+ command.includes("docker container inspect dokploy-traefik") &&
+ command.includes("'{{json .}}'")
+ ) {
+ return {
+ stdout: `${JSON.stringify({
+ State: {
+ Running: resourceTypes["dokploy-traefik"] === "standalone",
+ },
+ HostConfig: { Binds: [], RestartPolicy: { Name: "always" } },
+ NetworkSettings: { Networks: { "dokploy-network": {} } },
+ Config: { Labels: {} },
+ })}\n`,
+ stderr: "",
+ };
+ }
+ if (
+ command.includes("docker container inspect dokploy-traefik") &&
+ command.includes(".State.Running")
+ ) {
+ return { stdout: "true\n", stderr: "" };
+ }
+ return { stdout: "", stderr: "" };
+ });
+ execAsyncRemoteMock.mockImplementation(execAsyncMock);
+ });
+
+ test("recreates missing standalone Traefik with captured snapshot metadata", async () => {
+ await ensureTraefikRunningFromSnapshot({
+ resourceName: "dokploy-traefik",
+ resourceType: "standalone",
+ running: true,
+ env: "FOO=bar\nBAZ=qux",
+ additionalPorts: [
+ { targetPort: 8080, publishedPort: 8080, protocol: "tcp" },
+ ],
+ image: "traefik:v3.7.1",
+ binds: [
+ "/etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml:ro",
+ "/etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic",
+ "/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json",
+ "/var/run/docker.sock:/var/run/docker.sock:ro",
+ ],
+ networks: ["dokploy-network"],
+ labels: { "example.label": "preserved" },
+ restartPolicy: { Name: "unless-stopped" },
+ });
+
+ expect(initializeStandaloneTraefikMock).toHaveBeenCalledWith({
+ env: ["FOO=bar", "BAZ=qux"],
+ additionalPorts: [
+ { targetPort: 8080, publishedPort: 8080, protocol: "tcp" },
+ ],
+ image: "traefik:v3.7.1",
+ serverId: undefined,
+ binds: [
+ "/etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml:ro",
+ "/etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic",
+ "/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json",
+ "/var/run/docker.sock:/var/run/docker.sock:ro",
+ ],
+ networks: ["dokploy-network"],
+ labels: { "example.label": "preserved" },
+ serviceMounts: undefined,
+ restartPolicy: { Name: "unless-stopped" },
+ serviceNetworks: [],
+ servicePlacement: undefined,
+ serviceLabels: { "example.label": "preserved" },
+ serviceEndpointPorts: undefined,
+ });
+ expect(initializeTraefikServiceMock).not.toHaveBeenCalled();
+ });
+
+ test("captures standalone Traefik binds and networks in rollback snapshot", async () => {
+ resourceTypes["dokploy-traefik"] = "standalone";
+ execAsyncMock.mockImplementation(async (command: string) => {
+ const resourceName = command.match(/RESOURCE_NAME="([^"]+)"/)?.[1];
+ if (resourceName) {
+ return {
+ stdout: `${resourceTypes[resourceName] ?? "unknown"}\n`,
+ stderr: "",
+ };
+ }
+ if (command.includes("docker container inspect dokploy-traefik")) {
+ if (command.includes("'{{json .}}'")) {
+ return {
+ stdout: `${JSON.stringify({
+ State: { Running: true },
+ HostConfig: {
+ Binds: [
+ "/etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml:ro",
+ "/etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic",
+ "/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json",
+ "/var/run/docker.sock:/var/run/docker.sock:ro",
+ ],
+ RestartPolicy: { Name: "always" },
+ },
+ NetworkSettings: { Networks: { "dokploy-network": {} } },
+ Config: { Labels: { "example.label": "preserved" } },
+ })}\n`,
+ stderr: "",
+ };
+ }
+ if (command.includes(".Config.Image")) {
+ return { stdout: "traefik:v3.7.1\n", stderr: "" };
+ }
+ }
+ if (command.includes("docker container inspect dokploy-traefik")) {
+ return { stdout: "{}\n", stderr: "" };
+ }
+ return { stdout: "[]\n", stderr: "" };
+ });
+
+ const snapshot = await getDockerResourceSnapshot("dokploy-traefik");
+
+ expect(snapshot.binds).toEqual([
+ "/etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml:ro",
+ "/etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic",
+ "/etc/dokploy/traefik/acme.json:/letsencrypt/acme.json",
+ "/var/run/docker.sock:/var/run/docker.sock:ro",
+ ]);
+ expect(snapshot.networks).toEqual(["dokploy-network"]);
+ expect(snapshot.labels).toEqual({ "example.label": "preserved" });
+ expect(snapshot.restartPolicy).toEqual({ Name: "always" });
+ });
+
+ test("recreates missing Traefik service with captured service shape", async () => {
+ await ensureTraefikRunningFromSnapshot({
+ resourceName: "dokploy-traefik",
+ resourceType: "service",
+ running: false,
+ replicas: 2,
+ env: "FOO=bar",
+ additionalPorts: [
+ { targetPort: 8080, publishedPort: 8080, protocol: "tcp" },
+ ],
+ image: "traefik:v3.7.1",
+ mounts: [
+ {
+ Type: "bind",
+ Source: "/etc/dokploy/traefik/acme.json",
+ Target: "/letsencrypt/acme.json",
+ },
+ ],
+ networks: [{ Target: "dokploy-network" }],
+ labels: { "service.label": "preserved" },
+ containerLabels: { "container.label": "preserved" },
+ placement: { Constraints: ["node.role==manager"] },
+ endpointPorts: [
+ {
+ TargetPort: 443,
+ PublishedPort: 443,
+ Protocol: "tcp",
+ PublishMode: "host",
+ },
+ ],
+ });
+
+ expect(initializeTraefikServiceMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ env: ["FOO=bar"],
+ replicas: 2,
+ serviceMounts: [
+ {
+ Type: "bind",
+ Source: "/etc/dokploy/traefik/acme.json",
+ Target: "/letsencrypt/acme.json",
+ },
+ ],
+ serviceNetworks: [{ Target: "dokploy-network" }],
+ serviceLabels: { "service.label": "preserved" },
+ serviceContainerLabels: { "container.label": "preserved" },
+ servicePlacement: { Constraints: ["node.role==manager"] },
+ serviceEndpointPorts: [
+ {
+ TargetPort: 443,
+ PublishedPort: 443,
+ Protocol: "tcp",
+ PublishMode: "host",
+ },
+ ],
+ }),
+ );
+ });
+
+ test("falls back to generic setup when exact recreation fails", async () => {
+ initializeStandaloneTraefikMock.mockRejectedValueOnce(
+ new Error("stale mount"),
+ );
+
+ await ensureTraefikRunningFromSnapshot({
+ resourceName: "dokploy-traefik",
+ resourceType: "standalone",
+ running: true,
+ image: "traefik:v3.7.1",
+ });
+
+ expect(initializeStandaloneTraefikMock).toHaveBeenCalledOnce();
+ expect(initializeTraefikServiceMock).toHaveBeenCalledWith(
+ expect.objectContaining({ image: "traefik:v3.7.1" }),
+ );
+ });
+
+ test("falls back to standalone recreation when no service shape can be inferred", async () => {
+ resourceTypes.dokploy = "unknown";
+
+ await ensureTraefikRunningFromSnapshot({
+ resourceName: "dokploy-traefik",
+ resourceType: "unknown",
+ running: false,
+ });
+
+ expect(initializeStandaloneTraefikMock).toHaveBeenCalledWith({
+ env: undefined,
+ additionalPorts: [],
+ image: undefined,
+ serverId: undefined,
+ });
+ });
+});
diff --git a/apps/dokploy/__test__/caddy/migration/upstream-preflight.test.ts b/apps/dokploy/__test__/caddy/migration/upstream-preflight.test.ts
new file mode 100644
index 0000000000..0ae1d2d7f2
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/migration/upstream-preflight.test.ts
@@ -0,0 +1,270 @@
+import { fs, vol } from "memfs";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+vi.mock("node:fs", () => ({
+ ...fs,
+ default: fs,
+}));
+
+const execAsyncMock = vi.hoisted(() => vi.fn());
+const execAsyncRemoteMock = vi.hoisted(() => vi.fn());
+
+vi.mock("@dokploy/server/utils/process/execAsync", () => ({
+ execAsync: execAsyncMock,
+ execAsyncRemote: execAsyncRemoteMock,
+}));
+
+import type { CaddyMigrationReport, CaddyRouteFragment } from "@dokploy/server";
+import {
+ getCaddyMigrationArtifactPaths,
+ runCaddyMigrationUpstreamPreflight,
+} from "@dokploy/server";
+
+const createReport = (migrationId: string): CaddyMigrationReport => {
+ const artifactPaths = getCaddyMigrationArtifactPaths(migrationId);
+ return {
+ migrationId,
+ serverId: null,
+ createdAt: "2026-05-22T00:00:00.000Z",
+ updatedAt: "2026-05-22T00:00:00.000Z",
+ status: "prepared",
+ sourceProvider: "traefik",
+ targetProvider: "caddy",
+ artifactPaths,
+ inputs: {
+ traefikStaticConfigPath: "",
+ traefikStaticConfigFound: true,
+ dynamicFiles: [],
+ dbApplicationDomains: 0,
+ dbComposeDomains: 0,
+ composeFilesScanned: [],
+ composeFilesSkipped: [],
+ },
+ summary: {
+ fragments: 1,
+ routes: 1,
+ warnings: 0,
+ blockingWarnings: 0,
+ },
+ validation: { status: "passed", message: "ok" },
+ warnings: [],
+ events: [],
+ };
+};
+
+const writeFragment = (
+ report: CaddyMigrationReport,
+ fragment: CaddyRouteFragment,
+) => {
+ vol.mkdirSync(report.artifactPaths.fragmentsDir, { recursive: true });
+ vol.writeFileSync(
+ `${report.artifactPaths.fragmentsDir}/${fragment.id}.json`,
+ `${JSON.stringify(fragment, null, 2)}\n`,
+ );
+};
+
+describe("Caddy migration upstream preflight", () => {
+ beforeEach(() => {
+ vol.reset();
+ vi.clearAllMocks();
+ execAsyncMock.mockResolvedValue({ stdout: "passed\n", stderr: "" });
+ execAsyncRemoteMock.mockResolvedValue({ stdout: "passed\n", stderr: "" });
+ });
+
+ test("deduplicates upstream probes while preserving every route reference", async () => {
+ const report = createReport("caddy-preflight-dedupe");
+ writeFragment(report, {
+ version: 1,
+ id: "manual",
+ source: "manual",
+ routes: [
+ {
+ id: "route-a",
+ source: "manual",
+ hosts: ["a.example.com"],
+ upstreams: ["http://app:3000"],
+ },
+ {
+ id: "route-b",
+ source: "manual",
+ hosts: ["b.example.com"],
+ upstreams: ["http://app:3000"],
+ },
+ ],
+ });
+
+ const preflight = await runCaddyMigrationUpstreamPreflight(report);
+
+ expect(preflight.status).toBe("passed");
+ expect(execAsyncMock).toHaveBeenCalledTimes(1);
+ expect(preflight.checks).toHaveLength(1);
+ expect(preflight.checks[0]).toMatchObject({
+ dial: "app:3000",
+ host: "app",
+ port: 3000,
+ network: "dokploy-network",
+ status: "passed",
+ routes: [
+ expect.objectContaining({ routeId: "route-a" }),
+ expect.objectContaining({ routeId: "route-b" }),
+ ],
+ });
+ });
+
+ test("reports invalid no-port upstreams without running Docker", async () => {
+ const report = createReport("caddy-preflight-invalid");
+ writeFragment(report, {
+ version: 1,
+ id: "manual",
+ source: "manual",
+ routes: [
+ {
+ id: "admin",
+ source: "manual",
+ hosts: ["admin.example.com"],
+ upstreams: ["http://admin"],
+ },
+ ],
+ });
+
+ const preflight = await runCaddyMigrationUpstreamPreflight(report);
+
+ expect(preflight.status).toBe("failed");
+ expect(execAsyncMock).not.toHaveBeenCalled();
+ expect(preflight.checks[0]).toMatchObject({
+ dial: "http://admin",
+ network: "dokploy-network",
+ status: "failed",
+ reason: expect.stringContaining("explicit port"),
+ routes: [expect.objectContaining({ routeId: "admin" })],
+ });
+ });
+
+ test("records DNS and TCP probe failures with route context", async () => {
+ const report = createReport("caddy-preflight-dns");
+ writeFragment(report, {
+ version: 1,
+ id: "manual",
+ source: "manual",
+ routes: [
+ {
+ id: "missing",
+ source: "manual",
+ hosts: ["missing.example.com"],
+ upstreams: ["http://missing:3000"],
+ },
+ ],
+ });
+ execAsyncMock.mockRejectedValueOnce(
+ Object.assign(new Error("probe failed"), { stdout: "dns_failed\n" }),
+ );
+
+ const preflight = await runCaddyMigrationUpstreamPreflight(report);
+
+ expect(preflight.status).toBe("failed");
+ expect(preflight.checks[0]).toMatchObject({
+ dial: "missing:3000",
+ host: "missing",
+ port: 3000,
+ network: "dokploy-network",
+ status: "failed",
+ reason: "DNS resolution failed",
+ routes: [expect.objectContaining({ routeId: "missing" })],
+ });
+ });
+
+ test("probes identical dials separately when route networks differ", async () => {
+ const report = createReport("caddy-preflight-networks");
+ writeFragment(report, {
+ version: 1,
+ id: "manual",
+ source: "manual",
+ routes: [
+ {
+ id: "shared-a",
+ source: "manual",
+ hosts: ["a.example.com"],
+ upstreams: ["http://web:8080"],
+ upstreamNetwork: "project-a",
+ },
+ {
+ id: "shared-b",
+ source: "manual",
+ hosts: ["b.example.com"],
+ upstreams: ["http://web:8080"],
+ upstreamNetwork: "project-b",
+ },
+ ],
+ });
+
+ const preflight = await runCaddyMigrationUpstreamPreflight(report);
+
+ expect(preflight.status).toBe("passed");
+ expect(preflight.network).toBe("mixed");
+ expect(preflight.networks).toEqual(["project-a", "project-b"]);
+ expect(preflight.checks).toHaveLength(2);
+ expect(execAsyncMock).toHaveBeenCalledTimes(2);
+ expect(execAsyncMock.mock.calls[0]?.[0]).toContain("--network project-a");
+ expect(execAsyncMock.mock.calls[1]?.[0]).toContain("--network project-b");
+ });
+
+ test("uses a one-shot container probe on the route network", async () => {
+ const report = createReport("caddy-preflight-service");
+ writeFragment(report, {
+ version: 1,
+ id: "manual",
+ source: "manual",
+ routes: [
+ {
+ id: "app",
+ source: "manual",
+ hosts: ["app.example.com"],
+ upstreams: ["http://app:3000"],
+ },
+ ],
+ });
+ execAsyncMock.mockResolvedValueOnce({ stdout: "passed\n", stderr: "" });
+
+ const preflight = await runCaddyMigrationUpstreamPreflight(report);
+
+ expect(preflight.status).toBe("passed");
+ expect(preflight.probeMode).toBe("standalone");
+ expect(execAsyncMock.mock.calls[0]?.[0]).toContain("docker run --rm");
+ expect(execAsyncMock.mock.calls[0]?.[0]).toContain(
+ "--network dokploy-network",
+ );
+ });
+
+ test("falls back to a temporary service probe when the overlay network is not attachable", async () => {
+ const report = createReport("caddy-preflight-service-fallback");
+ writeFragment(report, {
+ version: 1,
+ id: "manual",
+ source: "manual",
+ routes: [
+ {
+ id: "app",
+ source: "manual",
+ hosts: ["app.example.com"],
+ upstreams: ["http://app:3000"],
+ },
+ ],
+ });
+ execAsyncMock
+ .mockRejectedValueOnce(
+ Object.assign(new Error("docker run failed"), {
+ stderr:
+ "Error response from daemon: network dokploy-network is not manually attachable",
+ }),
+ )
+ .mockResolvedValueOnce({ stdout: "passed\n", stderr: "" });
+
+ const preflight = await runCaddyMigrationUpstreamPreflight(report);
+
+ expect(preflight.status).toBe("passed");
+ expect(preflight.probeMode).toBe("service");
+ expect(execAsyncMock).toHaveBeenCalledTimes(2);
+ expect(execAsyncMock.mock.calls[0]?.[0]).toContain("docker run --rm");
+ expect(execAsyncMock.mock.calls[1]?.[0]).toContain("docker service create");
+ });
+});
diff --git a/apps/dokploy/__test__/caddy/preview-deployment.test.ts b/apps/dokploy/__test__/caddy/preview-deployment.test.ts
new file mode 100644
index 0000000000..c5d5db3cfa
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/preview-deployment.test.ts
@@ -0,0 +1,300 @@
+import { beforeEach, expect, test, vi } from "vitest";
+
+const previewDeploymentRow = {
+ previewDeploymentId: "preview-1",
+ applicationId: "app-1",
+ appName: "preview-my-app-fixedpw",
+ pullRequestCommentId: "comment-1",
+ domainId: null,
+};
+
+const insertedPreviewDeployment = { ...previewDeploymentRow };
+
+const dbMock = vi.hoisted(() => ({
+ query: {
+ organization: { findFirst: vi.fn() },
+ previewDeployments: { findFirst: vi.fn() },
+ },
+ insert: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/db", () => ({
+ db: dbMock,
+}));
+
+vi.mock("@dokploy/server/templates", () => ({
+ generatePassword: vi.fn(() => "fixedpw"),
+}));
+
+vi.mock("@dokploy/server/services/application", () => ({
+ findApplicationById: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/services/deployment", () => ({
+ removeDeploymentsByPreviewDeploymentId: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/services/domain", () => ({
+ createDomain: vi.fn(),
+ removeDomainById: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/services/github", () => ({
+ getIssueComment: vi.fn(() => "preview comment"),
+}));
+
+vi.mock("@dokploy/server/services/web-server-settings", () => ({
+ getWebServerSettings: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/utils/docker/utils", () => ({
+ removeService: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/utils/filesystem/directory", () => ({
+ removeDirectoryCode: vi.fn(),
+}));
+
+const createCommentMock = vi.hoisted(() => vi.fn());
+const deleteCommentMock = vi.hoisted(() => vi.fn());
+
+vi.mock("@dokploy/server/utils/providers/github", () => ({
+ authGithub: vi.fn(() => ({
+ rest: {
+ issues: {
+ createComment: createCommentMock,
+ deleteComment: deleteCommentMock,
+ },
+ },
+ })),
+}));
+
+vi.mock("@dokploy/server/utils/traefik/application", () => ({
+ removeTraefikConfig: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/utils/web-server/domain", () => ({
+ manageWebServerDomain: vi.fn(),
+ removeWebServerDomain: vi.fn(),
+}));
+
+import { findApplicationById } from "@dokploy/server/services/application";
+import {
+ createDomain,
+ removeDomainById,
+} from "@dokploy/server/services/domain";
+import {
+ createPreviewDeployment,
+ removePreviewDeployment,
+} from "@dokploy/server/services/preview-deployment";
+import {
+ manageWebServerDomain,
+ removeWebServerDomain,
+} from "@dokploy/server/utils/web-server/domain";
+
+const createApplication = () => ({
+ applicationId: "app-1",
+ appName: "my-app",
+ name: "My App",
+ owner: "dokploy",
+ repository: "dokploy",
+ github: {},
+ serverId: null,
+ server: {
+ ipAddress: "192.0.2.10",
+ },
+ previewWildcard: "*.sslip.io",
+ previewPath: "/",
+ previewPort: 3000,
+ previewHttps: true,
+ previewCertificateType: "letsencrypt",
+ previewCustomCertResolver: null,
+ environment: {
+ project: {
+ organizationId: "org-1",
+ },
+ },
+});
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ Object.assign(insertedPreviewDeployment, previewDeploymentRow);
+ dbMock.query.organization.findFirst.mockResolvedValue({ ownerId: "user-1" });
+ createCommentMock.mockResolvedValue({ data: { id: 12345 } });
+ dbMock.insert.mockReturnValue({
+ values: vi.fn().mockReturnValue({
+ returning: vi.fn().mockResolvedValue([insertedPreviewDeployment]),
+ }),
+ });
+ dbMock.update.mockReturnValue({
+ set: vi.fn().mockReturnValue({
+ where: vi.fn().mockResolvedValue([]),
+ }),
+ });
+ dbMock.delete.mockReturnValue({
+ where: vi.fn().mockReturnValue({
+ returning: vi.fn().mockResolvedValue([]),
+ }),
+ });
+ vi.mocked(removeDomainById).mockResolvedValue(undefined as never);
+ vi.mocked(removeWebServerDomain).mockResolvedValue(undefined as never);
+ deleteCommentMock.mockResolvedValue(undefined);
+});
+
+test("creates preview domains through the active web server provider", async () => {
+ const application = createApplication();
+ const domain = {
+ domainId: "domain-1",
+ host: "preview-my-app-fixedpw-192-0-2-10.sslip.io",
+ uniqueConfigKey: 17,
+ };
+ vi.mocked(findApplicationById).mockResolvedValue(application as never);
+ vi.mocked(createDomain).mockResolvedValue(domain as never);
+
+ await createPreviewDeployment({
+ applicationId: "app-1",
+ pullRequestId: "pr-1",
+ pullRequestNumber: "42",
+ branch: "feature/caddy-preview",
+ } as never);
+
+ expect(createDomain).toHaveBeenCalledWith(
+ expect.objectContaining({
+ host: "preview-my-app-fixedpw-192-0-2-10.sslip.io",
+ path: "/",
+ port: 3000,
+ https: true,
+ certificateType: "letsencrypt",
+ domainType: "preview",
+ previewDeploymentId: "preview-1",
+ }),
+ );
+ expect(manageWebServerDomain).toHaveBeenCalledWith(
+ expect.objectContaining({
+ applicationId: "app-1",
+ appName: "preview-my-app-fixedpw",
+ }),
+ domain,
+ );
+});
+
+test("cleans up preview domain state when provider route creation fails", async () => {
+ const application = createApplication();
+ const domain = {
+ domainId: "domain-1",
+ host: "preview-my-app-fixedpw-192-0-2-10.sslip.io",
+ uniqueConfigKey: 17,
+ };
+ vi.mocked(findApplicationById).mockResolvedValue(application as never);
+ vi.mocked(createDomain).mockResolvedValue(domain as never);
+ vi.mocked(manageWebServerDomain).mockRejectedValueOnce(
+ new Error("caddy reload failed") as never,
+ );
+
+ await expect(
+ createPreviewDeployment({
+ applicationId: "app-1",
+ pullRequestId: "pr-1",
+ pullRequestNumber: "42",
+ branch: "feature/caddy-preview",
+ } as never),
+ ).rejects.toThrow("caddy reload failed");
+
+ expect(removeDomainById).toHaveBeenCalledWith("domain-1");
+ expect(removeWebServerDomain).not.toHaveBeenCalled();
+ expect(dbMock.delete).toHaveBeenCalled();
+ expect(deleteCommentMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ owner: "dokploy",
+ repo: "dokploy",
+ comment_id: 12345,
+ }),
+ );
+});
+
+test("removes preview routes when preview domain linking fails after route creation", async () => {
+ const application = createApplication();
+ const domain = {
+ domainId: "domain-1",
+ host: "preview-my-app-fixedpw-192-0-2-10.sslip.io",
+ uniqueConfigKey: 17,
+ };
+ vi.mocked(findApplicationById).mockResolvedValue(application as never);
+ vi.mocked(createDomain).mockResolvedValue(domain as never);
+ dbMock.update.mockReturnValueOnce({
+ set: vi.fn().mockReturnValue({
+ where: vi.fn().mockRejectedValue(new Error("db update failed")),
+ }),
+ });
+
+ await expect(
+ createPreviewDeployment({
+ applicationId: "app-1",
+ pullRequestId: "pr-1",
+ pullRequestNumber: "42",
+ branch: "feature/caddy-preview",
+ } as never),
+ ).rejects.toThrow("db update failed");
+
+ expect(removeWebServerDomain).toHaveBeenCalledWith(
+ expect.objectContaining({
+ appName: "preview-my-app-fixedpw",
+ }),
+ 17,
+ );
+ expect(removeDomainById).toHaveBeenCalledWith("domain-1");
+ expect(dbMock.delete).toHaveBeenCalled();
+});
+
+test("removes preview domains through the active web server provider", async () => {
+ const application = createApplication();
+ vi.mocked(findApplicationById).mockResolvedValue(application as never);
+ dbMock.query.previewDeployments.findFirst.mockResolvedValue({
+ ...previewDeploymentRow,
+ domain: {
+ domainId: "domain-1",
+ uniqueConfigKey: 17,
+ },
+ application: {
+ applicationId: "app-1",
+ serverId: null,
+ },
+ });
+
+ await removePreviewDeployment("preview-1");
+
+ expect(removeWebServerDomain).toHaveBeenCalledWith(
+ expect.objectContaining({
+ applicationId: "app-1",
+ appName: "preview-my-app-fixedpw",
+ }),
+ 17,
+ );
+});
+
+test("keeps preview deployment row when provider route removal fails", async () => {
+ const application = createApplication();
+ vi.mocked(findApplicationById).mockResolvedValue(application as never);
+ dbMock.query.previewDeployments.findFirst.mockResolvedValue({
+ ...previewDeploymentRow,
+ domain: {
+ domainId: "domain-1",
+ uniqueConfigKey: 17,
+ },
+ application: {
+ applicationId: "app-1",
+ serverId: null,
+ },
+ });
+ vi.mocked(removeWebServerDomain).mockRejectedValueOnce(
+ new Error("caddy route cleanup failed") as never,
+ );
+
+ await expect(removePreviewDeployment("preview-1")).rejects.toThrow(
+ "caddy route cleanup failed",
+ );
+
+ expect(dbMock.delete).not.toHaveBeenCalled();
+});
diff --git a/apps/dokploy/__test__/caddy/provider-neutral-ui-contract.test.ts b/apps/dokploy/__test__/caddy/provider-neutral-ui-contract.test.ts
new file mode 100644
index 0000000000..f7411927c8
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/provider-neutral-ui-contract.test.ts
@@ -0,0 +1,114 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, test } from "vitest";
+
+const readSource = (relativePath: string) =>
+ readFileSync(new URL(relativePath, import.meta.url), "utf8");
+
+describe("provider-neutral Caddy UI contract", () => {
+ test("labels the account menu file-browser link as web-server files", () => {
+ const source = readSource("../../components/layouts/user-nav.tsx");
+
+ expect(source).toContain("permissions?.traefikFiles.read");
+ expect(source).toContain('router.push("/dashboard/traefik")');
+ expect(source).toContain("Web Server Files");
+ expect(source).not.toMatch(/>\s*Traefik\s*);
+ });
+
+ test("keeps custom-role file-browser permission copy provider-neutral", () => {
+ const source = readSource(
+ "../../components/proprietary/roles/manage-custom-roles.tsx",
+ );
+
+ expect(source).toContain("traefikFiles:");
+ expect(source).toContain('label: "Web Server Files"');
+ expect(source).toContain(
+ 'description: "Access to the active web server file browser"',
+ );
+ expect(source).toContain(
+ 'description: "View active web server configuration files"',
+ );
+ expect(source).toContain(
+ 'description: "Edit and save active web server configuration files"',
+ );
+ expect(source).not.toContain("Traefik Files");
+ expect(source).not.toContain("Traefik file system configuration");
+ expect(source).not.toContain("Traefik configuration files");
+ });
+
+ test("uses generic copy while active web-server provider is unresolved", () => {
+ const actions = readSource(
+ "../../components/dashboard/settings/servers/actions/show-traefik-actions.tsx",
+ );
+ const envEditor = readSource(
+ "../../components/dashboard/settings/web-server/edit-web-server-env.tsx",
+ );
+ const fileSystem = readSource(
+ "../../components/dashboard/file-system/show-traefik-system.tsx",
+ );
+
+ expect(actions).toContain("EditWebServerEnv");
+ expect(actions).not.toContain("EditTraefikEnv");
+ expect(envEditor).toContain("readWebServerEnv");
+ expect(envEditor).toContain("writeWebServerEnv");
+ expect(envEditor).not.toContain("readTraefikEnv");
+ expect(envEditor).not.toContain("writeTraefikEnv");
+ expect(actions).toContain(': "Web Server";');
+ expect(actions).toMatch(
+ /const resourceName =[\s\S]*activeProvider === "caddy"[\s\S]*"dokploy-caddy"[\s\S]*activeProvider === "traefik"[\s\S]*"dokploy-traefik"[\s\S]*: null;/,
+ );
+ expect(actions).toContain("!activeProvider ||");
+ expect(actions).toContain("{resourceName && (");
+ expect(actions).toContain('activeProvider === "traefik" && (');
+ expect(fileSystem).toContain('const isTraefik = provider === "traefik";');
+ expect(fileSystem).toContain(
+ "Provider-specific edit controls appear after Dokploy resolves the active provider.",
+ );
+ });
+
+ test("keeps application advanced web-server config provider-neutral while provider is unresolved", () => {
+ const source = readSource(
+ "../../components/dashboard/application/advanced/traefik/show-traefik-config.tsx",
+ );
+
+ expect(source).toContain('const isCaddy = activeProvider === "caddy";');
+ expect(source).toContain('const isTraefik = activeProvider === "traefik";');
+ expect(source).toContain(': "Web Server";');
+ expect(source).toContain(
+ "Provider-specific edit controls appear after Dokploy resolves the active provider.",
+ );
+ expect(source).toContain("{isTraefik && (");
+ expect(source).not.toContain('activeProvider !== "caddy"');
+ expect(source).not.toContain(
+ 'activeProvider === "caddy" ? "Caddy" : "Traefik"',
+ );
+ });
+
+ test("keeps read-only web-server files scrollable", () => {
+ const editor = readSource("../../components/shared/code-editor.tsx");
+ const fileEditor = readSource(
+ "../../components/dashboard/file-system/show-traefik-file.tsx",
+ );
+
+ expect(editor).toContain("pointer-events-none absolute");
+ expect(fileEditor).toContain(
+ 'activeProvider === "caddy" ? "json" : "yaml"',
+ );
+ expect(fileEditor).toContain("disabled={!isTraefik || canEdit}");
+ });
+
+ test("keeps Caddy web-server settings cards padded", () => {
+ const providerSelector = readSource(
+ "../../components/dashboard/settings/web-server/web-server-provider-selector.tsx",
+ );
+ const migrationPanel = readSource(
+ "../../components/dashboard/settings/web-server/caddy-migration-panel.tsx",
+ );
+
+ expect(providerSelector).toContain('CardHeader className="pb-3"');
+ expect(providerSelector).toContain('CardContent className="space-y-3"');
+ expect(migrationPanel).toContain('CardHeader className="pb-3"');
+ expect(migrationPanel).toContain('CardContent className="space-y-4"');
+ expect(providerSelector).not.toContain('CardHeader className="px-0 pt-0"');
+ expect(migrationPanel).not.toContain('CardHeader className="px-0"');
+ });
+});
diff --git a/apps/dokploy/__test__/caddy/request-log-cleanup.test.ts b/apps/dokploy/__test__/caddy/request-log-cleanup.test.ts
new file mode 100644
index 0000000000..fc65c3ad8e
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/request-log-cleanup.test.ts
@@ -0,0 +1,124 @@
+import { beforeEach, expect, test, vi } from "vitest";
+
+const existsSyncMock = vi.hoisted(() => vi.fn());
+const execAsyncMock = vi.hoisted(() => vi.fn());
+const getWebServerSettingsMock = vi.hoisted(() => vi.fn());
+const updateWebServerSettingsMock = vi.hoisted(() => vi.fn());
+const resolveWebServerProviderMock = vi.hoisted(() => vi.fn());
+const scheduleJobMock = vi.hoisted(() => vi.fn());
+const scheduledJobsMock = vi.hoisted(() => ({}) as Record);
+let scheduledCallback: (() => Promise) | undefined;
+
+vi.mock("node:fs", () => ({
+ default: {
+ existsSync: existsSyncMock,
+ },
+ existsSync: existsSyncMock,
+}));
+
+vi.mock("node-schedule", () => ({
+ scheduledJobs: scheduledJobsMock,
+ scheduleJob: scheduleJobMock,
+}));
+
+vi.mock("@dokploy/server/constants", () => ({
+ ACCESS_LOG_RETAINED_LINES: 1000,
+ paths: () => ({
+ DYNAMIC_TRAEFIK_PATH: "/etc/dokploy/traefik/dynamic",
+ CADDY_ACCESS_LOG_PATH: "/etc/dokploy/caddy/access.log",
+ }),
+}));
+
+vi.mock("@dokploy/server/services/web-server-settings", () => ({
+ getWebServerSettings: getWebServerSettingsMock,
+ updateWebServerSettings: updateWebServerSettingsMock,
+ resolveWebServerProvider: resolveWebServerProviderMock,
+}));
+
+vi.mock("@dokploy/server/utils/process/execAsync", () => ({
+ execAsync: execAsyncMock,
+}));
+
+import { startLogCleanup } from "@dokploy/server/utils/access-log/handler";
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ for (const key of Object.keys(scheduledJobsMock)) {
+ delete scheduledJobsMock[key];
+ }
+ scheduledCallback = undefined;
+ existsSyncMock.mockReturnValue(true);
+ execAsyncMock.mockResolvedValue({ stdout: "", stderr: "" });
+ updateWebServerSettingsMock.mockResolvedValue({});
+ scheduleJobMock.mockImplementation((name, _cron, callback) => {
+ if (name === "access-log-cleanup") {
+ scheduledCallback = callback;
+ }
+ return { cancel: vi.fn() };
+ });
+});
+
+test("keeps Traefik access-log cleanup and signal behavior", async () => {
+ resolveWebServerProviderMock.mockResolvedValue("traefik");
+ execAsyncMock.mockImplementation(async (command: string) => {
+ if (command.startsWith("docker ps -q")) {
+ return { stdout: "abc123\n", stderr: "" };
+ }
+ return { stdout: "", stderr: "" };
+ });
+
+ await startLogCleanup("0 0 * * *");
+ await scheduledCallback?.();
+
+ expect(execAsyncMock).toHaveBeenNthCalledWith(
+ 1,
+ "tail -n 1000 /etc/dokploy/traefik/dynamic/access.log > /etc/dokploy/traefik/dynamic/access.log.tmp && mv /etc/dokploy/traefik/dynamic/access.log.tmp /etc/dokploy/traefik/dynamic/access.log",
+ );
+ expect(execAsyncMock).toHaveBeenNthCalledWith(
+ 2,
+ 'docker ps -q --filter "name=dokploy-traefik" --filter "status=running" | head -n 1',
+ );
+ expect(execAsyncMock).toHaveBeenNthCalledWith(
+ 3,
+ "docker exec abc123 kill -USR1 1",
+ );
+});
+
+test("skips Traefik log reopen when no running container is found", async () => {
+ resolveWebServerProviderMock.mockResolvedValue("traefik");
+
+ await startLogCleanup("0 0 * * *");
+ await scheduledCallback?.();
+
+ expect(execAsyncMock).toHaveBeenCalledTimes(2);
+ expect(execAsyncMock).not.toHaveBeenCalledWith(
+ expect.stringContaining("kill -USR1"),
+ );
+});
+
+test("cleans Caddy access logs without signaling Traefik", async () => {
+ resolveWebServerProviderMock.mockResolvedValue("caddy");
+
+ await startLogCleanup("0 0 * * *");
+ await scheduledCallback?.();
+
+ expect(execAsyncMock).toHaveBeenCalledTimes(1);
+ expect(execAsyncMock).toHaveBeenCalledWith(
+ "tail -n 1000 /etc/dokploy/caddy/access.log > /etc/dokploy/caddy/access.log.tmp && cat /etc/dokploy/caddy/access.log.tmp > /etc/dokploy/caddy/access.log && rm /etc/dokploy/caddy/access.log.tmp",
+ );
+ expect(execAsyncMock).not.toHaveBeenCalledWith(
+ "docker exec dokploy-traefik kill -USR1 1",
+ );
+});
+
+test("does not persist invalid cleanup cron schedules", async () => {
+ const existingCancel = vi.fn();
+ scheduledJobsMock["access-log-cleanup"] = { cancel: existingCancel };
+ scheduleJobMock.mockReturnValueOnce(null);
+
+ const result = await startLogCleanup("not a cron");
+
+ expect(result).toBe(false);
+ expect(existingCancel).not.toHaveBeenCalled();
+ expect(updateWebServerSettingsMock).not.toHaveBeenCalled();
+});
diff --git a/apps/dokploy/__test__/caddy/request-log-utils.test.ts b/apps/dokploy/__test__/caddy/request-log-utils.test.ts
new file mode 100644
index 0000000000..fdb328e993
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/request-log-utils.test.ts
@@ -0,0 +1,110 @@
+import { parseRawConfig, processLogs } from "@dokploy/server";
+import { expect, test } from "vitest";
+
+const caddyLogEntry = JSON.stringify({
+ level: "info",
+ ts: 1_724_603_677.306,
+ logger: "http.log.access",
+ msg: "handled request",
+ server_name: "http",
+ request: {
+ remote_ip: "192.0.2.10",
+ remote_port: "54321",
+ client_ip: "198.51.100.20",
+ proto: "HTTP/2.0",
+ method: "GET",
+ host: "app.example.com",
+ uri: "/dashboard?_rsc=1",
+ tls: {
+ resumed: false,
+ },
+ headers: {
+ "User-Agent": ["curl/8.0"],
+ },
+ },
+ bytes_read: 7,
+ duration: 0.014729375,
+ size: 1234,
+ status: 200,
+});
+
+const traefikLogEntry = `{"ClientAddr":"172.19.0.1:56732","ClientHost":"172.19.0.1","ClientPort":"56732","ClientUsername":"-","DownstreamContentSize":0,"DownstreamStatus":304,"Duration":14729375,"OriginContentSize":0,"OriginDuration":14051833,"OriginStatus":304,"Overhead":677542,"RequestAddr":"app.traefik.test","RequestContentSize":0,"RequestCount":122,"RequestHost":"app.traefik.test","RequestMethod":"GET","RequestPath":"/dashboard?_rsc=1rugv","RequestPort":"-","RequestProtocol":"HTTP/1.1","RequestScheme":"http","RetryAttempts":0,"RouterName":"app-router@docker","ServiceAddr":"10.0.1.15:3000","ServiceName":"app-service@docker","ServiceURL":{"Scheme":"http","Opaque":"","User":null,"Host":"10.0.1.15:3000","Path":"","RawPath":"","ForceQuery":false,"RawQuery":"","Fragment":"","RawFragment":""},"StartLocal":"2024-08-25T04:34:37.306691884Z","StartUTC":"2024-08-25T04:34:37.306691884Z","entryPointName":"web","level":"info","msg":"","time":"2024-08-25T04:34:37Z"}`;
+
+test("normalizes Caddy JSON access logs into the Requests table shape", () => {
+ const result = parseRawConfig(caddyLogEntry);
+
+ expect(result.totalCount).toBe(1);
+ expect(result.data[0]).toMatchObject({
+ Provider: "caddy",
+ ClientAddr: "192.0.2.10:54321",
+ ClientHost: "198.51.100.20",
+ ClientPort: "54321",
+ DownstreamStatus: 200,
+ Duration: 14_729_375,
+ RequestHost: "app.example.com",
+ RequestMethod: "GET",
+ RequestPath: "/dashboard?_rsc=1",
+ RequestPort: "443",
+ RequestProtocol: "HTTP/2.0",
+ RequestScheme: "https",
+ ServiceName: "http",
+ request_User_Agent: "curl/8.0",
+ });
+});
+
+test("normalizes Caddy user-agent headers case-insensitively", () => {
+ const result = parseRawConfig(
+ JSON.stringify({
+ ...JSON.parse(caddyLogEntry),
+ request: {
+ ...JSON.parse(caddyLogEntry).request,
+ headers: {
+ "user-agent": ["lowercase-agent"],
+ },
+ },
+ }),
+ );
+
+ expect(result.data[0]?.request_User_Agent).toBe("lowercase-agent");
+});
+
+test("filters and groups normalized Caddy request logs", () => {
+ const rawConfig = `${caddyLogEntry}\n${JSON.stringify({
+ ...JSON.parse(caddyLogEntry),
+ ts: 1_724_607_277.306,
+ status: 404,
+ request: {
+ ...JSON.parse(caddyLogEntry).request,
+ host: "api.example.com",
+ uri: "/missing",
+ tls: undefined,
+ },
+ })}`;
+
+ expect(
+ parseRawConfig(rawConfig, undefined, undefined, "app.example", ["success"])
+ .totalCount,
+ ).toBe(1);
+ expect(
+ parseRawConfig(rawConfig, undefined, undefined, "api.example", ["client"])
+ .totalCount,
+ ).toBe(1);
+ expect(
+ processLogs(rawConfig, {
+ start: "2024-08-25T16:00:00.000Z",
+ end: "2024-08-25T17:00:00.000Z",
+ }),
+ ).toEqual([{ hour: "2024-08-25T16:00:00Z", count: 1 }]);
+});
+
+test("preserves existing Traefik request log parsing", () => {
+ const result = parseRawConfig(traefikLogEntry);
+
+ expect(result.totalCount).toBe(1);
+ expect(result.data[0]).toMatchObject({
+ Provider: "traefik",
+ RequestHost: "app.traefik.test",
+ DownstreamStatus: 304,
+ Duration: 14_729_375,
+ });
+});
diff --git a/apps/dokploy/__test__/caddy/request-logs-router.test.ts b/apps/dokploy/__test__/caddy/request-logs-router.test.ts
new file mode 100644
index 0000000000..dbd0880208
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/request-logs-router.test.ts
@@ -0,0 +1,378 @@
+import { mkdirSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { afterEach, beforeEach, expect, test, vi } from "vitest";
+
+const serverMocks = vi.hoisted(() => ({
+ applyCaddyMigration: vi.fn(),
+ checkGPUStatus: vi.fn(),
+ checkPortInUse: vi.fn(),
+ checkPostgresHealth: vi.fn(),
+ checkRedisHealth: vi.fn(),
+ checkWebServerHealth: vi.fn(),
+ cleanupAll: vi.fn(),
+ cleanupAllBackground: vi.fn(),
+ cleanupBuilders: vi.fn(),
+ cleanupContainers: vi.fn(),
+ cleanupImages: vi.fn(),
+ cleanupSystem: vi.fn(),
+ cleanupVolumes: vi.fn(),
+ compileAndWriteCaddyConfig: vi.fn(),
+ compileWriteAndValidateCaddyConfigSafely: vi.fn(),
+ compileWriteAndReloadCaddyConfigSafely: vi.fn(),
+ execAsync: vi.fn(),
+ findServerById: vi.fn(),
+ getCaddyCompileSettings: vi.fn(),
+ getCaddyMigrationReport: vi.fn(),
+ getCaddyTrustedProxySettings: vi.fn(),
+ getDockerDiskUsage: vi.fn(),
+ getDokployImageTag: vi.fn(),
+ getLogCleanupStatus: vi.fn(),
+ getUpdateData: vi.fn(),
+ getWebServerPaths: vi.fn(),
+ getWebServerResourceName: vi.fn(),
+ getWebServerSettings: vi.fn(),
+ isCaddyAdminAdditionalPort: vi.fn(),
+ isCaddyReservedAdditionalPort: vi.fn(),
+ parseRawConfig: vi.fn(),
+ paths: vi.fn(),
+ prepareCaddyMigration: vi.fn(),
+ prepareEnvironmentVariables: vi.fn(),
+ processLogs: vi.fn(),
+ readCaddyConfigFileIfExists: vi.fn(),
+ readConfig: vi.fn(),
+ readConfigInPath: vi.fn(),
+ readDirectory: vi.fn(),
+ readEnvironmentVariables: vi.fn(),
+ readMainConfig: vi.fn(),
+ readMonitoringConfig: vi.fn(),
+ readPorts: vi.fn(),
+ recreateDirectory: vi.fn(),
+ reloadCaddyAfterValidation: vi.fn(),
+ reloadDockerResource: vi.fn(),
+ resolveWebServerProvider: vi.fn(),
+ rollbackCaddyMigration: vi.fn(),
+ sendDockerCleanupNotifications: vi.fn(),
+ setupGPUSupport: vi.fn(),
+ spawnAsync: vi.fn(),
+ startLogCleanup: vi.fn(),
+ stopLogCleanup: vi.fn(),
+ updateCaddyTrustedProxySettings: vi.fn(),
+ updateLetsEncryptEmail: vi.fn(),
+ updateLocalWebServerProvider: vi.fn(),
+ updateRemoteWebServerProvider: vi.fn(),
+ updateServerById: vi.fn(),
+ updateServerCaddy: vi.fn(),
+ updateServerTraefik: vi.fn(),
+ updateWebServerSettings: vi.fn(),
+ writeConfig: vi.fn(),
+ writeMainConfig: vi.fn(),
+ writeTraefikConfigInPath: vi.fn(),
+ writeTraefikSetup: vi.fn(),
+ writeWebServerSetup: vi.fn(),
+}));
+
+vi.mock("@dokploy/server", () => ({
+ ...serverMocks,
+ ACCESS_LOG_RETAINED_LINES: 1000,
+ CLEANUP_CRON_JOB: "cleanup",
+ DEFAULT_UPDATE_DATA: { latestVersion: null, updateAvailable: false },
+ IS_CLOUD: false,
+}));
+
+vi.mock("@dokploy/trpc-openapi", () => ({
+ generateOpenApiDocument: vi.fn(),
+}));
+
+vi.mock("@/server/api/root", () => ({
+ appRouter: {},
+}));
+
+vi.mock("@/server/api/utils/audit", () => ({
+ audit: vi.fn(),
+}));
+
+vi.mock("@/server/queues/queueSetup", () => ({
+ cleanAllDeploymentQueue: vi.fn(),
+}));
+
+vi.mock("@/server/utils/backup", () => ({
+ removeJob: vi.fn(),
+ schedule: vi.fn(),
+}));
+
+import {
+ checkPortInUse,
+ compileAndWriteCaddyConfig,
+ compileWriteAndValidateCaddyConfigSafely,
+ getCaddyCompileSettings,
+ getWebServerResourceName,
+ getWebServerSettings,
+ parseRawConfig,
+ paths,
+ prepareEnvironmentVariables,
+ processLogs,
+ readEnvironmentVariables,
+ readMainConfig,
+ readMonitoringConfig,
+ readPorts,
+ resolveWebServerProvider,
+ updateWebServerSettings,
+ writeMainConfig,
+ writeWebServerSetup,
+} from "@dokploy/server";
+import { apiUpdateWebServerSettings } from "@dokploy/server/db/schema";
+import { settingsRouter } from "@/server/api/routers/settings";
+import { audit } from "@/server/api/utils/audit";
+
+const caller = settingsRouter.createCaller({
+ session: {
+ userId: "user-1",
+ activeOrganizationId: "org-1",
+ },
+ user: {
+ id: "user-1",
+ role: "owner",
+ ownerId: "user-1",
+ email: "owner@example.com",
+ enableEnterpriseFeatures: true,
+ isValidEnterpriseLicense: true,
+ },
+ req: { headers: {} },
+ res: {},
+} as never);
+
+const tempRoot = join(tmpdir(), "dokploy-caddy-request-logs-test");
+const caddyAccessLogPath = join(tempRoot, "caddy", "access.log");
+
+let requestLogsEnabled = false;
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ rmSync(tempRoot, { recursive: true, force: true });
+ mkdirSync(dirname(caddyAccessLogPath), { recursive: true });
+ requestLogsEnabled = false;
+ vi.mocked(paths).mockReturnValue({
+ DYNAMIC_TRAEFIK_PATH: join(tempRoot, "traefik", "dynamic"),
+ CADDY_ACCESS_LOG_PATH: caddyAccessLogPath,
+ } as never);
+ vi.mocked(getWebServerResourceName).mockImplementation((provider) =>
+ provider === "caddy" ? "dokploy-caddy" : "dokploy-traefik",
+ );
+ vi.mocked(getWebServerSettings).mockImplementation(
+ async () => ({ requestLogsEnabled }) as never,
+ );
+ vi.mocked(updateWebServerSettings).mockImplementation(async (updates) => {
+ if ("requestLogsEnabled" in updates) {
+ requestLogsEnabled = Boolean(updates.requestLogsEnabled);
+ }
+ return { requestLogsEnabled } as never;
+ });
+ vi.mocked(getCaddyCompileSettings).mockImplementation(async () => ({
+ letsEncryptEmail: "ops@example.com",
+ accessLogs: requestLogsEnabled ? { enabled: true } : null,
+ }));
+ vi.mocked(compileAndWriteCaddyConfig).mockResolvedValue({} as never);
+ vi.mocked(compileWriteAndValidateCaddyConfigSafely).mockResolvedValue(
+ {} as never,
+ );
+ vi.mocked(checkPortInUse).mockResolvedValue({ isInUse: false } as never);
+ vi.mocked(readEnvironmentVariables).mockResolvedValue("CADDY_ENV=1");
+ vi.mocked(prepareEnvironmentVariables).mockReturnValue(["CADDY_ENV=1"]);
+ vi.mocked(readPorts).mockResolvedValue([
+ { targetPort: 80, publishedPort: 80, protocol: "tcp" },
+ ] as never);
+ vi.mocked(writeWebServerSetup).mockResolvedValue(undefined as never);
+ vi.mocked(readMainConfig).mockReturnValue("entryPoints: {}\n");
+ vi.mocked(readMonitoringConfig).mockResolvedValue("traefik-log\n");
+ vi.mocked(parseRawConfig).mockReturnValue({
+ data: [],
+ totalCount: 1,
+ } as never);
+ vi.mocked(processLogs).mockReturnValue([
+ { hour: "2026-06-02T00:00:00Z", count: 1 },
+ ] as never);
+});
+
+afterEach(() => {
+ rmSync(tempRoot, { recursive: true, force: true });
+});
+
+test("keeps Traefik request toggles on the existing Traefik YAML path", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("traefik");
+
+ await caller.toggleRequests({ enable: true });
+
+ expect(writeMainConfig).toHaveBeenCalledWith(
+ expect.stringContaining("/etc/dokploy/traefik/dynamic/access.log"),
+ );
+ expect(compileAndWriteCaddyConfig).not.toHaveBeenCalled();
+ expect(compileWriteAndValidateCaddyConfigSafely).not.toHaveBeenCalled();
+ expect(audit).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ resourceName: "toggle-requests" }),
+ );
+});
+
+test("reports Caddy request analytics state from persisted settings", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy");
+ requestLogsEnabled = true;
+
+ const state = await caller.getRequestAnalyticsState();
+
+ expect(state).toEqual({
+ provider: "caddy",
+ enabled: true,
+ reloadResourceName: "dokploy-caddy",
+ accessLogPath: caddyAccessLogPath,
+ });
+ expect(readMainConfig).not.toHaveBeenCalled();
+});
+
+test("toggles Caddy request logs without writing Traefik main config", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy");
+
+ await caller.toggleRequests({ enable: true });
+
+ expect(updateWebServerSettings).toHaveBeenCalledWith({
+ requestLogsEnabled: true,
+ });
+ expect(getCaddyCompileSettings).toHaveBeenCalledWith();
+ expect(compileWriteAndValidateCaddyConfigSafely).toHaveBeenCalledWith({
+ letsEncryptEmail: "ops@example.com",
+ accessLogs: { enabled: true },
+ });
+ expect(compileAndWriteCaddyConfig).not.toHaveBeenCalled();
+ expect(writeMainConfig).not.toHaveBeenCalled();
+ expect(requestLogsEnabled).toBe(true);
+});
+
+test("preserves Caddy request-log settings when rewriting web-server setup", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy");
+ requestLogsEnabled = true;
+
+ await caller.writeWebServerEnv({ env: "CADDY_ENV=1" });
+
+ expect(writeWebServerSetup).toHaveBeenCalledWith(
+ "caddy",
+ expect.objectContaining({
+ env: ["CADDY_ENV=1"],
+ additionalPorts: [{ targetPort: 80, publishedPort: 80, protocol: "tcp" }],
+ letsEncryptEmail: "ops@example.com",
+ accessLogs: { enabled: true },
+ }),
+ );
+
+ vi.mocked(writeWebServerSetup).mockClear();
+ await caller.updateWebServerPorts({
+ additionalPorts: [
+ { targetPort: 9000, publishedPort: 9000, protocol: "tcp" },
+ ],
+ });
+
+ expect(checkPortInUse).toHaveBeenCalledWith(9000, undefined);
+ expect(writeWebServerSetup).toHaveBeenCalledWith(
+ "caddy",
+ expect.objectContaining({
+ env: ["CADDY_ENV=1"],
+ additionalPorts: [
+ { targetPort: 9000, publishedPort: 9000, protocol: "tcp" },
+ ],
+ letsEncryptEmail: "ops@example.com",
+ accessLogs: { enabled: true },
+ }),
+ );
+});
+
+test("restores Caddy request-log setting when generated config write fails", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy");
+ requestLogsEnabled = true;
+ vi.mocked(compileWriteAndValidateCaddyConfigSafely).mockRejectedValueOnce(
+ new Error("caddy write failed") as never,
+ );
+
+ await expect(caller.toggleRequests({ enable: false })).rejects.toThrow(
+ "caddy write failed",
+ );
+
+ expect(updateWebServerSettings).toHaveBeenNthCalledWith(1, {
+ requestLogsEnabled: false,
+ });
+ expect(updateWebServerSettings).toHaveBeenNthCalledWith(2, {
+ requestLogsEnabled: true,
+ });
+ expect(requestLogsEnabled).toBe(true);
+ expect(audit).not.toHaveBeenCalled();
+});
+
+test("does not allow generic web-server settings updates to toggle Caddy request logs", () => {
+ const parsed = apiUpdateWebServerSettings.parse({
+ requestLogsEnabled: true,
+ });
+
+ expect(parsed).not.toHaveProperty("requestLogsEnabled");
+});
+
+test("reads Caddy request stats from the Caddy access-log path", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy");
+ writeFileSync(caddyAccessLogPath, '{"caddy":true}\n');
+
+ const logs = await caller.readStatsLogs({
+ page: { pageIndex: 0, pageSize: 10 },
+ });
+ const stats = await caller.readStats();
+
+ expect(readMonitoringConfig).not.toHaveBeenCalled();
+ expect(parseRawConfig).toHaveBeenCalledWith(
+ '{"caddy":true}\n',
+ { pageIndex: 0, pageSize: 10 },
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ );
+ expect(processLogs).toHaveBeenCalledWith('{"caddy":true}\n', undefined);
+ expect(logs.totalCount).toBe(1);
+ expect(stats).toEqual([{ hour: "2026-06-02T00:00:00Z", count: 1 }]);
+});
+
+test("reads the latest Caddy request log entries for paginated stats", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy");
+ writeFileSync(
+ caddyAccessLogPath,
+ Array.from({ length: 1001 }, (_, index) =>
+ JSON.stringify({ caddy: true, index }),
+ ).join("\n"),
+ );
+
+ await caller.readStatsLogs({
+ page: { pageIndex: 0, pageSize: 10 },
+ });
+
+ const rawLog = vi.mocked(parseRawConfig).mock.calls[0]?.[0] as string;
+ expect(rawLog.split("\n").filter(Boolean)).toHaveLength(1000);
+ expect(rawLog).not.toContain('"index":0');
+ expect(rawLog).toContain('"index":1000');
+});
+
+test("bounds Caddy date-range request stats reads to the retained line window", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy");
+ writeFileSync(
+ caddyAccessLogPath,
+ Array.from({ length: 1001 }, (_, index) =>
+ JSON.stringify({ caddy: true, index }),
+ ).join("\n"),
+ );
+
+ await caller.readStats({
+ dateRange: {
+ start: "2026-06-02T00:00:00.000Z",
+ end: "2026-06-02T23:59:59.999Z",
+ },
+ });
+
+ const rawLog = vi.mocked(processLogs).mock.calls[0]?.[0] as string;
+ expect(rawLog.split("\n").filter(Boolean)).toHaveLength(1000);
+ expect(rawLog).not.toContain('"index":0');
+ expect(rawLog).toContain('"index":1000');
+});
diff --git a/apps/dokploy/__test__/caddy/setup.test.ts b/apps/dokploy/__test__/caddy/setup.test.ts
new file mode 100644
index 0000000000..a092672d4a
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/setup.test.ts
@@ -0,0 +1,260 @@
+import { afterEach, describe, expect, test, vi } from "vitest";
+
+const validateCaddyConfigWithContainerMock = vi.hoisted(() => vi.fn());
+const ensureDefaultCaddyConfigMock = vi.hoisted(() => vi.fn());
+const getRemoteDockerMock = vi.hoisted(() => vi.fn());
+
+vi.mock("@dokploy/server/utils/caddy/config", () => ({
+ CADDY_METRICS_PORT: 2020,
+ ensureDefaultCaddyConfig: ensureDefaultCaddyConfigMock,
+ reloadCaddyAfterValidation: vi.fn(),
+ validateCaddyConfigWithContainer: validateCaddyConfigWithContainerMock,
+}));
+
+vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
+ getRemoteDocker: getRemoteDockerMock,
+}));
+
+const loadCaddySetup = async () => {
+ vi.resetModules();
+ vi.unstubAllEnvs();
+ vi.stubEnv("CADDY_VERSION", "");
+ return import("@dokploy/server/setup/caddy-setup");
+};
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.clearAllMocks();
+});
+
+describe("Caddy runtime setup", () => {
+ test("defaults to the pinned Caddy 2.11.4 image tag", async () => {
+ const { CADDY_VERSION } = await loadCaddySetup();
+
+ expect(CADDY_VERSION).toBe("2.11.4");
+ });
+
+ test("uses the pinned default image for standalone Caddy", async () => {
+ const createContainer = vi.fn();
+ const start = vi.fn();
+ const remove = vi.fn().mockRejectedValue(new Error("missing"));
+ const docker = {
+ pull: vi.fn(
+ (_imageName: string, callback: (error: Error | null) => void) => {
+ callback(null);
+ },
+ ),
+ modem: { followProgress: vi.fn() },
+ createContainer,
+ getContainer: vi.fn(() => ({ remove, start })),
+ };
+ getRemoteDockerMock.mockResolvedValue(docker);
+ ensureDefaultCaddyConfigMock.mockResolvedValue(undefined);
+ validateCaddyConfigWithContainerMock.mockResolvedValue(undefined);
+ const { initializeStandaloneCaddy } = await loadCaddySetup();
+
+ await initializeStandaloneCaddy();
+
+ expect(docker.pull).toHaveBeenCalledWith(
+ "caddy:2.11.4",
+ expect.any(Function),
+ );
+ expect(createContainer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ Image: "caddy:2.11.4",
+ HostConfig: expect.objectContaining({
+ Binds: expect.arrayContaining([
+ expect.stringMatching(/\/caddy:\/etc\/caddy$/),
+ expect.stringMatching(/\/certificates:.*\/certificates:ro$/),
+ ]),
+ }),
+ }),
+ );
+ const binds = (createContainer.mock.calls[0]?.[0] as any).HostConfig.Binds;
+ expect(binds).toEqual(
+ expect.arrayContaining([expect.stringMatching(/\/caddy:\/etc\/caddy$/)]),
+ );
+ expect(binds).not.toEqual(
+ expect.arrayContaining([
+ expect.stringMatching(
+ /\/caddy\/caddy\.json:\/etc\/caddy\/caddy\.json$/,
+ ),
+ ]),
+ );
+ });
+
+ test("does not publish the Caddy admin port for standalone Caddy", async () => {
+ const createContainer = vi.fn();
+ const start = vi.fn();
+ const remove = vi.fn().mockRejectedValue(new Error("missing"));
+ const docker = {
+ pull: vi.fn(
+ (_imageName: string, callback: (error: Error | null) => void) => {
+ callback(null);
+ },
+ ),
+ modem: { followProgress: vi.fn() },
+ createContainer,
+ getContainer: vi.fn(() => ({ remove, start })),
+ };
+ getRemoteDockerMock.mockResolvedValue(docker);
+ ensureDefaultCaddyConfigMock.mockResolvedValue(undefined);
+ validateCaddyConfigWithContainerMock.mockResolvedValue(undefined);
+ const { initializeStandaloneCaddy } = await loadCaddySetup();
+
+ await initializeStandaloneCaddy({
+ additionalPorts: [
+ { targetPort: 2019, publishedPort: 2019, protocol: "tcp" },
+ { targetPort: 2020, publishedPort: 2020, protocol: "tcp" },
+ { targetPort: 9001, publishedPort: 2019, protocol: "tcp" },
+ { targetPort: 9002, publishedPort: 2020, protocol: "tcp" },
+ { targetPort: 8080, publishedPort: 18080, protocol: "tcp" },
+ { targetPort: 8082, publishedPort: 18082, protocol: "tcp" },
+ { targetPort: 9000, publishedPort: 9000, protocol: "tcp" },
+ ],
+ });
+
+ const createOptions = createContainer.mock.calls[0]?.[0] as any;
+ expect(createOptions.ExposedPorts).not.toHaveProperty("2019/tcp");
+ expect(createOptions.HostConfig.PortBindings).not.toHaveProperty(
+ "2019/tcp",
+ );
+ expect(createOptions.ExposedPorts).not.toHaveProperty("2020/tcp");
+ expect(createOptions.HostConfig.PortBindings).not.toHaveProperty(
+ "2020/tcp",
+ );
+ expect(createOptions.ExposedPorts).not.toHaveProperty("9001/tcp");
+ expect(createOptions.HostConfig.PortBindings["9001/tcp"]).toBeUndefined();
+ expect(createOptions.ExposedPorts).not.toHaveProperty("9002/tcp");
+ expect(createOptions.HostConfig.PortBindings["9002/tcp"]).toBeUndefined();
+ expect(createOptions.ExposedPorts).toHaveProperty("8080/tcp");
+ expect(createOptions.HostConfig.PortBindings["8080/tcp"]).toEqual([
+ { HostPort: "18080" },
+ ]);
+ expect(createOptions.ExposedPorts).toHaveProperty("8082/tcp");
+ expect(createOptions.HostConfig.PortBindings["8082/tcp"]).toEqual([
+ { HostPort: "18082" },
+ ]);
+ expect(createOptions.ExposedPorts).toHaveProperty("9000/tcp");
+ expect(createOptions.HostConfig.PortBindings["9000/tcp"]).toEqual([
+ { HostPort: "9000" },
+ ]);
+ });
+
+ test("passes access-log settings into standalone Caddy config generation", async () => {
+ const createContainer = vi.fn();
+ const start = vi.fn();
+ const remove = vi.fn().mockRejectedValue(new Error("missing"));
+ const docker = {
+ pull: vi.fn(
+ (_imageName: string, callback: (error: Error | null) => void) => {
+ callback(null);
+ },
+ ),
+ modem: { followProgress: vi.fn() },
+ createContainer,
+ getContainer: vi.fn(() => ({ remove, start })),
+ };
+ getRemoteDockerMock.mockResolvedValue(docker);
+ ensureDefaultCaddyConfigMock.mockResolvedValue(undefined);
+ validateCaddyConfigWithContainerMock.mockResolvedValue(undefined);
+ const { initializeStandaloneCaddy } = await loadCaddySetup();
+
+ await initializeStandaloneCaddy({
+ letsEncryptEmail: "ops@example.com",
+ accessLogs: { enabled: true },
+ });
+
+ expect(ensureDefaultCaddyConfigMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ letsEncryptEmail: "ops@example.com",
+ accessLogs: { enabled: true },
+ }),
+ );
+ });
+
+ test("does not publish the Caddy admin port for Caddy services", async () => {
+ const createService = vi.fn();
+ const docker = {
+ pull: vi.fn(
+ (_imageName: string, callback: (error: Error | null) => void) => {
+ callback(null);
+ },
+ ),
+ modem: { followProgress: vi.fn() },
+ createService,
+ getService: vi.fn(() => ({
+ inspect: vi.fn().mockRejectedValue(new Error("missing")),
+ })),
+ };
+ getRemoteDockerMock.mockResolvedValue(docker);
+ ensureDefaultCaddyConfigMock.mockResolvedValue(undefined);
+ const { initializeCaddyService } = await loadCaddySetup();
+
+ await initializeCaddyService({
+ accessLogs: { enabled: true },
+ additionalPorts: [
+ { targetPort: 2019, publishedPort: 2019, protocol: "tcp" },
+ { targetPort: 2020, publishedPort: 2020, protocol: "tcp" },
+ { targetPort: 9001, publishedPort: 2019, protocol: "tcp" },
+ { targetPort: 9002, publishedPort: 2020, protocol: "tcp" },
+ { targetPort: 8080, publishedPort: 18080, protocol: "tcp" },
+ { targetPort: 8082, publishedPort: 18082, protocol: "tcp" },
+ { targetPort: 9000, publishedPort: 9000, protocol: "tcp" },
+ ],
+ });
+
+ const createOptions = createService.mock.calls[0]?.[0] as any;
+ expect(createOptions.TaskTemplate.ContainerSpec.Mounts).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ Type: "bind",
+ Source: expect.stringMatching(/\/caddy$/),
+ Target: "/etc/caddy",
+ }),
+ ]),
+ );
+ expect(createOptions.EndpointSpec.Ports).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ TargetPort: 2019, Protocol: "tcp" }),
+ ]),
+ );
+ expect(createOptions.EndpointSpec.Ports).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ TargetPort: 2020, Protocol: "tcp" }),
+ ]),
+ );
+ expect(createOptions.EndpointSpec.Ports).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ PublishedPort: 2019, Protocol: "tcp" }),
+ ]),
+ );
+ expect(createOptions.EndpointSpec.Ports).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ PublishedPort: 2020, Protocol: "tcp" }),
+ ]),
+ );
+ expect(createOptions.EndpointSpec.Ports).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ TargetPort: 8080,
+ PublishedPort: 18080,
+ Protocol: "tcp",
+ }),
+ expect.objectContaining({
+ TargetPort: 8082,
+ PublishedPort: 18082,
+ Protocol: "tcp",
+ }),
+ expect.objectContaining({
+ TargetPort: 9000,
+ PublishedPort: 9000,
+ Protocol: "tcp",
+ }),
+ ]),
+ );
+ expect(ensureDefaultCaddyConfigMock).toHaveBeenCalledWith(
+ expect.objectContaining({ accessLogs: { enabled: true } }),
+ );
+ });
+});
diff --git a/apps/dokploy/__test__/caddy/trusted-proxy-settings-router.test.ts b/apps/dokploy/__test__/caddy/trusted-proxy-settings-router.test.ts
new file mode 100644
index 0000000000..f2d7c62d86
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/trusted-proxy-settings-router.test.ts
@@ -0,0 +1,543 @@
+import { beforeEach, expect, test, vi } from "vitest";
+
+const serverMocks = vi.hoisted(() => ({
+ applyCaddyMigration: vi.fn(),
+ checkGPUStatus: vi.fn(),
+ checkPortInUse: vi.fn(),
+ checkPostgresHealth: vi.fn(),
+ checkRedisHealth: vi.fn(),
+ checkWebServerHealth: vi.fn(),
+ cleanupAll: vi.fn(),
+ cleanupAllBackground: vi.fn(),
+ cleanupBuilders: vi.fn(),
+ cleanupContainers: vi.fn(),
+ cleanupImages: vi.fn(),
+ cleanupSystem: vi.fn(),
+ cleanupVolumes: vi.fn(),
+ compileWriteAndReloadCaddyConfigSafely: vi.fn(),
+ execAsync: vi.fn(),
+ findServerById: vi.fn(),
+ getCaddyCompileSettings: vi.fn(),
+ getCaddyMigrationReport: vi.fn(),
+ getCaddyTrustedProxySettings: vi.fn(),
+ getDockerDiskUsage: vi.fn(),
+ getDokployImageTag: vi.fn(),
+ getLogCleanupStatus: vi.fn(),
+ getUpdateData: vi.fn(),
+ getWebServerPaths: vi.fn(),
+ getWebServerResourceName: vi.fn(),
+ getWebServerSettings: vi.fn(),
+ isCaddyAdminAdditionalPort: vi.fn(),
+ isCaddyReservedAdditionalPort: vi.fn(),
+ parseRawConfig: vi.fn(),
+ paths: vi.fn(),
+ prepareCaddyMigration: vi.fn(),
+ prepareEnvironmentVariables: vi.fn(),
+ processLogs: vi.fn(),
+ readCaddyConfigFileIfExists: vi.fn(),
+ readConfig: vi.fn(),
+ readConfigInPath: vi.fn(),
+ readDirectory: vi.fn(),
+ readEnvironmentVariables: vi.fn(),
+ readMainConfig: vi.fn(),
+ readMonitoringConfig: vi.fn(),
+ readPorts: vi.fn(),
+ recreateDirectory: vi.fn(),
+ reloadCaddyAfterValidation: vi.fn(),
+ reloadDockerResource: vi.fn(),
+ resolveWebServerProvider: vi.fn(),
+ rollbackCaddyMigration: vi.fn(),
+ sendDockerCleanupNotifications: vi.fn(),
+ setupGPUSupport: vi.fn(),
+ spawnAsync: vi.fn(),
+ startLogCleanup: vi.fn(),
+ stopLogCleanup: vi.fn(),
+ updateCaddyTrustedProxySettings: vi.fn(),
+ updateLetsEncryptEmail: vi.fn(),
+ updateLocalWebServerProvider: vi.fn(),
+ updateRemoteWebServerProvider: vi.fn(),
+ updateServerById: vi.fn(),
+ updateServerCaddy: vi.fn(),
+ updateServerTraefik: vi.fn(),
+ updateWebServerSettings: vi.fn(),
+ writeConfig: vi.fn(),
+ writeMainConfig: vi.fn(),
+ writeTraefikConfigInPath: vi.fn(),
+ writeTraefikSetup: vi.fn(),
+ writeWebServerSetup: vi.fn(),
+}));
+
+vi.mock("@dokploy/server", () => ({
+ ...serverMocks,
+ CLEANUP_CRON_JOB: "cleanup",
+ DEFAULT_UPDATE_DATA: { latestVersion: null, updateAvailable: false },
+ IS_CLOUD: false,
+}));
+
+vi.mock("@dokploy/trpc-openapi", () => ({
+ generateOpenApiDocument: vi.fn(),
+}));
+
+vi.mock("@/server/api/root", () => ({
+ appRouter: {},
+}));
+
+vi.mock("@/server/api/utils/audit", () => ({
+ audit: vi.fn(),
+}));
+
+vi.mock("@/server/queues/queueSetup", () => ({
+ cleanAllDeploymentQueue: vi.fn(),
+}));
+
+vi.mock("@/server/utils/backup", () => ({
+ removeJob: vi.fn(),
+ schedule: vi.fn(),
+}));
+
+import {
+ checkPortInUse,
+ compileWriteAndReloadCaddyConfigSafely,
+ findServerById,
+ getCaddyCompileSettings,
+ getCaddyTrustedProxySettings,
+ getWebServerResourceName,
+ getWebServerSettings,
+ isCaddyAdminAdditionalPort,
+ isCaddyReservedAdditionalPort,
+ prepareEnvironmentVariables,
+ readEnvironmentVariables,
+ readPorts,
+ resolveWebServerProvider,
+ updateCaddyTrustedProxySettings,
+ updateServerCaddy,
+ updateWebServerSettings,
+ writeTraefikSetup,
+ writeWebServerSetup,
+} from "@dokploy/server";
+import { settingsRouter } from "@/server/api/routers/settings";
+import { audit } from "@/server/api/utils/audit";
+
+const caller = settingsRouter.createCaller({
+ session: {
+ userId: "user-1",
+ activeOrganizationId: "org-1",
+ },
+ user: {
+ id: "user-1",
+ role: "owner",
+ ownerId: "user-1",
+ email: "owner@example.com",
+ enableEnterpriseFeatures: true,
+ isValidEnterpriseLicense: true,
+ },
+ req: { headers: {} },
+ res: {},
+} as never);
+
+const staticInput = {
+ mode: "static" as const,
+ ranges: ["192.0.2.0/24"],
+ clientIpHeaders: ["X-Forwarded-For"],
+ strict: true,
+};
+
+type TrustedProxySettings =
+ | typeof staticInput
+ | {
+ mode: "cloudflare";
+ ranges?: string[] | null;
+ clientIpHeaders?: string[] | null;
+ strict?: boolean | null;
+ }
+ | null;
+
+let persistedTrustedProxySettings: TrustedProxySettings;
+let trustedProxyCallOrder: string[];
+
+const trustedProxyCompileConfig = (settings: TrustedProxySettings) => {
+ if (!settings) return null;
+ if (settings.mode === "cloudflare") {
+ return {
+ source: "cloudflare" as const,
+ clientIpHeaders: settings.clientIpHeaders ?? undefined,
+ strict: settings.strict ?? true,
+ };
+ }
+ return {
+ source: "static" as const,
+ ranges: settings.ranges,
+ clientIpHeaders: settings.clientIpHeaders,
+ strict: settings.strict,
+ };
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ persistedTrustedProxySettings = null;
+ trustedProxyCallOrder = [];
+ vi.mocked(getCaddyCompileSettings).mockImplementation(async () => {
+ trustedProxyCallOrder.push("read-compile-settings");
+ return {
+ letsEncryptEmail: "ops@example.com",
+ trustedProxies: trustedProxyCompileConfig(persistedTrustedProxySettings),
+ } as never;
+ });
+ vi.mocked(getCaddyTrustedProxySettings).mockImplementation(
+ async () => persistedTrustedProxySettings as never,
+ );
+ vi.mocked(updateCaddyTrustedProxySettings).mockImplementation(
+ async (settings) => {
+ trustedProxyCallOrder.push("persist-settings");
+ persistedTrustedProxySettings = settings as TrustedProxySettings;
+ return {} as never;
+ },
+ );
+ vi.mocked(compileWriteAndReloadCaddyConfigSafely).mockResolvedValue(
+ undefined as never,
+ );
+ vi.mocked(readEnvironmentVariables).mockResolvedValue("");
+ vi.mocked(prepareEnvironmentVariables).mockReturnValue([]);
+ vi.mocked(getWebServerResourceName).mockImplementation((provider) =>
+ provider === "caddy" ? "dokploy-caddy" : "dokploy-traefik",
+ );
+ vi.mocked(checkPortInUse).mockResolvedValue({ isInUse: false } as never);
+ vi.mocked(findServerById).mockResolvedValue({
+ serverId: "server-1",
+ organizationId: "org-1",
+ } as never);
+ vi.mocked(isCaddyAdminAdditionalPort).mockImplementation(
+ (port) => port.targetPort === 2019 && (port.protocol ?? "tcp") === "tcp",
+ );
+ vi.mocked(isCaddyReservedAdditionalPort).mockImplementation(
+ (port) =>
+ (port.targetPort === 2019 ||
+ port.publishedPort === 2019 ||
+ port.targetPort === 2020 ||
+ port.publishedPort === 2020) &&
+ (port.protocol ?? "tcp") === "tcp",
+ );
+ vi.mocked(writeWebServerSetup).mockResolvedValue(undefined as never);
+ vi.mocked(writeTraefikSetup).mockResolvedValue(undefined as never);
+});
+
+test("persists Caddy trusted proxy settings without rebuilding when Traefik is active", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("traefik");
+
+ const result = await caller.updateCaddyTrustedProxySettings(staticInput);
+
+ expect(updateCaddyTrustedProxySettings).toHaveBeenCalledTimes(1);
+ expect(updateCaddyTrustedProxySettings).toHaveBeenCalledWith(
+ staticInput,
+ undefined,
+ );
+ expect(compileWriteAndReloadCaddyConfigSafely).not.toHaveBeenCalled();
+ expect(audit).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ resourceName: "caddy-trusted-proxy" }),
+ );
+ expect(result).toEqual(staticInput);
+});
+
+test("rebuilds Caddy with persisted compile settings when Caddy is active", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy");
+
+ await caller.updateCaddyTrustedProxySettings(staticInput);
+
+ expect(getCaddyCompileSettings).toHaveBeenCalledWith(undefined);
+ expect(compileWriteAndReloadCaddyConfigSafely).toHaveBeenCalledWith({
+ serverId: undefined,
+ letsEncryptEmail: "ops@example.com",
+ trustedProxies: {
+ source: "static",
+ ranges: ["192.0.2.0/24"],
+ clientIpHeaders: ["X-Forwarded-For"],
+ strict: true,
+ },
+ });
+ expect(trustedProxyCallOrder).toEqual([
+ "persist-settings",
+ "read-compile-settings",
+ ]);
+ expect(audit).toHaveBeenCalled();
+});
+
+test("rebuilds remote Caddy with the remote persisted compile settings", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy");
+
+ await caller.updateCaddyTrustedProxySettings({
+ ...staticInput,
+ serverId: "server-1",
+ });
+
+ expect(resolveWebServerProvider).toHaveBeenCalledWith("server-1");
+ expect(updateCaddyTrustedProxySettings).toHaveBeenCalledWith(
+ staticInput,
+ "server-1",
+ );
+ expect(getCaddyCompileSettings).toHaveBeenCalledWith("server-1");
+ expect(compileWriteAndReloadCaddyConfigSafely).toHaveBeenCalledWith({
+ serverId: "server-1",
+ letsEncryptEmail: "ops@example.com",
+ trustedProxies: {
+ source: "static",
+ ranges: ["192.0.2.0/24"],
+ clientIpHeaders: ["X-Forwarded-For"],
+ strict: true,
+ },
+ });
+});
+
+test("restores previous trusted proxy settings when active Caddy rebuild fails", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy");
+ persistedTrustedProxySettings = {
+ mode: "cloudflare",
+ clientIpHeaders: ["CF-Connecting-IP"],
+ strict: true,
+ };
+ vi.mocked(compileWriteAndReloadCaddyConfigSafely).mockRejectedValueOnce(
+ new Error("caddy reload failed") as never,
+ );
+
+ await expect(
+ caller.updateCaddyTrustedProxySettings(staticInput),
+ ).rejects.toThrow("caddy reload failed");
+
+ expect(updateCaddyTrustedProxySettings).toHaveBeenNthCalledWith(
+ 1,
+ staticInput,
+ undefined,
+ );
+ expect(updateCaddyTrustedProxySettings).toHaveBeenNthCalledWith(
+ 2,
+ {
+ mode: "cloudflare",
+ clientIpHeaders: ["CF-Connecting-IP"],
+ strict: true,
+ },
+ undefined,
+ );
+ expect(persistedTrustedProxySettings).toEqual({
+ mode: "cloudflare",
+ clientIpHeaders: ["CF-Connecting-IP"],
+ strict: true,
+ });
+ expect(audit).not.toHaveBeenCalled();
+});
+
+test("restores remote trusted proxy settings when remote Caddy rebuild fails", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy");
+ persistedTrustedProxySettings = {
+ mode: "cloudflare",
+ clientIpHeaders: ["CF-Connecting-IP"],
+ strict: true,
+ };
+ vi.mocked(compileWriteAndReloadCaddyConfigSafely).mockRejectedValueOnce(
+ new Error("remote caddy reload failed") as never,
+ );
+
+ await expect(
+ caller.updateCaddyTrustedProxySettings({
+ ...staticInput,
+ serverId: "server-1",
+ }),
+ ).rejects.toThrow("remote caddy reload failed");
+
+ expect(resolveWebServerProvider).toHaveBeenCalledWith("server-1");
+ expect(getCaddyCompileSettings).toHaveBeenCalledWith("server-1");
+ expect(compileWriteAndReloadCaddyConfigSafely).toHaveBeenCalledWith({
+ serverId: "server-1",
+ letsEncryptEmail: "ops@example.com",
+ trustedProxies: {
+ source: "static",
+ ranges: ["192.0.2.0/24"],
+ clientIpHeaders: ["X-Forwarded-For"],
+ strict: true,
+ },
+ });
+ expect(updateCaddyTrustedProxySettings).toHaveBeenNthCalledWith(
+ 1,
+ staticInput,
+ "server-1",
+ );
+ expect(updateCaddyTrustedProxySettings).toHaveBeenNthCalledWith(
+ 2,
+ {
+ mode: "cloudflare",
+ clientIpHeaders: ["CF-Connecting-IP"],
+ strict: true,
+ },
+ "server-1",
+ );
+ expect(persistedTrustedProxySettings).toEqual({
+ mode: "cloudflare",
+ clientIpHeaders: ["CF-Connecting-IP"],
+ strict: true,
+ });
+ expect(audit).not.toHaveBeenCalled();
+});
+
+test("reports Caddy dashboard disabled instead of exposing the Caddy admin API", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy");
+
+ const result = await caller.getWebServerDashboardState({});
+
+ expect(result).toEqual({ provider: "caddy", enabled: false });
+ expect(readPorts).not.toHaveBeenCalled();
+});
+
+test("keeps Traefik dashboard state based on the Traefik dashboard port", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("traefik");
+ vi.mocked(readPorts).mockResolvedValue([
+ { targetPort: 8080, publishedPort: 8080, protocol: "tcp" },
+ ] as never);
+
+ const result = await caller.getWebServerDashboardState({});
+
+ expect(readPorts).toHaveBeenCalledWith("dokploy-traefik", undefined);
+ expect(result).toEqual({ provider: "traefik", enabled: true });
+});
+
+test("rejects direct dashboard toggles when Caddy is active", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy");
+
+ await expect(
+ caller.toggleDashboard({ enableDashboard: true }),
+ ).rejects.toThrow("Caddy admin API is kept local-only");
+
+ expect(readPorts).not.toHaveBeenCalled();
+ expect(readEnvironmentVariables).not.toHaveBeenCalled();
+ expect(prepareEnvironmentVariables).not.toHaveBeenCalled();
+ expect(checkPortInUse).not.toHaveBeenCalled();
+ expect(writeTraefikSetup).not.toHaveBeenCalled();
+ expect(audit).not.toHaveBeenCalled();
+});
+
+test("keeps dashboard toggles available when Traefik is active", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("traefik");
+ vi.mocked(readPorts).mockResolvedValue([]);
+ vi.mocked(readEnvironmentVariables).mockResolvedValue("TRAEFIK_ENV=1");
+ vi.mocked(prepareEnvironmentVariables).mockReturnValue(["TRAEFIK_ENV=1"]);
+
+ await caller.toggleDashboard({ enableDashboard: true });
+
+ expect(readPorts).toHaveBeenCalledWith("dokploy-traefik", undefined);
+ expect(readEnvironmentVariables).toHaveBeenCalledWith(
+ "dokploy-traefik",
+ undefined,
+ );
+ expect(checkPortInUse).toHaveBeenCalledWith(8080, undefined);
+ expect(writeTraefikSetup).toHaveBeenCalledWith({
+ env: ["TRAEFIK_ENV=1"],
+ additionalPorts: [
+ { targetPort: 8080, publishedPort: 8080, protocol: "tcp" },
+ ],
+ serverId: undefined,
+ });
+ expect(audit).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ resourceName: "toggle-dashboard" }),
+ );
+});
+
+test("restores dashboard settings when active Caddy domain rewrite fails", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy");
+ const previousSettings = {
+ host: "old-dashboard.example.com",
+ letsEncryptEmail: "old-ops@example.com",
+ certificateType: "letsencrypt",
+ https: true,
+ };
+ const updatedSettings = {
+ ...previousSettings,
+ host: "new-dashboard.example.com",
+ letsEncryptEmail: "new-ops@example.com",
+ };
+ vi.mocked(getWebServerSettings).mockResolvedValue(previousSettings as never);
+ vi.mocked(updateWebServerSettings)
+ .mockResolvedValueOnce(updatedSettings as never)
+ .mockResolvedValueOnce(previousSettings as never);
+ vi.mocked(updateServerCaddy).mockRejectedValueOnce(
+ new Error("caddy reload failed") as never,
+ );
+
+ await expect(
+ caller.assignDomainServer({
+ host: "new-dashboard.example.com",
+ letsEncryptEmail: "new-ops@example.com",
+ certificateType: "letsencrypt",
+ https: true,
+ }),
+ ).rejects.toThrow("caddy reload failed");
+
+ expect(updateWebServerSettings).toHaveBeenNthCalledWith(1, {
+ host: "new-dashboard.example.com",
+ letsEncryptEmail: "new-ops@example.com",
+ certificateType: "letsencrypt",
+ https: true,
+ });
+ expect(updateWebServerSettings).toHaveBeenNthCalledWith(2, previousSettings);
+ expect(updateServerCaddy).toHaveBeenCalledWith(
+ updatedSettings,
+ "new-dashboard.example.com",
+ );
+ expect(audit).not.toHaveBeenCalled();
+});
+
+test("reads remote Traefik dashboard state from the remote web server", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("traefik");
+ vi.mocked(readPorts).mockResolvedValue([
+ { targetPort: 8080, publishedPort: 8080, protocol: "tcp" },
+ ] as never);
+
+ const result = await caller.getWebServerDashboardState({
+ serverId: "server-1",
+ });
+
+ expect(resolveWebServerProvider).toHaveBeenCalledWith("server-1");
+ expect(readPorts).toHaveBeenCalledWith("dokploy-traefik", "server-1");
+ expect(result).toEqual({ provider: "traefik", enabled: true });
+});
+
+test("rejects Caddy admin port publishing before rebuilding the web server", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy");
+
+ await expect(
+ caller.updateWebServerPorts({
+ additionalPorts: [
+ { targetPort: 2019, publishedPort: 2019, protocol: "tcp" },
+ ],
+ }),
+ ).rejects.toThrow("reserved and cannot be published");
+
+ expect(checkPortInUse).not.toHaveBeenCalled();
+ expect(writeWebServerSetup).not.toHaveBeenCalled();
+});
+
+test("allows non-admin Caddy additional ports before rebuilding the web server", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy");
+
+ await caller.updateWebServerPorts({
+ additionalPorts: [
+ { targetPort: 8080, publishedPort: 18080, protocol: "tcp" },
+ { targetPort: 8082, publishedPort: 18082, protocol: "tcp" },
+ ],
+ });
+
+ expect(checkPortInUse).toHaveBeenCalledWith(18080, undefined);
+ expect(checkPortInUse).toHaveBeenCalledWith(18082, undefined);
+ expect(writeWebServerSetup).toHaveBeenCalledWith(
+ "caddy",
+ expect.objectContaining({
+ additionalPorts: [
+ { targetPort: 8080, publishedPort: 18080, protocol: "tcp" },
+ { targetPort: 8082, publishedPort: 18082, protocol: "tcp" },
+ ],
+ serverId: undefined,
+ }),
+ );
+ expect(audit).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ resourceName: "web-server-ports" }),
+ );
+});
diff --git a/apps/dokploy/__test__/caddy/web-server-file-system-router.test.ts b/apps/dokploy/__test__/caddy/web-server-file-system-router.test.ts
new file mode 100644
index 0000000000..8da82ddd0a
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/web-server-file-system-router.test.ts
@@ -0,0 +1,335 @@
+import { beforeEach, expect, test, vi } from "vitest";
+
+const serverMocks = vi.hoisted(() => ({
+ applyCaddyMigration: vi.fn(),
+ checkGPUStatus: vi.fn(),
+ checkPortInUse: vi.fn(),
+ checkPostgresHealth: vi.fn(),
+ checkRedisHealth: vi.fn(),
+ checkWebServerHealth: vi.fn(),
+ cleanupAll: vi.fn(),
+ cleanupAllBackground: vi.fn(),
+ cleanupBuilders: vi.fn(),
+ cleanupContainers: vi.fn(),
+ cleanupImages: vi.fn(),
+ cleanupSystem: vi.fn(),
+ cleanupVolumes: vi.fn(),
+ compileWriteAndReloadCaddyConfigSafely: vi.fn(),
+ execAsync: vi.fn(),
+ findServerById: vi.fn(),
+ getCaddyCompileSettings: vi.fn(),
+ getCaddyMigrationReport: vi.fn(),
+ getCaddyTrustedProxySettings: vi.fn(),
+ getDockerDiskUsage: vi.fn(),
+ getDokployImageTag: vi.fn(),
+ getLogCleanupStatus: vi.fn(),
+ getUpdateData: vi.fn(),
+ getWebServerPaths: vi.fn(),
+ getWebServerResourceName: vi.fn(),
+ getWebServerSettings: vi.fn(),
+ isCaddyAdminAdditionalPort: vi.fn(),
+ isCaddyReservedAdditionalPort: vi.fn(),
+ parseRawConfig: vi.fn(),
+ paths: vi.fn(),
+ prepareCaddyMigration: vi.fn(),
+ prepareEnvironmentVariables: vi.fn(),
+ processLogs: vi.fn(),
+ readCaddyConfigFileIfExists: vi.fn(),
+ readConfig: vi.fn(),
+ readConfigInPath: vi.fn(),
+ readDirectory: vi.fn(),
+ readEnvironmentVariables: vi.fn(),
+ readMainConfig: vi.fn(),
+ readMonitoringConfig: vi.fn(),
+ readPorts: vi.fn(),
+ recreateDirectory: vi.fn(),
+ reloadCaddyAfterValidation: vi.fn(),
+ reloadDockerResource: vi.fn(),
+ resolveWebServerProvider: vi.fn(),
+ rollbackCaddyMigration: vi.fn(),
+ sendDockerCleanupNotifications: vi.fn(),
+ setupGPUSupport: vi.fn(),
+ spawnAsync: vi.fn(),
+ startLogCleanup: vi.fn(),
+ stopLogCleanup: vi.fn(),
+ updateCaddyTrustedProxySettings: vi.fn(),
+ updateLetsEncryptEmail: vi.fn(),
+ updateLocalWebServerProvider: vi.fn(),
+ updateRemoteWebServerProvider: vi.fn(),
+ updateServerById: vi.fn(),
+ updateServerCaddy: vi.fn(),
+ updateServerTraefik: vi.fn(),
+ updateWebServerSettings: vi.fn(),
+ writeConfig: vi.fn(),
+ writeMainConfig: vi.fn(),
+ writeTraefikConfigInPath: vi.fn(),
+ writeTraefikSetup: vi.fn(),
+ writeWebServerSetup: vi.fn(),
+}));
+
+vi.mock("@dokploy/server", () => ({
+ ...serverMocks,
+ CLEANUP_CRON_JOB: "cleanup",
+ DEFAULT_UPDATE_DATA: { latestVersion: null, updateAvailable: false },
+ IS_CLOUD: false,
+}));
+
+vi.mock("@dokploy/trpc-openapi", () => ({
+ generateOpenApiDocument: vi.fn(),
+}));
+
+vi.mock("@/server/api/root", () => ({
+ appRouter: {},
+}));
+
+vi.mock("@dokploy/server/services/permission", () => ({
+ checkPermission: vi.fn(),
+}));
+
+vi.mock("@/server/api/utils/audit", () => ({
+ audit: vi.fn(),
+}));
+
+vi.mock("@/server/queues/queueSetup", () => ({
+ cleanAllDeploymentQueue: vi.fn(),
+}));
+
+vi.mock("@/server/utils/backup", () => ({
+ removeJob: vi.fn(),
+ schedule: vi.fn(),
+}));
+
+import {
+ findServerById,
+ getWebServerPaths,
+ paths,
+ readConfigInPath,
+ readDirectory,
+ resolveWebServerProvider,
+ writeTraefikConfigInPath,
+} from "@dokploy/server";
+import { settingsRouter } from "@/server/api/routers/settings";
+import { audit } from "@/server/api/utils/audit";
+
+const caller = settingsRouter.createCaller({
+ session: {
+ userId: "user-1",
+ activeOrganizationId: "org-1",
+ },
+ user: {
+ id: "user-1",
+ role: "owner",
+ ownerId: "user-1",
+ email: "owner@example.com",
+ enableEnterpriseFeatures: true,
+ isValidEnterpriseLicense: true,
+ },
+ req: { headers: {} },
+ res: {},
+} as never);
+
+const caddyPaths = {
+ MAIN_CADDY_PATH: "/etc/dokploy/caddy",
+ CADDY_CONFIG_PATH: "/etc/dokploy/caddy/caddy.json",
+ CADDY_FRAGMENTS_PATH: "/etc/dokploy/caddy/fragments",
+ CADDY_MIGRATIONS_PATH: "/etc/dokploy/caddy/migrations",
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(findServerById).mockResolvedValue({
+ serverId: "server-1",
+ organizationId: "org-1",
+ } as never);
+ vi.mocked(paths).mockReturnValue({
+ MAIN_TRAEFIK_PATH: "/etc/dokploy/traefik",
+ ...caddyPaths,
+ } as never);
+ vi.mocked(getWebServerPaths).mockImplementation(
+ (provider) =>
+ ({
+ basePath:
+ provider === "caddy"
+ ? caddyPaths.MAIN_CADDY_PATH
+ : "/etc/dokploy/traefik",
+ activeConfigPath:
+ provider === "caddy"
+ ? caddyPaths.CADDY_CONFIG_PATH
+ : "/etc/dokploy/traefik/traefik.yml",
+ fragmentsPath:
+ provider === "caddy"
+ ? caddyPaths.CADDY_FRAGMENTS_PATH
+ : "/etc/dokploy/traefik/dynamic",
+ }) as never,
+ );
+ vi.mocked(readConfigInPath).mockImplementation(
+ async (filePath: string) => `contents:${filePath}`,
+ );
+});
+
+test("reads and updates Traefik web-server files through provider-aware endpoints", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("traefik");
+ vi.mocked(readDirectory).mockResolvedValue([
+ {
+ id: "/etc/dokploy/traefik/dynamic/app.yml",
+ name: "app.yml",
+ type: "file",
+ },
+ ] as never);
+
+ const directories = await caller.readWebServerDirectories({
+ serverId: "server-1",
+ });
+ const file = await caller.readWebServerFile({
+ serverId: "server-1",
+ path: "dynamic/app.yml",
+ });
+ const updated = await caller.updateWebServerFile({
+ serverId: "server-1",
+ path: "dynamic/app.yml",
+ webServerConfig: "http: {}\n",
+ });
+
+ expect(directories).toHaveLength(1);
+ expect(readDirectory).toHaveBeenCalledWith(
+ "/etc/dokploy/traefik",
+ "server-1",
+ );
+ expect(readConfigInPath).toHaveBeenCalledWith(
+ "/etc/dokploy/traefik/dynamic/app.yml",
+ "server-1",
+ );
+ expect(writeTraefikConfigInPath).toHaveBeenCalledWith(
+ "/etc/dokploy/traefik/dynamic/app.yml",
+ "http: {}\n",
+ "server-1",
+ );
+ expect(audit).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ resourceName: "web-server-file" }),
+ );
+ expect(file).toBe("contents:/etc/dokploy/traefik/dynamic/app.yml");
+ expect(updated).toBe(true);
+});
+
+test("limits Caddy web-server file access to safe generated artifacts", async () => {
+ vi.mocked(resolveWebServerProvider).mockResolvedValue("caddy");
+ vi.mocked(readDirectory).mockImplementation(async (dirPath: string) => {
+ if (dirPath === caddyPaths.CADDY_FRAGMENTS_PATH) {
+ return [
+ {
+ id: `${caddyPaths.CADDY_FRAGMENTS_PATH}/app.json`,
+ name: "app.json",
+ type: "file",
+ },
+ ] as never;
+ }
+ if (dirPath === caddyPaths.CADDY_MIGRATIONS_PATH) {
+ return [
+ {
+ id: `${caddyPaths.CADDY_MIGRATIONS_PATH}/caddy-1`,
+ name: "caddy-1",
+ type: "directory",
+ children: [
+ {
+ id: `${caddyPaths.CADDY_MIGRATIONS_PATH}/caddy-1/caddy.json`,
+ name: "caddy.json",
+ type: "file",
+ },
+ {
+ id: `${caddyPaths.CADDY_MIGRATIONS_PATH}/caddy-1/backups`,
+ name: "backups",
+ type: "directory",
+ children: [
+ {
+ id: `${caddyPaths.CADDY_MIGRATIONS_PATH}/caddy-1/backups/traefik.yml`,
+ name: "traefik.yml",
+ type: "file",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: `${caddyPaths.CADDY_MIGRATIONS_PATH}/backups`,
+ name: "backups",
+ type: "directory",
+ children: [],
+ },
+ ] as never;
+ }
+ return [] as never;
+ });
+
+ const directories = await caller.readWebServerDirectories({
+ serverId: "server-1",
+ });
+ const file = await caller.readWebServerFile({
+ serverId: "server-1",
+ path: caddyPaths.CADDY_CONFIG_PATH,
+ });
+
+ expect(directories).toEqual([
+ {
+ id: caddyPaths.CADDY_CONFIG_PATH,
+ name: "caddy.json",
+ type: "file",
+ },
+ {
+ id: caddyPaths.CADDY_FRAGMENTS_PATH,
+ name: "fragments",
+ type: "directory",
+ children: [
+ {
+ id: `${caddyPaths.CADDY_FRAGMENTS_PATH}/app.json`,
+ name: "app.json",
+ type: "file",
+ },
+ ],
+ },
+ {
+ id: caddyPaths.CADDY_MIGRATIONS_PATH,
+ name: "migrations",
+ type: "directory",
+ children: [
+ {
+ id: `${caddyPaths.CADDY_MIGRATIONS_PATH}/caddy-1`,
+ name: "caddy-1",
+ type: "directory",
+ children: [
+ {
+ id: `${caddyPaths.CADDY_MIGRATIONS_PATH}/caddy-1/caddy.json`,
+ name: "caddy.json",
+ type: "file",
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+ expect(readConfigInPath).toHaveBeenCalledWith(
+ caddyPaths.CADDY_CONFIG_PATH,
+ "server-1",
+ );
+ expect(file).toBe(`contents:${caddyPaths.CADDY_CONFIG_PATH}`);
+ await expect(
+ caller.readWebServerFile({
+ serverId: "server-1",
+ path: `${caddyPaths.CADDY_MIGRATIONS_PATH}/caddy-1/backups/traefik.yml`,
+ }),
+ ).rejects.toThrow("migration backups");
+ await expect(
+ caller.readWebServerFile({
+ serverId: "server-1",
+ path: "/etc/dokploy/traefik/traefik.yml",
+ }),
+ ).rejects.toThrow("outside of active web server directory");
+ await expect(
+ caller.updateWebServerFile({
+ serverId: "server-1",
+ path: caddyPaths.CADDY_CONFIG_PATH,
+ webServerConfig: "{}",
+ }),
+ ).rejects.toThrow("read-only");
+ expect(writeTraefikConfigInPath).not.toHaveBeenCalled();
+});
diff --git a/apps/dokploy/__test__/caddy/web-server-health.test.ts b/apps/dokploy/__test__/caddy/web-server-health.test.ts
new file mode 100644
index 0000000000..b7509ee9c6
--- /dev/null
+++ b/apps/dokploy/__test__/caddy/web-server-health.test.ts
@@ -0,0 +1,83 @@
+import { beforeEach, expect, test, vi } from "vitest";
+
+const dockerMock = vi.hoisted(() => ({
+ getContainer: vi.fn(),
+ getService: vi.fn(),
+ listTasks: vi.fn(),
+}));
+
+vi.mock("@dokploy/server/constants", () => ({
+ docker: dockerMock,
+ paths: vi.fn(() => ({})),
+}));
+
+import {
+ checkTraefikHealth,
+ checkWebServerHealth,
+} from "@dokploy/server/utils/docker/utils";
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ dockerMock.getContainer.mockReturnValue({
+ inspect: vi.fn().mockResolvedValue({ State: { Running: true } }),
+ });
+ dockerMock.getService.mockReturnValue({
+ inspect: vi.fn().mockResolvedValue({
+ Spec: { Mode: { Replicated: { Replicas: 1 } } },
+ }),
+ });
+ dockerMock.listTasks.mockResolvedValue([
+ {
+ Status: {
+ State: "running",
+ ContainerStatus: { ContainerID: "container-1" },
+ },
+ },
+ ]);
+});
+
+test("checks the Caddy resource when the active web server provider is Caddy", async () => {
+ const result = await checkWebServerHealth("caddy");
+
+ expect(result).toEqual({ provider: "caddy", status: "healthy" });
+ expect(dockerMock.getContainer).toHaveBeenCalledWith("dokploy-caddy");
+ expect(dockerMock.getService).not.toHaveBeenCalled();
+});
+
+test("checks the Traefik resource when the active web server provider is Traefik", async () => {
+ const result = await checkWebServerHealth("traefik");
+
+ expect(result).toEqual({ provider: "traefik", status: "healthy" });
+ expect(dockerMock.getContainer).toHaveBeenCalledWith("dokploy-traefik");
+});
+
+test("falls back to the active Caddy swarm service when no standalone Caddy container exists", async () => {
+ dockerMock.getContainer.mockReturnValueOnce({
+ inspect: vi.fn().mockRejectedValue(new Error("missing container")),
+ });
+
+ const result = await checkWebServerHealth("caddy");
+
+ expect(result).toEqual({ provider: "caddy", status: "healthy" });
+ expect(dockerMock.getService).toHaveBeenCalledWith("dokploy-caddy");
+ expect(dockerMock.listTasks).toHaveBeenCalledWith({
+ filters: JSON.stringify({
+ service: ["dokploy-caddy"],
+ "desired-state": ["running"],
+ }),
+ });
+});
+
+test("keeps the Traefik-specific helper on the Traefik resource", async () => {
+ dockerMock.getContainer.mockReturnValueOnce({
+ inspect: vi.fn().mockResolvedValue({ State: { Running: false } }),
+ });
+
+ const result = await checkTraefikHealth();
+
+ expect(result).toEqual({
+ status: "unhealthy",
+ message: "Container is not running",
+ });
+ expect(dockerMock.getContainer).toHaveBeenCalledWith("dokploy-traefik");
+});
diff --git a/apps/dokploy/__test__/db/runtime-migration.test.ts b/apps/dokploy/__test__/db/runtime-migration.test.ts
new file mode 100644
index 0000000000..b7ddbca00e
--- /dev/null
+++ b/apps/dokploy/__test__/db/runtime-migration.test.ts
@@ -0,0 +1,85 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+const mocks = vi.hoisted(() => {
+ const db = { id: "db" };
+ const sql = { end: vi.fn().mockResolvedValue(undefined) };
+
+ return {
+ db,
+ drizzle: vi.fn(() => db),
+ logger: {
+ error: vi.fn(),
+ log: vi.fn(),
+ },
+ migrate: vi.fn().mockResolvedValue(undefined),
+ postgres: vi.fn(() => sql),
+ sql,
+ };
+});
+
+vi.mock("@dokploy/server/db/constants", () => ({
+ dbUrl: "postgres://dokploy:test@localhost:5432/dokploy",
+}));
+
+vi.mock("postgres", () => ({
+ default: mocks.postgres,
+}));
+
+vi.mock("drizzle-orm/postgres-js", () => ({
+ drizzle: mocks.drizzle,
+}));
+
+vi.mock("drizzle-orm/postgres-js/migrator", () => ({
+ migrate: mocks.migrate,
+}));
+
+import { runRuntimeMigrations } from "../../server/db/run-migrations";
+
+describe("runtime migrations", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.sql.end.mockResolvedValue(undefined);
+ mocks.migrate.mockResolvedValue(undefined);
+ });
+
+ test("runs the default migration folder and closes the connection once", async () => {
+ await runRuntimeMigrations({ logger: mocks.logger });
+
+ expect(mocks.postgres).toHaveBeenCalledWith(
+ "postgres://dokploy:test@localhost:5432/dokploy",
+ { max: 1 },
+ );
+ expect(mocks.drizzle).toHaveBeenCalledWith(mocks.sql);
+ expect(mocks.migrate).toHaveBeenCalledWith(mocks.db, {
+ migrationsFolder: "drizzle",
+ });
+ expect(mocks.logger.log).toHaveBeenCalledWith("Migration complete");
+ expect(mocks.logger.error).not.toHaveBeenCalled();
+ expect(mocks.sql.end).toHaveBeenCalledTimes(1);
+ });
+
+ test("supports an explicit migration folder", async () => {
+ await runRuntimeMigrations({
+ logger: mocks.logger,
+ migrationsFolder: "/app/drizzle",
+ });
+
+ expect(mocks.migrate).toHaveBeenCalledWith(mocks.db, {
+ migrationsFolder: "/app/drizzle",
+ });
+ expect(mocks.sql.end).toHaveBeenCalledTimes(1);
+ });
+
+ test("rejects migration failures and still closes the connection once", async () => {
+ const error = new Error("migration boom");
+ mocks.migrate.mockRejectedValueOnce(error);
+
+ await expect(runRuntimeMigrations({ logger: mocks.logger })).rejects.toBe(
+ error,
+ );
+
+ expect(mocks.logger.error).toHaveBeenCalledWith("Migration failed", error);
+ expect(mocks.logger.log).not.toHaveBeenCalled();
+ expect(mocks.sql.end).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/dokploy/__test__/git-provider/git-provider-access.test.ts b/apps/dokploy/__test__/git-provider/git-provider-access.test.ts
index 4ddf36244a..714607538e 100644
--- a/apps/dokploy/__test__/git-provider/git-provider-access.test.ts
+++ b/apps/dokploy/__test__/git-provider/git-provider-access.test.ts
@@ -1,8 +1,8 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
import {
canEditDeployGitSource,
getAccessibleGitProviderIds,
} from "@dokploy/server/services/git-provider";
+import { beforeEach, describe, expect, it, vi } from "vitest";
const mockDb = vi.hoisted(() => ({
query: {
diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts
index ba09c2c80a..1cfe74193d 100644
--- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts
+++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts
@@ -18,7 +18,10 @@ type WebServerSettings = typeof webServerSettings.$inferSelect;
const baseSettings: WebServerSettings = {
id: "",
+ webServerProvider: "traefik",
+ caddyTrustedProxyConfig: null,
https: false,
+ requestLogsEnabled: false,
certificateType: "none",
host: null,
serverIp: null,
diff --git a/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx b/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx
index 56df21c73c..164e65d37c 100644
--- a/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx
+++ b/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx
@@ -36,6 +36,7 @@ import {
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
+import { invalidateApplicationWebServerConfig } from "../../web-server-config-cache";
const AddRedirectSchema = z.object({
regex: z.string().min(1, "Regex required"),
@@ -133,9 +134,7 @@ export const HandleRedirect = ({
applicationId,
});
refetch();
- await utils.application.readTraefikConfig.invalidate({
- applicationId,
- });
+ await invalidateApplicationWebServerConfig(utils, applicationId);
onDialogToggle(false);
})
.catch(() => {
diff --git a/apps/dokploy/components/dashboard/application/advanced/redirects/show-redirects.tsx b/apps/dokploy/components/dashboard/application/advanced/redirects/show-redirects.tsx
index a14074ec59..e057af7677 100644
--- a/apps/dokploy/components/dashboard/application/advanced/redirects/show-redirects.tsx
+++ b/apps/dokploy/components/dashboard/application/advanced/redirects/show-redirects.tsx
@@ -10,6 +10,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
+import { invalidateApplicationWebServerConfig } from "../../web-server-config-cache";
import { HandleRedirect } from "./handle-redirect";
interface Props {
@@ -97,11 +98,12 @@ export const ShowRedirects = ({ applicationId }: Props) => {
await deleteRedirect({
redirectId: redirect.redirectId,
})
- .then(() => {
- refetch();
- utils.application.readTraefikConfig.invalidate({
+ .then(async () => {
+ await refetch();
+ await invalidateApplicationWebServerConfig(
+ utils,
applicationId,
- });
+ );
toast.success("Redirect deleted successfully");
})
.catch(() => {
diff --git a/apps/dokploy/components/dashboard/application/advanced/security/handle-security.tsx b/apps/dokploy/components/dashboard/application/advanced/security/handle-security.tsx
index 49a126881a..de1e9e0c37 100644
--- a/apps/dokploy/components/dashboard/application/advanced/security/handle-security.tsx
+++ b/apps/dokploy/components/dashboard/application/advanced/security/handle-security.tsx
@@ -25,6 +25,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
+import { invalidateApplicationWebServerConfig } from "../../web-server-config-cache";
const AddSecuritychema = z.object({
username: z.string().min(1, "Username is required"),
@@ -85,9 +86,7 @@ export const HandleSecurity = ({
await utils.application.one.invalidate({
applicationId,
});
- await utils.application.readTraefikConfig.invalidate({
- applicationId,
- });
+ await invalidateApplicationWebServerConfig(utils, applicationId);
await refetch();
setIsOpen(false);
})
diff --git a/apps/dokploy/components/dashboard/application/advanced/security/show-security.tsx b/apps/dokploy/components/dashboard/application/advanced/security/show-security.tsx
index 724953afec..888d54bc5f 100644
--- a/apps/dokploy/components/dashboard/application/advanced/security/show-security.tsx
+++ b/apps/dokploy/components/dashboard/application/advanced/security/show-security.tsx
@@ -13,6 +13,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
+import { invalidateApplicationWebServerConfig } from "../../web-server-config-cache";
import { HandleSecurity } from "./handle-security";
interface Props {
@@ -88,11 +89,12 @@ export const ShowSecurity = ({ applicationId }: Props) => {
await deleteSecurity({
securityId: security.securityId,
})
- .then(() => {
- refetch();
- utils.application.readTraefikConfig.invalidate({
+ .then(async () => {
+ await refetch();
+ await invalidateApplicationWebServerConfig(
+ utils,
applicationId,
- });
+ );
toast.success("Security deleted successfully");
})
.catch(() => {
diff --git a/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx b/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx
index 1dc4433352..d6edfed6f2 100644
--- a/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx
+++ b/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx
@@ -1,4 +1,5 @@
import { File, Loader2 } from "lucide-react";
+import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import {
Card,
@@ -17,7 +18,23 @@ interface Props {
export const ShowTraefikConfig = ({ applicationId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canRead = permissions?.traefikFiles.read ?? false;
- const { data, isPending } = api.application.readTraefikConfig.useQuery(
+ const { data: application } = api.application.one.useQuery(
+ { applicationId },
+ { enabled: !!applicationId && canRead },
+ );
+ const { data: activeProvider } =
+ api.settings.getActiveWebServerProvider.useQuery(
+ { serverId: application?.serverId || undefined },
+ { enabled: canRead && !!application },
+ );
+ const isCaddy = activeProvider === "caddy";
+ const isTraefik = activeProvider === "traefik";
+ const providerLabel = isCaddy
+ ? "Caddy"
+ : isTraefik
+ ? "Traefik"
+ : "Web Server";
+ const { data, isPending } = api.application.readWebServerConfig.useQuery(
{
applicationId,
},
@@ -30,15 +47,24 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
- Traefik
+ {providerLabel}
- Modify the traefik config, in rare cases you may need to add
- specific config, be careful because modifying incorrectly can break
- traefik and your application
+ {isCaddy
+ ? "Review generated Caddy route fragments for this application. Caddy manages certificates for HTTPS domains; Traefik YAML and custom certificate resolvers do not apply."
+ : isTraefik
+ ? "Modify the Traefik config in rare cases. Be careful: invalid Traefik YAML can break the application route."
+ : "Review the active web server configuration for this application. Provider-specific edit controls appear after Dokploy resolves the active provider."}
+ {isCaddy && (
+
+ For Caddy, domain changes generate JSON fragments and Caddy handles
+ ACME certificates automatically. Use Settings → Web Server for the
+ active Caddy config and migration artifacts.
+
+ )}
{isPending ? (
Loading...
@@ -48,7 +74,7 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
- No traefik config detected
+ No {providerLabel} config detected
) : (
@@ -60,9 +86,11 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
disabled
className="font-mono"
/>
-
-
-
+ {isTraefik && (
+
+
+
+ )}
)}
diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx
index be8901921f..c430ab718f 100644
--- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx
+++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx
@@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import z from "zod";
+import { invalidateApplicationWebServerConfig } from "@/components/dashboard/application/web-server-config-cache";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -87,7 +88,11 @@ export const domain = z
});
}
- if (input.certificateType === "custom" && !input.customCertResolver) {
+ if (
+ input.https &&
+ input.certificateType === "custom" &&
+ !input.customCertResolver
+ ) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customCertResolver"],
@@ -185,6 +190,22 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
const { mutateAsync: generateDomain, isPending: isLoadingGenerate } =
api.domain.generateDomain.useMutation();
+ const { data: activeProvider, isLoading: isLoadingProvider } =
+ api.settings.getActiveWebServerProvider.useQuery(
+ { serverId: application?.serverId || undefined },
+ { enabled: !!application },
+ );
+ const isCaddyProvider = activeProvider === "caddy";
+
+ const { data: certificates } = api.certificates.all.useQuery(undefined, {
+ enabled: isOpen && isCaddyProvider,
+ });
+ const caddyCertificates = (certificates ?? []).filter((certificate) =>
+ application?.serverId
+ ? certificate.serverId === application.serverId
+ : !certificate.serverId,
+ );
+
const { data: canGenerateTraefikMeDomains } =
api.domain.canGenerateTraefikMeDomains.useQuery({
serverId: application?.serverId || "",
@@ -271,12 +292,15 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
}
}, [form, data, isPending, domainId]);
- // Separate effect for handling custom cert resolver validation
+ // Separate effect for handling provider-specific certificate fields
useEffect(() => {
if (certificateType === "custom") {
form.trigger("customCertResolver");
}
- }, [certificateType, form]);
+ if (isCaddyProvider && certificateType !== "custom") {
+ form.setValue("customCertResolver", undefined);
+ }
+ }, [certificateType, form, isCaddyProvider]);
const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created",
@@ -306,9 +330,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
await utils.domain.byApplicationId.invalidate({
applicationId: id,
});
- await utils.application.readTraefikConfig.invalidate({
- applicationId: id,
- });
+ await invalidateApplicationWebServerConfig(utils, id);
} else if (data.domainType === "compose") {
await utils.domain.byComposeId.invalidate({
composeId: id,
@@ -344,6 +366,15 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
)}
+ {isCaddyProvider && (
+
+ This server uses Caddy. Dokploy will generate Caddy route fragments,
+ Caddy can manage HTTPS certificates for public DNS names or load an
+ uploaded certificate, and Traefik-only custom entrypoints and
+ middleware references are hidden.
+
+ )}
+
-
+
{dictionary.submit}
diff --git a/apps/dokploy/components/dashboard/application/web-server-config-cache.ts b/apps/dokploy/components/dashboard/application/web-server-config-cache.ts
new file mode 100644
index 0000000000..f2cf9ed234
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/web-server-config-cache.ts
@@ -0,0 +1,21 @@
+type ConfigCacheUtils = {
+ application: {
+ readTraefikConfig: {
+ invalidate(input: { applicationId: string }): Promise | unknown;
+ };
+ readWebServerConfig: {
+ invalidate(input: { applicationId: string }): Promise | unknown;
+ };
+ };
+};
+
+export const invalidateApplicationWebServerConfig = async (
+ utils: ConfigCacheUtils,
+ applicationId: string,
+) => {
+ const input = { applicationId };
+ await Promise.all([
+ utils.application.readTraefikConfig.invalidate(input),
+ utils.application.readWebServerConfig.invalidate(input),
+ ]);
+};
diff --git a/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx b/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx
index 0efd09860d..d937cdb88d 100644
--- a/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx
+++ b/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx
@@ -22,7 +22,7 @@ import { api } from "@/utils/api";
import { validateAndFormatYAML } from "../application/advanced/traefik/update-traefik-config";
const UpdateServerMiddlewareConfigSchema = z.object({
- traefikConfig: z.string(),
+ webServerConfig: z.string(),
});
type UpdateServerMiddlewareConfig = z.infer<
@@ -32,14 +32,24 @@ type UpdateServerMiddlewareConfig = z.infer<
interface Props {
path: string;
serverId?: string;
+ activeProvider?: "traefik" | "caddy";
}
-export const ShowTraefikFile = ({ path, serverId }: Props) => {
+export const ShowTraefikFile = ({ path, serverId, activeProvider }: Props) => {
+ const providerLabel =
+ activeProvider === "caddy"
+ ? "Caddy"
+ : activeProvider === "traefik"
+ ? "Traefik"
+ : "Web Server";
+ const isTraefik = activeProvider === "traefik";
const {
data,
refetch,
isLoading: isLoadingFile,
- } = api.settings.readTraefikFile.useQuery(
+ error: readError,
+ isError: isReadError,
+ } = api.settings.readWebServerFile.useQuery(
{
path,
serverId,
@@ -52,51 +62,64 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => {
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
const { mutateAsync, isPending, error, isError } =
- api.settings.updateTraefikFile.useMutation();
+ api.settings.updateWebServerFile.useMutation();
const form = useForm({
defaultValues: {
- traefikConfig: "",
+ webServerConfig: "",
},
- disabled: canEdit,
+ disabled: !isTraefik || canEdit,
resolver: zodResolver(UpdateServerMiddlewareConfigSchema),
});
useEffect(() => {
form.reset({
- traefikConfig: data || "",
+ webServerConfig: data || "",
});
}, [form, form.reset, data]);
const onSubmit = async (data: UpdateServerMiddlewareConfig) => {
+ if (!isTraefik) {
+ return;
+ }
if (!skipYamlValidation) {
- const { valid, error } = validateAndFormatYAML(data.traefikConfig);
+ const { valid, error } = validateAndFormatYAML(data.webServerConfig);
if (!valid) {
- form.setError("traefikConfig", {
+ form.setError("webServerConfig", {
type: "manual",
message: error || "Invalid YAML",
});
return;
}
}
- form.clearErrors("traefikConfig");
+ form.clearErrors("webServerConfig");
await mutateAsync({
- traefikConfig: data.traefikConfig,
+ webServerConfig: data.webServerConfig,
path,
serverId,
})
.then(async () => {
- toast.success("Traefik config Updated");
+ toast.success(`${providerLabel} config updated`);
refetch();
})
.catch(() => {
- toast.error("Error updating the Traefik config");
+ toast.error(`Error updating the ${providerLabel} config`);
});
};
return (
+ {isReadError && (
+
{readError?.message}
+ )}
{isError &&
{error?.message} }
+ {activeProvider === "caddy" && (
+
+ Caddy generated files are read-only from this view. Use Dokploy
+ settings, domains, and migration controls to change generated Caddy
+ config.
+
+ )}
-
-
-
- setSkipYamlValidation(checked === true)
- }
- />
-
- Skip YAML validation (for Go templating)
-
-
-
- Traefik supports Go templating in dynamic configs (e.g.{" "}
- {"{{range}}"}). Configs using
- templates will fail standard YAML validation. Check this to save
- without validation.
-
-
-
- Update
-
+ {isTraefik && (
+
+
+
+ setSkipYamlValidation(checked === true)
+ }
+ />
+
+ Skip YAML validation (for Go templating)
+
+
+
+ Traefik supports Go templating in dynamic configs (e.g.{" "}
+ {"{{range}}"}). Configs using
+ templates will fail standard YAML validation. Check this to save
+ without validation.
+
+
+
+ Update
+
+
-
+ )}
diff --git a/apps/dokploy/components/dashboard/file-system/show-traefik-system.tsx b/apps/dokploy/components/dashboard/file-system/show-traefik-system.tsx
index bf3c874463..f9dc25c538 100644
--- a/apps/dokploy/components/dashboard/file-system/show-traefik-system.tsx
+++ b/apps/dokploy/components/dashboard/file-system/show-traefik-system.tsx
@@ -21,16 +21,39 @@ import { ShowTraefikFile } from "./show-traefik-file";
interface Props {
serverId?: string;
+ activeProvider?: "traefik" | "caddy";
}
-export const ShowTraefikSystem = ({ serverId }: Props) => {
+export const ShowTraefikSystem = ({ serverId, activeProvider }: Props) => {
const [file, setFile] = React.useState(null);
+ const { data: resolvedProvider } =
+ api.settings.getActiveWebServerProvider.useQuery(
+ { serverId },
+ { enabled: !activeProvider },
+ );
+ const provider = activeProvider ?? resolvedProvider;
+ const providerLabel =
+ provider === "caddy"
+ ? "Caddy"
+ : provider === "traefik"
+ ? "Traefik"
+ : "Web Server";
+ const isCaddy = provider === "caddy";
+ const isTraefik = provider === "traefik";
+
+ React.useEffect(() => {
+ setFile(null);
+ }, []);
+
+ React.useEffect(() => {
+ setFile(null);
+ }, [provider]);
const {
data: directories,
isLoading,
error,
isError,
- } = api.settings.readDirectories.useQuery(
+ } = api.settings.readWebServerDirectories.useQuery(
{
serverId,
},
@@ -46,16 +69,22 @@ export const ShowTraefikSystem = ({ serverId }: Props) => {
- Traefik File System
+ {providerLabel} File System
- Manage all the files and directories in {"'/etc/dokploy/traefik'"}
- .
+ {isCaddy
+ ? "Review generated Caddy artifacts such as caddy.json, route fragments, and non-backup migration artifacts."
+ : provider === "traefik"
+ ? "Manage files and directories for the active Traefik web server."
+ : "Review files and directories for the active web server."}
-
- Adding invalid configuration to existing files, can break your
- Traefik instance, preventing access to your applications.
+
+ {isCaddy
+ ? "Caddy generated config is read-only here. Use Dokploy settings, domains, and migration controls to change generated Caddy config."
+ : isTraefik
+ ? "Adding invalid configuration to existing files can break your Traefik instance, preventing access to your applications."
+ : "Review active web server files here. Provider-specific edit controls appear after Dokploy resolves the active provider."}
@@ -86,7 +115,9 @@ export const ShowTraefikSystem = ({ serverId }: Props) => {
There are no directories or files in{" "}
- /etc/dokploy/traefik
+ {isCaddy
+ ? "/etc/dokploy/caddy"
+ : "/etc/dokploy/traefik"}
{" "}
on this server yet.
@@ -104,7 +135,11 @@ export const ShowTraefikSystem = ({ serverId }: Props) => {
/>
{file ? (
-
+
) : (
diff --git a/apps/dokploy/components/dashboard/requests/show-requests.tsx b/apps/dokploy/components/dashboard/requests/show-requests.tsx
index 83b5d2bf5c..413fe3a153 100644
--- a/apps/dokploy/components/dashboard/requests/show-requests.tsx
+++ b/apps/dokploy/components/dashboard/requests/show-requests.tsx
@@ -41,10 +41,13 @@ export type LogEntry = NonNullable<
>[0];
export const ShowRequests = () => {
- const { data: isActive, refetch } =
- api.settings.haveActivateRequests.useQuery();
+ const { data: requestAnalyticsState, refetch } =
+ api.settings.getRequestAnalyticsState.useQuery();
const { mutateAsync: toggleRequests } =
api.settings.toggleRequests.useMutation();
+ const isActive = requestAnalyticsState?.enabled ?? false;
+ const providerLabel =
+ requestAnalyticsState?.provider === "caddy" ? "Caddy" : "Traefik";
const { data: logCleanupStatus } =
api.settings.getLogCleanupStatus.useQuery();
@@ -101,13 +104,13 @@ export const ShowRequests = () => {
Requests
- See all the incoming requests that pass trough Traefik
+ See incoming requests handled by the active web server.
{shouldShowWarning && (
- When you activate, you need to reload traefik to apply the
- changes, you can reload traefik in{" "}
+ After activation, reload {providerLabel} to apply access-log
+ changes. You can reload the active web server in{" "}
{
At the scheduled time, the cleanup job will keep
only the last 1000 entries in the access log file
- and signal Traefik to reopen its log files. The
- default schedule is daily at midnight (0 0 * * *).
+ for the active web server. The default schedule is
+ daily at midnight (0 0 * * *).
@@ -174,7 +177,7 @@ export const ShowRequests = () => {
{
await toggleRequests({ enable: !isActive })
@@ -256,8 +259,8 @@ export const ShowRequests = () => {
Activate requests to see incoming traffic statistics and
- monitor your application's usage. After activation, you'll
- need to reload Traefik for the changes to take effect.
+ monitor your application's usage. After activation, reload
+ the active web server for the changes to take effect.
diff --git a/apps/dokploy/components/dashboard/search-command.tsx b/apps/dokploy/components/dashboard/search-command.tsx
index d20fcd698c..be8c23f23a 100644
--- a/apps/dokploy/components/dashboard/search-command.tsx
+++ b/apps/dokploy/components/dashboard/search-command.tsx
@@ -197,7 +197,7 @@ export const SearchCommand = () => {
setOpen(false);
}}
>
- Traefik
+ Web Server Files
{
diff --git a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx
index c69451c895..d158b381bc 100644
--- a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx
+++ b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx
@@ -46,14 +46,13 @@ export const ShowCertificates = () => {
Certificates
- Create certificates in the Traefik directory
+ Create uploaded certificates for web server domains
- Certificates are created in the Traefik directory. Traefik uses
- these certificates to secure your applications. Using invalid
- certificates can break your Traefik instance, preventing access to
- your applications.
+ Uploaded certificates can be used by supported web server
+ providers to secure your applications. Invalid certificates can
+ break domain access for the services that use them.
diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx
index 65957a881c..5c6d7867d4 100644
--- a/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx
@@ -13,7 +13,8 @@ import {
} from "@/components/ui/dropdown-menu";
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import { api } from "@/utils/api";
-import { EditTraefikEnv } from "../../web-server/edit-traefik-env";
+import { CaddyTrustedProxySettings } from "../../web-server/caddy-trusted-proxy-settings";
+import { EditWebServerEnv } from "../../web-server/edit-web-server-env";
import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports";
import { ShowModalLogs } from "../../web-server/show-modal-logs";
@@ -21,16 +22,34 @@ interface Props {
serverId?: string;
}
export const ShowTraefikActions = ({ serverId }: Props) => {
- const { mutateAsync: reloadTraefik, isPending: reloadTraefikIsLoading } =
- api.settings.reloadTraefik.useMutation();
+ const { data: activeProvider, isLoading: isLoadingProvider } =
+ api.settings.getActiveWebServerProvider.useQuery({ serverId });
+ const providerLabel =
+ activeProvider === "caddy"
+ ? "Caddy"
+ : activeProvider === "traefik"
+ ? "Traefik"
+ : "Web Server";
+ const resourceName =
+ activeProvider === "caddy"
+ ? "dokploy-caddy"
+ : activeProvider === "traefik"
+ ? "dokploy-traefik"
+ : null;
+
+ const { mutateAsync: reloadWebServer, isPending: reloadTraefikIsLoading } =
+ api.settings.reloadWebServer.useMutation();
const { mutateAsync: toggleDashboard, isPending: toggleDashboardIsLoading } =
api.settings.toggleDashboard.useMutation();
- const { data: haveTraefikDashboardPortEnabled, refetch: refetchDashboard } =
- api.settings.haveTraefikDashboardPortEnabled.useQuery({
+ const { data: webServerDashboardState, refetch: refetchDashboard } =
+ api.settings.getWebServerDashboardState.useQuery({
serverId,
});
+ const haveTraefikDashboardPortEnabled =
+ webServerDashboardState?.provider === "traefik" &&
+ webServerDashboardState.enabled;
const {
execute: executeWithHealthCheck,
@@ -50,7 +69,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
} = useHealthCheckAfterMutation({
initialDelay: 5000,
pollInterval: 4000,
- successMessage: "Traefik Reloaded",
+ successMessage: `${providerLabel} reloaded`,
});
return (
@@ -58,6 +77,8 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
{
>
{
}
variant="outline"
>
- Traefik
+ {providerLabel}
@@ -84,12 +106,12 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
onClick={async () => {
try {
await executeReloadWithHealthCheck(() =>
- reloadTraefik({ serverId }),
+ reloadWebServer({ serverId }),
);
} catch (error) {
const errorMessage =
(error as Error)?.message ||
- "Failed to reload Traefik. Please try again.";
+ `Failed to reload ${providerLabel}. Please try again.`;
toast.error(errorMessage);
}
}}
@@ -98,75 +120,90 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
>
Reload
-
- e.preventDefault()}
- className="cursor-pointer"
+ {resourceName && (
+
- View Logs
-
-
-
+ e.preventDefault()}
+ className="cursor-pointer"
+ >
+ View Logs
+
+
+ )}
+
e.preventDefault()}
className="cursor-pointer"
>
Modify Environment
-
+
-
-
- The Traefik container will be recreated from scratch. This
- means the container will be deleted and created again, which
- may cause downtime in your applications.
-
-
- Are you sure you want to{" "}
- {haveTraefikDashboardPortEnabled ? "disable" : "enable"} the
- Traefik dashboard?
-
-
- }
- onClick={async () => {
- try {
- await executeWithHealthCheck(() =>
- toggleDashboard({
- enableDashboard: !haveTraefikDashboardPortEnabled,
- serverId: serverId,
- }),
- );
- } catch (error) {
- const errorMessage =
- (error as Error)?.message ||
- "Failed to toggle dashboard. Please check if port 8080 is available.";
- toast.error(errorMessage);
+ {activeProvider === "caddy" && (
+
+ e.preventDefault()}
+ className="cursor-pointer"
+ >
+ Trusted Proxies
+
+
+ )}
+
+ {activeProvider === "traefik" && (
+
- e.preventDefault()}
- className="w-full cursor-pointer space-x-3"
+ description={
+
+
+ The Traefik container will be recreated from scratch. This
+ means the container will be deleted and created again, which
+ may cause downtime in your applications.
+
+
+ Are you sure you want to{" "}
+ {haveTraefikDashboardPortEnabled ? "disable" : "enable"} the
+ Traefik dashboard?
+
+
+ }
+ onClick={async () => {
+ try {
+ await executeWithHealthCheck(() =>
+ toggleDashboard({
+ enableDashboard: !haveTraefikDashboardPortEnabled,
+ serverId: serverId,
+ }),
+ );
+ } catch (error) {
+ const errorMessage =
+ (error as Error)?.message ||
+ "Failed to toggle dashboard. Please check if port 8080 is available.";
+ toast.error(errorMessage);
+ }
+ }}
+ disabled={toggleDashboardIsLoading || isHealthCheckExecuting}
+ type="default"
>
-
- {haveTraefikDashboardPortEnabled ? "Disable" : "Enable"}{" "}
- Dashboard
-
-
-
+ e.preventDefault()}
+ className="w-full cursor-pointer space-x-3"
+ >
+
+ {haveTraefikDashboardPortEnabled ? "Disable" : "Enable"}{" "}
+ Dashboard
+
+
+
+ )}
e.preventDefault()}
diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx
index 1fade3f5a6..5b15b6e324 100644
--- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx
@@ -240,8 +240,9 @@ export const ShowServers = () => {
Configure and initialize your
- server with Docker, Traefik, and
- other essential services
+ server with Docker, the web
+ server, and other essential
+ services
diff --git a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx
index 098faf5f17..4760ebf84b 100644
--- a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx
+++ b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx
@@ -457,9 +457,10 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
render={({ field }) => (
- Access to Traefik Files
+ Access to Web Server Files
- Allow the user to access to the Traefik Tab Files
+ Allow the user to access the active web server file
+ browser
diff --git a/apps/dokploy/components/dashboard/settings/web-server.tsx b/apps/dokploy/components/dashboard/settings/web-server.tsx
index a383fbf7d6..e2f9a85e42 100644
--- a/apps/dokploy/components/dashboard/settings/web-server.tsx
+++ b/apps/dokploy/components/dashboard/settings/web-server.tsx
@@ -13,7 +13,10 @@ import { ShowDokployActions } from "./servers/actions/show-dokploy-actions";
import { ShowStorageActions } from "./servers/actions/show-storage-actions";
import { ShowTraefikActions } from "./servers/actions/show-traefik-actions";
import { ToggleDockerCleanup } from "./servers/actions/toggle-docker-cleanup";
+import { CaddyMigrationPanel } from "./web-server/caddy-migration-panel";
+import { CaddyTrustedProxySettings } from "./web-server/caddy-trusted-proxy-settings";
import { UpdateServer } from "./web-server/update-server";
+import { WebServerProviderSelector } from "./web-server/web-server-provider-selector";
export const WebServer = () => {
const { data: webServerSettings } =
@@ -42,6 +45,10 @@ export const WebServer = () => {
*/}
+
+
+
+
diff --git a/apps/dokploy/components/dashboard/settings/web-server/caddy-migration-panel.tsx b/apps/dokploy/components/dashboard/settings/web-server/caddy-migration-panel.tsx
new file mode 100644
index 0000000000..a788b01d9e
--- /dev/null
+++ b/apps/dokploy/components/dashboard/settings/web-server/caddy-migration-panel.tsx
@@ -0,0 +1,319 @@
+import type { CaddyMigrationReport } from "@dokploy/server";
+import { AlertTriangle, CheckCircle2, Loader2, RotateCcw } from "lucide-react";
+import { useState } from "react";
+import { toast } from "sonner";
+import { AlertBlock } from "@/components/shared/alert-block";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Label } from "@/components/ui/label";
+import { api } from "@/utils/api";
+
+interface Props {
+ serverId?: string;
+}
+
+const statusLabel = (report: CaddyMigrationReport) => {
+ if (report.summary.blockingWarnings > 0) {
+ return "Blocked";
+ }
+ if (report.validation.status !== "passed") {
+ return "Validation required";
+ }
+ return "Ready";
+};
+
+export const CaddyMigrationPanel = ({ serverId }: Props) => {
+ const [migrationId, setMigrationId] = useState
(null);
+ const [maintenanceConfirmed, setMaintenanceConfirmed] = useState(false);
+
+ const { data: activeProvider } =
+ api.settings.getActiveWebServerProvider.useQuery({ serverId });
+ const utils = api.useUtils();
+ const { mutateAsync: prepareMigration, isPending: isPreparing } =
+ api.settings.prepareCaddyMigration.useMutation();
+ const { mutateAsync: applyMigration, isPending: isApplying } =
+ api.settings.applyCaddyMigration.useMutation();
+ const { mutateAsync: rollbackMigration, isPending: isRollingBack } =
+ api.settings.rollbackCaddyMigration.useMutation();
+ const { data: fetchedReport, refetch: refetchReport } =
+ api.settings.getCaddyMigrationReport.useQuery(
+ { migrationId: migrationId ?? "", serverId },
+ { enabled: !!migrationId },
+ );
+ const [preparedReport, setPreparedReport] =
+ useState(null);
+ const report = fetchedReport ?? preparedReport;
+ const blockingWarnings = report?.warnings.filter(
+ (warning) => warning.blocking,
+ );
+ const nonBlockingWarnings = report?.warnings.filter(
+ (warning) => !warning.blocking,
+ );
+ const isMutating = isPreparing || isApplying || isRollingBack;
+ const canApply =
+ !!report &&
+ report.summary.blockingWarnings === 0 &&
+ report.validation.status === "passed" &&
+ maintenanceConfirmed &&
+ !isMutating;
+ const canRollback = !!report && report.status !== "prepared" && !isMutating;
+
+ const handleDryRun = async () => {
+ try {
+ const nextReport = await prepareMigration({ serverId });
+ setPreparedReport(nextReport);
+ setMigrationId(nextReport.migrationId);
+ setMaintenanceConfirmed(false);
+ toast.success("Caddy migration dry run prepared");
+ } catch (error) {
+ toast.error(
+ (error as Error).message || "Error preparing Caddy migration",
+ );
+ }
+ };
+
+ const handleApply = async () => {
+ if (!report) return;
+ try {
+ await applyMigration({
+ migrationId: report.migrationId,
+ serverId,
+ confirmMaintenanceWindow: true,
+ });
+ toast.success("Caddy migration apply started");
+ await utils.settings.getActiveWebServerProvider.invalidate({ serverId });
+ await refetchReport();
+ } catch (error) {
+ toast.error((error as Error).message || "Error applying Caddy migration");
+ }
+ };
+
+ const handleRollback = async () => {
+ if (!report) return;
+ try {
+ await rollbackMigration({ migrationId: report.migrationId, serverId });
+ toast.success("Caddy rollback started");
+ await utils.settings.getActiveWebServerProvider.invalidate({ serverId });
+ await refetchReport();
+ } catch (error) {
+ toast.error(
+ (error as Error).message || "Error rolling back Caddy migration",
+ );
+ }
+ };
+
+ return (
+
+
+
+
+ Caddy migration
+
+ Prepare a reviewable Traefik → Caddy migration before any
+ maintenance-window cutover.
+
+
+
void handleDryRun()}
+ isLoading={isPreparing}
+ disabled={isMutating}
+ >
+ Dry-run migration
+
+
+
+
+ {activeProvider === "caddy" && (
+
+ Caddy is already the active provider. Dry runs are still useful for
+ reviewing translated Traefik artifacts before rollback or follow-up
+ changes.
+
+ )}
+
+ {isPreparing && (
+
+ Preparing migration
+ artifacts...
+
+ )}
+
+ {report && (
+
+
+
+
+
+ Dry run {report.migrationId}
+
+ 0
+ ? "destructive"
+ : "secondary"
+ }
+ >
+ {statusLabel(report)}
+
+
+
+ Created {new Date(report.createdAt).toLocaleString()} ·
+ Status: {report.status} · Validation:{" "}
+ {report.validation.status}
+
+
+
+
+
{report.summary.fragments}
+
Fragments
+
+
+
{report.summary.routes}
+
Routes
+
+
+
+ {report.summary.blockingWarnings}/{report.summary.warnings}
+
+
Blocking
+
+
+
+
+ {report.validation.message && (
+
+ {report.validation.message}
+
+ )}
+
+
+
+
Inputs
+
+
+ Traefik static config:{" "}
+ {report.inputs.traefikStaticConfigFound
+ ? "found"
+ : "not found"}
+
+ Dynamic files: {report.inputs.dynamicFiles.length}
+
+ Application domains: {report.inputs.dbApplicationDomains}
+
+ Compose domains: {report.inputs.dbComposeDomains}
+
+
+
+
Artifacts
+
+ Report: {report.artifactPaths.reportMd}
+ Draft Caddy JSON: {report.artifactPaths.caddyJson}
+ Fragments: {report.artifactPaths.fragmentsDir}
+
+
+
+
+ {blockingWarnings && blockingWarnings.length > 0 ? (
+
+
+
Blocking items
+
+ {blockingWarnings.map((warning, index) => (
+
+ {warning.source ? `${warning.source}: ` : ""}
+ {warning.message}
+
+ ))}
+
+
+
+ ) : (
+
}
+ >
+ No blocking migration items were reported.
+
+ )}
+
+ {nonBlockingWarnings && nonBlockingWarnings.length > 0 && (
+
+
+ Review non-blocking warnings ({nonBlockingWarnings.length})
+
+
+ {nonBlockingWarnings.map((warning, index) => (
+
+ {warning.source ? `${warning.source}: ` : ""}
+ {warning.message}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+ Apply stops Traefik, starts Caddy on ports 80/443/443 UDP,
+ and changes the active provider only after cutover checks
+ pass. Run this during a maintenance window. Changing Caddy
+ settings after a dry run requires preparing a fresh dry run.
+
+
+
+ setMaintenanceConfirmed(checked === true)
+ }
+ />
+
+ I am in a maintenance window and approve the cutover.
+
+
+
+
+
+
+
+ void handleRollback()}
+ isLoading={isRollingBack}
+ disabled={!canRollback}
+ >
+ Roll back to Traefik
+
+ void handleApply()}
+ isLoading={isApplying}
+ disabled={!canApply}
+ >
+ Apply Caddy cutover
+
+
+
+ )}
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/settings/web-server/caddy-trusted-proxy-settings.tsx b/apps/dokploy/components/dashboard/settings/web-server/caddy-trusted-proxy-settings.tsx
new file mode 100644
index 0000000000..9f1d92b8ae
--- /dev/null
+++ b/apps/dokploy/components/dashboard/settings/web-server/caddy-trusted-proxy-settings.tsx
@@ -0,0 +1,215 @@
+import { Cloud, ShieldCheck } from "lucide-react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { AlertBlock } from "@/components/shared/alert-block";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import { Textarea } from "@/components/ui/textarea";
+import { api } from "@/utils/api";
+
+type TrustedProxyMode = "disabled" | "cloudflare" | "static";
+
+interface Props {
+ serverId?: string;
+ children?: React.ReactNode;
+}
+
+const splitList = (value: string) =>
+ Array.from(
+ new Set(
+ value
+ .split(/[\n,]+/)
+ .map((item) => item.trim())
+ .filter((item) => item.length > 0),
+ ),
+ );
+
+const joinList = (values?: string[] | null) => (values ?? []).join("\n");
+
+const modeLabel = (mode?: TrustedProxyMode) => {
+ switch (mode) {
+ case "cloudflare":
+ return "Cloudflare";
+ case "static":
+ return "Static CIDRs";
+ default:
+ return "Disabled";
+ }
+};
+
+export const CaddyTrustedProxySettings = ({ serverId, children }: Props) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [mode, setMode] = useState("disabled");
+ const [ranges, setRanges] = useState("");
+ const [headers, setHeaders] = useState("");
+ const [strict, setStrict] = useState(true);
+
+ const { data: settings, refetch } =
+ api.settings.getCaddyTrustedProxySettings.useQuery({ serverId });
+ const { mutateAsync: updateSettings, isPending } =
+ api.settings.updateCaddyTrustedProxySettings.useMutation();
+
+ useEffect(() => {
+ if (!settings) return;
+ setMode(settings.mode);
+ setRanges(joinList(settings.ranges));
+ setHeaders(joinList(settings.clientIpHeaders));
+ setStrict(settings.strict !== false);
+ }, [settings]);
+
+ const save = async () => {
+ try {
+ await updateSettings({
+ serverId,
+ mode,
+ ranges: mode === "static" ? splitList(ranges) : [],
+ clientIpHeaders: splitList(headers),
+ strict,
+ });
+ await refetch();
+ toast.success("Caddy trusted proxy settings updated");
+ setIsOpen(false);
+ } catch (error) {
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : "Error updating Caddy trusted proxy settings",
+ );
+ }
+ };
+
+ const trigger = children ?? (
+
+
+
+
+
Caddy trusted proxies
+
+ Mode: {modeLabel(settings?.mode)}
+
+
+
+
+ Configure
+
+
+ );
+
+ return (
+
+ {trigger}
+
+
+ Caddy trusted proxies
+
+ Configure which proxy IPs Caddy trusts for client IP headers.
+
+
+
+
+
+ Mode
+ setMode(value as TrustedProxyMode)}
+ >
+
+
+
+
+ Disabled
+ Cloudflare
+ Static CIDRs
+
+
+
+
+ {mode === "cloudflare" && (
+
+
+
+
+ Caddy will trust Cloudflare IP ranges and use CF-Connecting-IP
+ before X-Forwarded-For. Use DNS-only or Full (strict) SSL mode
+ for origin traffic; Flexible SSL is not recommended.
+
+
+
+ )}
+
+ {mode === "static" && (
+
+ Trusted CIDR ranges
+
+ )}
+
+ {mode !== "disabled" && (
+ <>
+
+ Client IP headers
+
+
+
+
Strict proxy order
+
+ Require the trusted proxy chain to be ordered.
+
+
+
+
+ >
+ )}
+
+
+
+ setIsOpen(false)}
+ >
+ Cancel
+
+
+ Save
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx b/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx
index cb615a76e3..8c5a0e8db2 100644
--- a/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx
+++ b/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx
@@ -1,183 +1,3 @@
-import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
-import { useEffect, useState } from "react";
-import { useForm } from "react-hook-form";
-import { toast } from "sonner";
-import { z } from "zod";
-import { AlertBlock } from "@/components/shared/alert-block";
-import { CodeEditor } from "@/components/shared/code-editor";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
-import { api } from "@/utils/api";
+import { EditWebServerEnv } from "./edit-web-server-env";
-const schema = z.object({
- env: z.string(),
-});
-
-type Schema = z.infer;
-
-interface Props {
- children?: React.ReactNode;
- serverId?: string;
-}
-
-export const EditTraefikEnv = ({ children, serverId }: Props) => {
- const [canEdit, setCanEdit] = useState(true);
-
- const { data } = api.settings.readTraefikEnv.useQuery({
- serverId,
- });
-
- const { mutateAsync, isPending, error, isError } =
- api.settings.writeTraefikEnv.useMutation();
-
- const {
- execute: executeWithHealthCheck,
- isExecuting: isHealthCheckExecuting,
- } = useHealthCheckAfterMutation({
- initialDelay: 5000,
- successMessage: "Traefik Env Updated",
- });
-
- const form = useForm({
- defaultValues: {
- env: data || "",
- },
- disabled: canEdit,
- resolver: zodResolver(schema),
- });
-
- useEffect(() => {
- if (data) {
- form.reset({
- env: data || "",
- });
- }
- }, [form, form.reset, data]);
-
- const onSubmit = async (data: Schema) => {
- try {
- await executeWithHealthCheck(() =>
- mutateAsync({
- env: data.env,
- serverId,
- }),
- );
- } catch {
- toast.error("Error updating the Traefik env");
- }
- };
-
- // Add keyboard shortcut for Ctrl+S/Cmd+S
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (
- (e.ctrlKey || e.metaKey) &&
- e.code === "KeyS" &&
- !isPending &&
- !canEdit
- ) {
- e.preventDefault();
- form.handleSubmit(onSubmit)();
- }
- };
-
- document.addEventListener("keydown", handleKeyDown);
- return () => {
- document.removeEventListener("keydown", handleKeyDown);
- };
- }, [form, onSubmit, isPending, canEdit]);
-
- return (
-
- {children}
-
-
- Update Traefik Environment
-
- Update the traefik environment variables
-
-
- {isError && {error?.message} }
-
-
-
-
-
- Update
-
-
-
-
-
- );
-};
+export const EditTraefikEnv = EditWebServerEnv;
diff --git a/apps/dokploy/components/dashboard/settings/web-server/edit-web-server-env.tsx b/apps/dokploy/components/dashboard/settings/web-server/edit-web-server-env.tsx
new file mode 100644
index 0000000000..bc386a3724
--- /dev/null
+++ b/apps/dokploy/components/dashboard/settings/web-server/edit-web-server-env.tsx
@@ -0,0 +1,197 @@
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+import { AlertBlock } from "@/components/shared/alert-block";
+import { CodeEditor } from "@/components/shared/code-editor";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
+import { api } from "@/utils/api";
+
+const schema = z.object({
+ env: z.string(),
+});
+
+type Schema = z.infer;
+
+interface Props {
+ children?: React.ReactNode;
+ serverId?: string;
+}
+
+export const EditWebServerEnv = ({ children, serverId }: Props) => {
+ const [canEdit, setCanEdit] = useState(true);
+
+ const { data: activeProvider } =
+ api.settings.getActiveWebServerProvider.useQuery({ serverId });
+ const providerLabel =
+ activeProvider === "caddy"
+ ? "Caddy"
+ : activeProvider === "traefik"
+ ? "Traefik"
+ : "Web Server";
+
+ const { data } = api.settings.readWebServerEnv.useQuery({
+ serverId,
+ });
+
+ const { mutateAsync, isPending, error, isError } =
+ api.settings.writeWebServerEnv.useMutation();
+
+ const {
+ execute: executeWithHealthCheck,
+ isExecuting: isHealthCheckExecuting,
+ } = useHealthCheckAfterMutation({
+ initialDelay: 5000,
+ successMessage: `${providerLabel} env updated`,
+ });
+
+ const form = useForm({
+ defaultValues: {
+ env: data || "",
+ },
+ disabled: canEdit,
+ resolver: zodResolver(schema),
+ });
+
+ useEffect(() => {
+ if (data) {
+ form.reset({
+ env: data || "",
+ });
+ }
+ }, [form, form.reset, data]);
+
+ const onSubmit = async (data: Schema) => {
+ try {
+ await executeWithHealthCheck(() =>
+ mutateAsync({
+ env: data.env,
+ serverId,
+ }),
+ );
+ } catch {
+ toast.error(`Error updating the ${providerLabel} env`);
+ }
+ };
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (
+ (e.ctrlKey || e.metaKey) &&
+ e.code === "KeyS" &&
+ !isPending &&
+ !canEdit
+ ) {
+ e.preventDefault();
+ form.handleSubmit(onSubmit)();
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [form, onSubmit, isPending, canEdit]);
+
+ return (
+
+ {children}
+
+
+ Update {providerLabel} Environment
+
+ Update the active web server environment variables.
+
+
+ {isError && {error?.message} }
+
+
+
+
+
+ Update
+
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx b/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx
index 6f42c804b5..e7a703908a 100644
--- a/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx
+++ b/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx
@@ -48,17 +48,21 @@ const PortSchema = z.object({
protocol: z.enum(["tcp", "udp", "sctp"]),
});
-const TraefikPortsSchema = z.object({
+const WebServerPortsSchema = z.object({
ports: z.array(PortSchema),
});
-type TraefikPortsForm = z.infer;
+type WebServerPortsForm = z.infer;
export const ManageTraefikPorts = ({ children, serverId }: Props) => {
const [open, setOpen] = useState(false);
- const form = useForm({
- resolver: zodResolver(TraefikPortsSchema),
+ const { data: activeProvider } =
+ api.settings.getActiveWebServerProvider.useQuery({ serverId });
+ const providerLabel = activeProvider === "caddy" ? "Caddy" : "Traefik";
+
+ const form = useForm({
+ resolver: zodResolver(WebServerPortsSchema),
defaultValues: {
ports: [],
},
@@ -70,12 +74,12 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
});
const { data: currentPorts, refetch: refetchPorts } =
- api.settings.getTraefikPorts.useQuery({
+ api.settings.getWebServerPorts.useQuery({
serverId,
});
const { mutateAsync: updatePorts, isPending } =
- api.settings.updateTraefikPorts.useMutation();
+ api.settings.updateWebServerPorts.useMutation();
const {
execute: executeWithHealthCheck,
@@ -104,7 +108,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
append({ targetPort: 0, publishedPort: 0, protocol: "tcp" });
};
- const onSubmit = async (data: TraefikPortsForm) => {
+ const onSubmit = async (data: WebServerPortsForm) => {
try {
await executeWithHealthCheck(() =>
updatePorts({
@@ -114,7 +118,9 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
);
setOpen(false);
} catch (error) {
- toast.error((error as Error).message || "Error updating Traefik ports");
+ toast.error(
+ (error as Error).message || `Error updating ${providerLabel} ports`,
+ );
}
};
@@ -132,7 +138,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
- Add or remove additional ports for Traefik
+ Add or remove additional ports for {providerLabel}
{fields.length} port mapping{fields.length !== 1 ? "s" : ""}{" "}
configured
@@ -286,7 +292,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
Each port mapping defines how external traffic reaches
- your containers through Traefik.
+ your containers through {providerLabel}.
@@ -300,8 +306,8 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
All ports are bound directly to the host machine,
- allowing Traefik to handle incoming traffic and route
- it appropriately to your services.
+ allowing {providerLabel} to handle incoming traffic
+ and route it appropriately to your services.
@@ -309,9 +315,9 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
)}
- The Traefik container will be recreated from scratch. This
- means the container will be deleted and created again, which
- may cause downtime in your applications.
+ The {providerLabel} resource will be recreated from scratch.
+ This means the container or service will be deleted and
+ created again, which may cause downtime in your applications.
diff --git a/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx b/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx
index d8a35d142b..9fed4dd3a4 100644
--- a/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx
+++ b/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx
@@ -31,7 +31,10 @@ type ServiceStatus = {
type HealthResult = {
postgres: ServiceStatus;
redis: ServiceStatus;
- traefik: ServiceStatus;
+ webServer: ServiceStatus & {
+ provider: "traefik" | "caddy";
+ };
+ traefik?: ServiceStatus;
};
type ModalState = "idle" | "checking" | "results" | "updating";
@@ -56,6 +59,11 @@ const ServiceStatusItem = ({
);
+const webServerLabels: Record = {
+ traefik: "Traefik",
+ caddy: "Caddy",
+};
+
export const UpdateWebServer = ({
buttonClassName,
}: {
@@ -90,7 +98,7 @@ export const UpdateWebServer = ({
healthResult &&
healthResult.postgres.status === "healthy" &&
healthResult.redis.status === "healthy" &&
- healthResult.traefik.status === "healthy";
+ healthResult.webServer.status === "healthy";
const checkIsUpdateFinished = async () => {
try {
@@ -179,7 +187,7 @@ export const UpdateWebServer = ({
{modalState === "checking" && (
- Checking PostgreSQL, Redis and Traefik...
+ Checking PostgreSQL, Redis and the active web server...
)}
@@ -195,8 +203,8 @@ export const UpdateWebServer = ({
service={healthResult.redis}
/>
diff --git a/apps/dokploy/components/dashboard/settings/web-server/web-server-provider-selector.tsx b/apps/dokploy/components/dashboard/settings/web-server/web-server-provider-selector.tsx
new file mode 100644
index 0000000000..539f69cbc5
--- /dev/null
+++ b/apps/dokploy/components/dashboard/settings/web-server/web-server-provider-selector.tsx
@@ -0,0 +1,101 @@
+import type { WebServerProvider } from "@dokploy/server";
+import { toast } from "sonner";
+import { AlertBlock } from "@/components/shared/alert-block";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { api } from "@/utils/api";
+
+interface Props {
+ serverId?: string;
+}
+
+const providerLabels: Record = {
+ traefik: "Traefik",
+ caddy: "Caddy",
+};
+
+export const WebServerProviderSelector = ({ serverId }: Props) => {
+ const utils = api.useUtils();
+ const { data: activeProvider, isLoading } =
+ api.settings.getActiveWebServerProvider.useQuery({ serverId });
+ const { mutateAsync: updateProvider, isPending } =
+ api.settings.updateActiveWebServerProvider.useMutation();
+
+ const handleProviderChange = async (provider: WebServerProvider) => {
+ if (!activeProvider || provider === activeProvider) return;
+ if (provider === "caddy") {
+ toast.error(
+ "Run and apply a Caddy migration dry run to activate Caddy safely.",
+ );
+ return;
+ }
+ if (activeProvider === "caddy" && provider === "traefik") {
+ toast.error("Use the migration rollback action to return to Traefik.");
+ return;
+ }
+
+ try {
+ await updateProvider({ provider, serverId });
+ await utils.settings.getActiveWebServerProvider.invalidate({ serverId });
+ await utils.settings.getWebServerDashboardState.invalidate({ serverId });
+ await utils.settings.readWebServerConfig.invalidate({ serverId });
+ toast.success(`Active web server set to ${providerLabels[provider]}`);
+ } catch (error) {
+ toast.error(
+ (error as Error).message || "Error updating active web server provider",
+ );
+ }
+ };
+
+ return (
+
+
+ Active provider
+
+ Review the active web server Dokploy uses for provider-aware reload,
+ configuration, and new domain changes.
+
+
+
+
+ void handleProviderChange(value as WebServerProvider)
+ }
+ >
+
+
+
+
+ Traefik
+ Caddy
+
+
+ {activeProvider === "traefik" ? (
+
+ Traefik workflows remain available. Caddy activation is handled by
+ the migration apply flow below so cutover checks are not bypassed.
+
+ ) : (
+
+ Caddy is the active provider. Traefik-specific editors remain
+ available only for existing Traefik configuration review.
+
+ )}
+
+
+ );
+};
diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx
index 30739e3fb0..8cdbb4a4d6 100644
--- a/apps/dokploy/components/layouts/side.tsx
+++ b/apps/dokploy/components/layouts/side.tsx
@@ -186,10 +186,10 @@ const MENU: Menu = {
},
{
isSingle: true,
- title: "Traefik File System",
+ title: "Web Server Files",
url: "/dashboard/traefik",
icon: GalleryVerticalEnd,
- // Only enabled for users with access to Traefik files
+ // `traefikFiles` is the legacy permission key for active web-server files.
isEnabled: ({ permissions }) => !!permissions?.traefikFiles.read,
},
{
diff --git a/apps/dokploy/components/layouts/user-nav.tsx b/apps/dokploy/components/layouts/user-nav.tsx
index aa9747cfbe..2c507a9177 100644
--- a/apps/dokploy/components/layouts/user-nav.tsx
+++ b/apps/dokploy/components/layouts/user-nav.tsx
@@ -102,7 +102,7 @@ export const UserNav = () => {
router.push("/dashboard/traefik");
}}
>
- Traefik
+ Web Server Files
)}
{permissions?.docker.read && (
diff --git a/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx
index 51b31b84c7..07020d6695 100644
--- a/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx
+++ b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx
@@ -76,8 +76,8 @@ const RESOURCE_META: Record = {
description: "Access to Git providers (GitHub, GitLab, Bitbucket, Gitea)",
},
traefikFiles: {
- label: "Traefik Files",
- description: "Access to the Traefik file system configuration",
+ label: "Web Server Files",
+ description: "Access to the active web server file browser",
},
api: {
label: "API / CLI",
@@ -242,11 +242,11 @@ const ACTION_META: Record<
traefikFiles: {
read: {
label: "Read",
- description: "View Traefik configuration files",
+ description: "View active web server configuration files",
},
write: {
label: "Write",
- description: "Edit and save Traefik configuration files",
+ description: "Edit and save active web server configuration files",
},
},
api: {
diff --git a/apps/dokploy/components/shared/code-editor.tsx b/apps/dokploy/components/shared/code-editor.tsx
index f0347da607..3ccc141433 100644
--- a/apps/dokploy/components/shared/code-editor.tsx
+++ b/apps/dokploy/components/shared/code-editor.tsx
@@ -190,7 +190,7 @@ export const CodeEditor = ({
)}
>
{props.disabled && (
-
+
)}
diff --git a/apps/dokploy/drizzle/0173_cool_vargas.sql b/apps/dokploy/drizzle/0173_cool_vargas.sql
new file mode 100644
index 0000000000..d4af988630
--- /dev/null
+++ b/apps/dokploy/drizzle/0173_cool_vargas.sql
@@ -0,0 +1,10 @@
+DO $$ BEGIN
+ CREATE TYPE "public"."webServerProvider" AS ENUM('traefik', 'caddy');
+EXCEPTION
+ WHEN duplicate_object THEN NULL;
+END $$;--> statement-breakpoint
+ALTER TABLE "server" ADD COLUMN IF NOT EXISTS "webServerProvider" "webServerProvider" DEFAULT 'traefik' NOT NULL;--> statement-breakpoint
+ALTER TABLE "server" ADD COLUMN IF NOT EXISTS "caddyTrustedProxyConfig" jsonb DEFAULT 'null'::jsonb;--> statement-breakpoint
+ALTER TABLE "webServerSettings" ADD COLUMN IF NOT EXISTS "webServerProvider" "webServerProvider" DEFAULT 'traefik' NOT NULL;--> statement-breakpoint
+ALTER TABLE "webServerSettings" ADD COLUMN IF NOT EXISTS "caddyTrustedProxyConfig" jsonb DEFAULT 'null'::jsonb;--> statement-breakpoint
+ALTER TABLE "webServerSettings" ADD COLUMN IF NOT EXISTS "requestLogsEnabled" boolean DEFAULT false NOT NULL;
diff --git a/apps/dokploy/drizzle/meta/0173_snapshot.json b/apps/dokploy/drizzle/meta/0173_snapshot.json
new file mode 100644
index 0000000000..2e62dffac6
--- /dev/null
+++ b/apps/dokploy/drizzle/meta/0173_snapshot.json
@@ -0,0 +1,8503 @@
+{
+ "id": "3e6b4e06-e58b-4efd-8107-29289891235c",
+ "prevId": "d711bc8c-f41e-4de4-9733-90715f21cdc9",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is2FAEnabled": {
+ "name": "is2FAEnabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resetPasswordToken": {
+ "name": "resetPasswordToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resetPasswordExpiresAt": {
+ "name": "resetPasswordExpiresAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confirmationToken": {
+ "name": "confirmationToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confirmationExpiresAt": {
+ "name": "confirmationExpiresAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.apikey": {
+ "name": "apikey",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "start": {
+ "name": "start",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "prefix": {
+ "name": "prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config_id": {
+ "name": "config_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "reference_id": {
+ "name": "reference_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "refill_interval": {
+ "name": "refill_interval",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refill_amount": {
+ "name": "refill_amount",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_refill_at": {
+ "name": "last_refill_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rate_limit_enabled": {
+ "name": "rate_limit_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rate_limit_time_window": {
+ "name": "rate_limit_time_window",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rate_limit_max": {
+ "name": "rate_limit_max",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "request_count": {
+ "name": "request_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "remaining": {
+ "name": "remaining",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_request": {
+ "name": "last_request",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permissions": {
+ "name": "permissions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "apikey_reference_id_user_id_fk": {
+ "name": "apikey_reference_id_user_id_fk",
+ "tableFrom": "apikey",
+ "tableTo": "user",
+ "columnsFrom": [
+ "reference_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invitation": {
+ "name": "invitation",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "team_id": {
+ "name": "team_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "invitation_organization_id_organization_id_fk": {
+ "name": "invitation_organization_id_organization_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invitation_inviter_id_user_id_fk": {
+ "name": "invitation_inviter_id_user_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "user",
+ "columnsFrom": [
+ "inviter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.member": {
+ "name": "member",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "team_id": {
+ "name": "team_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "canCreateProjects": {
+ "name": "canCreateProjects",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "canAccessToSSHKeys": {
+ "name": "canAccessToSSHKeys",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "canCreateServices": {
+ "name": "canCreateServices",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "canDeleteProjects": {
+ "name": "canDeleteProjects",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "canDeleteServices": {
+ "name": "canDeleteServices",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "canAccessToDocker": {
+ "name": "canAccessToDocker",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "canAccessToAPI": {
+ "name": "canAccessToAPI",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "canAccessToGitProviders": {
+ "name": "canAccessToGitProviders",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "canAccessToTraefikFiles": {
+ "name": "canAccessToTraefikFiles",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "canDeleteEnvironments": {
+ "name": "canDeleteEnvironments",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "canCreateEnvironments": {
+ "name": "canCreateEnvironments",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "accesedProjects": {
+ "name": "accesedProjects",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "ARRAY[]::text[]"
+ },
+ "accessedEnvironments": {
+ "name": "accessedEnvironments",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "ARRAY[]::text[]"
+ },
+ "accesedServices": {
+ "name": "accesedServices",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "ARRAY[]::text[]"
+ },
+ "accessedGitProviders": {
+ "name": "accessedGitProviders",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "ARRAY[]::text[]"
+ },
+ "accessedServers": {
+ "name": "accessedServers",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "ARRAY[]::text[]"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "member_organization_id_organization_id_fk": {
+ "name": "member_organization_id_organization_id_fk",
+ "tableFrom": "member",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "member_user_id_user_id_fk": {
+ "name": "member_user_id_user_id_fk",
+ "tableFrom": "member",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.organization": {
+ "name": "organization",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "organization_owner_id_user_id_fk": {
+ "name": "organization_owner_id_user_id_fk",
+ "tableFrom": "organization",
+ "tableTo": "user",
+ "columnsFrom": [
+ "owner_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "organization_slug_unique": {
+ "name": "organization_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.organization_role": {
+ "name": "organization_role",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "organizationRole_organizationId_idx": {
+ "name": "organizationRole_organizationId_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "organizationRole_role_idx": {
+ "name": "organizationRole_role_idx",
+ "columns": [
+ {
+ "expression": "role",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "organization_role_organization_id_organization_id_fk": {
+ "name": "organization_role_organization_id_organization_id_fk",
+ "tableFrom": "organization_role",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.two_factor": {
+ "name": "two_factor",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "secret": {
+ "name": "secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "backup_codes": {
+ "name": "backup_codes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "two_factor_user_id_user_id_fk": {
+ "name": "two_factor_user_id_user_id_fk",
+ "tableFrom": "two_factor",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ai": {
+ "name": "ai",
+ "schema": "",
+ "columns": {
+ "aiId": {
+ "name": "aiId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "apiUrl": {
+ "name": "apiUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "apiKey": {
+ "name": "apiKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "isEnabled": {
+ "name": "isEnabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "ai_organizationId_organization_id_fk": {
+ "name": "ai_organizationId_organization_id_fk",
+ "tableFrom": "ai",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organizationId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.application": {
+ "name": "application",
+ "schema": "",
+ "columns": {
+ "applicationId": {
+ "name": "applicationId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "appName": {
+ "name": "appName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "env": {
+ "name": "env",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "previewEnv": {
+ "name": "previewEnv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "watchPaths": {
+ "name": "watchPaths",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "previewBuildArgs": {
+ "name": "previewBuildArgs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "previewBuildSecrets": {
+ "name": "previewBuildSecrets",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "previewLabels": {
+ "name": "previewLabels",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "previewWildcard": {
+ "name": "previewWildcard",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "previewPort": {
+ "name": "previewPort",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 3000
+ },
+ "previewHttps": {
+ "name": "previewHttps",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "previewPath": {
+ "name": "previewPath",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'/'"
+ },
+ "certificateType": {
+ "name": "certificateType",
+ "type": "certificateType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "previewCustomCertResolver": {
+ "name": "previewCustomCertResolver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "previewLimit": {
+ "name": "previewLimit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 3
+ },
+ "isPreviewDeploymentsActive": {
+ "name": "isPreviewDeploymentsActive",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "previewRequireCollaboratorPermissions": {
+ "name": "previewRequireCollaboratorPermissions",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": true
+ },
+ "rollbackActive": {
+ "name": "rollbackActive",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "buildArgs": {
+ "name": "buildArgs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "buildSecrets": {
+ "name": "buildSecrets",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "memoryReservation": {
+ "name": "memoryReservation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "memoryLimit": {
+ "name": "memoryLimit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cpuReservation": {
+ "name": "cpuReservation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cpuLimit": {
+ "name": "cpuLimit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "subtitle": {
+ "name": "subtitle",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "command": {
+ "name": "command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "args": {
+ "name": "args",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refreshToken": {
+ "name": "refreshToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sourceType": {
+ "name": "sourceType",
+ "type": "sourceType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'github'"
+ },
+ "cleanCache": {
+ "name": "cleanCache",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "repository": {
+ "name": "repository",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "owner": {
+ "name": "owner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "branch": {
+ "name": "branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "buildPath": {
+ "name": "buildPath",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'/'"
+ },
+ "triggerType": {
+ "name": "triggerType",
+ "type": "triggerType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'push'"
+ },
+ "autoDeploy": {
+ "name": "autoDeploy",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gitlabProjectId": {
+ "name": "gitlabProjectId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gitlabRepository": {
+ "name": "gitlabRepository",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gitlabOwner": {
+ "name": "gitlabOwner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gitlabBranch": {
+ "name": "gitlabBranch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gitlabBuildPath": {
+ "name": "gitlabBuildPath",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'/'"
+ },
+ "gitlabPathNamespace": {
+ "name": "gitlabPathNamespace",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "giteaRepository": {
+ "name": "giteaRepository",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "giteaOwner": {
+ "name": "giteaOwner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "giteaBranch": {
+ "name": "giteaBranch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "giteaBuildPath": {
+ "name": "giteaBuildPath",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'/'"
+ },
+ "bitbucketRepository": {
+ "name": "bitbucketRepository",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bitbucketRepositorySlug": {
+ "name": "bitbucketRepositorySlug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bitbucketOwner": {
+ "name": "bitbucketOwner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bitbucketBranch": {
+ "name": "bitbucketBranch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bitbucketBuildPath": {
+ "name": "bitbucketBuildPath",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'/'"
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dockerImage": {
+ "name": "dockerImage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "registryUrl": {
+ "name": "registryUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "customGitUrl": {
+ "name": "customGitUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "customGitBranch": {
+ "name": "customGitBranch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "customGitBuildPath": {
+ "name": "customGitBuildPath",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "customGitSSHKeyId": {
+ "name": "customGitSSHKeyId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enableSubmodules": {
+ "name": "enableSubmodules",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "dockerfile": {
+ "name": "dockerfile",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'Dockerfile'"
+ },
+ "dockerContextPath": {
+ "name": "dockerContextPath",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dockerBuildStage": {
+ "name": "dockerBuildStage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dropBuildPath": {
+ "name": "dropBuildPath",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "healthCheckSwarm": {
+ "name": "healthCheckSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "restartPolicySwarm": {
+ "name": "restartPolicySwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "placementSwarm": {
+ "name": "placementSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updateConfigSwarm": {
+ "name": "updateConfigSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rollbackConfigSwarm": {
+ "name": "rollbackConfigSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "modeSwarm": {
+ "name": "modeSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "labelsSwarm": {
+ "name": "labelsSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "networkSwarm": {
+ "name": "networkSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stopGracePeriodSwarm": {
+ "name": "stopGracePeriodSwarm",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "endpointSpecSwarm": {
+ "name": "endpointSpecSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ulimitsSwarm": {
+ "name": "ulimitsSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "replicas": {
+ "name": "replicas",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "applicationStatus": {
+ "name": "applicationStatus",
+ "type": "applicationStatus",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'idle'"
+ },
+ "buildType": {
+ "name": "buildType",
+ "type": "buildType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'nixpacks'"
+ },
+ "railpackVersion": {
+ "name": "railpackVersion",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'0.15.4'"
+ },
+ "herokuVersion": {
+ "name": "herokuVersion",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'24'"
+ },
+ "publishDirectory": {
+ "name": "publishDirectory",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "isStaticSpa": {
+ "name": "isStaticSpa",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createEnvFile": {
+ "name": "createEnvFile",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "registryId": {
+ "name": "registryId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rollbackRegistryId": {
+ "name": "rollbackRegistryId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "environmentId": {
+ "name": "environmentId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "githubId": {
+ "name": "githubId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gitlabId": {
+ "name": "gitlabId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "giteaId": {
+ "name": "giteaId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bitbucketId": {
+ "name": "bitbucketId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "serverId": {
+ "name": "serverId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "buildServerId": {
+ "name": "buildServerId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "buildRegistryId": {
+ "name": "buildRegistryId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "application_customGitSSHKeyId_ssh-key_sshKeyId_fk": {
+ "name": "application_customGitSSHKeyId_ssh-key_sshKeyId_fk",
+ "tableFrom": "application",
+ "tableTo": "ssh-key",
+ "columnsFrom": [
+ "customGitSSHKeyId"
+ ],
+ "columnsTo": [
+ "sshKeyId"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "application_registryId_registry_registryId_fk": {
+ "name": "application_registryId_registry_registryId_fk",
+ "tableFrom": "application",
+ "tableTo": "registry",
+ "columnsFrom": [
+ "registryId"
+ ],
+ "columnsTo": [
+ "registryId"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "application_rollbackRegistryId_registry_registryId_fk": {
+ "name": "application_rollbackRegistryId_registry_registryId_fk",
+ "tableFrom": "application",
+ "tableTo": "registry",
+ "columnsFrom": [
+ "rollbackRegistryId"
+ ],
+ "columnsTo": [
+ "registryId"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "application_environmentId_environment_environmentId_fk": {
+ "name": "application_environmentId_environment_environmentId_fk",
+ "tableFrom": "application",
+ "tableTo": "environment",
+ "columnsFrom": [
+ "environmentId"
+ ],
+ "columnsTo": [
+ "environmentId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "application_githubId_github_githubId_fk": {
+ "name": "application_githubId_github_githubId_fk",
+ "tableFrom": "application",
+ "tableTo": "github",
+ "columnsFrom": [
+ "githubId"
+ ],
+ "columnsTo": [
+ "githubId"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "application_gitlabId_gitlab_gitlabId_fk": {
+ "name": "application_gitlabId_gitlab_gitlabId_fk",
+ "tableFrom": "application",
+ "tableTo": "gitlab",
+ "columnsFrom": [
+ "gitlabId"
+ ],
+ "columnsTo": [
+ "gitlabId"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "application_giteaId_gitea_giteaId_fk": {
+ "name": "application_giteaId_gitea_giteaId_fk",
+ "tableFrom": "application",
+ "tableTo": "gitea",
+ "columnsFrom": [
+ "giteaId"
+ ],
+ "columnsTo": [
+ "giteaId"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "application_bitbucketId_bitbucket_bitbucketId_fk": {
+ "name": "application_bitbucketId_bitbucket_bitbucketId_fk",
+ "tableFrom": "application",
+ "tableTo": "bitbucket",
+ "columnsFrom": [
+ "bitbucketId"
+ ],
+ "columnsTo": [
+ "bitbucketId"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "application_serverId_server_serverId_fk": {
+ "name": "application_serverId_server_serverId_fk",
+ "tableFrom": "application",
+ "tableTo": "server",
+ "columnsFrom": [
+ "serverId"
+ ],
+ "columnsTo": [
+ "serverId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "application_buildServerId_server_serverId_fk": {
+ "name": "application_buildServerId_server_serverId_fk",
+ "tableFrom": "application",
+ "tableTo": "server",
+ "columnsFrom": [
+ "buildServerId"
+ ],
+ "columnsTo": [
+ "serverId"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "application_buildRegistryId_registry_registryId_fk": {
+ "name": "application_buildRegistryId_registry_registryId_fk",
+ "tableFrom": "application",
+ "tableTo": "registry",
+ "columnsFrom": [
+ "buildRegistryId"
+ ],
+ "columnsTo": [
+ "registryId"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "application_appName_unique": {
+ "name": "application_appName_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "appName"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.audit_log": {
+ "name": "audit_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_email": {
+ "name": "user_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_role": {
+ "name": "user_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resource_type": {
+ "name": "resource_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resource_id": {
+ "name": "resource_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resource_name": {
+ "name": "resource_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "auditLog_organizationId_idx": {
+ "name": "auditLog_organizationId_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "auditLog_userId_idx": {
+ "name": "auditLog_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "auditLog_createdAt_idx": {
+ "name": "auditLog_createdAt_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "audit_log_organization_id_organization_id_fk": {
+ "name": "audit_log_organization_id_organization_id_fk",
+ "tableFrom": "audit_log",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "audit_log_user_id_user_id_fk": {
+ "name": "audit_log_user_id_user_id_fk",
+ "tableFrom": "audit_log",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.backup": {
+ "name": "backup",
+ "schema": "",
+ "columns": {
+ "backupId": {
+ "name": "backupId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "appName": {
+ "name": "appName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "schedule": {
+ "name": "schedule",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "database": {
+ "name": "database",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "prefix": {
+ "name": "prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "serviceName": {
+ "name": "serviceName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "destinationId": {
+ "name": "destinationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "keepLatestCount": {
+ "name": "keepLatestCount",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "backupType": {
+ "name": "backupType",
+ "type": "backupType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'database'"
+ },
+ "databaseType": {
+ "name": "databaseType",
+ "type": "databaseType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "composeId": {
+ "name": "composeId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "postgresId": {
+ "name": "postgresId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mariadbId": {
+ "name": "mariadbId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mysqlId": {
+ "name": "mysqlId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mongoId": {
+ "name": "mongoId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "libsqlId": {
+ "name": "libsqlId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "backup_destinationId_destination_destinationId_fk": {
+ "name": "backup_destinationId_destination_destinationId_fk",
+ "tableFrom": "backup",
+ "tableTo": "destination",
+ "columnsFrom": [
+ "destinationId"
+ ],
+ "columnsTo": [
+ "destinationId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "backup_composeId_compose_composeId_fk": {
+ "name": "backup_composeId_compose_composeId_fk",
+ "tableFrom": "backup",
+ "tableTo": "compose",
+ "columnsFrom": [
+ "composeId"
+ ],
+ "columnsTo": [
+ "composeId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "backup_postgresId_postgres_postgresId_fk": {
+ "name": "backup_postgresId_postgres_postgresId_fk",
+ "tableFrom": "backup",
+ "tableTo": "postgres",
+ "columnsFrom": [
+ "postgresId"
+ ],
+ "columnsTo": [
+ "postgresId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "backup_mariadbId_mariadb_mariadbId_fk": {
+ "name": "backup_mariadbId_mariadb_mariadbId_fk",
+ "tableFrom": "backup",
+ "tableTo": "mariadb",
+ "columnsFrom": [
+ "mariadbId"
+ ],
+ "columnsTo": [
+ "mariadbId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "backup_mysqlId_mysql_mysqlId_fk": {
+ "name": "backup_mysqlId_mysql_mysqlId_fk",
+ "tableFrom": "backup",
+ "tableTo": "mysql",
+ "columnsFrom": [
+ "mysqlId"
+ ],
+ "columnsTo": [
+ "mysqlId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "backup_mongoId_mongo_mongoId_fk": {
+ "name": "backup_mongoId_mongo_mongoId_fk",
+ "tableFrom": "backup",
+ "tableTo": "mongo",
+ "columnsFrom": [
+ "mongoId"
+ ],
+ "columnsTo": [
+ "mongoId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "backup_libsqlId_libsql_libsqlId_fk": {
+ "name": "backup_libsqlId_libsql_libsqlId_fk",
+ "tableFrom": "backup",
+ "tableTo": "libsql",
+ "columnsFrom": [
+ "libsqlId"
+ ],
+ "columnsTo": [
+ "libsqlId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "backup_userId_user_id_fk": {
+ "name": "backup_userId_user_id_fk",
+ "tableFrom": "backup",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "backup_appName_unique": {
+ "name": "backup_appName_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "appName"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.bitbucket": {
+ "name": "bitbucket",
+ "schema": "",
+ "columns": {
+ "bitbucketId": {
+ "name": "bitbucketId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "bitbucketUsername": {
+ "name": "bitbucketUsername",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bitbucketEmail": {
+ "name": "bitbucketEmail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "appPassword": {
+ "name": "appPassword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "apiToken": {
+ "name": "apiToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bitbucketWorkspaceName": {
+ "name": "bitbucketWorkspaceName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gitProviderId": {
+ "name": "gitProviderId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bitbucket_gitProviderId_git_provider_gitProviderId_fk": {
+ "name": "bitbucket_gitProviderId_git_provider_gitProviderId_fk",
+ "tableFrom": "bitbucket",
+ "tableTo": "git_provider",
+ "columnsFrom": [
+ "gitProviderId"
+ ],
+ "columnsTo": [
+ "gitProviderId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.certificate": {
+ "name": "certificate",
+ "schema": "",
+ "columns": {
+ "certificateId": {
+ "name": "certificateId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "certificateData": {
+ "name": "certificateData",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "privateKey": {
+ "name": "privateKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "certificatePath": {
+ "name": "certificatePath",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "autoRenew": {
+ "name": "autoRenew",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "serverId": {
+ "name": "serverId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "certificate_organizationId_organization_id_fk": {
+ "name": "certificate_organizationId_organization_id_fk",
+ "tableFrom": "certificate",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organizationId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "certificate_serverId_server_serverId_fk": {
+ "name": "certificate_serverId_server_serverId_fk",
+ "tableFrom": "certificate",
+ "tableTo": "server",
+ "columnsFrom": [
+ "serverId"
+ ],
+ "columnsTo": [
+ "serverId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "certificate_certificatePath_unique": {
+ "name": "certificate_certificatePath_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "certificatePath"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.compose": {
+ "name": "compose",
+ "schema": "",
+ "columns": {
+ "composeId": {
+ "name": "composeId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "appName": {
+ "name": "appName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "env": {
+ "name": "env",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "composeFile": {
+ "name": "composeFile",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "refreshToken": {
+ "name": "refreshToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sourceType": {
+ "name": "sourceType",
+ "type": "sourceTypeCompose",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'github'"
+ },
+ "composeType": {
+ "name": "composeType",
+ "type": "composeType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'docker-compose'"
+ },
+ "repository": {
+ "name": "repository",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "owner": {
+ "name": "owner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "branch": {
+ "name": "branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "autoDeploy": {
+ "name": "autoDeploy",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gitlabProjectId": {
+ "name": "gitlabProjectId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gitlabRepository": {
+ "name": "gitlabRepository",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gitlabOwner": {
+ "name": "gitlabOwner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gitlabBranch": {
+ "name": "gitlabBranch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gitlabPathNamespace": {
+ "name": "gitlabPathNamespace",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bitbucketRepository": {
+ "name": "bitbucketRepository",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bitbucketRepositorySlug": {
+ "name": "bitbucketRepositorySlug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bitbucketOwner": {
+ "name": "bitbucketOwner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bitbucketBranch": {
+ "name": "bitbucketBranch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "giteaRepository": {
+ "name": "giteaRepository",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "giteaOwner": {
+ "name": "giteaOwner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "giteaBranch": {
+ "name": "giteaBranch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "customGitUrl": {
+ "name": "customGitUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "customGitBranch": {
+ "name": "customGitBranch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "customGitSSHKeyId": {
+ "name": "customGitSSHKeyId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "command": {
+ "name": "command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "enableSubmodules": {
+ "name": "enableSubmodules",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "composePath": {
+ "name": "composePath",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'./docker-compose.yml'"
+ },
+ "suffix": {
+ "name": "suffix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "randomize": {
+ "name": "randomize",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "isolatedDeployment": {
+ "name": "isolatedDeployment",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "isolatedDeploymentsVolume": {
+ "name": "isolatedDeploymentsVolume",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "triggerType": {
+ "name": "triggerType",
+ "type": "triggerType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'push'"
+ },
+ "composeStatus": {
+ "name": "composeStatus",
+ "type": "applicationStatus",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'idle'"
+ },
+ "environmentId": {
+ "name": "environmentId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "watchPaths": {
+ "name": "watchPaths",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "githubId": {
+ "name": "githubId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gitlabId": {
+ "name": "gitlabId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bitbucketId": {
+ "name": "bitbucketId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "giteaId": {
+ "name": "giteaId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "serverId": {
+ "name": "serverId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "compose_customGitSSHKeyId_ssh-key_sshKeyId_fk": {
+ "name": "compose_customGitSSHKeyId_ssh-key_sshKeyId_fk",
+ "tableFrom": "compose",
+ "tableTo": "ssh-key",
+ "columnsFrom": [
+ "customGitSSHKeyId"
+ ],
+ "columnsTo": [
+ "sshKeyId"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "compose_environmentId_environment_environmentId_fk": {
+ "name": "compose_environmentId_environment_environmentId_fk",
+ "tableFrom": "compose",
+ "tableTo": "environment",
+ "columnsFrom": [
+ "environmentId"
+ ],
+ "columnsTo": [
+ "environmentId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "compose_githubId_github_githubId_fk": {
+ "name": "compose_githubId_github_githubId_fk",
+ "tableFrom": "compose",
+ "tableTo": "github",
+ "columnsFrom": [
+ "githubId"
+ ],
+ "columnsTo": [
+ "githubId"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "compose_gitlabId_gitlab_gitlabId_fk": {
+ "name": "compose_gitlabId_gitlab_gitlabId_fk",
+ "tableFrom": "compose",
+ "tableTo": "gitlab",
+ "columnsFrom": [
+ "gitlabId"
+ ],
+ "columnsTo": [
+ "gitlabId"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "compose_bitbucketId_bitbucket_bitbucketId_fk": {
+ "name": "compose_bitbucketId_bitbucket_bitbucketId_fk",
+ "tableFrom": "compose",
+ "tableTo": "bitbucket",
+ "columnsFrom": [
+ "bitbucketId"
+ ],
+ "columnsTo": [
+ "bitbucketId"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "compose_giteaId_gitea_giteaId_fk": {
+ "name": "compose_giteaId_gitea_giteaId_fk",
+ "tableFrom": "compose",
+ "tableTo": "gitea",
+ "columnsFrom": [
+ "giteaId"
+ ],
+ "columnsTo": [
+ "giteaId"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "compose_serverId_server_serverId_fk": {
+ "name": "compose_serverId_server_serverId_fk",
+ "tableFrom": "compose",
+ "tableTo": "server",
+ "columnsFrom": [
+ "serverId"
+ ],
+ "columnsTo": [
+ "serverId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.deployment": {
+ "name": "deployment",
+ "schema": "",
+ "columns": {
+ "deploymentId": {
+ "name": "deploymentId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "deploymentStatus",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'running'"
+ },
+ "logPath": {
+ "name": "logPath",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pid": {
+ "name": "pid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "applicationId": {
+ "name": "applicationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "composeId": {
+ "name": "composeId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "serverId": {
+ "name": "serverId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "isPreviewDeployment": {
+ "name": "isPreviewDeployment",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "previewDeploymentId": {
+ "name": "previewDeploymentId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "startedAt": {
+ "name": "startedAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "finishedAt": {
+ "name": "finishedAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "errorMessage": {
+ "name": "errorMessage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scheduleId": {
+ "name": "scheduleId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "backupId": {
+ "name": "backupId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rollbackId": {
+ "name": "rollbackId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "volumeBackupId": {
+ "name": "volumeBackupId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "buildServerId": {
+ "name": "buildServerId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "deployment_applicationId_application_applicationId_fk": {
+ "name": "deployment_applicationId_application_applicationId_fk",
+ "tableFrom": "deployment",
+ "tableTo": "application",
+ "columnsFrom": [
+ "applicationId"
+ ],
+ "columnsTo": [
+ "applicationId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "deployment_composeId_compose_composeId_fk": {
+ "name": "deployment_composeId_compose_composeId_fk",
+ "tableFrom": "deployment",
+ "tableTo": "compose",
+ "columnsFrom": [
+ "composeId"
+ ],
+ "columnsTo": [
+ "composeId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "deployment_serverId_server_serverId_fk": {
+ "name": "deployment_serverId_server_serverId_fk",
+ "tableFrom": "deployment",
+ "tableTo": "server",
+ "columnsFrom": [
+ "serverId"
+ ],
+ "columnsTo": [
+ "serverId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "deployment_previewDeploymentId_preview_deployments_previewDeploymentId_fk": {
+ "name": "deployment_previewDeploymentId_preview_deployments_previewDeploymentId_fk",
+ "tableFrom": "deployment",
+ "tableTo": "preview_deployments",
+ "columnsFrom": [
+ "previewDeploymentId"
+ ],
+ "columnsTo": [
+ "previewDeploymentId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "deployment_scheduleId_schedule_scheduleId_fk": {
+ "name": "deployment_scheduleId_schedule_scheduleId_fk",
+ "tableFrom": "deployment",
+ "tableTo": "schedule",
+ "columnsFrom": [
+ "scheduleId"
+ ],
+ "columnsTo": [
+ "scheduleId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "deployment_backupId_backup_backupId_fk": {
+ "name": "deployment_backupId_backup_backupId_fk",
+ "tableFrom": "deployment",
+ "tableTo": "backup",
+ "columnsFrom": [
+ "backupId"
+ ],
+ "columnsTo": [
+ "backupId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "deployment_rollbackId_rollback_rollbackId_fk": {
+ "name": "deployment_rollbackId_rollback_rollbackId_fk",
+ "tableFrom": "deployment",
+ "tableTo": "rollback",
+ "columnsFrom": [
+ "rollbackId"
+ ],
+ "columnsTo": [
+ "rollbackId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "deployment_volumeBackupId_volume_backup_volumeBackupId_fk": {
+ "name": "deployment_volumeBackupId_volume_backup_volumeBackupId_fk",
+ "tableFrom": "deployment",
+ "tableTo": "volume_backup",
+ "columnsFrom": [
+ "volumeBackupId"
+ ],
+ "columnsTo": [
+ "volumeBackupId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "deployment_buildServerId_server_serverId_fk": {
+ "name": "deployment_buildServerId_server_serverId_fk",
+ "tableFrom": "deployment",
+ "tableTo": "server",
+ "columnsFrom": [
+ "buildServerId"
+ ],
+ "columnsTo": [
+ "serverId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.destination": {
+ "name": "destination",
+ "schema": "",
+ "columns": {
+ "destinationId": {
+ "name": "destinationId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "accessKey": {
+ "name": "accessKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "secretAccessKey": {
+ "name": "secretAccessKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "bucket": {
+ "name": "bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "region": {
+ "name": "region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "endpoint": {
+ "name": "endpoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "additionalFlags": {
+ "name": "additionalFlags",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "destination_organizationId_organization_id_fk": {
+ "name": "destination_organizationId_organization_id_fk",
+ "tableFrom": "destination",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organizationId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.domain": {
+ "name": "domain",
+ "schema": "",
+ "columns": {
+ "domainId": {
+ "name": "domainId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "host": {
+ "name": "host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "https": {
+ "name": "https",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "port": {
+ "name": "port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 3000
+ },
+ "customEntrypoint": {
+ "name": "customEntrypoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'/'"
+ },
+ "serviceName": {
+ "name": "serviceName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "domainType": {
+ "name": "domainType",
+ "type": "domainType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'application'"
+ },
+ "uniqueConfigKey": {
+ "name": "uniqueConfigKey",
+ "type": "serial",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "composeId": {
+ "name": "composeId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "customCertResolver": {
+ "name": "customCertResolver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "applicationId": {
+ "name": "applicationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "previewDeploymentId": {
+ "name": "previewDeploymentId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "certificateType": {
+ "name": "certificateType",
+ "type": "certificateType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "internalPath": {
+ "name": "internalPath",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'/'"
+ },
+ "stripPath": {
+ "name": "stripPath",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "middlewares": {
+ "name": "middlewares",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "ARRAY[]::text[]"
+ },
+ "forwardAuthEnabled": {
+ "name": "forwardAuthEnabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "domain_composeId_compose_composeId_fk": {
+ "name": "domain_composeId_compose_composeId_fk",
+ "tableFrom": "domain",
+ "tableTo": "compose",
+ "columnsFrom": [
+ "composeId"
+ ],
+ "columnsTo": [
+ "composeId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "domain_applicationId_application_applicationId_fk": {
+ "name": "domain_applicationId_application_applicationId_fk",
+ "tableFrom": "domain",
+ "tableTo": "application",
+ "columnsFrom": [
+ "applicationId"
+ ],
+ "columnsTo": [
+ "applicationId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "domain_previewDeploymentId_preview_deployments_previewDeploymentId_fk": {
+ "name": "domain_previewDeploymentId_preview_deployments_previewDeploymentId_fk",
+ "tableFrom": "domain",
+ "tableTo": "preview_deployments",
+ "columnsFrom": [
+ "previewDeploymentId"
+ ],
+ "columnsTo": [
+ "previewDeploymentId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.environment": {
+ "name": "environment",
+ "schema": "",
+ "columns": {
+ "environmentId": {
+ "name": "environmentId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "env": {
+ "name": "env",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "projectId": {
+ "name": "projectId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "isDefault": {
+ "name": "isDefault",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "environment_projectId_project_projectId_fk": {
+ "name": "environment_projectId_project_projectId_fk",
+ "tableFrom": "environment",
+ "tableTo": "project",
+ "columnsFrom": [
+ "projectId"
+ ],
+ "columnsTo": [
+ "projectId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.forward_auth_settings": {
+ "name": "forward_auth_settings",
+ "schema": "",
+ "columns": {
+ "forwardAuthSettingsId": {
+ "name": "forwardAuthSettingsId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "authDomain": {
+ "name": "authDomain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "baseDomain": {
+ "name": "baseDomain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "https": {
+ "name": "https",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "certificateType": {
+ "name": "certificateType",
+ "type": "certificateType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'letsencrypt'"
+ },
+ "customCertResolver": {
+ "name": "customCertResolver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "providerId": {
+ "name": "providerId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "serverId": {
+ "name": "serverId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "forward_auth_settings_providerId_sso_provider_provider_id_fk": {
+ "name": "forward_auth_settings_providerId_sso_provider_provider_id_fk",
+ "tableFrom": "forward_auth_settings",
+ "tableTo": "sso_provider",
+ "columnsFrom": [
+ "providerId"
+ ],
+ "columnsTo": [
+ "provider_id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "forward_auth_settings_serverId_server_serverId_fk": {
+ "name": "forward_auth_settings_serverId_server_serverId_fk",
+ "tableFrom": "forward_auth_settings",
+ "tableTo": "server",
+ "columnsFrom": [
+ "serverId"
+ ],
+ "columnsTo": [
+ "serverId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "forward_auth_settings_serverId_unique": {
+ "name": "forward_auth_settings_serverId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "serverId"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.git_provider": {
+ "name": "git_provider",
+ "schema": "",
+ "columns": {
+ "gitProviderId": {
+ "name": "gitProviderId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "providerType": {
+ "name": "providerType",
+ "type": "gitProviderType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'github'"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sharedWithOrganization": {
+ "name": "sharedWithOrganization",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "git_provider_organizationId_organization_id_fk": {
+ "name": "git_provider_organizationId_organization_id_fk",
+ "tableFrom": "git_provider",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organizationId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "git_provider_userId_user_id_fk": {
+ "name": "git_provider_userId_user_id_fk",
+ "tableFrom": "git_provider",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.gitea": {
+ "name": "gitea",
+ "schema": "",
+ "columns": {
+ "giteaId": {
+ "name": "giteaId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "giteaUrl": {
+ "name": "giteaUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'https://gitea.com'"
+ },
+ "giteaInternalUrl": {
+ "name": "giteaInternalUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "redirect_uri": {
+ "name": "redirect_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_secret": {
+ "name": "client_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gitProviderId": {
+ "name": "gitProviderId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'repo,repo:status,read:user,read:org'"
+ },
+ "last_authenticated_at": {
+ "name": "last_authenticated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "gitea_gitProviderId_git_provider_gitProviderId_fk": {
+ "name": "gitea_gitProviderId_git_provider_gitProviderId_fk",
+ "tableFrom": "gitea",
+ "tableTo": "git_provider",
+ "columnsFrom": [
+ "gitProviderId"
+ ],
+ "columnsTo": [
+ "gitProviderId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.github": {
+ "name": "github",
+ "schema": "",
+ "columns": {
+ "githubId": {
+ "name": "githubId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "githubAppName": {
+ "name": "githubAppName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "githubAppId": {
+ "name": "githubAppId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "githubClientId": {
+ "name": "githubClientId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "githubClientSecret": {
+ "name": "githubClientSecret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "githubInstallationId": {
+ "name": "githubInstallationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "githubPrivateKey": {
+ "name": "githubPrivateKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "githubWebhookSecret": {
+ "name": "githubWebhookSecret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gitProviderId": {
+ "name": "gitProviderId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "github_gitProviderId_git_provider_gitProviderId_fk": {
+ "name": "github_gitProviderId_git_provider_gitProviderId_fk",
+ "tableFrom": "github",
+ "tableTo": "git_provider",
+ "columnsFrom": [
+ "gitProviderId"
+ ],
+ "columnsTo": [
+ "gitProviderId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.gitlab": {
+ "name": "gitlab",
+ "schema": "",
+ "columns": {
+ "gitlabId": {
+ "name": "gitlabId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "gitlabUrl": {
+ "name": "gitlabUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'https://gitlab.com'"
+ },
+ "gitlabInternalUrl": {
+ "name": "gitlabInternalUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "application_id": {
+ "name": "application_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "redirect_uri": {
+ "name": "redirect_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "secret": {
+ "name": "secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "group_name": {
+ "name": "group_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gitProviderId": {
+ "name": "gitProviderId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "gitlab_gitProviderId_git_provider_gitProviderId_fk": {
+ "name": "gitlab_gitProviderId_git_provider_gitProviderId_fk",
+ "tableFrom": "gitlab",
+ "tableTo": "git_provider",
+ "columnsFrom": [
+ "gitProviderId"
+ ],
+ "columnsTo": [
+ "gitProviderId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.libsql": {
+ "name": "libsql",
+ "schema": "",
+ "columns": {
+ "libsqlId": {
+ "name": "libsqlId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "appName": {
+ "name": "appName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "databaseUser": {
+ "name": "databaseUser",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "databasePassword": {
+ "name": "databasePassword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sqldNode": {
+ "name": "sqldNode",
+ "type": "sqldNode",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'primary'"
+ },
+ "sqldPrimaryUrl": {
+ "name": "sqldPrimaryUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enableNamespaces": {
+ "name": "enableNamespaces",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "dockerImage": {
+ "name": "dockerImage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "command": {
+ "name": "command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "env": {
+ "name": "env",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "memoryReservation": {
+ "name": "memoryReservation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "memoryLimit": {
+ "name": "memoryLimit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cpuReservation": {
+ "name": "cpuReservation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cpuLimit": {
+ "name": "cpuLimit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "externalPort": {
+ "name": "externalPort",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "externalGRPCPort": {
+ "name": "externalGRPCPort",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "externalAdminPort": {
+ "name": "externalAdminPort",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "applicationStatus": {
+ "name": "applicationStatus",
+ "type": "applicationStatus",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'idle'"
+ },
+ "healthCheckSwarm": {
+ "name": "healthCheckSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "restartPolicySwarm": {
+ "name": "restartPolicySwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "placementSwarm": {
+ "name": "placementSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updateConfigSwarm": {
+ "name": "updateConfigSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rollbackConfigSwarm": {
+ "name": "rollbackConfigSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "modeSwarm": {
+ "name": "modeSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "labelsSwarm": {
+ "name": "labelsSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "networkSwarm": {
+ "name": "networkSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stopGracePeriodSwarm": {
+ "name": "stopGracePeriodSwarm",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "endpointSpecSwarm": {
+ "name": "endpointSpecSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "replicas": {
+ "name": "replicas",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "environmentId": {
+ "name": "environmentId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "serverId": {
+ "name": "serverId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "libsql_environmentId_environment_environmentId_fk": {
+ "name": "libsql_environmentId_environment_environmentId_fk",
+ "tableFrom": "libsql",
+ "tableTo": "environment",
+ "columnsFrom": [
+ "environmentId"
+ ],
+ "columnsTo": [
+ "environmentId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "libsql_serverId_server_serverId_fk": {
+ "name": "libsql_serverId_server_serverId_fk",
+ "tableFrom": "libsql",
+ "tableTo": "server",
+ "columnsFrom": [
+ "serverId"
+ ],
+ "columnsTo": [
+ "serverId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "libsql_appName_unique": {
+ "name": "libsql_appName_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "appName"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mariadb": {
+ "name": "mariadb",
+ "schema": "",
+ "columns": {
+ "mariadbId": {
+ "name": "mariadbId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "appName": {
+ "name": "appName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "databaseName": {
+ "name": "databaseName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "databaseUser": {
+ "name": "databaseUser",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "databasePassword": {
+ "name": "databasePassword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rootPassword": {
+ "name": "rootPassword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dockerImage": {
+ "name": "dockerImage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "command": {
+ "name": "command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "args": {
+ "name": "args",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "env": {
+ "name": "env",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "memoryReservation": {
+ "name": "memoryReservation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "memoryLimit": {
+ "name": "memoryLimit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cpuReservation": {
+ "name": "cpuReservation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cpuLimit": {
+ "name": "cpuLimit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "externalPort": {
+ "name": "externalPort",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "applicationStatus": {
+ "name": "applicationStatus",
+ "type": "applicationStatus",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'idle'"
+ },
+ "healthCheckSwarm": {
+ "name": "healthCheckSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "restartPolicySwarm": {
+ "name": "restartPolicySwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "placementSwarm": {
+ "name": "placementSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updateConfigSwarm": {
+ "name": "updateConfigSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rollbackConfigSwarm": {
+ "name": "rollbackConfigSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "modeSwarm": {
+ "name": "modeSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "labelsSwarm": {
+ "name": "labelsSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "networkSwarm": {
+ "name": "networkSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stopGracePeriodSwarm": {
+ "name": "stopGracePeriodSwarm",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "endpointSpecSwarm": {
+ "name": "endpointSpecSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ulimitsSwarm": {
+ "name": "ulimitsSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "replicas": {
+ "name": "replicas",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "environmentId": {
+ "name": "environmentId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "serverId": {
+ "name": "serverId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "mariadb_environmentId_environment_environmentId_fk": {
+ "name": "mariadb_environmentId_environment_environmentId_fk",
+ "tableFrom": "mariadb",
+ "tableTo": "environment",
+ "columnsFrom": [
+ "environmentId"
+ ],
+ "columnsTo": [
+ "environmentId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mariadb_serverId_server_serverId_fk": {
+ "name": "mariadb_serverId_server_serverId_fk",
+ "tableFrom": "mariadb",
+ "tableTo": "server",
+ "columnsFrom": [
+ "serverId"
+ ],
+ "columnsTo": [
+ "serverId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "mariadb_appName_unique": {
+ "name": "mariadb_appName_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "appName"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mongo": {
+ "name": "mongo",
+ "schema": "",
+ "columns": {
+ "mongoId": {
+ "name": "mongoId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "appName": {
+ "name": "appName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "databaseUser": {
+ "name": "databaseUser",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "databasePassword": {
+ "name": "databasePassword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dockerImage": {
+ "name": "dockerImage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'mongo:8'"
+ },
+ "command": {
+ "name": "command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "args": {
+ "name": "args",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "env": {
+ "name": "env",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "memoryReservation": {
+ "name": "memoryReservation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "memoryLimit": {
+ "name": "memoryLimit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cpuReservation": {
+ "name": "cpuReservation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cpuLimit": {
+ "name": "cpuLimit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "externalPort": {
+ "name": "externalPort",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "applicationStatus": {
+ "name": "applicationStatus",
+ "type": "applicationStatus",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'idle'"
+ },
+ "healthCheckSwarm": {
+ "name": "healthCheckSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "restartPolicySwarm": {
+ "name": "restartPolicySwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "placementSwarm": {
+ "name": "placementSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updateConfigSwarm": {
+ "name": "updateConfigSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rollbackConfigSwarm": {
+ "name": "rollbackConfigSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "modeSwarm": {
+ "name": "modeSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "labelsSwarm": {
+ "name": "labelsSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "networkSwarm": {
+ "name": "networkSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stopGracePeriodSwarm": {
+ "name": "stopGracePeriodSwarm",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "endpointSpecSwarm": {
+ "name": "endpointSpecSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ulimitsSwarm": {
+ "name": "ulimitsSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "replicas": {
+ "name": "replicas",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "environmentId": {
+ "name": "environmentId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "serverId": {
+ "name": "serverId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "replicaSets": {
+ "name": "replicaSets",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "mongo_environmentId_environment_environmentId_fk": {
+ "name": "mongo_environmentId_environment_environmentId_fk",
+ "tableFrom": "mongo",
+ "tableTo": "environment",
+ "columnsFrom": [
+ "environmentId"
+ ],
+ "columnsTo": [
+ "environmentId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mongo_serverId_server_serverId_fk": {
+ "name": "mongo_serverId_server_serverId_fk",
+ "tableFrom": "mongo",
+ "tableTo": "server",
+ "columnsFrom": [
+ "serverId"
+ ],
+ "columnsTo": [
+ "serverId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "mongo_appName_unique": {
+ "name": "mongo_appName_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "appName"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mount": {
+ "name": "mount",
+ "schema": "",
+ "columns": {
+ "mountId": {
+ "name": "mountId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "mountType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hostPath": {
+ "name": "hostPath",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "volumeName": {
+ "name": "volumeName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "filePath": {
+ "name": "filePath",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "serviceType": {
+ "name": "serviceType",
+ "type": "serviceType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'application'"
+ },
+ "mountPath": {
+ "name": "mountPath",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "applicationId": {
+ "name": "applicationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "composeId": {
+ "name": "composeId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "libsqlId": {
+ "name": "libsqlId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mariadbId": {
+ "name": "mariadbId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mongoId": {
+ "name": "mongoId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mysqlId": {
+ "name": "mysqlId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "postgresId": {
+ "name": "postgresId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "redisId": {
+ "name": "redisId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "mount_applicationId_application_applicationId_fk": {
+ "name": "mount_applicationId_application_applicationId_fk",
+ "tableFrom": "mount",
+ "tableTo": "application",
+ "columnsFrom": [
+ "applicationId"
+ ],
+ "columnsTo": [
+ "applicationId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mount_composeId_compose_composeId_fk": {
+ "name": "mount_composeId_compose_composeId_fk",
+ "tableFrom": "mount",
+ "tableTo": "compose",
+ "columnsFrom": [
+ "composeId"
+ ],
+ "columnsTo": [
+ "composeId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mount_libsqlId_libsql_libsqlId_fk": {
+ "name": "mount_libsqlId_libsql_libsqlId_fk",
+ "tableFrom": "mount",
+ "tableTo": "libsql",
+ "columnsFrom": [
+ "libsqlId"
+ ],
+ "columnsTo": [
+ "libsqlId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mount_mariadbId_mariadb_mariadbId_fk": {
+ "name": "mount_mariadbId_mariadb_mariadbId_fk",
+ "tableFrom": "mount",
+ "tableTo": "mariadb",
+ "columnsFrom": [
+ "mariadbId"
+ ],
+ "columnsTo": [
+ "mariadbId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mount_mongoId_mongo_mongoId_fk": {
+ "name": "mount_mongoId_mongo_mongoId_fk",
+ "tableFrom": "mount",
+ "tableTo": "mongo",
+ "columnsFrom": [
+ "mongoId"
+ ],
+ "columnsTo": [
+ "mongoId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mount_mysqlId_mysql_mysqlId_fk": {
+ "name": "mount_mysqlId_mysql_mysqlId_fk",
+ "tableFrom": "mount",
+ "tableTo": "mysql",
+ "columnsFrom": [
+ "mysqlId"
+ ],
+ "columnsTo": [
+ "mysqlId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mount_postgresId_postgres_postgresId_fk": {
+ "name": "mount_postgresId_postgres_postgresId_fk",
+ "tableFrom": "mount",
+ "tableTo": "postgres",
+ "columnsFrom": [
+ "postgresId"
+ ],
+ "columnsTo": [
+ "postgresId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mount_redisId_redis_redisId_fk": {
+ "name": "mount_redisId_redis_redisId_fk",
+ "tableFrom": "mount",
+ "tableTo": "redis",
+ "columnsFrom": [
+ "redisId"
+ ],
+ "columnsTo": [
+ "redisId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mysql": {
+ "name": "mysql",
+ "schema": "",
+ "columns": {
+ "mysqlId": {
+ "name": "mysqlId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "appName": {
+ "name": "appName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "databaseName": {
+ "name": "databaseName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "databaseUser": {
+ "name": "databaseUser",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "databasePassword": {
+ "name": "databasePassword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rootPassword": {
+ "name": "rootPassword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dockerImage": {
+ "name": "dockerImage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "command": {
+ "name": "command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "args": {
+ "name": "args",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "env": {
+ "name": "env",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "memoryReservation": {
+ "name": "memoryReservation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "memoryLimit": {
+ "name": "memoryLimit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cpuReservation": {
+ "name": "cpuReservation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cpuLimit": {
+ "name": "cpuLimit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "externalPort": {
+ "name": "externalPort",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "applicationStatus": {
+ "name": "applicationStatus",
+ "type": "applicationStatus",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'idle'"
+ },
+ "healthCheckSwarm": {
+ "name": "healthCheckSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "restartPolicySwarm": {
+ "name": "restartPolicySwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "placementSwarm": {
+ "name": "placementSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updateConfigSwarm": {
+ "name": "updateConfigSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rollbackConfigSwarm": {
+ "name": "rollbackConfigSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "modeSwarm": {
+ "name": "modeSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "labelsSwarm": {
+ "name": "labelsSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "networkSwarm": {
+ "name": "networkSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stopGracePeriodSwarm": {
+ "name": "stopGracePeriodSwarm",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "endpointSpecSwarm": {
+ "name": "endpointSpecSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ulimitsSwarm": {
+ "name": "ulimitsSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "replicas": {
+ "name": "replicas",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "environmentId": {
+ "name": "environmentId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "serverId": {
+ "name": "serverId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "mysql_environmentId_environment_environmentId_fk": {
+ "name": "mysql_environmentId_environment_environmentId_fk",
+ "tableFrom": "mysql",
+ "tableTo": "environment",
+ "columnsFrom": [
+ "environmentId"
+ ],
+ "columnsTo": [
+ "environmentId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mysql_serverId_server_serverId_fk": {
+ "name": "mysql_serverId_server_serverId_fk",
+ "tableFrom": "mysql",
+ "tableTo": "server",
+ "columnsFrom": [
+ "serverId"
+ ],
+ "columnsTo": [
+ "serverId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "mysql_appName_unique": {
+ "name": "mysql_appName_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "appName"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.custom": {
+ "name": "custom",
+ "schema": "",
+ "columns": {
+ "customId": {
+ "name": "customId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "endpoint": {
+ "name": "endpoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "headers": {
+ "name": "headers",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.discord": {
+ "name": "discord",
+ "schema": "",
+ "columns": {
+ "discordId": {
+ "name": "discordId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "webhookUrl": {
+ "name": "webhookUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "decoration": {
+ "name": "decoration",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.email": {
+ "name": "email",
+ "schema": "",
+ "columns": {
+ "emailId": {
+ "name": "emailId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "smtpServer": {
+ "name": "smtpServer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "smtpPort": {
+ "name": "smtpPort",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fromAddress": {
+ "name": "fromAddress",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "toAddress": {
+ "name": "toAddress",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.gotify": {
+ "name": "gotify",
+ "schema": "",
+ "columns": {
+ "gotifyId": {
+ "name": "gotifyId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "serverUrl": {
+ "name": "serverUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "appToken": {
+ "name": "appToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 5
+ },
+ "decoration": {
+ "name": "decoration",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.lark": {
+ "name": "lark",
+ "schema": "",
+ "columns": {
+ "larkId": {
+ "name": "larkId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "webhookUrl": {
+ "name": "webhookUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mattermost": {
+ "name": "mattermost",
+ "schema": "",
+ "columns": {
+ "mattermostId": {
+ "name": "mattermostId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "webhookUrl": {
+ "name": "webhookUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.notification": {
+ "name": "notification",
+ "schema": "",
+ "columns": {
+ "notificationId": {
+ "name": "notificationId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "appDeploy": {
+ "name": "appDeploy",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "appBuildError": {
+ "name": "appBuildError",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "databaseBackup": {
+ "name": "databaseBackup",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "volumeBackup": {
+ "name": "volumeBackup",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "dokployRestart": {
+ "name": "dokployRestart",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "dokployBackup": {
+ "name": "dokployBackup",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "dockerCleanup": {
+ "name": "dockerCleanup",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "serverThreshold": {
+ "name": "serverThreshold",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "notificationType": {
+ "name": "notificationType",
+ "type": "notificationType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slackId": {
+ "name": "slackId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "telegramId": {
+ "name": "telegramId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "discordId": {
+ "name": "discordId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "emailId": {
+ "name": "emailId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resendId": {
+ "name": "resendId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gotifyId": {
+ "name": "gotifyId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ntfyId": {
+ "name": "ntfyId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mattermostId": {
+ "name": "mattermostId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "customId": {
+ "name": "customId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "larkId": {
+ "name": "larkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pushoverId": {
+ "name": "pushoverId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "teamsId": {
+ "name": "teamsId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "notification_slackId_slack_slackId_fk": {
+ "name": "notification_slackId_slack_slackId_fk",
+ "tableFrom": "notification",
+ "tableTo": "slack",
+ "columnsFrom": [
+ "slackId"
+ ],
+ "columnsTo": [
+ "slackId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notification_telegramId_telegram_telegramId_fk": {
+ "name": "notification_telegramId_telegram_telegramId_fk",
+ "tableFrom": "notification",
+ "tableTo": "telegram",
+ "columnsFrom": [
+ "telegramId"
+ ],
+ "columnsTo": [
+ "telegramId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notification_discordId_discord_discordId_fk": {
+ "name": "notification_discordId_discord_discordId_fk",
+ "tableFrom": "notification",
+ "tableTo": "discord",
+ "columnsFrom": [
+ "discordId"
+ ],
+ "columnsTo": [
+ "discordId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notification_emailId_email_emailId_fk": {
+ "name": "notification_emailId_email_emailId_fk",
+ "tableFrom": "notification",
+ "tableTo": "email",
+ "columnsFrom": [
+ "emailId"
+ ],
+ "columnsTo": [
+ "emailId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notification_resendId_resend_resendId_fk": {
+ "name": "notification_resendId_resend_resendId_fk",
+ "tableFrom": "notification",
+ "tableTo": "resend",
+ "columnsFrom": [
+ "resendId"
+ ],
+ "columnsTo": [
+ "resendId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notification_gotifyId_gotify_gotifyId_fk": {
+ "name": "notification_gotifyId_gotify_gotifyId_fk",
+ "tableFrom": "notification",
+ "tableTo": "gotify",
+ "columnsFrom": [
+ "gotifyId"
+ ],
+ "columnsTo": [
+ "gotifyId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notification_ntfyId_ntfy_ntfyId_fk": {
+ "name": "notification_ntfyId_ntfy_ntfyId_fk",
+ "tableFrom": "notification",
+ "tableTo": "ntfy",
+ "columnsFrom": [
+ "ntfyId"
+ ],
+ "columnsTo": [
+ "ntfyId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notification_mattermostId_mattermost_mattermostId_fk": {
+ "name": "notification_mattermostId_mattermost_mattermostId_fk",
+ "tableFrom": "notification",
+ "tableTo": "mattermost",
+ "columnsFrom": [
+ "mattermostId"
+ ],
+ "columnsTo": [
+ "mattermostId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notification_customId_custom_customId_fk": {
+ "name": "notification_customId_custom_customId_fk",
+ "tableFrom": "notification",
+ "tableTo": "custom",
+ "columnsFrom": [
+ "customId"
+ ],
+ "columnsTo": [
+ "customId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notification_larkId_lark_larkId_fk": {
+ "name": "notification_larkId_lark_larkId_fk",
+ "tableFrom": "notification",
+ "tableTo": "lark",
+ "columnsFrom": [
+ "larkId"
+ ],
+ "columnsTo": [
+ "larkId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notification_pushoverId_pushover_pushoverId_fk": {
+ "name": "notification_pushoverId_pushover_pushoverId_fk",
+ "tableFrom": "notification",
+ "tableTo": "pushover",
+ "columnsFrom": [
+ "pushoverId"
+ ],
+ "columnsTo": [
+ "pushoverId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notification_teamsId_teams_teamsId_fk": {
+ "name": "notification_teamsId_teams_teamsId_fk",
+ "tableFrom": "notification",
+ "tableTo": "teams",
+ "columnsFrom": [
+ "teamsId"
+ ],
+ "columnsTo": [
+ "teamsId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notification_organizationId_organization_id_fk": {
+ "name": "notification_organizationId_organization_id_fk",
+ "tableFrom": "notification",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organizationId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ntfy": {
+ "name": "ntfy",
+ "schema": "",
+ "columns": {
+ "ntfyId": {
+ "name": "ntfyId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "serverUrl": {
+ "name": "serverUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "topic": {
+ "name": "topic",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "accessToken": {
+ "name": "accessToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 3
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.pushover": {
+ "name": "pushover",
+ "schema": "",
+ "columns": {
+ "pushoverId": {
+ "name": "pushoverId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "userKey": {
+ "name": "userKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "apiToken": {
+ "name": "apiToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "retry": {
+ "name": "retry",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expire": {
+ "name": "expire",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.resend": {
+ "name": "resend",
+ "schema": "",
+ "columns": {
+ "resendId": {
+ "name": "resendId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "apiKey": {
+ "name": "apiKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fromAddress": {
+ "name": "fromAddress",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "toAddress": {
+ "name": "toAddress",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.slack": {
+ "name": "slack",
+ "schema": "",
+ "columns": {
+ "slackId": {
+ "name": "slackId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "webhookUrl": {
+ "name": "webhookUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.teams": {
+ "name": "teams",
+ "schema": "",
+ "columns": {
+ "teamsId": {
+ "name": "teamsId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "webhookUrl": {
+ "name": "webhookUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.telegram": {
+ "name": "telegram",
+ "schema": "",
+ "columns": {
+ "telegramId": {
+ "name": "telegramId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "botToken": {
+ "name": "botToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "chatId": {
+ "name": "chatId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "messageThreadId": {
+ "name": "messageThreadId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.patch": {
+ "name": "patch",
+ "schema": "",
+ "columns": {
+ "patchId": {
+ "name": "patchId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "patchType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'update'"
+ },
+ "filePath": {
+ "name": "filePath",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "applicationId": {
+ "name": "applicationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "composeId": {
+ "name": "composeId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "patch_applicationId_application_applicationId_fk": {
+ "name": "patch_applicationId_application_applicationId_fk",
+ "tableFrom": "patch",
+ "tableTo": "application",
+ "columnsFrom": [
+ "applicationId"
+ ],
+ "columnsTo": [
+ "applicationId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "patch_composeId_compose_composeId_fk": {
+ "name": "patch_composeId_compose_composeId_fk",
+ "tableFrom": "patch",
+ "tableTo": "compose",
+ "columnsFrom": [
+ "composeId"
+ ],
+ "columnsTo": [
+ "composeId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "patch_filepath_application_unique": {
+ "name": "patch_filepath_application_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "filePath",
+ "applicationId"
+ ]
+ },
+ "patch_filepath_compose_unique": {
+ "name": "patch_filepath_compose_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "filePath",
+ "composeId"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.port": {
+ "name": "port",
+ "schema": "",
+ "columns": {
+ "portId": {
+ "name": "portId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "publishedPort": {
+ "name": "publishedPort",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "publishMode": {
+ "name": "publishMode",
+ "type": "publishModeType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'host'"
+ },
+ "targetPort": {
+ "name": "targetPort",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "protocol": {
+ "name": "protocol",
+ "type": "protocolType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "applicationId": {
+ "name": "applicationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "port_applicationId_application_applicationId_fk": {
+ "name": "port_applicationId_application_applicationId_fk",
+ "tableFrom": "port",
+ "tableTo": "application",
+ "columnsFrom": [
+ "applicationId"
+ ],
+ "columnsTo": [
+ "applicationId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.postgres": {
+ "name": "postgres",
+ "schema": "",
+ "columns": {
+ "postgresId": {
+ "name": "postgresId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "appName": {
+ "name": "appName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "databaseName": {
+ "name": "databaseName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "databaseUser": {
+ "name": "databaseUser",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "databasePassword": {
+ "name": "databasePassword",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dockerImage": {
+ "name": "dockerImage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "command": {
+ "name": "command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "args": {
+ "name": "args",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "env": {
+ "name": "env",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "memoryReservation": {
+ "name": "memoryReservation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "externalPort": {
+ "name": "externalPort",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "memoryLimit": {
+ "name": "memoryLimit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cpuReservation": {
+ "name": "cpuReservation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cpuLimit": {
+ "name": "cpuLimit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "applicationStatus": {
+ "name": "applicationStatus",
+ "type": "applicationStatus",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'idle'"
+ },
+ "healthCheckSwarm": {
+ "name": "healthCheckSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "restartPolicySwarm": {
+ "name": "restartPolicySwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "placementSwarm": {
+ "name": "placementSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updateConfigSwarm": {
+ "name": "updateConfigSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rollbackConfigSwarm": {
+ "name": "rollbackConfigSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "modeSwarm": {
+ "name": "modeSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "labelsSwarm": {
+ "name": "labelsSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "networkSwarm": {
+ "name": "networkSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stopGracePeriodSwarm": {
+ "name": "stopGracePeriodSwarm",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "endpointSpecSwarm": {
+ "name": "endpointSpecSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ulimitsSwarm": {
+ "name": "ulimitsSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "replicas": {
+ "name": "replicas",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "environmentId": {
+ "name": "environmentId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "serverId": {
+ "name": "serverId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "postgres_environmentId_environment_environmentId_fk": {
+ "name": "postgres_environmentId_environment_environmentId_fk",
+ "tableFrom": "postgres",
+ "tableTo": "environment",
+ "columnsFrom": [
+ "environmentId"
+ ],
+ "columnsTo": [
+ "environmentId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "postgres_serverId_server_serverId_fk": {
+ "name": "postgres_serverId_server_serverId_fk",
+ "tableFrom": "postgres",
+ "tableTo": "server",
+ "columnsFrom": [
+ "serverId"
+ ],
+ "columnsTo": [
+ "serverId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "postgres_appName_unique": {
+ "name": "postgres_appName_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "appName"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.preview_deployments": {
+ "name": "preview_deployments",
+ "schema": "",
+ "columns": {
+ "previewDeploymentId": {
+ "name": "previewDeploymentId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "branch": {
+ "name": "branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pullRequestId": {
+ "name": "pullRequestId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pullRequestNumber": {
+ "name": "pullRequestNumber",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pullRequestURL": {
+ "name": "pullRequestURL",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pullRequestTitle": {
+ "name": "pullRequestTitle",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pullRequestCommentId": {
+ "name": "pullRequestCommentId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "previewStatus": {
+ "name": "previewStatus",
+ "type": "applicationStatus",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'idle'"
+ },
+ "appName": {
+ "name": "appName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "applicationId": {
+ "name": "applicationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "domainId": {
+ "name": "domainId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "preview_deployments_applicationId_application_applicationId_fk": {
+ "name": "preview_deployments_applicationId_application_applicationId_fk",
+ "tableFrom": "preview_deployments",
+ "tableTo": "application",
+ "columnsFrom": [
+ "applicationId"
+ ],
+ "columnsTo": [
+ "applicationId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "preview_deployments_domainId_domain_domainId_fk": {
+ "name": "preview_deployments_domainId_domain_domainId_fk",
+ "tableFrom": "preview_deployments",
+ "tableTo": "domain",
+ "columnsFrom": [
+ "domainId"
+ ],
+ "columnsTo": [
+ "domainId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "preview_deployments_appName_unique": {
+ "name": "preview_deployments_appName_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "appName"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.project": {
+ "name": "project",
+ "schema": "",
+ "columns": {
+ "projectId": {
+ "name": "projectId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "env": {
+ "name": "env",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "project_organizationId_organization_id_fk": {
+ "name": "project_organizationId_organization_id_fk",
+ "tableFrom": "project",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organizationId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.redirect": {
+ "name": "redirect",
+ "schema": "",
+ "columns": {
+ "redirectId": {
+ "name": "redirectId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "regex": {
+ "name": "regex",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "replacement": {
+ "name": "replacement",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permanent": {
+ "name": "permanent",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "uniqueConfigKey": {
+ "name": "uniqueConfigKey",
+ "type": "serial",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "applicationId": {
+ "name": "applicationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "redirect_applicationId_application_applicationId_fk": {
+ "name": "redirect_applicationId_application_applicationId_fk",
+ "tableFrom": "redirect",
+ "tableTo": "application",
+ "columnsFrom": [
+ "applicationId"
+ ],
+ "columnsTo": [
+ "applicationId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.redis": {
+ "name": "redis",
+ "schema": "",
+ "columns": {
+ "redisId": {
+ "name": "redisId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "appName": {
+ "name": "appName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dockerImage": {
+ "name": "dockerImage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "command": {
+ "name": "command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "args": {
+ "name": "args",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "env": {
+ "name": "env",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "memoryReservation": {
+ "name": "memoryReservation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "memoryLimit": {
+ "name": "memoryLimit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cpuReservation": {
+ "name": "cpuReservation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cpuLimit": {
+ "name": "cpuLimit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "externalPort": {
+ "name": "externalPort",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "applicationStatus": {
+ "name": "applicationStatus",
+ "type": "applicationStatus",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'idle'"
+ },
+ "healthCheckSwarm": {
+ "name": "healthCheckSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "restartPolicySwarm": {
+ "name": "restartPolicySwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "placementSwarm": {
+ "name": "placementSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updateConfigSwarm": {
+ "name": "updateConfigSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rollbackConfigSwarm": {
+ "name": "rollbackConfigSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "modeSwarm": {
+ "name": "modeSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "labelsSwarm": {
+ "name": "labelsSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "networkSwarm": {
+ "name": "networkSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stopGracePeriodSwarm": {
+ "name": "stopGracePeriodSwarm",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "endpointSpecSwarm": {
+ "name": "endpointSpecSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ulimitsSwarm": {
+ "name": "ulimitsSwarm",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "replicas": {
+ "name": "replicas",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "environmentId": {
+ "name": "environmentId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "serverId": {
+ "name": "serverId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "redis_environmentId_environment_environmentId_fk": {
+ "name": "redis_environmentId_environment_environmentId_fk",
+ "tableFrom": "redis",
+ "tableTo": "environment",
+ "columnsFrom": [
+ "environmentId"
+ ],
+ "columnsTo": [
+ "environmentId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "redis_serverId_server_serverId_fk": {
+ "name": "redis_serverId_server_serverId_fk",
+ "tableFrom": "redis",
+ "tableTo": "server",
+ "columnsFrom": [
+ "serverId"
+ ],
+ "columnsTo": [
+ "serverId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "redis_appName_unique": {
+ "name": "redis_appName_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "appName"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.registry": {
+ "name": "registry",
+ "schema": "",
+ "columns": {
+ "registryId": {
+ "name": "registryId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "registryName": {
+ "name": "registryName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "imagePrefix": {
+ "name": "imagePrefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "registryUrl": {
+ "name": "registryUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "selfHosted": {
+ "name": "selfHosted",
+ "type": "RegistryType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'cloud'"
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "registry_organizationId_organization_id_fk": {
+ "name": "registry_organizationId_organization_id_fk",
+ "tableFrom": "registry",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organizationId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.rollback": {
+ "name": "rollback",
+ "schema": "",
+ "columns": {
+ "rollbackId": {
+ "name": "rollbackId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "deploymentId": {
+ "name": "deploymentId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "serial",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fullContext": {
+ "name": "fullContext",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "rollback_deploymentId_deployment_deploymentId_fk": {
+ "name": "rollback_deploymentId_deployment_deploymentId_fk",
+ "tableFrom": "rollback",
+ "tableTo": "deployment",
+ "columnsFrom": [
+ "deploymentId"
+ ],
+ "columnsTo": [
+ "deploymentId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.schedule": {
+ "name": "schedule",
+ "schema": "",
+ "columns": {
+ "scheduleId": {
+ "name": "scheduleId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cronExpression": {
+ "name": "cronExpression",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "appName": {
+ "name": "appName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "serviceName": {
+ "name": "serviceName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "shellType": {
+ "name": "shellType",
+ "type": "shellType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'bash'"
+ },
+ "scheduleType": {
+ "name": "scheduleType",
+ "type": "scheduleType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'application'"
+ },
+ "command": {
+ "name": "command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "script": {
+ "name": "script",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "applicationId": {
+ "name": "applicationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "composeId": {
+ "name": "composeId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "serverId": {
+ "name": "serverId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "schedule_applicationId_application_applicationId_fk": {
+ "name": "schedule_applicationId_application_applicationId_fk",
+ "tableFrom": "schedule",
+ "tableTo": "application",
+ "columnsFrom": [
+ "applicationId"
+ ],
+ "columnsTo": [
+ "applicationId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "schedule_composeId_compose_composeId_fk": {
+ "name": "schedule_composeId_compose_composeId_fk",
+ "tableFrom": "schedule",
+ "tableTo": "compose",
+ "columnsFrom": [
+ "composeId"
+ ],
+ "columnsTo": [
+ "composeId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "schedule_serverId_server_serverId_fk": {
+ "name": "schedule_serverId_server_serverId_fk",
+ "tableFrom": "schedule",
+ "tableTo": "server",
+ "columnsFrom": [
+ "serverId"
+ ],
+ "columnsTo": [
+ "serverId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "schedule_organizationId_organization_id_fk": {
+ "name": "schedule_organizationId_organization_id_fk",
+ "tableFrom": "schedule",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organizationId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.security": {
+ "name": "security",
+ "schema": "",
+ "columns": {
+ "securityId": {
+ "name": "securityId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "applicationId": {
+ "name": "applicationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "security_applicationId_application_applicationId_fk": {
+ "name": "security_applicationId_application_applicationId_fk",
+ "tableFrom": "security",
+ "tableTo": "application",
+ "columnsFrom": [
+ "applicationId"
+ ],
+ "columnsTo": [
+ "applicationId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "security_username_applicationId_unique": {
+ "name": "security_username_applicationId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "username",
+ "applicationId"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.server": {
+ "name": "server",
+ "schema": "",
+ "columns": {
+ "serverId": {
+ "name": "serverId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "port": {
+ "name": "port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'root'"
+ },
+ "appName": {
+ "name": "appName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enableDockerCleanup": {
+ "name": "enableDockerCleanup",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "webServerProvider": {
+ "name": "webServerProvider",
+ "type": "webServerProvider",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'traefik'"
+ },
+ "caddyTrustedProxyConfig": {
+ "name": "caddyTrustedProxyConfig",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'null'::jsonb"
+ },
+ "buildsConcurrency": {
+ "name": "buildsConcurrency",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "serverStatus": {
+ "name": "serverStatus",
+ "type": "serverStatus",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "serverType": {
+ "name": "serverType",
+ "type": "serverType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'deploy'"
+ },
+ "command": {
+ "name": "command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "sshKeyId": {
+ "name": "sshKeyId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metricsConfig": {
+ "name": "metricsConfig",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"server\":{\"type\":\"Remote\",\"refreshRate\":60,\"port\":4500,\"token\":\"\",\"urlCallback\":\"\",\"cronJob\":\"\",\"retentionDays\":2,\"thresholds\":{\"cpu\":0,\"memory\":0}},\"containers\":{\"refreshRate\":60,\"services\":{\"include\":[],\"exclude\":[]}}}'::jsonb"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "server_organizationId_organization_id_fk": {
+ "name": "server_organizationId_organization_id_fk",
+ "tableFrom": "server",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organizationId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "server_sshKeyId_ssh-key_sshKeyId_fk": {
+ "name": "server_sshKeyId_ssh-key_sshKeyId_fk",
+ "tableFrom": "server",
+ "tableTo": "ssh-key",
+ "columnsFrom": [
+ "sshKeyId"
+ ],
+ "columnsTo": [
+ "sshKeyId"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ssh-key": {
+ "name": "ssh-key",
+ "schema": "",
+ "columns": {
+ "sshKeyId": {
+ "name": "sshKeyId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "privateKey": {
+ "name": "privateKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "publicKey": {
+ "name": "publicKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "lastUsedAt": {
+ "name": "lastUsedAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "ssh-key_organizationId_organization_id_fk": {
+ "name": "ssh-key_organizationId_organization_id_fk",
+ "tableFrom": "ssh-key",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organizationId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sso_provider": {
+ "name": "sso_provider",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "oidc_config": {
+ "name": "oidc_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "saml_config": {
+ "name": "saml_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sso_provider_user_id_user_id_fk": {
+ "name": "sso_provider_user_id_user_id_fk",
+ "tableFrom": "sso_provider",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "sso_provider_organization_id_organization_id_fk": {
+ "name": "sso_provider_organization_id_organization_id_fk",
+ "tableFrom": "sso_provider",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sso_provider_provider_id_unique": {
+ "name": "sso_provider_provider_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "provider_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.project_tag": {
+ "name": "project_tag",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "projectId": {
+ "name": "projectId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "project_tag_projectId_project_projectId_fk": {
+ "name": "project_tag_projectId_project_projectId_fk",
+ "tableFrom": "project_tag",
+ "tableTo": "project",
+ "columnsFrom": [
+ "projectId"
+ ],
+ "columnsTo": [
+ "projectId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "project_tag_tagId_tag_tagId_fk": {
+ "name": "project_tag_tagId_tag_tagId_fk",
+ "tableFrom": "project_tag",
+ "tableTo": "tag",
+ "columnsFrom": [
+ "tagId"
+ ],
+ "columnsTo": [
+ "tagId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "unique_project_tag": {
+ "name": "unique_project_tag",
+ "nullsNotDistinct": false,
+ "columns": [
+ "projectId",
+ "tagId"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tag": {
+ "name": "tag",
+ "schema": "",
+ "columns": {
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organizationId": {
+ "name": "organizationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "tag_organizationId_organization_id_fk": {
+ "name": "tag_organizationId_organization_id_fk",
+ "tableFrom": "tag",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organizationId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "unique_org_tag_name": {
+ "name": "unique_org_tag_name",
+ "nullsNotDistinct": false,
+ "columns": [
+ "organizationId",
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "firstName": {
+ "name": "firstName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "lastName": {
+ "name": "lastName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "isRegistered": {
+ "name": "isRegistered",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "expirationDate": {
+ "name": "expirationDate",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "two_factor_enabled": {
+ "name": "two_factor_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'user'"
+ },
+ "enablePaidFeatures": {
+ "name": "enablePaidFeatures",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "allowImpersonation": {
+ "name": "allowImpersonation",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "enableEnterpriseFeatures": {
+ "name": "enableEnterpriseFeatures",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "licenseKey": {
+ "name": "licenseKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "isValidEnterpriseLicense": {
+ "name": "isValidEnterpriseLicense",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "stripeCustomerId": {
+ "name": "stripeCustomerId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripeSubscriptionId": {
+ "name": "stripeSubscriptionId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "serversQuantity": {
+ "name": "serversQuantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "sendInvoiceNotifications": {
+ "name": "sendInvoiceNotifications",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "isEnterpriseCloud": {
+ "name": "isEnterpriseCloud",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "trustedOrigins": {
+ "name": "trustedOrigins",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bookmarkedTemplates": {
+ "name": "bookmarkedTemplates",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "ARRAY[]::text[]"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.volume_backup": {
+ "name": "volume_backup",
+ "schema": "",
+ "columns": {
+ "volumeBackupId": {
+ "name": "volumeBackupId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "volumeName": {
+ "name": "volumeName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "prefix": {
+ "name": "prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "serviceType": {
+ "name": "serviceType",
+ "type": "serviceType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'application'"
+ },
+ "appName": {
+ "name": "appName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "serviceName": {
+ "name": "serviceName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "turnOff": {
+ "name": "turnOff",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "cronExpression": {
+ "name": "cronExpression",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "keepLatestCount": {
+ "name": "keepLatestCount",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "applicationId": {
+ "name": "applicationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "postgresId": {
+ "name": "postgresId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mariadbId": {
+ "name": "mariadbId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mongoId": {
+ "name": "mongoId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mysqlId": {
+ "name": "mysqlId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "redisId": {
+ "name": "redisId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "libsqlId": {
+ "name": "libsqlId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "composeId": {
+ "name": "composeId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "destinationId": {
+ "name": "destinationId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "volume_backup_applicationId_application_applicationId_fk": {
+ "name": "volume_backup_applicationId_application_applicationId_fk",
+ "tableFrom": "volume_backup",
+ "tableTo": "application",
+ "columnsFrom": [
+ "applicationId"
+ ],
+ "columnsTo": [
+ "applicationId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "volume_backup_postgresId_postgres_postgresId_fk": {
+ "name": "volume_backup_postgresId_postgres_postgresId_fk",
+ "tableFrom": "volume_backup",
+ "tableTo": "postgres",
+ "columnsFrom": [
+ "postgresId"
+ ],
+ "columnsTo": [
+ "postgresId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "volume_backup_mariadbId_mariadb_mariadbId_fk": {
+ "name": "volume_backup_mariadbId_mariadb_mariadbId_fk",
+ "tableFrom": "volume_backup",
+ "tableTo": "mariadb",
+ "columnsFrom": [
+ "mariadbId"
+ ],
+ "columnsTo": [
+ "mariadbId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "volume_backup_mongoId_mongo_mongoId_fk": {
+ "name": "volume_backup_mongoId_mongo_mongoId_fk",
+ "tableFrom": "volume_backup",
+ "tableTo": "mongo",
+ "columnsFrom": [
+ "mongoId"
+ ],
+ "columnsTo": [
+ "mongoId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "volume_backup_mysqlId_mysql_mysqlId_fk": {
+ "name": "volume_backup_mysqlId_mysql_mysqlId_fk",
+ "tableFrom": "volume_backup",
+ "tableTo": "mysql",
+ "columnsFrom": [
+ "mysqlId"
+ ],
+ "columnsTo": [
+ "mysqlId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "volume_backup_redisId_redis_redisId_fk": {
+ "name": "volume_backup_redisId_redis_redisId_fk",
+ "tableFrom": "volume_backup",
+ "tableTo": "redis",
+ "columnsFrom": [
+ "redisId"
+ ],
+ "columnsTo": [
+ "redisId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "volume_backup_libsqlId_libsql_libsqlId_fk": {
+ "name": "volume_backup_libsqlId_libsql_libsqlId_fk",
+ "tableFrom": "volume_backup",
+ "tableTo": "libsql",
+ "columnsFrom": [
+ "libsqlId"
+ ],
+ "columnsTo": [
+ "libsqlId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "volume_backup_composeId_compose_composeId_fk": {
+ "name": "volume_backup_composeId_compose_composeId_fk",
+ "tableFrom": "volume_backup",
+ "tableTo": "compose",
+ "columnsFrom": [
+ "composeId"
+ ],
+ "columnsTo": [
+ "composeId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "volume_backup_destinationId_destination_destinationId_fk": {
+ "name": "volume_backup_destinationId_destination_destinationId_fk",
+ "tableFrom": "volume_backup",
+ "tableTo": "destination",
+ "columnsFrom": [
+ "destinationId"
+ ],
+ "columnsTo": [
+ "destinationId"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.webServerSettings": {
+ "name": "webServerSettings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "webServerProvider": {
+ "name": "webServerProvider",
+ "type": "webServerProvider",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'traefik'"
+ },
+ "caddyTrustedProxyConfig": {
+ "name": "caddyTrustedProxyConfig",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'null'::jsonb"
+ },
+ "requestLogsEnabled": {
+ "name": "requestLogsEnabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "serverIp": {
+ "name": "serverIp",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "certificateType": {
+ "name": "certificateType",
+ "type": "certificateType",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "https": {
+ "name": "https",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "host": {
+ "name": "host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "letsEncryptEmail": {
+ "name": "letsEncryptEmail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sshPrivateKey": {
+ "name": "sshPrivateKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enableDockerCleanup": {
+ "name": "enableDockerCleanup",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "logCleanupCron": {
+ "name": "logCleanupCron",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'0 0 * * *'"
+ },
+ "metricsConfig": {
+ "name": "metricsConfig",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"server\":{\"type\":\"Dokploy\",\"refreshRate\":60,\"port\":4500,\"token\":\"\",\"retentionDays\":2,\"cronJob\":\"\",\"urlCallback\":\"\",\"thresholds\":{\"cpu\":0,\"memory\":0}},\"containers\":{\"refreshRate\":60,\"services\":{\"include\":[],\"exclude\":[]}}}'::jsonb"
+ },
+ "whitelabelingConfig": {
+ "name": "whitelabelingConfig",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{\"appName\":null,\"appDescription\":null,\"logoUrl\":null,\"faviconUrl\":null,\"customCss\":null,\"loginLogoUrl\":null,\"supportUrl\":null,\"docsUrl\":null,\"errorPageTitle\":null,\"errorPageDescription\":null,\"metaTitle\":null,\"footerText\":null}'::jsonb"
+ },
+ "remoteServersOnly": {
+ "name": "remoteServersOnly",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "buildsConcurrency": {
+ "name": "buildsConcurrency",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "enforceSSO": {
+ "name": "enforceSSO",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "cleanupCacheApplications": {
+ "name": "cleanupCacheApplications",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "cleanupCacheOnPreviews": {
+ "name": "cleanupCacheOnPreviews",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "cleanupCacheOnCompose": {
+ "name": "cleanupCacheOnCompose",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.buildType": {
+ "name": "buildType",
+ "schema": "public",
+ "values": [
+ "dockerfile",
+ "heroku_buildpacks",
+ "paketo_buildpacks",
+ "nixpacks",
+ "static",
+ "railpack"
+ ]
+ },
+ "public.sourceType": {
+ "name": "sourceType",
+ "schema": "public",
+ "values": [
+ "docker",
+ "git",
+ "github",
+ "gitlab",
+ "bitbucket",
+ "gitea",
+ "drop"
+ ]
+ },
+ "public.backupType": {
+ "name": "backupType",
+ "schema": "public",
+ "values": [
+ "database",
+ "compose"
+ ]
+ },
+ "public.databaseType": {
+ "name": "databaseType",
+ "schema": "public",
+ "values": [
+ "postgres",
+ "mariadb",
+ "mysql",
+ "mongo",
+ "web-server",
+ "libsql"
+ ]
+ },
+ "public.composeType": {
+ "name": "composeType",
+ "schema": "public",
+ "values": [
+ "docker-compose",
+ "stack"
+ ]
+ },
+ "public.sourceTypeCompose": {
+ "name": "sourceTypeCompose",
+ "schema": "public",
+ "values": [
+ "git",
+ "github",
+ "gitlab",
+ "bitbucket",
+ "gitea",
+ "raw"
+ ]
+ },
+ "public.deploymentStatus": {
+ "name": "deploymentStatus",
+ "schema": "public",
+ "values": [
+ "running",
+ "done",
+ "error",
+ "cancelled"
+ ]
+ },
+ "public.domainType": {
+ "name": "domainType",
+ "schema": "public",
+ "values": [
+ "compose",
+ "application",
+ "preview"
+ ]
+ },
+ "public.gitProviderType": {
+ "name": "gitProviderType",
+ "schema": "public",
+ "values": [
+ "github",
+ "gitlab",
+ "bitbucket",
+ "gitea"
+ ]
+ },
+ "public.mountType": {
+ "name": "mountType",
+ "schema": "public",
+ "values": [
+ "bind",
+ "volume",
+ "file"
+ ]
+ },
+ "public.serviceType": {
+ "name": "serviceType",
+ "schema": "public",
+ "values": [
+ "application",
+ "postgres",
+ "mysql",
+ "mariadb",
+ "mongo",
+ "redis",
+ "compose",
+ "libsql"
+ ]
+ },
+ "public.notificationType": {
+ "name": "notificationType",
+ "schema": "public",
+ "values": [
+ "slack",
+ "telegram",
+ "discord",
+ "email",
+ "resend",
+ "gotify",
+ "ntfy",
+ "mattermost",
+ "pushover",
+ "custom",
+ "lark",
+ "teams"
+ ]
+ },
+ "public.patchType": {
+ "name": "patchType",
+ "schema": "public",
+ "values": [
+ "create",
+ "update",
+ "delete"
+ ]
+ },
+ "public.protocolType": {
+ "name": "protocolType",
+ "schema": "public",
+ "values": [
+ "tcp",
+ "udp"
+ ]
+ },
+ "public.publishModeType": {
+ "name": "publishModeType",
+ "schema": "public",
+ "values": [
+ "ingress",
+ "host"
+ ]
+ },
+ "public.RegistryType": {
+ "name": "RegistryType",
+ "schema": "public",
+ "values": [
+ "selfHosted",
+ "cloud"
+ ]
+ },
+ "public.scheduleType": {
+ "name": "scheduleType",
+ "schema": "public",
+ "values": [
+ "application",
+ "compose",
+ "server",
+ "dokploy-server"
+ ]
+ },
+ "public.shellType": {
+ "name": "shellType",
+ "schema": "public",
+ "values": [
+ "bash",
+ "sh"
+ ]
+ },
+ "public.serverStatus": {
+ "name": "serverStatus",
+ "schema": "public",
+ "values": [
+ "active",
+ "inactive"
+ ]
+ },
+ "public.serverType": {
+ "name": "serverType",
+ "schema": "public",
+ "values": [
+ "deploy",
+ "build"
+ ]
+ },
+ "public.applicationStatus": {
+ "name": "applicationStatus",
+ "schema": "public",
+ "values": [
+ "idle",
+ "running",
+ "done",
+ "error"
+ ]
+ },
+ "public.certificateType": {
+ "name": "certificateType",
+ "schema": "public",
+ "values": [
+ "letsencrypt",
+ "none",
+ "custom"
+ ]
+ },
+ "public.sqldNode": {
+ "name": "sqldNode",
+ "schema": "public",
+ "values": [
+ "primary",
+ "replica"
+ ]
+ },
+ "public.triggerType": {
+ "name": "triggerType",
+ "schema": "public",
+ "values": [
+ "push",
+ "tag"
+ ]
+ },
+ "public.webServerProvider": {
+ "name": "webServerProvider",
+ "schema": "public",
+ "values": [
+ "traefik",
+ "caddy"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/dokploy/drizzle/meta/_journal.json b/apps/dokploy/drizzle/meta/_journal.json
index b5a1d7003d..b51736018f 100644
--- a/apps/dokploy/drizzle/meta/_journal.json
+++ b/apps/dokploy/drizzle/meta/_journal.json
@@ -1212,6 +1212,13 @@
"when": 1781045439162,
"tag": "0172_quick_the_professor",
"breakpoints": true
+ },
+ {
+ "idx": 173,
+ "version": "7",
+ "when": 1783117153967,
+ "tag": "0173_cool_vargas",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/apps/dokploy/esbuild.config.ts b/apps/dokploy/esbuild.config.ts
index fa053d726c..e590de8254 100644
--- a/apps/dokploy/esbuild.config.ts
+++ b/apps/dokploy/esbuild.config.ts
@@ -29,6 +29,7 @@ try {
"reset-password": "reset-password.ts",
"reset-2fa": "reset-2fa.ts",
"migrate-auth-secret": "scripts/migrate-auth-secret.ts",
+ "caddy-migration-rollback": "scripts/caddy-migration-rollback.ts",
},
bundle: true,
platform: "node",
diff --git a/apps/dokploy/migration.ts b/apps/dokploy/migration.ts
index 984197b2ae..0f077acdfa 100644
--- a/apps/dokploy/migration.ts
+++ b/apps/dokploy/migration.ts
@@ -1,19 +1,7 @@
-import { dbUrl } from "@dokploy/server/db";
-import { drizzle } from "drizzle-orm/postgres-js";
-import { migrate } from "drizzle-orm/postgres-js/migrator";
-import postgres from "postgres";
+import { runRuntimeMigrations } from "./server/db/run-migrations";
-const sql = postgres(dbUrl, { max: 1 });
-const db = drizzle(sql);
-
-await migrate(db, { migrationsFolder: "drizzle" })
- .then(() => {
- console.log("Migration complete");
- sql.end();
- })
- .catch((error) => {
- console.log("Migration failed", error);
- })
- .finally(() => {
- sql.end();
- });
+try {
+ await runRuntimeMigrations();
+} catch {
+ process.exit(1);
+}
diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json
index 3920d55357..b39786b742 100644
--- a/apps/dokploy/package.json
+++ b/apps/dokploy/package.json
@@ -20,6 +20,8 @@
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
"migration:run": "tsx -r dotenv/config migration.ts",
"manual-migration:run": "tsx -r dotenv/config migrate.ts",
+ "caddy:migration:rollback": "tsx -r dotenv/config scripts/caddy-migration-rollback.ts",
+ "caddy:migration:rollback:runtime": "node -r dotenv/config dist/caddy-migration-rollback.mjs",
"migration:up": "drizzle-kit up --config ./server/db/drizzle.config.ts",
"migration:drop": "drizzle-kit drop --config ./server/db/drizzle.config.ts",
"db:push": "drizzle-kit push --config ./server/db/drizzle.config.ts",
diff --git a/apps/dokploy/scripts/caddy-migration-rollback.ts b/apps/dokploy/scripts/caddy-migration-rollback.ts
new file mode 100644
index 0000000000..40e9936a3f
--- /dev/null
+++ b/apps/dokploy/scripts/caddy-migration-rollback.ts
@@ -0,0 +1,87 @@
+import { pathToFileURL } from "node:url";
+import type { CaddyMigrationReport } from "@dokploy/server";
+
+interface RollbackArgs {
+ migrationId: string;
+ serverId?: string;
+}
+
+interface CliIo {
+ stdout: Pick;
+ stderr: Pick;
+}
+
+const usage =
+ "Usage: caddy-migration-rollback --migration-id [--server-id ]";
+
+export const parseCaddyRollbackArgs = (argv: string[]): RollbackArgs => {
+ let migrationId = "";
+ let serverId: string | undefined;
+ for (let index = 0; index < argv.length; index++) {
+ const arg = argv[index];
+ if (arg === "--migration-id") {
+ migrationId = argv[++index] ?? "";
+ } else if (arg === "--server-id") {
+ serverId = argv[++index] || undefined;
+ } else if (arg === "--help" || arg === "-h") {
+ throw new Error(usage);
+ } else {
+ throw new Error(`Unknown argument "${arg}". ${usage}`);
+ }
+ }
+ if (!migrationId) {
+ throw new Error(`Missing --migration-id. ${usage}`);
+ }
+ return { migrationId, serverId };
+};
+
+const isHelpRequest = (argv: string[]) =>
+ argv.some((arg) => arg === "--help" || arg === "-h");
+
+const buildOutput = (report: CaddyMigrationReport) => ({
+ migrationId: report.migrationId,
+ status: report.status,
+ providerTarget: "traefik",
+ warnings: report.warnings,
+ summary: report.summary,
+ reportPath: report.artifactPaths.reportJson,
+});
+
+export const runCaddyMigrationRollbackCli = async (
+ argv = process.argv.slice(2),
+ io: CliIo = process,
+) => {
+ if (isHelpRequest(argv)) {
+ io.stdout.write(`${usage}\n`);
+ return 0;
+ }
+
+ try {
+ const args = parseCaddyRollbackArgs(argv);
+ const { rollbackCaddyMigration } = await import("@dokploy/server");
+ const report = await rollbackCaddyMigration(args);
+ const output = buildOutput(report);
+ io.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
+ return report.status === "rolled_back" ? 0 : 1;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Rollback failed";
+ io.stderr.write(`${message}\n`);
+ io.stdout.write(
+ `${JSON.stringify(
+ {
+ status: "failed",
+ providerTarget: "traefik",
+ error: message,
+ },
+ null,
+ 2,
+ )}\n`,
+ );
+ return 1;
+ }
+};
+
+if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
+ const code = await runCaddyMigrationRollbackCli();
+ process.exit(code);
+}
diff --git a/apps/dokploy/server/api/routers/ai.ts b/apps/dokploy/server/api/routers/ai.ts
index 81e03fe26a..fc8c43ee90 100644
--- a/apps/dokploy/server/api/routers/ai.ts
+++ b/apps/dokploy/server/api/routers/ai.ts
@@ -5,7 +5,7 @@ import {
deploySuggestionSchema,
} from "@dokploy/server/db/schema/ai";
import {
- createDomain,
+ createComposeDomain,
createMount,
findEnvironmentById,
} from "@dokploy/server/index";
@@ -352,12 +352,17 @@ ${input.logs}`,
if (input.domains && input.domains?.length > 0) {
for (const domain of input.domains) {
- await createDomain({
- ...domain,
- domainType: "compose",
- certificateType: "none",
- composeId: compose.composeId,
- });
+ await createComposeDomain(
+ compose,
+ {
+ ...domain,
+ domainType: "compose",
+ certificateType: "none",
+ composeId: compose.composeId,
+ },
+ undefined,
+ ctx.session.activeOrganizationId,
+ );
}
}
if (input.configFiles && input.configFiles?.length > 0) {
diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts
index e1bbedcb9e..a153fd0a5b 100644
--- a/apps/dokploy/server/api/routers/application.ts
+++ b/apps/dokploy/server/api/routers/application.ts
@@ -1,8 +1,10 @@
import {
clearOldDeployments,
createApplication,
+ createCaddyApplicationRouteFragment,
deleteAllMiddlewares,
findApplicationById,
+ findDomainsByApplicationId,
findEnvironmentById,
findProjectById,
getAccessibleServerIds,
@@ -18,6 +20,7 @@ import {
removeMonitoringDirectory,
removeService,
removeTraefikConfig,
+ resolveWebServerProvider,
startService,
startServiceRemote,
stopService,
@@ -787,6 +790,45 @@ export const applicationRouter = createTRPCRouter({
}
return traefikConfig;
}),
+ readWebServerConfig: protectedProcedure
+ .input(apiFindOneApplication)
+ .query(async ({ input, ctx }) => {
+ await checkServicePermissionAndAccess(ctx, input.applicationId, {
+ traefikFiles: ["read"],
+ });
+ const application = await findApplicationById(input.applicationId);
+ const provider = await resolveWebServerProvider(
+ application.serverId || undefined,
+ );
+
+ if (provider === "traefik") {
+ if (application.serverId) {
+ return await readRemoteConfig(
+ application.serverId,
+ application.appName,
+ );
+ }
+ return readConfig(application.appName);
+ }
+
+ const domains = await findDomainsByApplicationId(input.applicationId);
+ const fragments = domains.map((domain) =>
+ createCaddyApplicationRouteFragment(
+ application as never,
+ domain as never,
+ ),
+ );
+ return `${JSON.stringify(
+ {
+ provider,
+ message:
+ "Generated Caddy route fragments for this application. Caddy manages HTTPS certificates automatically for HTTPS domains; Traefik custom certificate resolvers do not apply.",
+ fragments,
+ },
+ null,
+ 2,
+ )}\n`;
+ }),
dropDeployment: protectedProcedure
.input(
diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts
index 403dc25d9b..04c3af6d39 100644
--- a/apps/dokploy/server/api/routers/compose.ts
+++ b/apps/dokploy/server/api/routers/compose.ts
@@ -1,11 +1,11 @@
import {
- addDomainToCompose,
+ addDomainToComposeForWebServer,
clearOldDeployments,
cloneCompose,
createCommand,
createCompose,
createComposeByTemplate,
- createDomain,
+ createComposeDomain,
createMount,
deleteMount,
execAsync,
@@ -25,8 +25,8 @@ import {
randomizeIsolatedDeploymentComposeFile,
removeCompose,
removeComposeDirectory,
+ removeComposeDomainsForWebServer,
removeDeploymentsByComposeId,
- removeDomainById,
startCompose,
stopCompose,
updateCompose,
@@ -402,7 +402,10 @@ export const composeRouter = createTRPCRouter({
});
const compose = await findComposeById(input.composeId);
const domains = await findDomainsByComposeId(input.composeId);
- const composeFile = await addDomainToCompose(compose, domains);
+ const composeFile = await addDomainToComposeForWebServer(
+ compose,
+ domains,
+ );
return stringify(composeFile, {
lineWidth: 1000,
});
@@ -659,13 +662,18 @@ export const composeRouter = createTRPCRouter({
if (generate.domains && generate.domains?.length > 0) {
for (const domain of generate.domains) {
- await createDomain({
- ...domain,
- domainType: "compose",
- certificateType: "none",
- composeId: compose.composeId,
- host: domain.host || "",
- });
+ await createComposeDomain(
+ compose,
+ {
+ ...domain,
+ domainType: "compose",
+ certificateType: "none",
+ composeId: compose.composeId,
+ host: domain.host || "",
+ },
+ undefined,
+ ctx.session.activeOrganizationId,
+ );
}
}
@@ -952,9 +960,12 @@ export const composeRouter = createTRPCRouter({
await deleteMount(mount.mountId);
}
- for (const domain of compose.domains) {
- await removeDomainById(domain.domainId);
- }
+ await removeComposeDomainsForWebServer(
+ compose,
+ compose.domains,
+ undefined,
+ ctx.session.activeOrganizationId,
+ );
let serverIp = "127.0.0.1";
@@ -1015,13 +1026,18 @@ export const composeRouter = createTRPCRouter({
if (processedTemplate.domains && processedTemplate.domains.length > 0) {
for (const domain of processedTemplate.domains) {
- await createDomain({
- ...domain,
- domainType: "compose",
- certificateType: "none",
- composeId: compose.composeId,
- host: domain.host || "",
- });
+ await createComposeDomain(
+ compose,
+ {
+ ...domain,
+ domainType: "compose",
+ certificateType: "none",
+ composeId: compose.composeId,
+ host: domain.host || "",
+ },
+ undefined,
+ ctx.session.activeOrganizationId,
+ );
}
}
diff --git a/apps/dokploy/server/api/routers/domain.ts b/apps/dokploy/server/api/routers/domain.ts
index 8210fcf8a5..0b975afc96 100644
--- a/apps/dokploy/server/api/routers/domain.ts
+++ b/apps/dokploy/server/api/routers/domain.ts
@@ -1,6 +1,9 @@
import {
+ assertCaddyDomainSupported,
+ createComposeDomain,
createDomain,
findApplicationById,
+ findComposeById,
findDomainById,
findDomainsByApplicationId,
findDomainsByComposeId,
@@ -8,9 +11,11 @@ import {
findServerById,
generateTraefikMeDomain,
getWebServerSettings,
- manageDomain,
- removeDomain,
+ manageWebServerDomain,
+ refreshCaddyComposeRoutes,
removeDomainById,
+ removeWebServerDomain,
+ resolveWebServerProvider,
updateDomainById,
validateDomain,
} from "@dokploy/server";
@@ -31,6 +36,23 @@ import {
apiUpdateDomain,
} from "@/server/db/schema";
+const toDomainUpdateFields = (
+ domain: Awaited>,
+) => ({
+ host: domain.host,
+ https: domain.https,
+ port: domain.port,
+ customEntrypoint: domain.customEntrypoint,
+ path: domain.path,
+ serviceName: domain.serviceName,
+ domainType: domain.domainType,
+ customCertResolver: domain.customCertResolver,
+ certificateType: domain.certificateType,
+ internalPath: domain.internalPath,
+ stripPath: domain.stripPath,
+ middlewares: domain.middlewares,
+});
+
export const domainRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateDomain)
@@ -40,7 +62,23 @@ export const domainRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, input.composeId, {
domain: ["create"],
});
- } else if (input.domainType === "application" && input.applicationId) {
+ const compose = await findComposeById(input.composeId);
+ const provider = await resolveWebServerProvider(compose.serverId);
+ const domain = await createComposeDomain(
+ compose,
+ input,
+ provider,
+ ctx.session.activeOrganizationId,
+ );
+ await audit(ctx, {
+ action: "create",
+ resourceType: "domain",
+ resourceId: domain.domainId,
+ resourceName: domain.host,
+ });
+ return domain;
+ }
+ if (input.domainType === "application" && input.applicationId) {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
domain: ["create"],
});
@@ -118,6 +156,101 @@ export const domainRouter = createTRPCRouter({
});
}
+ const nextDomain = { ...currentDomain, ...input };
+ if (currentDomain.applicationId) {
+ const application = await findApplicationById(
+ currentDomain.applicationId,
+ );
+ if (
+ (await resolveWebServerProvider(application.serverId)) === "caddy"
+ ) {
+ assertCaddyDomainSupported(nextDomain);
+ await manageWebServerDomain(application, nextDomain);
+ try {
+ const result = await updateDomainById(input.domainId, input);
+ if (!result) {
+ throw new Error("Error updating domain");
+ }
+ await audit(ctx, {
+ action: "update",
+ resourceType: "domain",
+ resourceId: result.domainId,
+ resourceName: result.host,
+ });
+ return result;
+ } catch (error) {
+ await manageWebServerDomain(application, currentDomain);
+ throw error;
+ }
+ }
+ } else if (currentDomain.previewDeploymentId) {
+ const previewDeployment = await findPreviewDeploymentById(
+ currentDomain.previewDeploymentId,
+ );
+ const application = await findApplicationById(
+ previewDeployment.applicationId,
+ );
+ application.appName = previewDeployment.appName;
+ if (
+ (await resolveWebServerProvider(application.serverId)) === "caddy"
+ ) {
+ assertCaddyDomainSupported(nextDomain);
+ await manageWebServerDomain(application, nextDomain);
+ try {
+ const result = await updateDomainById(input.domainId, input);
+ if (!result) {
+ throw new Error("Error updating domain");
+ }
+ await audit(ctx, {
+ action: "update",
+ resourceType: "domain",
+ resourceId: result.domainId,
+ resourceName: result.host,
+ });
+ return result;
+ } catch (error) {
+ await manageWebServerDomain(application, currentDomain);
+ throw error;
+ }
+ }
+ } else if (currentDomain.composeId) {
+ const compose = await findComposeById(currentDomain.composeId);
+ if ((await resolveWebServerProvider(compose.serverId)) === "caddy") {
+ assertCaddyDomainSupported(nextDomain);
+ const result = await updateDomainById(input.domainId, input);
+ if (!result) {
+ throw new Error("Error updating domain");
+ }
+ try {
+ await refreshCaddyComposeRoutes(
+ compose,
+ undefined,
+ "caddy",
+ ctx.session.activeOrganizationId,
+ );
+ await audit(ctx, {
+ action: "update",
+ resourceType: "domain",
+ resourceId: result.domainId,
+ resourceName: result.host,
+ });
+ return result;
+ } catch (error) {
+ await updateDomainById(
+ input.domainId,
+ toDomainUpdateFields(currentDomain),
+ );
+ await refreshCaddyComposeRoutes(
+ compose,
+ undefined,
+ "caddy",
+ ctx.session.activeOrganizationId,
+ );
+ throw error;
+ }
+ }
+ }
+
const result = await updateDomainById(input.domainId, input);
const domain = await findDomainById(input.domainId);
await audit(ctx, {
@@ -128,7 +261,7 @@ export const domainRouter = createTRPCRouter({
});
if (domain.applicationId) {
const application = await findApplicationById(domain.applicationId);
- await manageDomain(application, domain);
+ await manageWebServerDomain(application, domain);
} else if (domain.previewDeploymentId) {
const previewDeployment = await findPreviewDeploymentById(
domain.previewDeploymentId,
@@ -137,7 +270,7 @@ export const domainRouter = createTRPCRouter({
previewDeployment.applicationId,
);
application.appName = previewDeployment.appName;
- await manageDomain(application, domain);
+ await manageWebServerDomain(application, domain);
}
return result;
}),
@@ -176,6 +309,86 @@ export const domainRouter = createTRPCRouter({
});
}
+ if (domain.applicationId) {
+ const application = await findApplicationById(domain.applicationId);
+ if (
+ (await resolveWebServerProvider(application.serverId)) === "caddy"
+ ) {
+ await removeWebServerDomain(application, domain.uniqueConfigKey);
+ try {
+ const result = await removeDomainById(input.domainId);
+ await audit(ctx, {
+ action: "delete",
+ resourceType: "domain",
+ resourceId: domain.domainId,
+ resourceName: domain.host,
+ });
+ return result;
+ } catch (error) {
+ await manageWebServerDomain(application, domain);
+ throw error;
+ }
+ }
+ } else if (domain.previewDeploymentId) {
+ const previewDeployment = await findPreviewDeploymentById(
+ domain.previewDeploymentId,
+ );
+ const application = await findApplicationById(
+ previewDeployment.applicationId,
+ );
+ application.appName = previewDeployment.appName;
+ if (
+ (await resolveWebServerProvider(application.serverId)) === "caddy"
+ ) {
+ await removeWebServerDomain(application, domain.uniqueConfigKey);
+ try {
+ const result = await removeDomainById(input.domainId);
+ await audit(ctx, {
+ action: "delete",
+ resourceType: "domain",
+ resourceId: domain.domainId,
+ resourceName: domain.host,
+ });
+ return result;
+ } catch (error) {
+ await manageWebServerDomain(application, domain);
+ throw error;
+ }
+ }
+ } else if (domain.composeId) {
+ const compose = await findComposeById(domain.composeId);
+ if ((await resolveWebServerProvider(compose.serverId)) === "caddy") {
+ const domains = await findDomainsByComposeId(compose.composeId);
+ const remainingDomains = domains.filter(
+ (item) => item.domainId !== domain.domainId,
+ );
+ try {
+ await refreshCaddyComposeRoutes(
+ compose,
+ remainingDomains,
+ "caddy",
+ ctx.session.activeOrganizationId,
+ );
+ const result = await removeDomainById(input.domainId);
+ await audit(ctx, {
+ action: "delete",
+ resourceType: "domain",
+ resourceId: domain.domainId,
+ resourceName: domain.host,
+ });
+ return result;
+ } catch (error) {
+ await refreshCaddyComposeRoutes(
+ compose,
+ undefined,
+ "caddy",
+ ctx.session.activeOrganizationId,
+ );
+ throw error;
+ }
+ }
+ }
+
const result = await removeDomainById(input.domainId);
await audit(ctx, {
action: "delete",
@@ -186,7 +399,16 @@ export const domainRouter = createTRPCRouter({
if (domain.applicationId) {
const application = await findApplicationById(domain.applicationId);
- await removeDomain(application, domain.uniqueConfigKey);
+ await removeWebServerDomain(application, domain.uniqueConfigKey);
+ } else if (domain.previewDeploymentId) {
+ const previewDeployment = await findPreviewDeploymentById(
+ domain.previewDeploymentId,
+ );
+ const application = await findApplicationById(
+ previewDeployment.applicationId,
+ );
+ application.appName = previewDeployment.appName;
+ await removeWebServerDomain(application, domain.uniqueConfigKey);
}
return result;
diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts
index 2e35aee2a1..47af6cde03 100644
--- a/apps/dokploy/server/api/routers/project.ts
+++ b/apps/dokploy/server/api/routers/project.ts
@@ -2,6 +2,7 @@ import {
createApplication,
createBackup,
createCompose,
+ createComposeDomain,
createDomain,
createLibsql,
createMariadb,
@@ -977,11 +978,16 @@ export const projectRouter = createTRPCRouter({
for (const domain of domains) {
const { domainId, ...rest } = domain;
- await createDomain({
- ...rest,
- composeId: newCompose.composeId,
- domainType: "compose",
- });
+ await createComposeDomain(
+ newCompose,
+ {
+ ...rest,
+ composeId: newCompose.composeId,
+ domainType: "compose",
+ },
+ undefined,
+ ctx.session.activeOrganizationId,
+ );
}
break;
diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts
index 9f8bc1fe77..006c908bfc 100644
--- a/apps/dokploy/server/api/routers/settings.ts
+++ b/apps/dokploy/server/api/routers/settings.ts
@@ -4,7 +4,7 @@ import {
checkPortInUse,
checkPostgresHealth,
checkRedisHealth,
- checkTraefikHealth,
+ checkWebServerHealth,
cleanupAll,
cleanupAllBackground,
cleanupBuilders,
@@ -12,9 +12,11 @@ import {
cleanupImages,
cleanupSystem,
cleanupVolumes,
+ compileWriteAndValidateCaddyConfigSafely,
DEFAULT_UPDATE_DATA,
execAsync,
findServerById,
+ getCaddyCompileSettings,
getDockerDiskUsage,
getDokployImageTag,
getLogCleanupStatus,
@@ -30,10 +32,10 @@ import {
readDirectory,
readEnvironmentVariables,
readMainConfig,
- readMonitoringConfig,
readPorts,
recreateDirectory,
reloadDockerResource,
+ resolveWebServerProvider,
sendDockerCleanupNotifications,
setupGPUSupport,
spawnAsync,
@@ -41,6 +43,7 @@ import {
stopLogCleanup,
updateLetsEncryptEmail,
updateServerById,
+ updateServerCaddy,
updateServerTraefik,
updateWebServerSettings,
writeConfig,
@@ -83,8 +86,15 @@ import {
protectedProcedure,
publicProcedure,
} from "../trpc";
+import {
+ ensureServerAccess,
+ getRequestAnalyticsState,
+ readActiveRequestAccessLog,
+ webServerProcedures,
+} from "./web-server";
export const settingsRouter = createTRPCRouter({
+ ...webServerProcedures,
getWebServerSettings: protectedProcedure.query(async () => {
if (IS_CLOUD) {
return null;
@@ -170,6 +180,16 @@ export const settingsRouter = createTRPCRouter({
toggleDashboard: adminProcedure
.input(apiEnableDashboard)
.mutation(async ({ input, ctx }) => {
+ await ensureServerAccess(ctx, input.serverId);
+ const provider = await resolveWebServerProvider(input.serverId);
+ if (provider !== "traefik") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "The dashboard toggle is only available for Traefik. The Caddy admin API is kept local-only and is not exposed through Dokploy.",
+ });
+ }
+
const ports = await readPorts("dokploy-traefik", input.serverId);
const env = await readEnvironmentVariables(
"dokploy-traefik",
@@ -323,6 +343,14 @@ export const settingsRouter = createTRPCRouter({
if (IS_CLOUD) {
return true;
}
+ const previousSettings = await getWebServerSettings();
+ if (!previousSettings) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Web server settings not found",
+ });
+ }
+
const settings = await updateWebServerSettings({
host: input.host,
letsEncryptEmail: input.letsEncryptEmail,
@@ -337,9 +365,24 @@ export const settingsRouter = createTRPCRouter({
});
}
- updateServerTraefik(settings, input.host);
- if (input.letsEncryptEmail) {
- updateLetsEncryptEmail(input.letsEncryptEmail);
+ const provider = await resolveWebServerProvider();
+ if (provider === "caddy") {
+ try {
+ await updateServerCaddy(settings, input.host);
+ } catch (error) {
+ await updateWebServerSettings({
+ host: previousSettings.host,
+ letsEncryptEmail: previousSettings.letsEncryptEmail,
+ certificateType: previousSettings.certificateType,
+ https: previousSettings.https,
+ });
+ throw error;
+ }
+ } else {
+ updateServerTraefik(settings, input.host);
+ if (input.letsEncryptEmail) {
+ updateLetsEncryptEmail(input.letsEncryptEmail);
+ }
}
await audit(ctx, {
@@ -790,7 +833,6 @@ export const settingsRouter = createTRPCRouter({
);
return envVars;
}),
-
writeTraefikEnv: adminProcedure
.input(z.object({ env: z.string(), serverId: z.string().optional() }))
.mutation(async ({ input, ctx }) => {
@@ -818,7 +860,6 @@ export const settingsRouter = createTRPCRouter({
const ports = await readPorts("dokploy-traefik", input?.serverId);
return ports.some((port) => port.targetPort === 8080);
}),
-
readStatsLogs: protectedProcedure
.meta({
openapi: {
@@ -836,7 +877,7 @@ export const settingsRouter = createTRPCRouter({
totalCount: 0,
};
}
- const rawConfig = await readMonitoringConfig(
+ const rawConfig = await readActiveRequestAccessLog(
!!input.dateRange?.start && !!input.dateRange?.end,
);
@@ -876,26 +917,14 @@ export const settingsRouter = createTRPCRouter({
if (IS_CLOUD) {
return [];
}
- const rawConfig = await readMonitoringConfig(
+ const rawConfig = await readActiveRequestAccessLog(
!!input?.dateRange?.start || !!input?.dateRange?.end,
);
const processedLogs = processLogs(rawConfig as string, input?.dateRange);
return processedLogs || [];
}),
haveActivateRequests: protectedProcedure.query(async () => {
- if (IS_CLOUD) {
- return true;
- }
- const config = readMainConfig();
-
- if (!config) return false;
- const parsedConfig = parse(config) as {
- accessLog?: {
- filePath: string;
- };
- };
-
- return !!parsedConfig?.accessLog?.filePath;
+ return (await getRequestAnalyticsState()).enabled;
}),
toggleRequests: protectedProcedure
.input(
@@ -907,6 +936,31 @@ export const settingsRouter = createTRPCRouter({
if (IS_CLOUD) {
return true;
}
+ const provider = await resolveWebServerProvider();
+ if (provider === "caddy") {
+ const previousSettings = await getWebServerSettings();
+ const previousEnabled = Boolean(previousSettings?.requestLogsEnabled);
+ await updateWebServerSettings({
+ requestLogsEnabled: input.enable,
+ });
+ try {
+ await compileWriteAndValidateCaddyConfigSafely(
+ await getCaddyCompileSettings(),
+ );
+ } catch (error) {
+ await updateWebServerSettings({
+ requestLogsEnabled: previousEnabled,
+ });
+ throw error;
+ }
+ await audit(ctx, {
+ action: "update",
+ resourceType: "settings",
+ resourceName: "toggle-requests",
+ });
+ return true;
+ }
+
const mainConfig = readMainConfig();
if (!mainConfig) return false;
@@ -962,21 +1016,29 @@ export const settingsRouter = createTRPCRouter({
}
}),
checkInfrastructureHealth: adminProcedure.query(async () => {
+ const provider = await resolveWebServerProvider();
if (IS_CLOUD) {
+ const webServer = { provider, status: "healthy" as const };
return {
postgres: { status: "healthy" as const },
redis: { status: "healthy" as const },
- traefik: { status: "healthy" as const },
+ webServer,
+ traefik: { status: webServer.status },
};
}
- const [postgres, redis, traefik] = await Promise.all([
+ const [postgres, redis, webServer] = await Promise.all([
checkPostgresHealth(),
checkRedisHealth(),
- checkTraefikHealth(),
+ checkWebServerHealth(provider),
]);
- return { postgres, redis, traefik };
+ return {
+ postgres,
+ redis,
+ webServer,
+ traefik: { status: webServer.status, message: webServer.message },
+ };
}),
setupGPU: adminProcedure
.input(
diff --git a/apps/dokploy/server/api/routers/web-server.ts b/apps/dokploy/server/api/routers/web-server.ts
new file mode 100644
index 0000000000..a3af4c1537
--- /dev/null
+++ b/apps/dokploy/server/api/routers/web-server.ts
@@ -0,0 +1,839 @@
+import { createReadStream, existsSync } from "node:fs";
+import { createInterface } from "node:readline";
+import {
+ ACCESS_LOG_RETAINED_LINES,
+ applyCaddyMigration as applyCaddyMigrationCutover,
+ checkPortInUse,
+ compileWriteAndReloadCaddyConfigSafely,
+ findServerById,
+ getCaddyCompileSettings,
+ getCaddyTrustedProxySettings,
+ getWebServerPaths,
+ getWebServerResourceName,
+ getWebServerSettings,
+ IS_CLOUD,
+ isCaddyReservedAdditionalPort,
+ paths,
+ prepareCaddyMigration as prepareCaddyMigrationDryRun,
+ prepareEnvironmentVariables,
+ readCaddyConfigFileIfExists,
+ getCaddyMigrationReport as readCaddyMigrationReport,
+ readConfigInPath,
+ readDirectory,
+ readEnvironmentVariables,
+ readMainConfig,
+ readMonitoringConfig,
+ readPorts,
+ reloadCaddyAfterValidation,
+ reloadDockerResource,
+ resolveWebServerProvider,
+ rollbackCaddyMigration as rollbackCaddyMigrationCutover,
+ updateCaddyTrustedProxySettings,
+ updateLocalWebServerProvider,
+ updateRemoteWebServerProvider,
+ type WebServerProvider,
+ writeMainConfig,
+ writeTraefikConfigInPath,
+ writeWebServerSetup,
+} from "@dokploy/server";
+
+import { checkPermission } from "@dokploy/server/services/permission";
+
+import { TRPCError } from "@trpc/server";
+
+import { parse } from "yaml";
+import { z } from "zod";
+import { audit } from "@/server/api/utils/audit";
+import { apiServerSchema } from "@/server/db/schema";
+
+import { adminProcedure, protectedProcedure } from "../trpc";
+
+const webServerProviderSchema = z.enum(["traefik", "caddy"]);
+
+const apiWebServerProvider = z.object({
+ provider: webServerProviderSchema,
+ serverId: z.string().optional(),
+});
+
+const apiWebServerConfig = z.object({
+ webServerConfig: z.string().min(1),
+ serverId: z.string().optional(),
+});
+
+const apiReadWebServerFile = z.object({
+ path: z.string().min(1),
+ serverId: z.string().optional(),
+});
+
+const apiModifyWebServerFile = apiReadWebServerFile.extend({
+ webServerConfig: z.string().min(1),
+});
+
+const apiWriteWebServerEnv = z.object({
+ env: z.string(),
+ serverId: z.string().optional(),
+});
+
+const apiWebServerPorts = z.object({
+ serverId: z.string().optional(),
+ additionalPorts: z.array(
+ z.object({
+ targetPort: z.number(),
+ publishedPort: z.number(),
+ protocol: z.enum(["tcp", "udp", "sctp"]),
+ }),
+ ),
+});
+
+const apiCaddyTrustedProxySettings = z.object({
+ serverId: z.string().optional(),
+ mode: z.enum(["disabled", "cloudflare", "static"]),
+ ranges: z.array(z.string()).optional().nullable(),
+ clientIpHeaders: z.array(z.string()).optional().nullable(),
+ strict: z.boolean().optional().nullable(),
+});
+
+const apiCaddyMigration = z.object({
+ serverId: z.string().optional(),
+});
+
+const apiCaddyMigrationById = apiCaddyMigration.extend({
+ migrationId: z.string().min(1),
+});
+
+const apiApplyCaddyMigration = apiCaddyMigrationById.extend({
+ confirmMaintenanceWindow: z.literal(true),
+});
+
+export const ensureServerAccess = async (
+ ctx: { session?: { activeOrganizationId?: string | null } | null },
+ serverId?: string,
+) => {
+ if (!serverId) return;
+ const remoteServer = await findServerById(serverId);
+ if (remoteServer.organizationId !== ctx.session?.activeOrganizationId) {
+ throw new TRPCError({ code: "UNAUTHORIZED" });
+ }
+};
+
+const normalizeWebServerPath = (filePath: string) =>
+ filePath.replace(/\\/g, "/").replace(/\/+$/g, "");
+
+const isPathWithin = (targetPath: string, basePath: string) => {
+ const normalizedTarget = normalizeWebServerPath(targetPath);
+ const normalizedBase = normalizeWebServerPath(basePath);
+ return (
+ normalizedTarget === normalizedBase ||
+ normalizedTarget.startsWith(`${normalizedBase}/`)
+ );
+};
+
+const isCaddyMigrationBackupPath = (filePath: string, serverId?: string) => {
+ const caddyPaths = paths(!!serverId);
+ const normalizedPath = normalizeWebServerPath(filePath);
+ const migrationsPath = normalizeWebServerPath(
+ caddyPaths.CADDY_MIGRATIONS_PATH,
+ );
+ if (!isPathWithin(normalizedPath, migrationsPath)) {
+ return false;
+ }
+ return normalizedPath
+ .slice(migrationsPath.length)
+ .split("/")
+ .filter(Boolean)
+ .includes("backups");
+};
+
+const assertCaddyReadableFilePath = (filePath: string, serverId?: string) => {
+ const caddyPaths = paths(!!serverId);
+ const normalizedPath = normalizeWebServerPath(filePath);
+ if (isCaddyMigrationBackupPath(normalizedPath, serverId)) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "Caddy migration backups may contain TLS or internal configuration and are not readable from the file editor.",
+ });
+ }
+ if (
+ normalizedPath === normalizeWebServerPath(caddyPaths.CADDY_CONFIG_PATH) ||
+ isPathWithin(normalizedPath, caddyPaths.CADDY_FRAGMENTS_PATH) ||
+ isPathWithin(normalizedPath, caddyPaths.CADDY_MIGRATIONS_PATH)
+ ) {
+ return;
+ }
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "Caddy file access is limited to caddy.json, route fragments, and non-backup migration artifacts.",
+ });
+};
+
+type WebServerDirectoryNode = {
+ id: string;
+ name: string;
+ type: "file" | "directory";
+ children?: WebServerDirectoryNode[];
+};
+
+const pruneCaddyBackupDirectoryNodes = (
+ nodes: WebServerDirectoryNode[],
+ serverId?: string,
+): WebServerDirectoryNode[] =>
+ nodes
+ .filter((node) => !isCaddyMigrationBackupPath(node.id, serverId))
+ .map((node) =>
+ node.children
+ ? {
+ ...node,
+ children: pruneCaddyBackupDirectoryNodes(node.children, serverId),
+ }
+ : node,
+ );
+
+const readCaddySafeDirectoryTree = async (serverId?: string) => {
+ const caddyPaths = paths(!!serverId);
+ const readOptionalDirectory = async (dirPath: string) => {
+ try {
+ return await readDirectory(dirPath, serverId);
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ (error as NodeJS.ErrnoException).code === "ENOENT"
+ ) {
+ return [];
+ }
+ throw error;
+ }
+ };
+ return [
+ {
+ id: caddyPaths.CADDY_CONFIG_PATH,
+ name: "caddy.json",
+ type: "file" as const,
+ },
+ {
+ id: caddyPaths.CADDY_FRAGMENTS_PATH,
+ name: "fragments",
+ type: "directory" as const,
+ children: await readOptionalDirectory(caddyPaths.CADDY_FRAGMENTS_PATH),
+ },
+ {
+ id: caddyPaths.CADDY_MIGRATIONS_PATH,
+ name: "migrations",
+ type: "directory" as const,
+ children: pruneCaddyBackupDirectoryNodes(
+ await readOptionalDirectory(caddyPaths.CADDY_MIGRATIONS_PATH),
+ serverId,
+ ),
+ },
+ ];
+};
+
+const resolveWebServerFilePath = (
+ filePath: string,
+ provider: WebServerProvider,
+ serverId?: string,
+) => {
+ if (
+ filePath.includes("../") ||
+ filePath.includes("..\\") ||
+ filePath.includes("\0") ||
+ filePath.includes("\x00")
+ ) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid path: path traversal or null bytes are not allowed",
+ });
+ }
+
+ const basePath = getWebServerPaths(provider, !!serverId).basePath;
+ if (filePath.startsWith("/")) {
+ if (filePath !== basePath && !filePath.startsWith(`${basePath}/`)) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid path: outside of active web server directory",
+ });
+ }
+ return filePath;
+ }
+
+ return `${basePath}/${filePath.replace(/^\/+/, "")}`;
+};
+
+const getMainTraefikConfigPath = (serverId?: string) =>
+ `${paths(!!serverId).MAIN_TRAEFIK_PATH}/traefik.yml`;
+
+const readProviderMainConfig = async (
+ provider: WebServerProvider,
+ serverId?: string,
+) => {
+ if (provider === "caddy") {
+ return readCaddyConfigFileIfExists({ serverId });
+ }
+
+ if (serverId) {
+ return readConfigInPath(getMainTraefikConfigPath(serverId), serverId);
+ }
+ return readMainConfig();
+};
+
+const writeProviderMainConfig = async (
+ provider: WebServerProvider,
+ content: string,
+ serverId?: string,
+) => {
+ if (provider === "caddy") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "Caddy caddy.json is generated from route fragments and is read-only. Use Caddy migration/domain actions to update it.",
+ });
+ }
+
+ if (serverId) {
+ await writeTraefikConfigInPath(
+ getMainTraefikConfigPath(serverId),
+ content,
+ serverId,
+ );
+ return;
+ }
+ writeMainConfig(content);
+};
+
+const reloadWebServerProvider = async (
+ provider: WebServerProvider,
+ serverId?: string,
+) => {
+ if (provider === "caddy") {
+ await reloadCaddyAfterValidation(serverId);
+ return;
+ }
+ await reloadDockerResource(getWebServerResourceName(provider), serverId);
+};
+
+const getCaddySetupOptions = async (
+ provider: WebServerProvider,
+ serverId?: string,
+) => {
+ if (provider !== "caddy") return {};
+ return getCaddyCompileSettings(serverId);
+};
+
+const getLocalAccessLogPath = (provider: WebServerProvider) => {
+ const currentPaths = paths();
+ return provider === "caddy"
+ ? currentPaths.CADDY_ACCESS_LOG_PATH
+ : `${currentPaths.DYNAMIC_TRAEFIK_PATH}/access.log`;
+};
+
+const readAccessLogFile = async (filePath: string) => {
+ if (!existsSync(filePath)) {
+ return "";
+ }
+
+ const recentLines: string[] = [];
+ const fileStream = createReadStream(filePath, { encoding: "utf8" });
+ const readline = createInterface({
+ input: fileStream,
+ crlfDelay: Number.POSITIVE_INFINITY,
+ });
+ for await (const line of readline) {
+ const trimmed = line.trim();
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
+ recentLines.push(line);
+ if (recentLines.length > ACCESS_LOG_RETAINED_LINES) {
+ recentLines.shift();
+ }
+ }
+ }
+ return recentLines.length ? `${recentLines.join("\n")}\n` : "";
+};
+
+export const readActiveRequestAccessLog = async (readAll = false) => {
+ const provider = await resolveWebServerProvider();
+ if (provider === "traefik") {
+ return (await readMonitoringConfig(readAll)) ?? "";
+ }
+ return readAccessLogFile(getLocalAccessLogPath(provider));
+};
+
+export const getRequestAnalyticsState = async () => {
+ if (IS_CLOUD) {
+ return {
+ provider: "traefik" as const,
+ enabled: true,
+ reloadResourceName: "dokploy-traefik",
+ accessLogPath: getLocalAccessLogPath("traefik"),
+ };
+ }
+
+ const provider = await resolveWebServerProvider();
+ if (provider === "caddy") {
+ const settings = await getWebServerSettings();
+ return {
+ provider,
+ enabled: Boolean(settings?.requestLogsEnabled),
+ reloadResourceName: getWebServerResourceName(provider),
+ accessLogPath: getLocalAccessLogPath(provider),
+ };
+ }
+
+ const config = readMainConfig();
+ const parsedConfig = config
+ ? (parse(config) as { accessLog?: { filePath?: string } })
+ : null;
+ return {
+ provider,
+ enabled: Boolean(parsedConfig?.accessLog?.filePath),
+ reloadResourceName: getWebServerResourceName(provider),
+ accessLogPath: getLocalAccessLogPath(provider),
+ };
+};
+
+// Provider-neutral web-server and Caddy migration procedures, spread into
+// settingsRouter so client paths stay api.settings.* — extracted from
+// settings.ts to keep that router reviewable.
+export const webServerProcedures = {
+ // protectedProcedure: consumed by member-visible surfaces (domain forms,
+ // application traefik/caddy config views, the web-server files page), which
+ // need the provider name to render the right fields. ensureServerAccess
+ // still scopes remote-server lookups to the caller's organization.
+ getActiveWebServerProvider: protectedProcedure
+ .input(apiServerSchema)
+ .query(async ({ input, ctx }) => {
+ await ensureServerAccess(ctx, input?.serverId);
+ return resolveWebServerProvider(input?.serverId);
+ }),
+ getCaddyTrustedProxySettings: adminProcedure
+ .input(apiServerSchema)
+ .query(async ({ input, ctx }) => {
+ await ensureServerAccess(ctx, input?.serverId);
+ if (IS_CLOUD && !input?.serverId) {
+ return {
+ mode: "disabled" as const,
+ ranges: [],
+ clientIpHeaders: [],
+ strict: true,
+ };
+ }
+
+ const settings = await getCaddyTrustedProxySettings(input?.serverId);
+ return (
+ settings ?? {
+ mode: "disabled" as const,
+ ranges: [],
+ clientIpHeaders: [],
+ strict: true,
+ }
+ );
+ }),
+ updateCaddyTrustedProxySettings: adminProcedure
+ .input(apiCaddyTrustedProxySettings)
+ .mutation(async ({ input, ctx }) => {
+ await ensureServerAccess(ctx, input.serverId);
+ if (IS_CLOUD && !input.serverId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "Caddy trusted proxy settings are only available for a local or remote web server.",
+ });
+ }
+
+ const previousSettings = await getCaddyTrustedProxySettings(
+ input.serverId,
+ );
+ const nextSettings =
+ input.mode === "disabled"
+ ? null
+ : {
+ mode: input.mode,
+ ranges: input.ranges,
+ clientIpHeaders: input.clientIpHeaders,
+ strict: input.strict,
+ };
+
+ const provider = await resolveWebServerProvider(input.serverId);
+ await updateCaddyTrustedProxySettings(nextSettings, input.serverId);
+ try {
+ if (provider === "caddy") {
+ await compileWriteAndReloadCaddyConfigSafely({
+ serverId: input.serverId,
+ ...(await getCaddyCompileSettings(input.serverId)),
+ });
+ }
+ } catch (error) {
+ await updateCaddyTrustedProxySettings(previousSettings, input.serverId);
+ throw error;
+ }
+
+ await audit(ctx, {
+ action: "update",
+ resourceType: "settings",
+ resourceName: "caddy-trusted-proxy",
+ });
+ return (
+ (await getCaddyTrustedProxySettings(input.serverId)) ?? {
+ mode: "disabled" as const,
+ ranges: [],
+ clientIpHeaders: [],
+ strict: true,
+ }
+ );
+ }),
+ updateActiveWebServerProvider: adminProcedure
+ .input(apiWebServerProvider)
+ .mutation(async ({ input, ctx }) => {
+ if (input.provider === "caddy") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "Caddy can only be activated through the migration apply flow after validation succeeds.",
+ });
+ }
+ if (input.serverId) {
+ await ensureServerAccess(ctx, input.serverId);
+ }
+ const currentProvider = await resolveWebServerProvider(input.serverId);
+ if (currentProvider === "caddy" && input.provider === "traefik") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "Use the Caddy migration rollback flow to return to Traefik safely.",
+ });
+ }
+ if (input.serverId) {
+ await updateRemoteWebServerProvider(input.serverId, input.provider);
+ } else {
+ await updateLocalWebServerProvider(input.provider);
+ }
+ await audit(ctx, {
+ action: "update",
+ resourceType: "settings",
+ resourceName: "web-server-provider",
+ });
+ return true;
+ }),
+ reloadWebServer: adminProcedure
+ .input(apiServerSchema)
+ .mutation(async ({ input, ctx }) => {
+ await ensureServerAccess(ctx, input?.serverId);
+ const provider = await resolveWebServerProvider(input?.serverId);
+ void reloadWebServerProvider(provider, input?.serverId).catch((err) => {
+ console.error("reloadWebServer background:", err);
+ });
+ await audit(ctx, {
+ action: "reload",
+ resourceType: "settings",
+ resourceName: getWebServerResourceName(provider),
+ });
+ return true;
+ }),
+ prepareCaddyMigration: adminProcedure
+ .input(apiCaddyMigration)
+ .mutation(async ({ input, ctx }) => {
+ await ensureServerAccess(ctx, input.serverId);
+ const report = await prepareCaddyMigrationDryRun({
+ serverId: input.serverId,
+ });
+ await audit(ctx, {
+ action: "create",
+ resourceType: "settings",
+ resourceName: "caddy-migration-dry-run",
+ });
+ return report;
+ }),
+ getCaddyMigrationReport: adminProcedure
+ .input(apiCaddyMigrationById)
+ .query(async ({ input, ctx }) => {
+ await ensureServerAccess(ctx, input.serverId);
+ return readCaddyMigrationReport({
+ migrationId: input.migrationId,
+ serverId: input.serverId,
+ });
+ }),
+ applyCaddyMigration: adminProcedure
+ .input(apiApplyCaddyMigration)
+ .mutation(async ({ input, ctx }) => {
+ await ensureServerAccess(ctx, input.serverId);
+ const report = await readCaddyMigrationReport({
+ migrationId: input.migrationId,
+ serverId: input.serverId,
+ });
+ if (report.summary.blockingWarnings > 0) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Cannot apply Caddy migration with ${report.summary.blockingWarnings} blocking warning(s)`,
+ });
+ }
+ if (report.validation.status !== "passed") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "Cannot apply Caddy migration because draft validation did not pass",
+ });
+ }
+ void applyCaddyMigrationCutover({
+ migrationId: input.migrationId,
+ serverId: input.serverId,
+ }).catch((err) => {
+ console.error("applyCaddyMigration background:", err);
+ });
+ await audit(ctx, {
+ action: "update",
+ resourceType: "settings",
+ resourceName: "caddy-migration-apply",
+ });
+ return { started: true, migrationId: input.migrationId };
+ }),
+ rollbackCaddyMigration: adminProcedure
+ .input(apiCaddyMigrationById)
+ .mutation(async ({ input, ctx }) => {
+ await ensureServerAccess(ctx, input.serverId);
+ void rollbackCaddyMigrationCutover({
+ migrationId: input.migrationId,
+ serverId: input.serverId,
+ }).catch((err) => {
+ console.error("rollbackCaddyMigration background:", err);
+ });
+ await audit(ctx, {
+ action: "update",
+ resourceType: "settings",
+ resourceName: "caddy-migration-rollback",
+ });
+ return { started: true, migrationId: input.migrationId };
+ }),
+ readWebServerConfig: adminProcedure
+ .input(apiServerSchema)
+ .query(async ({ input, ctx }) => {
+ if (IS_CLOUD && !input?.serverId) {
+ return true;
+ }
+ await ensureServerAccess(ctx, input?.serverId);
+ const provider = await resolveWebServerProvider(input?.serverId);
+ return readProviderMainConfig(provider, input?.serverId);
+ }),
+
+ updateWebServerConfig: adminProcedure
+ .input(apiWebServerConfig)
+ .mutation(async ({ input, ctx }) => {
+ if (IS_CLOUD && !input.serverId) {
+ return true;
+ }
+ await ensureServerAccess(ctx, input.serverId);
+ const provider = await resolveWebServerProvider(input.serverId);
+ await writeProviderMainConfig(
+ provider,
+ input.webServerConfig,
+ input.serverId,
+ );
+ await audit(ctx, {
+ action: "update",
+ resourceType: "settings",
+ resourceName: "web-server-config",
+ });
+ return true;
+ }),
+ readWebServerDirectories: protectedProcedure
+ .input(apiServerSchema)
+ .query(async ({ ctx, input }) => {
+ await checkPermission(ctx, { traefikFiles: ["read"] });
+ await ensureServerAccess(ctx, input?.serverId);
+ const provider = await resolveWebServerProvider(input?.serverId);
+ if (provider === "caddy") {
+ return readCaddySafeDirectoryTree(input?.serverId);
+ }
+ const { basePath } = getWebServerPaths(provider, !!input?.serverId);
+ try {
+ const result = await readDirectory(basePath, input?.serverId);
+ return result || [];
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ (error as NodeJS.ErrnoException).code === "ENOENT"
+ ) {
+ return [];
+ }
+ throw error;
+ }
+ }),
+ updateWebServerFile: protectedProcedure
+ .input(apiModifyWebServerFile)
+ .mutation(async ({ input, ctx }) => {
+ await checkPermission(ctx, { traefikFiles: ["write"] });
+ await ensureServerAccess(ctx, input.serverId);
+ const provider = await resolveWebServerProvider(input.serverId);
+ if (provider === "caddy") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "Caddy generated config files are read-only from the file editor.",
+ });
+ }
+ const filePath = resolveWebServerFilePath(
+ input.path,
+ provider,
+ input.serverId,
+ );
+ await writeTraefikConfigInPath(
+ filePath,
+ input.webServerConfig,
+ input.serverId,
+ );
+ await audit(ctx, {
+ action: "update",
+ resourceType: "settings",
+ resourceName: "web-server-file",
+ });
+ return true;
+ }),
+
+ readWebServerFile: protectedProcedure
+ .input(apiReadWebServerFile)
+ .query(async ({ input, ctx }) => {
+ await checkPermission(ctx, { traefikFiles: ["read"] });
+ await ensureServerAccess(ctx, input.serverId);
+ const provider = await resolveWebServerProvider(input.serverId);
+ const filePath = resolveWebServerFilePath(
+ input.path,
+ provider,
+ input.serverId,
+ );
+ if (provider === "caddy") {
+ assertCaddyReadableFilePath(filePath, input.serverId);
+ }
+ return readConfigInPath(filePath, input.serverId);
+ }),
+ readWebServerEnv: adminProcedure
+ .input(apiServerSchema)
+ .query(async ({ input, ctx }) => {
+ await ensureServerAccess(ctx, input?.serverId);
+ const provider = await resolveWebServerProvider(input?.serverId);
+ return readEnvironmentVariables(
+ getWebServerResourceName(provider),
+ input?.serverId,
+ );
+ }),
+ writeWebServerEnv: adminProcedure
+ .input(apiWriteWebServerEnv)
+ .mutation(async ({ input, ctx }) => {
+ await ensureServerAccess(ctx, input.serverId);
+ const provider = await resolveWebServerProvider(input.serverId);
+ const resourceName = getWebServerResourceName(provider);
+ const envs = prepareEnvironmentVariables(input.env);
+ const ports = await readPorts(resourceName, input.serverId);
+
+ void writeWebServerSetup(provider, {
+ env: envs,
+ additionalPorts: ports,
+ serverId: input.serverId,
+ ...(await getCaddySetupOptions(provider, input.serverId)),
+ }).catch((err) => {
+ console.error("writeWebServerEnv background writeWebServerSetup:", err);
+ });
+ await audit(ctx, {
+ action: "update",
+ resourceType: "settings",
+ resourceName: "web-server-env",
+ });
+ return true;
+ }),
+ getWebServerDashboardState: adminProcedure
+ .input(apiServerSchema)
+ .query(async ({ input, ctx }) => {
+ await ensureServerAccess(ctx, input?.serverId);
+ const provider = await resolveWebServerProvider(input?.serverId);
+ if (provider === "traefik") {
+ const ports = await readPorts("dokploy-traefik", input?.serverId);
+ return {
+ provider,
+ enabled: ports.some((port) => port.targetPort === 8080),
+ };
+ }
+
+ return { provider, enabled: false };
+ }),
+
+ getRequestAnalyticsState: protectedProcedure.query(async () => {
+ return getRequestAnalyticsState();
+ }),
+ updateWebServerPorts: adminProcedure
+ .input(apiWebServerPorts)
+ .mutation(async ({ input, ctx }) => {
+ try {
+ await ensureServerAccess(ctx, input.serverId);
+ if (IS_CLOUD && !input.serverId) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "Please set a serverId to update web server ports",
+ });
+ }
+ const provider = await resolveWebServerProvider(input.serverId);
+ const resourceName = getWebServerResourceName(provider);
+ const env = await readEnvironmentVariables(
+ resourceName,
+ input.serverId,
+ );
+
+ if (provider === "caddy") {
+ const reservedPort = input.additionalPorts.find(
+ isCaddyReservedAdditionalPort,
+ );
+ if (reservedPort) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Caddy port mapping ${reservedPort.publishedPort}->${reservedPort.targetPort}/${reservedPort.protocol ?? "tcp"} is reserved and cannot be published.`,
+ });
+ }
+ }
+
+ for (const port of input.additionalPorts) {
+ const portCheck = await checkPortInUse(
+ port.publishedPort,
+ input.serverId,
+ );
+ if (portCheck.isInUse) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: `Port ${port.publishedPort} is already in use by ${portCheck.conflictingContainer}`,
+ });
+ }
+ }
+ const preparedEnv = prepareEnvironmentVariables(env);
+
+ void writeWebServerSetup(provider, {
+ env: preparedEnv,
+ additionalPorts: input.additionalPorts,
+ serverId: input.serverId,
+ ...(await getCaddySetupOptions(provider, input.serverId)),
+ }).catch((err) => {
+ console.error(
+ "updateWebServerPorts background writeWebServerSetup:",
+ err,
+ );
+ });
+ await audit(ctx, {
+ action: "update",
+ resourceType: "settings",
+ resourceName: "web-server-ports",
+ });
+ return true;
+ } catch (error) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ error instanceof Error
+ ? error.message
+ : "Error updating web server ports",
+ cause: error,
+ });
+ }
+ }),
+ getWebServerPorts: adminProcedure
+ .input(apiServerSchema)
+ .query(async ({ input, ctx }) => {
+ await ensureServerAccess(ctx, input?.serverId);
+ const provider = await resolveWebServerProvider(input?.serverId);
+ return readPorts(getWebServerResourceName(provider), input?.serverId);
+ }),
+};
diff --git a/apps/dokploy/server/db/migration.ts b/apps/dokploy/server/db/migration.ts
index 8a24afdc50..da381cfea4 100644
--- a/apps/dokploy/server/db/migration.ts
+++ b/apps/dokploy/server/db/migration.ts
@@ -1,20 +1,5 @@
-import { dbUrl } from "@dokploy/server/db";
-import { drizzle } from "drizzle-orm/postgres-js";
-import { migrate } from "drizzle-orm/postgres-js/migrator";
-import postgres from "postgres";
+import { runRuntimeMigrations } from "./run-migrations";
-const sql = postgres(dbUrl, { max: 1 });
-const db = drizzle(sql);
-
-export const migration = async () =>
- await migrate(db, { migrationsFolder: "drizzle" })
- .then(() => {
- console.log("Migration complete");
- sql.end();
- })
- .catch((error) => {
- console.log("Migration failed", error);
- })
- .finally(() => {
- sql.end();
- });
+export const migration = async () => {
+ await runRuntimeMigrations();
+};
diff --git a/apps/dokploy/server/db/run-migrations.ts b/apps/dokploy/server/db/run-migrations.ts
new file mode 100644
index 0000000000..202c5fbf24
--- /dev/null
+++ b/apps/dokploy/server/db/run-migrations.ts
@@ -0,0 +1,29 @@
+import { dbUrl } from "@dokploy/server/db/constants";
+import { drizzle } from "drizzle-orm/postgres-js";
+import { migrate } from "drizzle-orm/postgres-js/migrator";
+import postgres from "postgres";
+
+export type RuntimeMigrationLogger = Pick;
+
+export type RunRuntimeMigrationsOptions = {
+ logger?: RuntimeMigrationLogger;
+ migrationsFolder?: string;
+};
+
+export const runRuntimeMigrations = async ({
+ logger = console,
+ migrationsFolder = "drizzle",
+}: RunRuntimeMigrationsOptions = {}) => {
+ const sql = postgres(dbUrl, { max: 1 });
+
+ try {
+ const db = drizzle(sql);
+ await migrate(db, { migrationsFolder });
+ logger.log("Migration complete");
+ } catch (error) {
+ logger.error("Migration failed", error);
+ throw error;
+ } finally {
+ await sql.end();
+ }
+};
diff --git a/packages/server/src/constants/index.ts b/packages/server/src/constants/index.ts
index 706a0dbecb..b25c620e52 100644
--- a/packages/server/src/constants/index.ts
+++ b/packages/server/src/constants/index.ts
@@ -12,6 +12,7 @@ export const DOKPLOY_DOCKER_PORT = process.env.DOKPLOY_DOCKER_PORT
: undefined;
export const CLEANUP_CRON_JOB = "50 23 * * *";
+export const ACCESS_LOG_RETAINED_LINES = 1000;
type DockerSocketCandidate = {
label: string;
@@ -90,11 +91,25 @@ export const paths = (isServer = false) => {
: path.join(process.cwd(), ".docker");
const MAIN_TRAEFIK_PATH = `${BASE_PATH}/traefik`;
const DYNAMIC_TRAEFIK_PATH = `${MAIN_TRAEFIK_PATH}/dynamic`;
+ const MAIN_CADDY_PATH = `${BASE_PATH}/caddy`;
+ const CADDY_CONFIG_PATH = `${MAIN_CADDY_PATH}/caddy.json`;
+ const CADDY_FRAGMENTS_PATH = `${MAIN_CADDY_PATH}/fragments`;
+ const CADDY_ACCESS_LOG_PATH = `${MAIN_CADDY_PATH}/access.log`;
+ const CADDY_DATA_PATH = `${MAIN_CADDY_PATH}/data`;
+ const CADDY_CONFIG_DIR_PATH = `${MAIN_CADDY_PATH}/config`;
+ const CADDY_MIGRATIONS_PATH = `${MAIN_CADDY_PATH}/migrations`;
return {
BASE_PATH,
MAIN_TRAEFIK_PATH,
DYNAMIC_TRAEFIK_PATH,
+ MAIN_CADDY_PATH,
+ CADDY_CONFIG_PATH,
+ CADDY_FRAGMENTS_PATH,
+ CADDY_ACCESS_LOG_PATH,
+ CADDY_DATA_PATH,
+ CADDY_CONFIG_DIR_PATH,
+ CADDY_MIGRATIONS_PATH,
LOGS_PATH: `${BASE_PATH}/logs`,
APPLICATIONS_PATH: `${BASE_PATH}/applications`,
COMPOSE_PATH: `${BASE_PATH}/compose`,
diff --git a/packages/server/src/db/schema/domain.ts b/packages/server/src/db/schema/domain.ts
index 092275fde2..28ee0d6e71 100644
--- a/packages/server/src/db/schema/domain.ts
+++ b/packages/server/src/db/schema/domain.ts
@@ -11,7 +11,7 @@ import {
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
-import { domain } from "../validations/domain";
+import { domain, validateDomainSettings } from "../validations/domain";
import { applications } from "./application";
import { compose } from "./compose";
import { previewDeployments } from "./preview-deployments";
@@ -79,24 +79,26 @@ const createSchema = createInsertSchema(domains, {
domainType: z.enum(["compose", "application", "preview"]).optional(),
});
-export const apiCreateDomain = createSchema.pick({
- host: true,
- path: true,
- port: true,
- customEntrypoint: true,
- https: true,
- applicationId: true,
- certificateType: true,
- customCertResolver: true,
- composeId: true,
- serviceName: true,
- domainType: true,
- previewDeploymentId: true,
- internalPath: true,
- stripPath: true,
- middlewares: true,
- forwardAuthEnabled: true,
-});
+export const apiCreateDomain = createSchema
+ .pick({
+ host: true,
+ path: true,
+ port: true,
+ customEntrypoint: true,
+ https: true,
+ applicationId: true,
+ certificateType: true,
+ customCertResolver: true,
+ composeId: true,
+ serviceName: true,
+ domainType: true,
+ previewDeploymentId: true,
+ internalPath: true,
+ stripPath: true,
+ middlewares: true,
+ forwardAuthEnabled: true,
+ })
+ .superRefine(validateDomainSettings);
export const apiFindDomain = z.object({
domainId: z.string().min(1),
@@ -130,4 +132,5 @@ export const apiUpdateDomain = createSchema
middlewares: true,
forwardAuthEnabled: true,
})
- .merge(createSchema.pick({ domainId: true }).required());
+ .merge(createSchema.pick({ domainId: true }).required())
+ .superRefine(validateDomainSettings);
diff --git a/packages/server/src/db/schema/server.ts b/packages/server/src/db/schema/server.ts
index 85ba663ef4..8120512cb7 100644
--- a/packages/server/src/db/schema/server.ts
+++ b/packages/server/src/db/schema/server.ts
@@ -10,6 +10,7 @@ import {
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
+import type { CaddyTrustedProxySettings } from "../../utils/caddy/types";
import { organization } from "./account";
import { applications } from "./application";
import { certificates } from "./certificate";
@@ -22,6 +23,7 @@ import { mysql } from "./mysql";
import { postgres } from "./postgres";
import { redis } from "./redis";
import { schedules } from "./schedule";
+import { webServerProvider } from "./shared";
import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils";
export const serverStatus = pgEnum("serverStatus", ["active", "inactive"]);
@@ -41,6 +43,12 @@ export const server = pgTable("server", {
.notNull()
.$defaultFn(() => generateAppName("server")),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
+ webServerProvider: webServerProvider("webServerProvider")
+ .notNull()
+ .default("traefik"),
+ caddyTrustedProxyConfig: jsonb("caddyTrustedProxyConfig")
+ .$type()
+ .default(null),
buildsConcurrency: integer("buildsConcurrency").notNull().default(1),
createdAt: text("createdAt").notNull(),
organizationId: text("organizationId")
@@ -137,6 +145,7 @@ const createSchema = createInsertSchema(server, {
name: z.string().min(1),
description: z.string().optional(),
serverType: z.enum(["deploy", "build"]).optional(),
+ webServerProvider: z.enum(["traefik", "caddy"]).optional(),
});
export const apiCreateServer = createSchema
diff --git a/packages/server/src/db/schema/shared.ts b/packages/server/src/db/schema/shared.ts
index 6566968c64..2ab58c79bd 100644
--- a/packages/server/src/db/schema/shared.ts
+++ b/packages/server/src/db/schema/shared.ts
@@ -14,6 +14,11 @@ export const certificateType = pgEnum("certificateType", [
"custom",
]);
+export const webServerProvider = pgEnum("webServerProvider", [
+ "traefik",
+ "caddy",
+]);
+
export const triggerType = pgEnum("triggerType", ["push", "tag"]);
export const sqldNode = pgEnum("sqldNode", ["primary", "replica"]);
diff --git a/packages/server/src/db/schema/web-server-settings.ts b/packages/server/src/db/schema/web-server-settings.ts
index e9745b5336..f49a614f2e 100644
--- a/packages/server/src/db/schema/web-server-settings.ts
+++ b/packages/server/src/db/schema/web-server-settings.ts
@@ -10,7 +10,8 @@ import {
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
-import { certificateType } from "./shared";
+import type { CaddyTrustedProxySettings } from "../../utils/caddy/types";
+import { certificateType, webServerProvider } from "./shared";
export const webServerSettings = pgTable("webServerSettings", {
id: text("id")
@@ -18,6 +19,13 @@ export const webServerSettings = pgTable("webServerSettings", {
.primaryKey()
.$defaultFn(() => nanoid()),
// Web Server Configuration
+ webServerProvider: webServerProvider("webServerProvider")
+ .notNull()
+ .default("traefik"),
+ caddyTrustedProxyConfig: jsonb("caddyTrustedProxyConfig")
+ .$type()
+ .default(null),
+ requestLogsEnabled: boolean("requestLogsEnabled").notNull().default(false),
serverIp: text("serverIp"),
certificateType: certificateType("certificateType").notNull().default("none"),
https: boolean("https").notNull().default(false),
@@ -132,46 +140,59 @@ const createSchema = createInsertSchema(webServerSettings, {
id: z.string().min(1),
});
-export const apiUpdateWebServerSettings = createSchema.partial().extend({
- serverIp: z.string().optional(),
- certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
- https: z.boolean().optional(),
- host: z.string().optional(),
- letsEncryptEmail: z.string().email().optional().nullable(),
- sshPrivateKey: z.string().optional(),
- enableDockerCleanup: z.boolean().optional(),
- logCleanupCron: z.string().optional().nullable(),
- metricsConfig: z
- .object({
- server: z.object({
- type: z.enum(["Dokploy", "Remote"]),
- refreshRate: z.number(),
- port: z.number(),
- token: z.string(),
- urlCallback: z.string(),
- retentionDays: z.number(),
- cronJob: z.string(),
- thresholds: z.object({
- cpu: z.number(),
- memory: z.number(),
+export const apiUpdateWebServerSettings = createSchema
+ .omit({ requestLogsEnabled: true })
+ .partial()
+ .extend({
+ webServerProvider: z.enum(["traefik", "caddy"]).optional(),
+ caddyTrustedProxyConfig: z
+ .object({
+ mode: z.enum(["disabled", "cloudflare", "static"]),
+ ranges: z.array(z.string()).optional().nullable(),
+ clientIpHeaders: z.array(z.string()).optional().nullable(),
+ strict: z.boolean().optional().nullable(),
+ })
+ .optional()
+ .nullable(),
+ serverIp: z.string().optional(),
+ certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
+ https: z.boolean().optional(),
+ host: z.string().optional(),
+ letsEncryptEmail: z.string().email().optional().nullable(),
+ sshPrivateKey: z.string().optional(),
+ enableDockerCleanup: z.boolean().optional(),
+ logCleanupCron: z.string().optional().nullable(),
+ metricsConfig: z
+ .object({
+ server: z.object({
+ type: z.enum(["Dokploy", "Remote"]),
+ refreshRate: z.number(),
+ port: z.number(),
+ token: z.string(),
+ urlCallback: z.string(),
+ retentionDays: z.number(),
+ cronJob: z.string(),
+ thresholds: z.object({
+ cpu: z.number(),
+ memory: z.number(),
+ }),
}),
- }),
- containers: z.object({
- refreshRate: z.number(),
- services: z.object({
- include: z.array(z.string()),
- exclude: z.array(z.string()),
+ containers: z.object({
+ refreshRate: z.number(),
+ services: z.object({
+ include: z.array(z.string()),
+ exclude: z.array(z.string()),
+ }),
}),
- }),
- })
- .optional(),
- cleanupCacheApplications: z.boolean().optional(),
- cleanupCacheOnPreviews: z.boolean().optional(),
- cleanupCacheOnCompose: z.boolean().optional(),
- remoteServersOnly: z.boolean().optional(),
- enforceSSO: z.boolean().optional(),
- buildsConcurrency: z.number().int().min(1).max(100).optional(),
-});
+ })
+ .optional(),
+ cleanupCacheApplications: z.boolean().optional(),
+ cleanupCacheOnPreviews: z.boolean().optional(),
+ cleanupCacheOnCompose: z.boolean().optional(),
+ remoteServersOnly: z.boolean().optional(),
+ enforceSSO: z.boolean().optional(),
+ buildsConcurrency: z.number().int().min(1).max(100).optional(),
+ });
export const apiUpdateWebServerBuildsConcurrency = z.object({
buildsConcurrency: z.number().int().min(1).max(100),
diff --git a/packages/server/src/db/validations/domain.ts b/packages/server/src/db/validations/domain.ts
index 059e3cae25..522ee20ed0 100644
--- a/packages/server/src/db/validations/domain.ts
+++ b/packages/server/src/db/validations/domain.ts
@@ -4,6 +4,63 @@ import {
VALID_HOSTNAME_REGEX,
} from "../../utils/hostname-validation";
+type DomainSettingsValidationInput = {
+ https?: boolean | null;
+ certificateType?: "letsencrypt" | "none" | "custom" | null;
+ customCertResolver?: string | null;
+ stripPath?: boolean | null;
+ path?: string | null;
+ internalPath?: string | null;
+};
+
+export const validateDomainSettings = (
+ input: DomainSettingsValidationInput,
+ ctx: z.RefinementCtx,
+) => {
+ if (input.https && !input.certificateType) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["certificateType"],
+ message: "Required",
+ });
+ }
+
+ if (
+ input.https &&
+ input.certificateType === "custom" &&
+ !input.customCertResolver
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["customCertResolver"],
+ message: "Required when certificate type is custom",
+ });
+ }
+
+ // Validate stripPath requires a valid path
+ if (input.stripPath && (!input.path || input.path === "/")) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["stripPath"],
+ message:
+ "Strip path can only be enabled when a path other than '/' is specified",
+ });
+ }
+
+ // Validate internalPath starts with /
+ if (
+ input.internalPath &&
+ input.internalPath !== "/" &&
+ !input.internalPath.startsWith("/")
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["internalPath"],
+ message: "Internal path must start with '/'",
+ });
+ }
+};
+
export const domain = z
.object({
host: z
@@ -26,49 +83,10 @@ export const domain = z
.optional(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
- customCertResolver: z.string(),
+ customCertResolver: z.string().optional(),
middlewares: z.array(z.string()).optional(),
})
- .superRefine((input, ctx) => {
- if (input.https && !input.certificateType) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ["certificateType"],
- message: "Required",
- });
- }
-
- if (input.certificateType === "custom" && !input.customCertResolver) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ["customCertResolver"],
- message: "Required when certificate type is custom",
- });
- }
-
- // Validate stripPath requires a valid path
- if (input.stripPath && (!input.path || input.path === "/")) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ["stripPath"],
- message:
- "Strip path can only be enabled when a path other than '/' is specified",
- });
- }
-
- // Validate internalPath starts with /
- if (
- input.internalPath &&
- input.internalPath !== "/" &&
- !input.internalPath.startsWith("/")
- ) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ["internalPath"],
- message: "Internal path must start with '/'",
- });
- }
- });
+ .superRefine(validateDomainSettings);
export const domainCompose = z
.object({
@@ -92,47 +110,8 @@ export const domainCompose = z
.optional(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
- customCertResolver: z.string(),
+ customCertResolver: z.string().optional(),
serviceName: z.string().min(1, { message: "Service name is required" }),
middlewares: z.array(z.string()).optional(),
})
- .superRefine((input, ctx) => {
- if (input.https && !input.certificateType) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ["certificateType"],
- message: "Required",
- });
- }
-
- if (input.certificateType === "custom" && !input.customCertResolver) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ["customCertResolver"],
- message: "Required when certificate type is custom",
- });
- }
-
- // Validate stripPath requires a valid path
- if (input.stripPath && (!input.path || input.path === "/")) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ["stripPath"],
- message:
- "Strip path can only be enabled when a path other than '/' is specified",
- });
- }
-
- // Validate internalPath starts with /
- if (
- input.internalPath &&
- input.internalPath !== "/" &&
- !input.internalPath.startsWith("/")
- ) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ["internalPath"],
- message: "Internal path must start with '/'",
- });
- }
- });
+ .superRefine(validateDomainSettings);
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index b8a2fcec70..282581a90e 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -50,6 +50,7 @@ export * from "./services/ssh-key";
export * from "./services/user";
export * from "./services/volume-backups";
export * from "./services/web-server-settings";
+export * from "./setup/caddy-setup";
export * from "./setup/config-paths";
export * from "./setup/forward-auth-setup";
export * from "./setup/monitoring-setup";
@@ -87,6 +88,21 @@ export * from "./utils/builders/nixpacks";
export * from "./utils/builders/paketo";
export * from "./utils/builders/static";
export * from "./utils/builders/utils";
+export * from "./utils/caddy/compose";
+export * from "./utils/caddy/config";
+export * from "./utils/caddy/domain";
+export * from "./utils/caddy/migration/apply";
+export * from "./utils/caddy/migration/compose-label-translator";
+export * from "./utils/caddy/migration/dynamic-file-translator";
+export * from "./utils/caddy/migration/files";
+export * from "./utils/caddy/migration/prepare";
+export * from "./utils/caddy/migration/rollback";
+export * from "./utils/caddy/migration/traefik-rule-parser";
+export * from "./utils/caddy/migration/types";
+export * from "./utils/caddy/migration/upstream-preflight";
+export * from "./utils/caddy/types";
+export * from "./utils/caddy/upstream-targets";
+export * from "./utils/caddy/web-server";
export * from "./utils/cluster/upload";
export * from "./utils/crons/enterprise";
export * from "./utils/databases/rebuild";
@@ -138,5 +154,8 @@ export * from "./utils/traefik/types";
export * from "./utils/traefik/web-server";
export * from "./utils/volume-backups/index";
export * from "./utils/watch-paths/should-deploy";
+export * from "./utils/web-server/domain";
+export * from "./utils/web-server/paths";
+export * from "./utils/web-server/providers";
export * from "./verification/send-verification-email";
export * from "./wss/utils";
diff --git a/packages/server/src/services/certificate.ts b/packages/server/src/services/certificate.ts
index aa5c3983c2..072650b768 100644
--- a/packages/server/src/services/certificate.ts
+++ b/packages/server/src/services/certificate.ts
@@ -5,14 +5,17 @@ import { db } from "@dokploy/server/db";
import {
type apiCreateCertificate,
certificates,
+ domains,
} from "@dokploy/server/db/schema";
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
import { TRPCError } from "@trpc/server";
-import { eq } from "drizzle-orm";
+import { and, eq } from "drizzle-orm";
+import { quote } from "shell-quote";
import { stringify } from "yaml";
import type { z } from "zod";
import { encodeBase64 } from "../utils/docker/utils";
import { execAsyncRemote } from "../utils/process/execAsync";
+import { resolveWebServerProvider } from "./web-server-settings";
export type Certificate = typeof certificates.$inferSelect;
@@ -31,6 +34,97 @@ export const findCertificateById = async (certificateId: string) => {
return certificate;
};
+export const findCertificateByPath = async (certificatePath: string) => {
+ return db.query.certificates.findFirst({
+ where: eq(certificates.certificatePath, certificatePath),
+ });
+};
+
+export const assertCertificatePathAvailableForServer = async (
+ certificatePath: string,
+ serverId?: string | null,
+ organizationId?: string | null,
+) => {
+ if (!organizationId) {
+ throw new Error(
+ "Caddy custom certificate validation requires organization context.",
+ );
+ }
+
+ const certificate = await findCertificateByPath(certificatePath);
+ const expectedServerId = serverId ?? null;
+
+ if (
+ !certificate ||
+ (certificate.serverId ?? null) !== expectedServerId ||
+ certificate.organizationId !== organizationId
+ ) {
+ throw new Error(
+ `Caddy custom certificate "${certificatePath}" is not available for this server and organization. Use an uploaded certificate assigned to the same server and project organization.`,
+ );
+ }
+
+ await assertCertificateFilesReadable(certificate);
+
+ return certificate;
+};
+
+const assertCertificateFilesReadable = async (certificate: Certificate) => {
+ const { CERTIFICATES_PATH } = paths(!!certificate.serverId);
+ const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath);
+ const crtPath = path.join(certDir, "chain.crt");
+ const keyPath = path.join(certDir, "privkey.key");
+
+ try {
+ if (certificate.serverId) {
+ await execAsyncRemote(
+ certificate.serverId,
+ `test -r ${quote([crtPath])} && test -r ${quote([keyPath])}`,
+ );
+ return;
+ }
+
+ await fs.promises.access(crtPath, fs.constants.R_OK);
+ await fs.promises.access(keyPath, fs.constants.R_OK);
+ } catch {
+ throw new Error(
+ `Caddy custom certificate "${certificate.certificatePath}" is missing readable chain.crt or privkey.key files.`,
+ );
+ }
+};
+
+const findActiveCaddyDomainUsingCertificate = async (
+ certificate: Certificate,
+) => {
+ const provider = await resolveWebServerProvider(certificate.serverId);
+ if (provider !== "caddy") {
+ return null;
+ }
+
+ return db.query.domains.findFirst({
+ where: and(
+ eq(domains.customCertResolver, certificate.certificatePath),
+ eq(domains.certificateType, "custom"),
+ eq(domains.https, true),
+ ),
+ });
+};
+
+const assertCertificateNotUsedByActiveCaddyDomain = async (
+ certificate: Certificate,
+ action: "delete" | "update",
+) => {
+ const domain = await findActiveCaddyDomainUsingCertificate(certificate);
+ if (!domain) {
+ return;
+ }
+
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Cannot ${action} certificate "${certificate.name}" because active Caddy domain "${domain.host}" uses it. Change or remove that domain certificate first.`,
+ });
+};
+
export const createCertificate = async (
certificateData: z.infer,
organizationId: string,
@@ -52,18 +146,19 @@ export const createCertificate = async (
const cer = certificate[0];
- createCertificateFiles(cer);
+ await createCertificateFiles(cer);
return cer;
};
export const removeCertificateById = async (certificateId: string) => {
const certificate = await findCertificateById(certificateId);
+ await assertCertificateNotUsedByActiveCaddyDomain(certificate, "delete");
const { CERTIFICATES_PATH } = paths(!!certificate.serverId);
const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath);
if (certificate.serverId) {
- await execAsyncRemote(certificate.serverId, `rm -rf ${certDir}`);
+ await execAsyncRemote(certificate.serverId, `rm -rf ${quote([certDir])}`);
} else {
await removeDirectoryIfExistsContent(certDir);
}
@@ -135,6 +230,11 @@ export const updateCertificate = async (
privateKey?: string;
},
) => {
+ const current = await findCertificateById(certificateId);
+ if (updates.certificateData || updates.privateKey) {
+ await assertCertificateNotUsedByActiveCaddyDomain(current, "update");
+ }
+
const updated = await db
.update(certificates)
.set({
diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts
index 7a887cdc41..4e2fc2e1dd 100644
--- a/packages/server/src/services/compose.ts
+++ b/packages/server/src/services/compose.ts
@@ -11,8 +11,10 @@ import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
import { randomizeSpecificationFile } from "@dokploy/server/utils/docker/compose";
import {
cloneCompose,
+ getCaddyComposeRouteTargetsForWebServer,
loadDockerCompose,
loadDockerComposeRemote,
+ writeCaddyComposeRoutesForTargets,
} from "@dokploy/server/utils/docker/domain";
import type { ComposeSpecification } from "@dokploy/server/utils/docker/types";
import { sendBuildErrorNotifications } from "@dokploy/server/utils/notifications/build-error";
@@ -271,6 +273,9 @@ export const deployCompose = async ({
}
}
+ const caddyComposeRouteTargets =
+ await getCaddyComposeRouteTargetsForWebServer(entity, compose.domains);
+
command = "set -e;";
command += await getBuildComposeCommand(entity);
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
@@ -280,6 +285,16 @@ export const deployCompose = async ({
await execAsync(commandWithLog);
}
+ if (caddyComposeRouteTargets) {
+ await writeCaddyComposeRoutesForTargets(
+ entity,
+ caddyComposeRouteTargets,
+ {
+ organizationId: compose.environment.project.organizationId,
+ },
+ );
+ }
+
await updateDeploymentStatus(deployment.deploymentId, "done");
await updateCompose(composeId, {
composeStatus: "done",
@@ -385,6 +400,9 @@ export const rebuildCompose = async ({
}
}
+ const caddyComposeRouteTargets =
+ await getCaddyComposeRouteTargetsForWebServer(compose, compose.domains);
+
command = "set -e;";
command += await getBuildComposeCommand(compose);
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
@@ -394,6 +412,16 @@ export const rebuildCompose = async ({
await execAsync(commandWithLog);
}
+ if (caddyComposeRouteTargets) {
+ await writeCaddyComposeRoutesForTargets(
+ compose,
+ caddyComposeRouteTargets,
+ {
+ organizationId: compose.environment.project.organizationId,
+ },
+ );
+ }
+
await updateDeploymentStatus(deployment.deploymentId, "done");
await updateCompose(composeId, {
composeStatus: "done",
diff --git a/packages/server/src/services/domain.ts b/packages/server/src/services/domain.ts
index e1460fd482..2e5c6abae5 100644
--- a/packages/server/src/services/domain.ts
+++ b/packages/server/src/services/domain.ts
@@ -1,25 +1,61 @@
import dns from "node:dns";
import { promisify } from "node:util";
import { db } from "@dokploy/server/db";
-import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
+import {
+ getWebServerSettings,
+ resolveWebServerProvider,
+} from "@dokploy/server/services/web-server-settings";
import { generateRandomDomain } from "@dokploy/server/templates";
-import { manageDomain } from "@dokploy/server/utils/traefik/domain";
+import {
+ getCaddyComposeRouteTargetsForWebServer,
+ writeCaddyComposeRoutesForTargets,
+} from "@dokploy/server/utils/docker/domain";
+import { manageWebServerDomain } from "@dokploy/server/utils/web-server/domain";
+import type { WebServerProvider } from "@dokploy/server/utils/web-server/providers";
import { TRPCError } from "@trpc/server";
-import { eq } from "drizzle-orm";
+import { eq, inArray } from "drizzle-orm";
import type { z } from "zod";
import { type apiCreateDomain, domains } from "../db/schema";
import { findApplicationById } from "./application";
import { detectCDNProvider } from "./cdn";
+import type { Compose } from "./compose";
import { findServerById } from "./server";
export type Domain = typeof domains.$inferSelect;
+const resolveApplicationDomainPort = async (
+ input: z.infer,
+) => {
+ if (
+ input.port != null ||
+ !input.applicationId ||
+ input.composeId ||
+ input.previewDeploymentId ||
+ input.domainType === "compose" ||
+ input.domainType === "preview"
+ ) {
+ return input.port;
+ }
+
+ const applicationId = input.applicationId;
+ const applicationDomains = await db.query.domains.findMany({
+ where: eq(domains.applicationId, applicationId),
+ orderBy: (domainFields, { asc }) => [asc(domainFields.uniqueConfigKey)],
+ });
+
+ return applicationDomains.find(
+ (domain) => typeof domain.port === "number" && domain.port > 0,
+ )?.port;
+};
+
export const createDomain = async (input: z.infer) => {
- const result = await db.transaction(async (tx) => {
+ const port = await resolveApplicationDomainPort(input);
+ const domain = await db.transaction(async (tx) => {
const domain = await tx
.insert(domains)
.values({
...input,
+ ...(port != null && { port }),
host: input.host?.trim(),
} as typeof domains.$inferInsert)
.returning()
@@ -32,15 +68,20 @@ export const createDomain = async (input: z.infer) => {
});
}
- if (domain.applicationId) {
- const application = await findApplicationById(domain.applicationId);
- await manageDomain(application, domain);
- }
-
return domain;
});
- return result;
+ if (domain.applicationId) {
+ const application = await findApplicationById(domain.applicationId);
+ try {
+ await manageWebServerDomain(application, domain);
+ } catch (error) {
+ await removeDomainById(domain.domainId).catch(() => undefined);
+ throw error;
+ }
+ }
+
+ return domain;
};
export const generateTraefikMeDomain = async (
@@ -140,6 +181,125 @@ export const removeDomainById = async (domainId: string) => {
return result[0];
};
+export const refreshCaddyComposeRoutes = async (
+ compose: Compose,
+ domainsInput?: Domain[],
+ provider?: WebServerProvider,
+ organizationId?: string | null,
+) => {
+ const domainsArray =
+ domainsInput ?? (await findDomainsByComposeId(compose.composeId));
+ const routeTargets = await getCaddyComposeRouteTargetsForWebServer(
+ compose,
+ domainsArray,
+ provider,
+ );
+ if (routeTargets) {
+ await writeCaddyComposeRoutesForTargets(compose, routeTargets, {
+ organizationId,
+ });
+ }
+};
+
+export const createComposeDomain = async (
+ compose: Compose,
+ input: z.infer,
+ provider?: WebServerProvider,
+ organizationId?: string | null,
+) => {
+ const domain = await createDomain(input);
+ try {
+ await refreshCaddyComposeRoutes(
+ compose,
+ undefined,
+ provider,
+ organizationId,
+ );
+ return domain;
+ } catch (error) {
+ await removeDomainById(domain.domainId);
+ await refreshCaddyComposeRoutes(
+ compose,
+ undefined,
+ provider,
+ organizationId,
+ );
+ throw error;
+ }
+};
+
+export const removeComposeDomainsForWebServer = async (
+ compose: Compose,
+ domainsToRemove: Domain[],
+ provider?: WebServerProvider,
+ organizationId?: string | null,
+) => {
+ if (domainsToRemove.length === 0) {
+ return [];
+ }
+
+ const resolvedProvider =
+ provider ?? (await resolveWebServerProvider(compose.serverId));
+ const currentDomains = await findDomainsByComposeId(compose.composeId);
+ const domainIdsToRemove = new Set(
+ domainsToRemove.map((domain) => domain.domainId),
+ );
+ const removableDomains = currentDomains.filter((domain) =>
+ domainIdsToRemove.has(domain.domainId),
+ );
+
+ if (removableDomains.length === 0) {
+ return [];
+ }
+
+ if (resolvedProvider !== "caddy") {
+ return db.transaction(async (tx) =>
+ tx
+ .delete(domains)
+ .where(
+ inArray(
+ domains.domainId,
+ removableDomains.map((domain) => domain.domainId),
+ ),
+ )
+ .returning(),
+ );
+ }
+
+ const remainingDomains = currentDomains.filter(
+ (domain) => !domainIdsToRemove.has(domain.domainId),
+ );
+
+ await refreshCaddyComposeRoutes(
+ compose,
+ remainingDomains,
+ resolvedProvider,
+ organizationId,
+ );
+
+ try {
+ return await db.transaction(async (tx) =>
+ tx
+ .delete(domains)
+ .where(
+ inArray(
+ domains.domainId,
+ removableDomains.map((domain) => domain.domainId),
+ ),
+ )
+ .returning(),
+ );
+ } catch (error) {
+ await refreshCaddyComposeRoutes(
+ compose,
+ currentDomains,
+ resolvedProvider,
+ organizationId,
+ );
+ throw error;
+ }
+};
+
export const getDomainHost = (domain: Domain) => {
return `${domain.https ? "https" : "http"}://${domain.host}`;
};
diff --git a/packages/server/src/services/preview-deployment.ts b/packages/server/src/services/preview-deployment.ts
index 20f64259bc..8acf57d0dd 100644
--- a/packages/server/src/services/preview-deployment.ts
+++ b/packages/server/src/services/preview-deployment.ts
@@ -13,10 +13,13 @@ 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 {
+ manageWebServerDomain,
+ removeWebServerDomain,
+} from "../utils/web-server/domain";
import { findApplicationById } from "./application";
import { removeDeploymentsByPreviewDeploymentId } from "./deployment";
-import { createDomain } from "./domain";
+import { createDomain, removeDomainById } from "./domain";
import { type Github, getIssueComment } from "./github";
import { getWebServerSettings } from "./web-server-settings";
@@ -55,6 +58,15 @@ export const removePreviewDeployment = async (previewDeploymentId: string) => {
);
application.appName = previewDeployment.appName;
+ if (previewDeployment.domain) {
+ await removeWebServerDomain(
+ application,
+ previewDeployment.domain.uniqueConfigKey,
+ );
+ } else {
+ await removeTraefikConfig(application?.appName, application?.serverId);
+ }
+
const cleanupOperations = [
async () =>
await removeService(application?.appName, application?.serverId),
@@ -65,8 +77,6 @@ export const removePreviewDeployment = async (previewDeploymentId: string) => {
),
async () =>
await removeDirectoryCode(application?.appName, application?.serverId),
- async () =>
- await removeTraefikConfig(application?.appName, application?.serverId),
async () =>
await db
.delete(previewDeployments)
@@ -174,32 +184,64 @@ export const createPreviewDeployment = async (
});
}
- const newDomain = await createDomain({
- host: generateDomain,
- path: application.previewPath,
- port: application.previewPort,
- https: application.previewHttps,
- certificateType: application.previewCertificateType,
- customCertResolver: application.previewCustomCertResolver,
- domainType: "preview",
- previewDeploymentId: previewDeployment.previewDeploymentId,
- });
-
application.appName = appName;
+ let newDomain: Awaited> | undefined;
+ let routeCreated = false;
- await manageDomain(application, newDomain);
+ try {
+ newDomain = await createDomain({
+ host: generateDomain,
+ path: application.previewPath,
+ port: application.previewPort,
+ https: application.previewHttps,
+ certificateType: application.previewCertificateType,
+ customCertResolver: application.previewCustomCertResolver,
+ domainType: "preview",
+ previewDeploymentId: previewDeployment.previewDeploymentId,
+ });
- await db
- .update(previewDeployments)
- .set({
- domainId: newDomain.domainId,
- })
- .where(
- eq(
- previewDeployments.previewDeploymentId,
- previewDeployment.previewDeploymentId,
- ),
- );
+ await manageWebServerDomain(application, newDomain);
+ routeCreated = true;
+
+ await db
+ .update(previewDeployments)
+ .set({
+ domainId: newDomain.domainId,
+ })
+ .where(
+ eq(
+ previewDeployments.previewDeploymentId,
+ previewDeployment.previewDeploymentId,
+ ),
+ );
+ } catch (error) {
+ if (routeCreated && newDomain) {
+ await removeWebServerDomain(application, newDomain.uniqueConfigKey).catch(
+ () => undefined,
+ );
+ }
+ if (newDomain) {
+ await removeDomainById(newDomain.domainId).catch(() => undefined);
+ }
+ await db
+ .delete(previewDeployments)
+ .where(
+ eq(
+ previewDeployments.previewDeploymentId,
+ previewDeployment.previewDeploymentId,
+ ),
+ )
+ .returning()
+ .catch(() => undefined);
+ await octokit.rest.issues
+ .deleteComment({
+ owner: application?.owner || "",
+ repo: application?.repository || "",
+ comment_id: issue.data.id,
+ })
+ .catch(() => undefined);
+ throw error;
+ }
return previewDeployment;
};
diff --git a/packages/server/src/services/settings.ts b/packages/server/src/services/settings.ts
index ecfb7f6de3..b358be8ee6 100644
--- a/packages/server/src/services/settings.ts
+++ b/packages/server/src/services/settings.ts
@@ -7,13 +7,21 @@ import {
import { and, eq } from "drizzle-orm";
import semver from "semver";
+import { quote } from "shell-quote";
import { db } from "../db";
import { compose } from "../db/schema";
+import {
+ type CaddyOptions,
+ initializeCaddyService,
+ initializeStandaloneCaddy,
+} from "../setup/caddy-setup";
import {
initializeStandaloneTraefik,
initializeTraefikService,
type TraefikOptions,
} from "../setup/traefik-setup";
+import type { CaddyMigrationResourceSnapshot } from "../utils/caddy/migration/types";
+import type { WebServerProvider } from "../utils/web-server/providers";
export interface IUpdateData {
latestVersion: string | null;
updateAvailable: boolean;
@@ -311,6 +319,304 @@ export const reloadDockerResource = async (
}
};
+const runDockerResourceCommand = async (command: string, serverId?: string) => {
+ if (serverId) {
+ return execAsyncRemote(serverId, command);
+ }
+ return execAsync(command);
+};
+
+const readDockerResourceJson = async >(
+ command: string,
+ serverId?: string,
+): Promise => {
+ const { stdout } = await runDockerResourceCommand(command, serverId);
+ const trimmed = stdout.trim();
+ return trimmed ? (JSON.parse(trimmed) as T) : undefined;
+};
+
+const asRecord = (value: unknown): Record | undefined =>
+ value && typeof value === "object"
+ ? (value as Record)
+ : undefined;
+
+const asStringRecord = (value: unknown): Record | undefined => {
+ const record = asRecord(value);
+ if (!record) return undefined;
+ return Object.fromEntries(
+ Object.entries(record).filter(
+ (entry): entry is [string, string] => typeof entry[1] === "string",
+ ),
+ );
+};
+
+const asRecordArray = (
+ value: unknown,
+): Array> | undefined =>
+ Array.isArray(value)
+ ? value.filter(
+ (item): item is Record =>
+ !!item && typeof item === "object",
+ )
+ : undefined;
+
+const asStringArray = (value: unknown): string[] | undefined =>
+ Array.isArray(value)
+ ? value.filter((item): item is string => typeof item === "string")
+ : undefined;
+
+const readDockerResourceImage = async (
+ resourceName: string,
+ resourceType: "service" | "standalone",
+ serverId?: string,
+) => {
+ const command =
+ resourceType === "service"
+ ? `docker service inspect ${resourceName} --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}'`
+ : `docker container inspect ${resourceName} --format '{{.Config.Image}}'`;
+ const { stdout } = await runDockerResourceCommand(command, serverId);
+ return stdout.trim() || undefined;
+};
+
+export const getDockerResourceSnapshot = async (
+ resourceName: string,
+ serverId?: string,
+): Promise => {
+ const resourceType = await getDockerResourceType(resourceName, serverId);
+ if (resourceType === "unknown") {
+ return { resourceName, resourceType, running: false };
+ }
+
+ if (resourceType === "service") {
+ const spec =
+ (await readDockerResourceJson>(
+ `docker service inspect ${resourceName} --format '{{json .Spec}}'`,
+ serverId,
+ ).catch(() => undefined)) ?? {};
+ const mode = asRecord(spec.Mode);
+ const replicated = asRecord(mode?.Replicated);
+ const replicas = Number(replicated?.Replicas ?? 1);
+ const taskTemplate = asRecord(spec.TaskTemplate);
+ const containerSpec = asRecord(taskTemplate?.ContainerSpec);
+ const endpointSpec = asRecord(spec.EndpointSpec);
+ return {
+ resourceName,
+ resourceType,
+ replicas,
+ running: replicas > 0,
+ env: await readEnvironmentVariables(resourceName, serverId).catch(
+ () => undefined,
+ ),
+ additionalPorts: await readPorts(resourceName, serverId).catch(() => []),
+ image: await readDockerResourceImage(
+ resourceName,
+ resourceType,
+ serverId,
+ ).catch(() => undefined),
+ mounts: asRecordArray(containerSpec?.Mounts),
+ networks: asRecordArray(taskTemplate?.Networks),
+ labels: asStringRecord(spec.Labels),
+ containerLabels: asStringRecord(containerSpec?.Labels),
+ placement: asRecord(taskTemplate?.Placement),
+ endpointPorts: asRecordArray(endpointSpec?.Ports),
+ };
+ }
+
+ const inspect =
+ (await readDockerResourceJson>(
+ `docker container inspect ${resourceName} --format '{{json .}}'`,
+ serverId,
+ ).catch(() => undefined)) ?? {};
+ const state = asRecord(inspect.State);
+ const hostConfig = asRecord(inspect.HostConfig);
+ const networkSettings = asRecord(inspect.NetworkSettings);
+ const networksRecord = asRecord(networkSettings?.Networks);
+ const config = asRecord(inspect.Config);
+ return {
+ resourceName,
+ resourceType,
+ running: state?.Running === true,
+ env: await readEnvironmentVariables(resourceName, serverId).catch(
+ () => undefined,
+ ),
+ additionalPorts: await readPorts(resourceName, serverId).catch(() => []),
+ image: await readDockerResourceImage(
+ resourceName,
+ resourceType,
+ serverId,
+ ).catch(() => undefined),
+ binds: asStringArray(hostConfig?.Binds),
+ networks: networksRecord ? Object.keys(networksRecord) : undefined,
+ labels: asStringRecord(config?.Labels),
+ restartPolicy: asRecord(hostConfig?.RestartPolicy),
+ };
+};
+
+export const stopDockerResource = async (
+ resourceName: string,
+ serverId?: string,
+) => {
+ const resourceType = await getDockerResourceType(resourceName, serverId);
+ if (resourceType === "service") {
+ await runDockerResourceCommand(
+ `docker service scale ${resourceName}=0`,
+ serverId,
+ );
+ return;
+ }
+ if (resourceType === "standalone") {
+ await runDockerResourceCommand(`docker stop ${resourceName}`, serverId);
+ }
+};
+
+export const startDockerResourceFromSnapshot = async (
+ snapshot: CaddyMigrationResourceSnapshot,
+ serverId?: string,
+) => {
+ if (!snapshot.running) {
+ return;
+ }
+ if (snapshot.resourceType === "service") {
+ await runDockerResourceCommand(
+ `docker service scale ${snapshot.resourceName}=${snapshot.replicas ?? 1}`,
+ serverId,
+ );
+ return;
+ }
+ if (snapshot.resourceType === "standalone") {
+ await runDockerResourceCommand(
+ `docker start ${snapshot.resourceName}`,
+ serverId,
+ );
+ }
+};
+
+const envStringToArray = (env?: string) =>
+ env && env !== "[redacted]" ? env.split("\n").filter(Boolean) : undefined;
+
+export const ensureTraefikRunningFromSnapshot = async (
+ snapshot?: CaddyMigrationResourceSnapshot,
+ serverId?: string,
+) => {
+ let restartError: unknown;
+ if (snapshot?.running) {
+ try {
+ await startDockerResourceFromSnapshot(snapshot, serverId);
+ await waitForDockerResourceRunning("dokploy-traefik", serverId, {
+ retries: 10,
+ intervalMs: 1000,
+ });
+ return;
+ } catch (error) {
+ restartError = error;
+ }
+ }
+
+ const recreateInput: TraefikOptions = {
+ env: envStringToArray(snapshot?.env),
+ additionalPorts: snapshot?.additionalPorts ?? [],
+ image: snapshot?.image,
+ serverId,
+ binds: snapshot?.binds,
+ networks: snapshot?.networks?.filter(
+ (item): item is string => typeof item === "string",
+ ),
+ labels: snapshot?.labels,
+ restartPolicy: snapshot?.restartPolicy as TraefikOptions["restartPolicy"],
+ replicas: snapshot?.replicas,
+ serviceMounts: snapshot?.mounts as TraefikOptions["serviceMounts"],
+ serviceNetworks: snapshot?.networks?.filter(
+ (item): item is Record => typeof item === "object",
+ ) as TraefikOptions["serviceNetworks"],
+ servicePlacement: snapshot?.placement as TraefikOptions["servicePlacement"],
+ serviceLabels: snapshot?.labels,
+ serviceContainerLabels: snapshot?.containerLabels,
+ serviceEndpointPorts:
+ snapshot?.endpointPorts as TraefikOptions["serviceEndpointPorts"],
+ };
+
+ const runExactRecreate = async () => {
+ if (snapshot?.resourceType === "service") {
+ await initializeTraefikService(recreateInput);
+ await reconnectServicesToTraefik(serverId);
+ return;
+ }
+ if (snapshot?.resourceType === "standalone") {
+ await initializeStandaloneTraefik(recreateInput);
+ await reconnectServicesToTraefik(serverId);
+ return;
+ }
+ await writeTraefikSetup(recreateInput);
+ };
+
+ try {
+ try {
+ await runExactRecreate();
+ } catch (exactError) {
+ if (snapshot?.resourceType === "unknown") {
+ throw exactError;
+ }
+ try {
+ await writeTraefikSetup(recreateInput);
+ } catch (fallbackError) {
+ const exactMessage =
+ exactError instanceof Error
+ ? exactError.message
+ : "exact Traefik recreation failed";
+ const fallbackMessage =
+ fallbackError instanceof Error
+ ? fallbackError.message
+ : "generic Traefik recreation failed";
+ throw new Error(
+ `Exact Traefik recreation failed: ${exactMessage}; generic recreation failed: ${fallbackMessage}`,
+ );
+ }
+ }
+ await waitForDockerResourceRunning("dokploy-traefik", serverId, {
+ retries: 20,
+ intervalMs: 1000,
+ });
+ } catch (error) {
+ const recreateMessage =
+ error instanceof Error ? error.message : "Traefik recreation failed";
+ if (restartError) {
+ const restartMessage =
+ restartError instanceof Error
+ ? restartError.message
+ : "Traefik restart failed";
+ throw new Error(
+ `Unable to restore Traefik. Restart failed: ${restartMessage}; recreate failed: ${recreateMessage}`,
+ );
+ }
+ throw error;
+ }
+};
+
+export const waitForDockerResourceRunning = async (
+ resourceName: string,
+ serverId?: string,
+ options: { retries?: number; intervalMs?: number } = {},
+) => {
+ const retries = options.retries ?? 20;
+ const intervalMs = options.intervalMs ?? 1000;
+ for (let attempt = 0; attempt < retries; attempt++) {
+ const snapshot = await getDockerResourceSnapshot(resourceName, serverId);
+ if (snapshot.resourceType === "service" && snapshot.running) {
+ const { stdout } = await runDockerResourceCommand(
+ `docker service ps ${resourceName} --filter desired-state=running --format '{{.CurrentState}}' | grep -m1 '^Running' || true`,
+ serverId,
+ );
+ if (stdout.trim()) {
+ return snapshot;
+ }
+ } else if (snapshot.running) {
+ return snapshot;
+ }
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
+ }
+ throw new Error(`Docker resource ${resourceName} did not become running`);
+};
+
export const readEnvironmentVariables = async (
resourceName: string,
serverId?: string,
@@ -414,7 +720,7 @@ export const checkPortInUse = async (
): Promise<{ isInUse: boolean; conflictingContainer?: string }> => {
try {
// Check if port is in use by a Docker container
- const dockerCommand = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`;
+ const dockerCommand = `docker ps -a --format '{{.Names}}' | grep -Ev '^(dokploy-traefik|dokploy-caddy)$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`;
const { stdout: dockerOut } = serverId
? await execAsyncRemote(serverId, dockerCommand)
: await execAsync(dockerCommand);
@@ -451,33 +757,83 @@ export const checkPortInUse = async (
}
};
-export const writeTraefikSetup = async (input: TraefikOptions) => {
+export const writeCaddySetup = async (input: CaddyOptions) => {
const resourceType = await getDockerResourceType(
- "dokploy-traefik",
+ "dokploy-caddy",
input.serverId,
);
-
- if (resourceType === "service") {
- await initializeTraefikService({
+ const traefikResourceType =
+ resourceType === "unknown"
+ ? await getDockerResourceType("dokploy-traefik", input.serverId)
+ : resourceType;
+ const fallbackResourceType =
+ traefikResourceType === "unknown"
+ ? await getDockerResourceType("dokploy", input.serverId)
+ : traefikResourceType;
+ const setupType =
+ fallbackResourceType === "service" ? "service" : "standalone";
+
+ if (setupType === "service") {
+ await initializeCaddyService({
env: input.env,
additionalPorts: input.additionalPorts,
serverId: input.serverId,
+ letsEncryptEmail: input.letsEncryptEmail,
+ trustedProxies: input.trustedProxies,
+ accessLogs: input.accessLogs,
});
- await reconnectServicesToTraefik(input.serverId);
- } else if (resourceType === "standalone") {
- await initializeStandaloneTraefik({
+ await reconnectServicesToWebServer("dokploy-caddy", input.serverId);
+ } else {
+ await initializeStandaloneCaddy({
env: input.env,
additionalPorts: input.additionalPorts,
serverId: input.serverId,
+ letsEncryptEmail: input.letsEncryptEmail,
+ trustedProxies: input.trustedProxies,
+ accessLogs: input.accessLogs,
});
+ await reconnectServicesToWebServer("dokploy-caddy", input.serverId);
+ }
+};
+
+export const writeWebServerSetup = async (
+ provider: WebServerProvider,
+ input: TraefikOptions | CaddyOptions,
+) => {
+ if (provider === "caddy") {
+ return writeCaddySetup(input as CaddyOptions);
+ }
+
+ return writeTraefikSetup(input as TraefikOptions);
+};
+
+export const writeTraefikSetup = async (input: TraefikOptions) => {
+ const resourceType = await getDockerResourceType(
+ "dokploy-traefik",
+ input.serverId,
+ );
+ const fallbackResourceType =
+ resourceType === "unknown"
+ ? await getDockerResourceType("dokploy", input.serverId)
+ : resourceType;
+ const setupType =
+ fallbackResourceType === "service" ? "service" : "standalone";
+
+ if (setupType === "service") {
+ await initializeTraefikService(input);
await reconnectServicesToTraefik(input.serverId);
} else {
- throw new Error("Traefik resource type not found");
+ await initializeStandaloneTraefik(input);
+
+ await reconnectServicesToTraefik(input.serverId);
}
};
-export const reconnectServicesToTraefik = async (serverId?: string) => {
+export const reconnectServicesToWebServer = async (
+ resourceName: "dokploy-traefik" | "dokploy-caddy",
+ serverId?: string,
+) => {
const composeResult = await db.query.compose.findMany({
where: and(
...(serverId ? [eq(compose.serverId, serverId)] : []),
@@ -491,7 +847,13 @@ export const reconnectServicesToTraefik = async (serverId?: string) => {
let commands = "";
for (const compose of composeResult) {
- commands += `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n`;
+ const networkName = quote([compose.appName]);
+ const quotedResourceName = quote([resourceName]);
+ commands += `if docker service inspect ${quotedResourceName} >/dev/null 2>&1; then\n`;
+ commands += ` docker service inspect ${quotedResourceName} --format '{{range .Spec.TaskTemplate.Networks}}{{println .Target}}{{end}}' | grep -qx ${networkName} || docker service update --network-add ${networkName} ${quotedResourceName} >/dev/null\n`;
+ commands += "else\n";
+ commands += ` docker network connect ${networkName} $(docker ps --filter "name=${resourceName}" -q) >/dev/null 2>&1 || true\n`;
+ commands += "fi\n";
}
if (serverId) {
@@ -500,3 +862,7 @@ export const reconnectServicesToTraefik = async (serverId?: string) => {
await execAsync(commands);
}
};
+
+export const reconnectServicesToTraefik = async (serverId?: string) => {
+ await reconnectServicesToWebServer("dokploy-traefik", serverId);
+};
diff --git a/packages/server/src/services/user.ts b/packages/server/src/services/user.ts
index 2437517052..8ae5529454 100644
--- a/packages/server/src/services/user.ts
+++ b/packages/server/src/services/user.ts
@@ -199,6 +199,13 @@ export const canAccessToTraefikFiles = async (
return canAccessToTraefikFiles;
};
+export const canAccessToWebServerFiles = async (
+ userId: string,
+ organizationId: string,
+) => {
+ return canAccessToTraefikFiles(userId, organizationId);
+};
+
export const checkServiceAccess = async (
userId: string,
serviceId: string,
diff --git a/packages/server/src/services/web-server-settings.ts b/packages/server/src/services/web-server-settings.ts
index 289d119c99..4cd7252c2a 100644
--- a/packages/server/src/services/web-server-settings.ts
+++ b/packages/server/src/services/web-server-settings.ts
@@ -1,7 +1,81 @@
import { db } from "@dokploy/server/db";
-import { webServerSettings } from "@dokploy/server/db/schema";
+import { server, webServerSettings } from "@dokploy/server/db/schema";
+import { assertValidCaddyTrustedProxyConfig } from "@dokploy/server/utils/caddy/config";
+import type {
+ CaddyAccessLogConfig,
+ CaddyTrustedProxyConfig,
+ CaddyTrustedProxySettings,
+} from "@dokploy/server/utils/caddy/types";
+import {
+ normalizeWebServerProvider,
+ type WebServerProvider,
+} from "@dokploy/server/utils/web-server/providers";
import { eq } from "drizzle-orm";
+const normalizeStringList = (values: string[] | null | undefined) =>
+ Array.from(
+ new Set(
+ (values ?? [])
+ .map((value) => value.trim())
+ .filter((value) => value.length > 0),
+ ),
+ );
+
+export const normalizeCaddyTrustedProxySettings = (
+ settings: CaddyTrustedProxySettings | null | undefined,
+): CaddyTrustedProxySettings | null => {
+ if (!settings || settings.mode === "disabled") {
+ return null;
+ }
+
+ const normalized: CaddyTrustedProxySettings =
+ settings.mode === "cloudflare"
+ ? {
+ mode: "cloudflare",
+ clientIpHeaders: normalizeStringList(settings.clientIpHeaders),
+ strict: settings.strict !== false,
+ }
+ : {
+ mode: "static",
+ ranges: normalizeStringList(settings.ranges),
+ clientIpHeaders: normalizeStringList(settings.clientIpHeaders),
+ strict: settings.strict !== false,
+ };
+
+ assertValidCaddyTrustedProxyConfig(
+ caddyTrustedProxySettingsToConfig(normalized),
+ );
+
+ return normalized;
+};
+
+export const caddyTrustedProxySettingsToConfig = (
+ settings: CaddyTrustedProxySettings | null | undefined,
+): CaddyTrustedProxyConfig | null => {
+ if (!settings || settings.mode === "disabled") {
+ return null;
+ }
+
+ if (settings.mode === "cloudflare") {
+ return {
+ source: "cloudflare",
+ clientIpHeaders: settings.clientIpHeaders?.length
+ ? settings.clientIpHeaders
+ : undefined,
+ strict: settings.strict,
+ };
+ }
+
+ return {
+ source: "static",
+ ranges: settings.ranges ?? [],
+ clientIpHeaders: settings.clientIpHeaders?.length
+ ? settings.clientIpHeaders
+ : undefined,
+ strict: settings.strict,
+ };
+};
+
/**
* Get the web server settings (singleton - only one row should exist)
*/
@@ -42,3 +116,138 @@ export const updateWebServerSettings = async (
return updated;
};
+
+export const getLocalWebServerProvider =
+ async (): Promise => {
+ const settings = await getWebServerSettings();
+ return normalizeWebServerProvider(settings?.webServerProvider);
+ };
+
+export const updateLocalWebServerProvider = async (
+ provider: WebServerProvider,
+) => {
+ return updateWebServerSettings({ webServerProvider: provider });
+};
+
+export const getRemoteWebServerProvider = async (
+ serverId: string,
+): Promise => {
+ const remoteServer = await db.query.server.findFirst({
+ where: eq(server.serverId, serverId),
+ columns: {
+ webServerProvider: true,
+ },
+ });
+
+ if (!remoteServer) {
+ throw new Error(`Server not found: ${serverId}`);
+ }
+
+ return normalizeWebServerProvider(remoteServer.webServerProvider);
+};
+
+export const updateRemoteWebServerProvider = async (
+ serverId: string,
+ provider: WebServerProvider,
+) => {
+ const [updated] = await db
+ .update(server)
+ .set({ webServerProvider: provider })
+ .where(eq(server.serverId, serverId))
+ .returning();
+
+ return updated;
+};
+
+export const getCaddyTrustedProxySettings = async (
+ serverId?: string | null,
+): Promise => {
+ if (serverId) {
+ const remoteServer = await db.query.server.findFirst({
+ where: eq(server.serverId, serverId),
+ columns: {
+ caddyTrustedProxyConfig: true,
+ },
+ });
+
+ if (!remoteServer) {
+ throw new Error(`Server not found: ${serverId}`);
+ }
+
+ return normalizeCaddyTrustedProxySettings(
+ remoteServer.caddyTrustedProxyConfig,
+ );
+ }
+
+ const settings = await getWebServerSettings();
+ return normalizeCaddyTrustedProxySettings(settings?.caddyTrustedProxyConfig);
+};
+
+export const getCaddyTrustedProxyConfig = async (
+ serverId?: string | null,
+): Promise => {
+ return caddyTrustedProxySettingsToConfig(
+ await getCaddyTrustedProxySettings(serverId),
+ );
+};
+
+export const getCaddyCompileSettings = async (
+ serverId?: string | null,
+): Promise<{
+ letsEncryptEmail?: string | null;
+ trustedProxies?: CaddyTrustedProxyConfig | null;
+ accessLogs?: CaddyAccessLogConfig | null;
+}> => {
+ if (serverId) {
+ return {
+ trustedProxies: await getCaddyTrustedProxyConfig(serverId),
+ };
+ }
+
+ const settings = await getWebServerSettings();
+ return localWebServerSettingsToCaddyCompileSettings(settings);
+};
+
+export const localWebServerSettingsToCaddyCompileSettings = (
+ settings: typeof webServerSettings.$inferSelect | null | undefined,
+): {
+ letsEncryptEmail?: string | null;
+ trustedProxies?: CaddyTrustedProxyConfig | null;
+ accessLogs?: CaddyAccessLogConfig | null;
+} => {
+ return {
+ letsEncryptEmail: settings?.letsEncryptEmail,
+ trustedProxies: caddyTrustedProxySettingsToConfig(
+ normalizeCaddyTrustedProxySettings(settings?.caddyTrustedProxyConfig),
+ ),
+ accessLogs: settings?.requestLogsEnabled ? { enabled: true } : null,
+ };
+};
+
+export const updateCaddyTrustedProxySettings = async (
+ settings: CaddyTrustedProxySettings | null,
+ serverId?: string | null,
+) => {
+ const normalized = normalizeCaddyTrustedProxySettings(settings);
+ if (serverId) {
+ const [updated] = await db
+ .update(server)
+ .set({ caddyTrustedProxyConfig: normalized })
+ .where(eq(server.serverId, serverId))
+ .returning();
+
+ return updated;
+ }
+
+ return updateWebServerSettings({ caddyTrustedProxyConfig: normalized });
+};
+
+export const resolveWebServerProvider = async (
+ serverId?: string | null,
+): Promise => {
+ if (serverId) {
+ return getRemoteWebServerProvider(serverId);
+ }
+
+ return getLocalWebServerProvider();
+};
diff --git a/packages/server/src/setup/caddy-setup.ts b/packages/server/src/setup/caddy-setup.ts
new file mode 100644
index 0000000000..611646e3ba
--- /dev/null
+++ b/packages/server/src/setup/caddy-setup.ts
@@ -0,0 +1,332 @@
+import type { ContainerCreateOptions, CreateServiceOptions } from "dockerode";
+import { paths } from "../constants";
+import {
+ CADDY_METRICS_PORT,
+ ensureDefaultCaddyConfig,
+ reloadCaddyAfterValidation,
+ validateCaddyConfigWithContainer,
+} from "../utils/caddy/config";
+import type {
+ CaddyAccessLogConfig,
+ CaddyTrustedProxyConfig,
+} from "../utils/caddy/types";
+import { getRemoteDocker } from "../utils/servers/remote-docker";
+
+export const CADDY_SSL_PORT =
+ Number.parseInt(process.env.CADDY_SSL_PORT ?? "", 10) || 443;
+export const CADDY_PORT =
+ Number.parseInt(process.env.CADDY_PORT ?? "", 10) || 80;
+export const CADDY_HTTP3_PORT =
+ Number.parseInt(process.env.CADDY_HTTP3_PORT ?? "", 10) || 443;
+export const CADDY_ADMIN_PORT = 2019;
+const CADDY_RESERVED_TCP_PORTS = new Set([
+ CADDY_ADMIN_PORT,
+ CADDY_METRICS_PORT,
+]);
+const CADDY_TRAEFIK_CARRY_OVER_TCP_TARGET_PORTS = new Set([
+ 8080,
+ 8082,
+ ...CADDY_RESERVED_TCP_PORTS,
+]);
+export const CADDY_VERSION = process.env.CADDY_VERSION || "2.11.4";
+
+export interface CaddyOptions {
+ env?: string[];
+ serverId?: string;
+ additionalPorts?: {
+ targetPort: number;
+ publishedPort: number;
+ protocol?: string;
+ }[];
+ letsEncryptEmail?: string | null;
+ trustedProxies?: CaddyTrustedProxyConfig | null;
+ accessLogs?: CaddyAccessLogConfig | null;
+}
+
+type CaddyAdditionalPort = NonNullable[number];
+
+const usesTcp = (port: CaddyAdditionalPort) =>
+ (port.protocol ?? "tcp") === "tcp";
+
+export const isCaddyAdminAdditionalPort = (port: CaddyAdditionalPort) =>
+ usesTcp(port) &&
+ (port.targetPort === CADDY_ADMIN_PORT ||
+ port.publishedPort === CADDY_ADMIN_PORT);
+
+export const isCaddyAdminPort = isCaddyAdminAdditionalPort;
+export const isCaddyReservedAdditionalPort = (port: CaddyAdditionalPort) =>
+ usesTcp(port) &&
+ (CADDY_RESERVED_TCP_PORTS.has(port.targetPort) ||
+ CADDY_RESERVED_TCP_PORTS.has(port.publishedPort));
+
+export const isTraefikCarryOverPortForCaddyMigration = (
+ port: CaddyAdditionalPort,
+) =>
+ usesTcp(port) &&
+ (CADDY_TRAEFIK_CARRY_OVER_TCP_TARGET_PORTS.has(port.targetPort) ||
+ CADDY_RESERVED_TCP_PORTS.has(port.publishedPort));
+
+export const filterCaddyAdditionalPorts = (
+ additionalPorts: CaddyOptions["additionalPorts"] = [],
+) => additionalPorts.filter((port) => !isCaddyReservedAdditionalPort(port));
+
+export const filterTraefikCarryOverPortsForCaddyMigration = (
+ additionalPorts: CaddyOptions["additionalPorts"] = [],
+) =>
+ additionalPorts.filter(
+ (port) => !isTraefikCarryOverPortForCaddyMigration(port),
+ );
+
+const getCaddyMounts = (serverId?: string) => {
+ const { CADDY_CONFIG_DIR_PATH, CADDY_DATA_PATH, MAIN_CADDY_PATH } = paths(
+ !!serverId,
+ );
+ const { CERTIFICATES_PATH } = paths(!!serverId);
+
+ return {
+ MAIN_CADDY_PATH,
+ binds: [
+ `${MAIN_CADDY_PATH}:/etc/caddy`,
+ `${CADDY_DATA_PATH}:/data`,
+ `${CADDY_CONFIG_DIR_PATH}:/config`,
+ `${CERTIFICATES_PATH}:${CERTIFICATES_PATH}:ro`,
+ ],
+ serviceMounts: [
+ {
+ Type: "bind" as const,
+ Source: MAIN_CADDY_PATH,
+ Target: "/etc/caddy",
+ },
+ {
+ Type: "bind" as const,
+ Source: CADDY_DATA_PATH,
+ Target: "/data",
+ },
+ {
+ Type: "bind" as const,
+ Source: CADDY_CONFIG_DIR_PATH,
+ Target: "/config",
+ },
+ {
+ Type: "bind" as const,
+ Source: CERTIFICATES_PATH,
+ Target: CERTIFICATES_PATH,
+ ReadOnly: true,
+ },
+ ],
+ };
+};
+
+const buildStandalonePorts = (
+ additionalPorts: CaddyOptions["additionalPorts"],
+) => {
+ const exposedPorts: Record = {
+ [`${CADDY_PORT}/tcp`]: {},
+ [`${CADDY_SSL_PORT}/tcp`]: {},
+ [`${CADDY_HTTP3_PORT}/udp`]: {},
+ };
+
+ const portBindings: Record> = {
+ [`${CADDY_PORT}/tcp`]: [{ HostPort: CADDY_PORT.toString() }],
+ [`${CADDY_SSL_PORT}/tcp`]: [{ HostPort: CADDY_SSL_PORT.toString() }],
+ [`${CADDY_HTTP3_PORT}/udp`]: [{ HostPort: CADDY_HTTP3_PORT.toString() }],
+ };
+
+ for (const port of filterCaddyAdditionalPorts(additionalPorts)) {
+ const portKey = `${port.targetPort}/${port.protocol ?? "tcp"}`;
+ exposedPorts[portKey] = {};
+ portBindings[portKey] = [{ HostPort: port.publishedPort.toString() }];
+ }
+
+ return { exposedPorts, portBindings };
+};
+
+const pullImage = async (
+ docker: Awaited>,
+ imageName: string,
+) => {
+ await new Promise((resolve, reject) => {
+ docker.pull(
+ imageName,
+ (error: Error | null, stream?: NodeJS.ReadableStream) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+ if (!stream) {
+ resolve();
+ return;
+ }
+ docker.modem.followProgress(stream, (progressError?: Error | null) => {
+ if (progressError) {
+ reject(progressError);
+ return;
+ }
+ resolve();
+ });
+ },
+ );
+ });
+};
+
+const buildServicePorts = (
+ additionalPorts: CaddyOptions["additionalPorts"],
+) => [
+ {
+ TargetPort: 443,
+ PublishedPort: CADDY_SSL_PORT,
+ PublishMode: "host" as const,
+ Protocol: "tcp" as const,
+ },
+ {
+ TargetPort: 443,
+ PublishedPort: CADDY_HTTP3_PORT,
+ PublishMode: "host" as const,
+ Protocol: "udp" as const,
+ },
+ {
+ TargetPort: 80,
+ PublishedPort: CADDY_PORT,
+ PublishMode: "host" as const,
+ Protocol: "tcp" as const,
+ },
+ ...filterCaddyAdditionalPorts(additionalPorts).map((port) => ({
+ TargetPort: port.targetPort,
+ PublishedPort: port.publishedPort,
+ Protocol: port.protocol as "tcp" | "udp" | "sctp" | undefined,
+ PublishMode: "host" as const,
+ })),
+];
+
+export const initializeStandaloneCaddy = async ({
+ env,
+ serverId,
+ additionalPorts = [],
+ letsEncryptEmail,
+ trustedProxies,
+ accessLogs,
+}: CaddyOptions = {}) => {
+ await ensureDefaultCaddyConfig({
+ serverId,
+ letsEncryptEmail,
+ trustedProxies,
+ accessLogs,
+ });
+ const imageName = `caddy:${CADDY_VERSION}`;
+ const containerName = "dokploy-caddy";
+ const { binds } = getCaddyMounts(serverId);
+ const { exposedPorts, portBindings } = buildStandalonePorts(additionalPorts);
+
+ const settings: ContainerCreateOptions = {
+ name: containerName,
+ Image: imageName,
+ Cmd: ["caddy", "run", "--config", "/etc/caddy/caddy.json"],
+ NetworkingConfig: {
+ EndpointsConfig: {
+ "dokploy-network": {},
+ },
+ },
+ ExposedPorts: exposedPorts,
+ HostConfig: {
+ RestartPolicy: {
+ Name: "always",
+ },
+ Binds: binds,
+ PortBindings: portBindings,
+ },
+ Env: env,
+ };
+
+ const docker = await getRemoteDocker(serverId);
+ try {
+ await pullImage(docker, imageName);
+ console.log("Caddy Image Pulled ✅");
+ } catch (error) {
+ console.log("Caddy Image Not Found: Pulling ", error);
+ }
+ try {
+ const container = docker.getContainer(containerName);
+ await container.remove({ force: true });
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+ } catch {}
+
+ await docker.createContainer(settings);
+ const newContainer = docker.getContainer(containerName);
+ await newContainer.start();
+ await validateCaddyConfigWithContainer(serverId);
+ console.log("Caddy Started ✅");
+};
+
+export const initializeCaddyService = async ({
+ env,
+ additionalPorts = [],
+ serverId,
+ letsEncryptEmail,
+ trustedProxies,
+ accessLogs,
+}: CaddyOptions) => {
+ await ensureDefaultCaddyConfig({
+ serverId,
+ letsEncryptEmail,
+ trustedProxies,
+ accessLogs,
+ });
+ const imageName = `caddy:${CADDY_VERSION}`;
+ const appName = "dokploy-caddy";
+ const { serviceMounts } = getCaddyMounts(serverId);
+
+ const settings: CreateServiceOptions = {
+ Name: appName,
+ TaskTemplate: {
+ ContainerSpec: {
+ Image: imageName,
+ Command: ["caddy", "run", "--config", "/etc/caddy/caddy.json"],
+ Env: env,
+ Mounts: serviceMounts,
+ },
+ Networks: [{ Target: "dokploy-network" }],
+ Placement: {
+ Constraints: ["node.role==manager"],
+ },
+ },
+ Mode: {
+ Replicated: {
+ Replicas: 1,
+ },
+ },
+ EndpointSpec: {
+ Ports: buildServicePorts(additionalPorts),
+ },
+ };
+ const docker = await getRemoteDocker(serverId);
+ try {
+ await pullImage(docker, imageName);
+ console.log("Caddy Image Pulled ✅");
+ } catch (error) {
+ console.log("Caddy Image Not Found: Pulling ", error);
+ }
+ try {
+ const service = docker.getService(appName);
+ const inspect = await service.inspect();
+
+ await service.update({
+ version: inspect.Version.Index,
+ ...settings,
+ TaskTemplate: {
+ ...settings.TaskTemplate,
+ ForceUpdate: (inspect.Spec.TaskTemplate.ForceUpdate ?? 0) + 1,
+ },
+ });
+ console.log("Caddy Updated ✅");
+ } catch {
+ await docker.createService(settings);
+ console.log("Caddy Started ✅");
+ }
+};
+
+export const createDefaultCaddyConfig = async (options: CaddyOptions = {}) => {
+ await ensureDefaultCaddyConfig(options);
+};
+
+export const validateCaddyConfig = validateCaddyConfigWithContainer;
+export const reloadCaddy = reloadCaddyAfterValidation;
diff --git a/packages/server/src/setup/traefik-setup.ts b/packages/server/src/setup/traefik-setup.ts
index 9fcebe5408..5212aa8f14 100644
--- a/packages/server/src/setup/traefik-setup.ts
+++ b/packages/server/src/setup/traefik-setup.ts
@@ -22,23 +22,57 @@ export const TRAEFIK_HTTP3_PORT =
Number.parseInt(process.env.TRAEFIK_HTTP3_PORT!, 10) || 443;
export const TRAEFIK_VERSION = process.env.TRAEFIK_VERSION || "3.6.7";
+type TraefikServiceTaskTemplate = NonNullable<
+ CreateServiceOptions["TaskTemplate"]
+>;
+type TraefikServiceContainerTaskTemplate = Extract<
+ TraefikServiceTaskTemplate,
+ { ContainerSpec?: unknown }
+>;
+type TraefikServiceContainerSpec = NonNullable<
+ TraefikServiceContainerTaskTemplate["ContainerSpec"]
+>;
+type TraefikServiceEndpointSpec = NonNullable<
+ CreateServiceOptions["EndpointSpec"]
+>;
+type TraefikStandaloneHostConfig = NonNullable<
+ ContainerCreateOptions["HostConfig"]
+>;
+
export interface TraefikOptions {
env?: string[];
serverId?: string;
+ image?: string;
additionalPorts?: {
targetPort: number;
publishedPort: number;
protocol?: string;
}[];
+ binds?: string[];
+ networks?: string[];
+ labels?: Record;
+ restartPolicy?: TraefikStandaloneHostConfig["RestartPolicy"];
+ replicas?: number;
+ serviceMounts?: TraefikServiceContainerSpec["Mounts"];
+ serviceNetworks?: TraefikServiceTaskTemplate["Networks"];
+ servicePlacement?: TraefikServiceTaskTemplate["Placement"];
+ serviceLabels?: Record;
+ serviceContainerLabels?: Record;
+ serviceEndpointPorts?: TraefikServiceEndpointSpec["Ports"];
}
export const initializeStandaloneTraefik = async ({
env,
serverId,
+ image,
additionalPorts = [],
+ binds,
+ networks,
+ labels,
+ restartPolicy,
}: TraefikOptions = {}) => {
const { MAIN_TRAEFIK_PATH, DYNAMIC_TRAEFIK_PATH } = paths(!!serverId);
- const imageName = `traefik:v${TRAEFIK_VERSION}`;
+ const imageName = image ?? `traefik:v${TRAEFIK_VERSION}`;
const containerName = "dokploy-traefik";
const exposedPorts: Record = {
@@ -58,6 +92,12 @@ export const initializeStandaloneTraefik = async ({
const enableDashboard = additionalPorts.some(
(port) => port.targetPort === 8080,
);
+ const endpointNetworks = Object.fromEntries(
+ (networks?.length ? networks : ["dokploy-network"]).map((network) => [
+ network,
+ {},
+ ]),
+ );
if (enableDashboard) {
exposedPorts["8080/tcp"] = {};
@@ -74,16 +114,14 @@ export const initializeStandaloneTraefik = async ({
name: containerName,
Image: imageName,
NetworkingConfig: {
- EndpointsConfig: {
- "dokploy-network": {},
- },
+ EndpointsConfig: endpointNetworks,
},
ExposedPorts: exposedPorts,
HostConfig: {
- RestartPolicy: {
+ RestartPolicy: restartPolicy ?? {
Name: "always",
},
- Binds: [
+ Binds: binds ?? [
`${MAIN_TRAEFIK_PATH}/traefik.yml:/etc/traefik/traefik.yml`,
`${DYNAMIC_TRAEFIK_PATH}:/etc/dokploy/traefik/dynamic`,
"/var/run/docker.sock:/var/run/docker.sock",
@@ -91,6 +129,7 @@ export const initializeStandaloneTraefik = async ({
PortBindings: portBindings,
},
Env: env,
+ Labels: labels,
};
const docker = await getRemoteDocker(serverId);
@@ -119,20 +158,30 @@ export const initializeStandaloneTraefik = async ({
export const initializeTraefikService = async ({
env,
+ image,
additionalPorts = [],
serverId,
+ serviceMounts,
+ serviceNetworks,
+ servicePlacement,
+ serviceLabels,
+ serviceContainerLabels,
+ serviceEndpointPorts,
+ replicas,
}: TraefikOptions) => {
const { MAIN_TRAEFIK_PATH, DYNAMIC_TRAEFIK_PATH } = paths(!!serverId);
- const imageName = `traefik:v${TRAEFIK_VERSION}`;
+ const imageName = image ?? `traefik:v${TRAEFIK_VERSION}`;
const appName = "dokploy-traefik";
const settings: CreateServiceOptions = {
Name: appName,
+ Labels: serviceLabels,
TaskTemplate: {
ContainerSpec: {
Image: imageName,
Env: env,
- Mounts: [
+ Labels: serviceContainerLabels,
+ Mounts: serviceMounts ?? [
{
Type: "bind",
Source: `${MAIN_TRAEFIK_PATH}/traefik.yml`,
@@ -150,18 +199,18 @@ export const initializeTraefikService = async ({
},
],
},
- Networks: [{ Target: "dokploy-network" }],
- Placement: {
+ Networks: serviceNetworks ?? [{ Target: "dokploy-network" }],
+ Placement: servicePlacement ?? {
Constraints: ["node.role==manager"],
},
},
Mode: {
Replicated: {
- Replicas: 1,
+ Replicas: replicas ?? 1,
},
},
EndpointSpec: {
- Ports: [
+ Ports: serviceEndpointPorts ?? [
{
TargetPort: 443,
PublishedPort: TRAEFIK_SSL_PORT,
diff --git a/packages/server/src/utils/access-log/handler.ts b/packages/server/src/utils/access-log/handler.ts
index 0feae622cf..45a66e80f2 100644
--- a/packages/server/src/utils/access-log/handler.ts
+++ b/packages/server/src/utils/access-log/handler.ts
@@ -1,11 +1,13 @@
import fs from "node:fs";
import path from "node:path";
-import { paths } from "@dokploy/server/constants";
+import { ACCESS_LOG_RETAINED_LINES, paths } from "@dokploy/server/constants";
import {
getWebServerSettings,
+ resolveWebServerProvider,
updateWebServerSettings,
} from "@dokploy/server/services/web-server-settings";
import { scheduledJobs, scheduleJob } from "node-schedule";
+import { quote } from "shell-quote";
import { execAsync } from "../process/execAsync";
const LOG_CLEANUP_JOB_NAME = "access-log-cleanup";
@@ -14,41 +16,71 @@ export const startLogCleanup = async (
cronExpression = "0 0 * * *",
): Promise => {
try {
+ const validationJob = scheduleJob(
+ `${LOG_CLEANUP_JOB_NAME}-validation`,
+ cronExpression,
+ () => {},
+ );
+ if (!validationJob) {
+ return false;
+ }
+ validationJob.cancel();
+
const existingJob = scheduledJobs[LOG_CLEANUP_JOB_NAME];
if (existingJob) {
existingJob.cancel();
}
- scheduleJob(LOG_CLEANUP_JOB_NAME, cronExpression, async () => {
- try {
- const { DYNAMIC_TRAEFIK_PATH } = paths();
- const accessLogPath = path.join(DYNAMIC_TRAEFIK_PATH, "access.log");
+ const cleanupJob = scheduleJob(
+ LOG_CLEANUP_JOB_NAME,
+ cronExpression,
+ async () => {
+ try {
+ const provider = await resolveWebServerProvider();
+ const currentPaths = paths();
+ const accessLogPath =
+ provider === "caddy"
+ ? currentPaths.CADDY_ACCESS_LOG_PATH
+ : path.join(currentPaths.DYNAMIC_TRAEFIK_PATH, "access.log");
- if (!fs.existsSync(accessLogPath)) {
- console.error("Access log file does not exist");
- return;
- }
+ if (!fs.existsSync(accessLogPath)) {
+ console.error("Access log file does not exist");
+ return;
+ }
- await execAsync(
- `tail -n 1000 ${accessLogPath} > ${accessLogPath}.tmp && mv ${accessLogPath}.tmp ${accessLogPath}`,
- );
+ const quotedAccessLogPath = quote([accessLogPath]);
+ const quotedTempPath = quote([`${accessLogPath}.tmp`]);
+ if (provider === "caddy") {
+ await execAsync(
+ `tail -n ${ACCESS_LOG_RETAINED_LINES} ${quotedAccessLogPath} > ${quotedTempPath} && cat ${quotedTempPath} > ${quotedAccessLogPath} && rm ${quotedTempPath}`,
+ );
+ return;
+ }
- // Traefik can run as a standalone container ("dokploy-traefik") or a
- // swarm service task ("dokploy-traefik.1."), so resolve the
- // running container id dynamically instead of assuming the name.
- const { stdout: containerId } = await execAsync(
- 'docker ps -q --filter "name=dokploy-traefik" --filter "status=running" | head -n 1',
- );
- const traefikContainerId = containerId.trim();
- if (!traefikContainerId) {
- console.error("Traefik container not found, skipping log reopen");
- return;
+ await execAsync(
+ `tail -n ${ACCESS_LOG_RETAINED_LINES} ${quotedAccessLogPath} > ${quotedTempPath} && mv ${quotedTempPath} ${quotedAccessLogPath}`,
+ );
+
+ // Traefik can run as a standalone container ("dokploy-traefik") or a
+ // swarm service task ("dokploy-traefik.1."), so resolve the
+ // running container id dynamically instead of assuming the name.
+ const { stdout: containerId } = await execAsync(
+ 'docker ps -q --filter "name=dokploy-traefik" --filter "status=running" | head -n 1',
+ );
+ const traefikContainerId = containerId.trim();
+ if (!traefikContainerId) {
+ console.error("Traefik container not found, skipping log reopen");
+ return;
+ }
+ await execAsync(`docker exec ${traefikContainerId} kill -USR1 1`);
+ } catch (error) {
+ console.error("Error during log cleanup:", error);
}
- await execAsync(`docker exec ${traefikContainerId} kill -USR1 1`);
- } catch (error) {
- console.error("Error during log cleanup:", error);
- }
- });
+ },
+ );
+ if (!cleanupJob) {
+ return false;
+ }
await updateWebServerSettings({
logCleanupCron: cronExpression,
diff --git a/packages/server/src/utils/access-log/types.ts b/packages/server/src/utils/access-log/types.ts
index 6c35f9bb57..b6a395db56 100644
--- a/packages/server/src/utils/access-log/types.ts
+++ b/packages/server/src/utils/access-log/types.ts
@@ -1,4 +1,4 @@
-export interface LogEntry {
+export interface TraefikLogEntry {
ClientAddr: string;
ClientHost: string;
ClientPort: string;
@@ -46,3 +46,31 @@ export interface LogEntry {
request_User_Agent: string;
time: string;
}
+
+export interface CaddyRawAccessLogEntry {
+ level?: string;
+ ts?: number;
+ logger?: string;
+ msg?: string;
+ server_name?: string;
+ request?: {
+ remote_ip?: string;
+ remote_port?: string;
+ client_ip?: string;
+ proto?: string;
+ method?: string;
+ host?: string;
+ uri?: string;
+ tls?: unknown;
+ headers?: Record;
+ };
+ bytes_read?: number;
+ duration?: number;
+ size?: number;
+ status?: number;
+ resp_headers?: Record;
+}
+
+export type LogEntry = TraefikLogEntry & {
+ Provider?: "traefik" | "caddy";
+};
diff --git a/packages/server/src/utils/access-log/utils.ts b/packages/server/src/utils/access-log/utils.ts
index 9b472ee27d..b40e0222a1 100644
--- a/packages/server/src/utils/access-log/utils.ts
+++ b/packages/server/src/utils/access-log/utils.ts
@@ -1,11 +1,140 @@
import _ from "lodash";
-import type { LogEntry } from "./types";
+import type { CaddyRawAccessLogEntry, LogEntry } from "./types";
interface HourlyData {
hour: string;
count: number;
}
+const emptyServiceURL = {
+ Scheme: "",
+ Opaque: "",
+ User: null,
+ Host: "",
+ Path: "",
+ RawPath: "",
+ ForceQuery: false,
+ RawQuery: "",
+ Fragment: "",
+ RawFragment: "",
+};
+
+const isTraefikLogEntry = (value: unknown): value is LogEntry => {
+ const entry = value as Partial;
+ return (
+ typeof entry.StartUTC === "string" &&
+ typeof entry.RequestHost === "string" &&
+ typeof entry.DownstreamStatus === "number"
+ );
+};
+
+const isCaddyLogEntry = (value: unknown): value is CaddyRawAccessLogEntry => {
+ const entry = value as CaddyRawAccessLogEntry;
+ return (
+ typeof entry.ts === "number" &&
+ typeof entry.status === "number" &&
+ typeof entry.request === "object" &&
+ entry.request !== null
+ );
+};
+
+const getRequestPath = (uri = "") => {
+ return uri;
+};
+
+const getHostPort = (host = "", tls?: unknown) => {
+ const match = host.match(/:(\d+)$/);
+ if (match?.[1]) return match[1];
+ return tls ? "443" : "80";
+};
+
+const getHeaderValue = (
+ headers: Record | undefined,
+ name: string,
+) => {
+ if (!headers) return "";
+ const match = Object.entries(headers).find(
+ ([headerName]) => headerName.toLowerCase() === name.toLowerCase(),
+ );
+ return match?.[1]?.[0] ?? "";
+};
+
+const normalizeCaddyLogEntry = (
+ entry: CaddyRawAccessLogEntry,
+): LogEntry | null => {
+ if (!isCaddyLogEntry(entry)) return null;
+ const ts = entry.ts;
+ if (typeof ts !== "number") return null;
+ const timestamp = new Date(ts * 1000);
+ if (Number.isNaN(timestamp.getTime())) return null;
+
+ const request = entry.request ?? {};
+ const startUTC = timestamp.toISOString();
+ const remoteIp = request.remote_ip ?? "";
+ const remotePort = request.remote_port ?? "";
+ const clientHost = request.client_ip ?? remoteIp;
+ const clientAddr =
+ remoteIp && remotePort ? `${remoteIp}:${remotePort}` : remoteIp;
+ const serviceName = entry.server_name ?? entry.logger ?? "caddy";
+
+ return {
+ Provider: "caddy",
+ ClientAddr: clientAddr,
+ ClientHost: clientHost,
+ ClientPort: remotePort,
+ ClientUsername: "-",
+ DownstreamContentSize: entry.size ?? 0,
+ DownstreamStatus: entry.status ?? 0,
+ Duration: Math.round((entry.duration ?? 0) * 1_000_000_000),
+ OriginContentSize: entry.size ?? 0,
+ OriginDuration: Math.round((entry.duration ?? 0) * 1_000_000_000),
+ OriginStatus: entry.status ?? 0,
+ Overhead: 0,
+ RequestAddr: request.host ?? "",
+ RequestContentSize: entry.bytes_read ?? 0,
+ RequestCount: 0,
+ RequestHost: request.host ?? "",
+ RequestMethod: request.method ?? "",
+ RequestPath: getRequestPath(request.uri),
+ RequestPort: getHostPort(request.host, request.tls),
+ RequestProtocol: request.proto ?? "",
+ RequestScheme: request.tls ? "https" : "http",
+ RetryAttempts: 0,
+ RouterName: serviceName,
+ ServiceAddr: "",
+ ServiceName: serviceName,
+ ServiceURL: emptyServiceURL,
+ StartLocal: startUTC,
+ StartUTC: startUTC,
+ downstream_Content_Type: "",
+ entryPointName: request.tls ? "https" : "http",
+ level: entry.level ?? "info",
+ msg: entry.msg ?? "",
+ origin_Content_Type: "",
+ request_Content_Type: "",
+ request_User_Agent: getHeaderValue(request.headers, "User-Agent"),
+ time: startUTC,
+ };
+};
+
+export const parseAccessLogLine = (line: string): LogEntry | null => {
+ const trimmed = line.trim();
+ if (!trimmed || !trimmed.startsWith("{") || !trimmed.endsWith("}")) {
+ return null;
+ }
+
+ try {
+ const parsed = JSON.parse(trimmed);
+ if (isTraefikLogEntry(parsed)) {
+ return { ...parsed, Provider: parsed.Provider ?? "traefik" };
+ }
+ return normalizeCaddyLogEntry(parsed);
+ } catch (error) {
+ console.error("Error parsing log line:", error);
+ return null;
+ }
+};
+
export function processLogs(
logString: string,
dateRange?: { start?: string; end?: string },
@@ -16,14 +145,10 @@ export function processLogs(
const hourlyData = _(logString)
.split("\n")
- .filter((line) => {
- const trimmed = line.trim();
- // Check if the line starts with { and ends with } to ensure it's a potential JSON object
- return trimmed !== "" && trimmed.startsWith("{") && trimmed.endsWith("}");
- })
.map((entry) => {
try {
- const log: LogEntry = JSON.parse(entry);
+ const log = parseAccessLogLine(entry);
+ if (!log) return null;
if (log.ServiceName === "dokploy-service-app@file") {
return null;
}
@@ -44,7 +169,7 @@ export function processLogs(
return `${date.toISOString().slice(0, 13)}:00:00Z`;
} catch (error) {
- console.error("Error parsing log entry:", error);
+ console.error("Error processing log entry:", error);
return null;
}
})
@@ -79,23 +204,9 @@ export function parseRawConfig(
return { data: [], totalCount: 0 };
}
- // Split logs into chunks to avoid memory issues
let parsedLogs = _(rawConfig)
.split("\n")
- .filter((line) => {
- const trimmed = line.trim();
- return (
- trimmed !== "" && trimmed.startsWith("{") && trimmed.endsWith("}")
- );
- })
- .map((line) => {
- try {
- return JSON.parse(line) as LogEntry;
- } catch (error) {
- console.error("Error parsing log line:", error);
- return null;
- }
- })
+ .map(parseAccessLogLine)
.compact()
.value();
diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts
index 790116cb6d..46ac77ed86 100644
--- a/packages/server/src/utils/builders/compose.ts
+++ b/packages/server/src/utils/builders/compose.ts
@@ -1,5 +1,6 @@
import { dirname, join } from "node:path";
import { paths } from "@dokploy/server/constants";
+import { resolveWebServerProvider } from "@dokploy/server/services/web-server-settings";
import type { InferResultType } from "@dokploy/server/types/with";
import boxen from "boxen";
import { quote } from "shell-quote";
@@ -9,6 +10,7 @@ import {
getEnvironmentVariablesObject,
prepareEnvironmentVariables,
} from "../docker/utils";
+import { getWebServerResourceName } from "../web-server/providers";
export type ComposeNested = InferResultType<
"compose",
@@ -23,6 +25,8 @@ export const getBuildComposeCommand = async (compose: ComposeNested) => {
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const exportEnvCommand = getExportEnvCommand(compose);
+ const provider = await resolveWebServerProvider(compose.serverId);
+ const webServerResourceName = getWebServerResourceName(provider);
const newCompose = await writeDomainsToCompose(compose, domains);
const logContent = `
App Name: ${appName}
@@ -55,7 +59,7 @@ Compose Type: ${composeType} ✅`;
${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create ${compose.composeType === "stack" ? "--driver overlay" : ""} --attachable ${compose.appName}` : ""}
env -i PATH="$PATH" HOME="$HOME" ${exportEnvCommand} docker ${command.split(" ").join(" ")} 2>&1 || { echo "Error: ❌ Docker command failed"; exit 1; }
- ${compose.isolatedDeployment ? `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1` : ""}
+ ${compose.isolatedDeployment ? `docker network connect ${compose.appName} $(docker ps --filter "name=${webServerResourceName}" -q) >/dev/null 2>&1` : ""}
echo "Docker Compose Deployed: ✅";
} || {
diff --git a/packages/server/src/utils/caddy/compose.ts b/packages/server/src/utils/caddy/compose.ts
new file mode 100644
index 0000000000..8dcb1f2c7b
--- /dev/null
+++ b/packages/server/src/utils/caddy/compose.ts
@@ -0,0 +1,178 @@
+import type { Compose } from "@dokploy/server/services/compose";
+import type { Domain } from "@dokploy/server/services/domain";
+import { getCaddyCompileSettings } from "@dokploy/server/services/web-server-settings";
+import {
+ compileWriteAndReloadCaddyConfigSafelyLockHeld,
+ readCaddyRouteFragments,
+ removeCaddyRouteFragment,
+ restoreCaddyRouteFragments,
+ withCaddyConfigLock,
+ writeCaddyRouteFragment,
+} from "./config";
+import {
+ assertCaddyDomainCertificateAvailable,
+ assertCaddyDomainSupported,
+ getCaddyCustomCertificateFiles,
+} from "./domain";
+import type { CaddyRouteFragment, CaddyRouteIntent } from "./types";
+import { getCaddyComposeRuntimeTarget } from "./upstream-targets";
+
+const CADDY_FRAGMENT_VERSION = 1;
+
+const toPunycode = (host: string): string => {
+ try {
+ return new URL(`http://${host}`).hostname;
+ } catch {
+ return host;
+ }
+};
+
+export const getCaddyComposeFragmentPrefix = (appName: string) =>
+ `compose.${appName}.`;
+
+export const getCaddyComposeFragmentId = (
+ appName: string,
+ uniqueConfigKey: number,
+) => `${getCaddyComposeFragmentPrefix(appName)}${uniqueConfigKey}`;
+
+const createCaddyRouteId = (appName: string, uniqueConfigKey: number) =>
+ `${appName}-compose-route-${uniqueConfigKey}`;
+
+export const createCaddyComposeRouteIntent = (
+ compose: Pick<
+ Compose,
+ "appName" | "composeType" | "isolatedDeployment" | "serverId"
+ >,
+ domain: Domain,
+ finalServiceName: string,
+ options: {
+ upstreamServiceName?: string;
+ upstreamNetwork?: string | null;
+ } = {},
+): CaddyRouteIntent => {
+ assertCaddyDomainSupported(domain);
+ const publicPath = domain.path && domain.path !== "/" ? domain.path : null;
+ const internalPath =
+ domain.internalPath &&
+ domain.internalPath !== "/" &&
+ domain.internalPath !== domain.path
+ ? domain.internalPath
+ : null;
+ const runtimeTarget = getCaddyComposeRuntimeTarget(compose, finalServiceName);
+ const upstreamServiceName = options.upstreamServiceName ?? runtimeTarget.host;
+ const upstreamNetwork = options.upstreamNetwork ?? runtimeTarget.network;
+
+ return {
+ id: createCaddyRouteId(compose.appName, domain.uniqueConfigKey),
+ source: "dokploy-compose",
+ hosts: [toPunycode(domain.host)],
+ pathPrefix: publicPath,
+ https: domain.https && !domain.customEntrypoint,
+ upstreams: [`http://${upstreamServiceName}:${domain.port || 80}`],
+ upstreamNetwork,
+ tlsCertificate:
+ domain.https &&
+ domain.certificateType === "custom" &&
+ domain.customCertResolver
+ ? getCaddyCustomCertificateFiles(
+ compose.serverId,
+ domain.customCertResolver,
+ )
+ : null,
+ transforms: {
+ stripPrefix: domain.stripPath ? publicPath : null,
+ addPrefix: internalPath,
+ },
+ };
+};
+
+export const createCaddyComposeRouteFragment = (
+ compose: Pick<
+ Compose,
+ "appName" | "composeType" | "isolatedDeployment" | "serverId"
+ >,
+ domain: Domain,
+ finalServiceName: string,
+ options: {
+ upstreamServiceName?: string;
+ upstreamNetwork?: string | null;
+ } = {},
+): CaddyRouteFragment => ({
+ version: CADDY_FRAGMENT_VERSION,
+ id: getCaddyComposeFragmentId(compose.appName, domain.uniqueConfigKey),
+ source: "dokploy-compose",
+ description: `Dokploy compose route for ${compose.appName}:${domain.uniqueConfigKey}`,
+ routes: [
+ createCaddyComposeRouteIntent(compose, domain, finalServiceName, options),
+ ],
+});
+
+export const writeCaddyComposeRouteFragments = async (
+ compose: Compose,
+ domains: Array<{ domain: Domain; finalServiceName: string }>,
+ options: {
+ organizationId?: string | null;
+ } = {},
+) => {
+ const serverId = compose.serverId || undefined;
+ const organizationId =
+ options.organizationId ??
+ (
+ compose as Compose & {
+ environment?: { project?: { organizationId?: string | null } };
+ }
+ ).environment?.project?.organizationId;
+ for (const { domain } of domains) {
+ await assertCaddyDomainCertificateAvailable(
+ serverId,
+ domain,
+ organizationId,
+ );
+ }
+ const routeFragmentOptions = { serverId };
+ const fragmentPrefix = getCaddyComposeFragmentPrefix(compose.appName);
+ const nextFragmentIds = new Set(
+ domains.map(({ domain }) =>
+ getCaddyComposeFragmentId(compose.appName, domain.uniqueConfigKey),
+ ),
+ );
+ await withCaddyConfigLock(serverId, async () => {
+ let changed = false;
+
+ const existingFragments = await readCaddyRouteFragments(
+ routeFragmentOptions,
+ );
+ try {
+ for (const fragment of existingFragments) {
+ if (
+ fragment.source === "dokploy-compose" &&
+ fragment.id.startsWith(fragmentPrefix) &&
+ !nextFragmentIds.has(fragment.id)
+ ) {
+ await removeCaddyRouteFragment(fragment.id, routeFragmentOptions);
+ changed = true;
+ }
+ }
+
+ for (const { domain, finalServiceName } of domains) {
+ await writeCaddyRouteFragment(
+ createCaddyComposeRouteFragment(compose, domain, finalServiceName),
+ routeFragmentOptions,
+ );
+ changed = true;
+ }
+
+ if (!changed) {
+ return;
+ }
+
+ await compileWriteAndReloadCaddyConfigSafelyLockHeld({
+ serverId,
+ ...(await getCaddyCompileSettings(serverId)),
+ });
+ } catch (error) {
+ await restoreCaddyRouteFragments(existingFragments, routeFragmentOptions);
+ throw error;
+ }
+ });
+};
diff --git a/packages/server/src/utils/caddy/config.ts b/packages/server/src/utils/caddy/config.ts
new file mode 100644
index 0000000000..dbbae6b2ef
--- /dev/null
+++ b/packages/server/src/utils/caddy/config.ts
@@ -0,0 +1,1151 @@
+import {
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ readFileSync,
+ renameSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { isIP } from "node:net";
+import * as path from "node:path";
+import { paths } from "@dokploy/server/constants";
+import {
+ execAsync,
+ execAsyncRemote,
+} from "@dokploy/server/utils/process/execAsync";
+import { quote } from "shell-quote";
+import type {
+ CaddyCompileOptions,
+ CaddyFragmentStoreOptions,
+ CaddyHeaderMap,
+ CaddyJsonObject,
+ CaddyRouteFragment,
+ CaddyRouteIntent,
+} from "./types";
+
+const CADDY_FRAGMENT_VERSION = 1;
+const CADDY_VERSION = process.env.CADDY_VERSION || "2.11.4";
+const CADDY_ACCESS_LOG_CONTAINER_PATH = "/etc/caddy/access.log";
+export const CADDY_METRICS_PORT = 2020;
+export const CLOUDFLARE_TRUSTED_PROXY_RANGES = [
+ "173.245.48.0/20",
+ "103.21.244.0/22",
+ "103.22.200.0/22",
+ "103.31.4.0/22",
+ "141.101.64.0/18",
+ "108.162.192.0/18",
+ "190.93.240.0/20",
+ "188.114.96.0/20",
+ "197.234.240.0/22",
+ "198.41.128.0/17",
+ "162.158.0.0/15",
+ "104.16.0.0/13",
+ "104.24.0.0/14",
+ "172.64.0.0/13",
+ "131.0.72.0/22",
+ "2400:cb00::/32",
+ "2606:4700::/32",
+ "2803:f800::/32",
+ "2405:b500::/32",
+ "2405:8100::/32",
+ "2a06:98c0::/29",
+ "2c0f:f248::/32",
+] as const;
+
+const DEFAULT_CLIENT_IP_HEADERS = ["X-Forwarded-For"];
+const CLOUDFLARE_CLIENT_IP_HEADERS = ["CF-Connecting-IP", "X-Forwarded-For"];
+
+const assertSafeFragmentId = (id: string) => {
+ if (
+ !/^[a-zA-Z0-9_.-]+$/.test(id) ||
+ id === "." ||
+ id === ".." ||
+ id.split(".").some((segment) => segment === "")
+ ) {
+ throw new Error(
+ `Invalid Caddy fragment id "${id}". Use letters, numbers, dash, underscore, or dot only; dot path segments are not allowed.`,
+ );
+ }
+};
+
+const assertPathWithinBase = (basePath: string, targetPath: string) => {
+ const resolvedBase = path.resolve(basePath);
+ const resolvedTarget = path.resolve(targetPath);
+ if (
+ resolvedTarget !== resolvedBase &&
+ !resolvedTarget.startsWith(`${resolvedBase}${path.sep}`)
+ ) {
+ throw new Error(`Resolved path "${targetPath}" escapes "${basePath}"`);
+ }
+};
+
+export const getCaddyFragmentFilePath = (
+ fragmentId: string,
+ isServer = false,
+) => {
+ assertSafeFragmentId(fragmentId);
+ const fragmentsPath = paths(isServer).CADDY_FRAGMENTS_PATH;
+ const filePath = path.join(fragmentsPath, `${fragmentId}.json`);
+ assertPathWithinBase(fragmentsPath, filePath);
+ return filePath;
+};
+
+export const getCaddyActiveConfigPath = (isServer = false) => {
+ return paths(isServer).CADDY_CONFIG_PATH;
+};
+
+export const validateCaddyRouteIntent = (route: CaddyRouteIntent) => {
+ if (!route.id) {
+ throw new Error("Caddy route intent requires an id");
+ }
+ if (!route.hosts.length) {
+ throw new Error(`Caddy route "${route.id}" requires at least one host`);
+ }
+ if (
+ !route.upstreams.length &&
+ !route.redirectScheme &&
+ !route.staticResponse
+ ) {
+ throw new Error(`Caddy route "${route.id}" requires at least one upstream`);
+ }
+ if (route.staticResponse) {
+ const statusCode = route.staticResponse.statusCode;
+ if (!Number.isInteger(statusCode) || statusCode < 100 || statusCode > 999) {
+ throw new Error(
+ `Caddy route "${route.id}" has invalid static response status code`,
+ );
+ }
+ }
+ for (const upstream of route.upstreams) {
+ if (!upstream.trim()) {
+ throw new Error(`Caddy route "${route.id}" has an empty upstream`);
+ }
+ try {
+ parseCaddyUpstream(upstream);
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "invalid upstream format";
+ throw new Error(
+ `Caddy route "${route.id}" has invalid upstream "${upstream}": ${message}`,
+ );
+ }
+ }
+};
+
+export const validateCaddyRouteFragment = (fragment: CaddyRouteFragment) => {
+ assertSafeFragmentId(fragment.id);
+ if (fragment.version !== CADDY_FRAGMENT_VERSION) {
+ throw new Error(
+ `Unsupported Caddy fragment version for "${fragment.id}": ${fragment.version}`,
+ );
+ }
+ for (const route of fragment.routes) {
+ validateCaddyRouteIntent(route);
+ }
+};
+
+const normalizePathPrefix = (prefix?: string | null) => {
+ if (!prefix || prefix === "/") {
+ return undefined;
+ }
+ return prefix.startsWith("/") ? prefix : `/${prefix}`;
+};
+
+const normalizeExactPath = (exactPath?: string | null) => {
+ if (!exactPath) {
+ return undefined;
+ }
+ return exactPath.startsWith("/") ? exactPath : `/${exactPath}`;
+};
+
+const getPathSpecificity = (route: CaddyRouteIntent) => {
+ return (route.pathExact ?? route.pathPrefix ?? "").length;
+};
+
+const routeSourcePrecedence: Record = {
+ manual: 0,
+ "traefik-dynamic-file": 0,
+ "traefik-compose-label": 0,
+ "dokploy-application": 1,
+ "dokploy-compose": 1,
+ "dokploy-dashboard": 1,
+};
+
+export const sortCaddyRouteIntents = (routes: CaddyRouteIntent[]) => {
+ return [...routes].sort((a, b) => {
+ const priorityDiff = (b.priority ?? 0) - (a.priority ?? 0);
+ if (priorityDiff !== 0) return priorityDiff;
+
+ const specificityDiff = getPathSpecificity(b) - getPathSpecificity(a);
+ if (specificityDiff !== 0) return specificityDiff;
+
+ const sourcePrecedenceDiff =
+ routeSourcePrecedence[a.source] - routeSourcePrecedence[b.source];
+ if (sourcePrecedenceDiff !== 0) return sourcePrecedenceDiff;
+
+ const sourceDiff = a.source.localeCompare(b.source);
+ if (sourceDiff !== 0) return sourceDiff;
+
+ return a.id.localeCompare(b.id);
+ });
+};
+
+const createRouteMatcher = (route: CaddyRouteIntent) => {
+ const matcher: CaddyJsonObject = {
+ host: route.hosts,
+ };
+ const exactPath = normalizeExactPath(route.pathExact);
+ const pathPrefix = normalizePathPrefix(route.pathPrefix);
+
+ if (exactPath) {
+ matcher.path = [exactPath];
+ } else if (pathPrefix) {
+ matcher.path = [`${pathPrefix}*`];
+ }
+ if (route.allowedRemoteIps?.length) {
+ matcher.remote_ip = {
+ ranges: route.allowedRemoteIps,
+ };
+ }
+
+ return [matcher];
+};
+
+const createRouteMatcherWithoutRemoteIp = (route: CaddyRouteIntent) => {
+ const { allowedRemoteIps: _allowedRemoteIps, ...unrestrictedRoute } = route;
+ return createRouteMatcher(unrestrictedRoute);
+};
+
+const normalizeHeaderValues = (headers: CaddyHeaderMap) => {
+ return Object.fromEntries(
+ Object.entries(headers).map(([name, value]) => [
+ name,
+ Array.isArray(value) ? value : [value],
+ ]),
+ );
+};
+
+const assertValidTrustedProxyRange = (range: string) => {
+ const [address, prefix, ...extra] = range.split("/");
+ if (!address || !prefix || extra.length > 0) {
+ throw new Error(`Invalid Caddy trusted proxy CIDR range "${range}"`);
+ }
+ const ipVersion = isIP(address);
+ if (!ipVersion) {
+ throw new Error(`Invalid Caddy trusted proxy address "${address}"`);
+ }
+ if (!/^\d+$/.test(prefix)) {
+ throw new Error(`Invalid Caddy trusted proxy prefix "${prefix}"`);
+ }
+ const prefixNumber = Number(prefix);
+ const maxPrefix = ipVersion === 4 ? 32 : 128;
+ if (prefixNumber < 0 || prefixNumber > maxPrefix) {
+ throw new Error(
+ `Invalid Caddy trusted proxy prefix "${prefix}" for ${address}`,
+ );
+ }
+};
+
+const normalizeClientIpHeaders = (headers: string[]) => {
+ if (!headers.length) {
+ throw new Error(
+ "Caddy trusted proxies require at least one client IP header",
+ );
+ }
+ const seen = new Set();
+ return headers.map((header) => {
+ const normalized = header.trim();
+ if (!normalized) {
+ throw new Error("Caddy trusted proxy client IP header cannot be empty");
+ }
+ const key = normalized.toLowerCase();
+ if (seen.has(key)) {
+ throw new Error(
+ `Duplicate Caddy trusted proxy client IP header "${normalized}"`,
+ );
+ }
+ seen.add(key);
+ return normalized;
+ });
+};
+
+const createTrustedProxyServerOptions = (
+ trustedProxies: CaddyCompileOptions["trustedProxies"],
+) => {
+ if (!trustedProxies) {
+ return {};
+ }
+ const ranges =
+ trustedProxies.source === "cloudflare"
+ ? [...CLOUDFLARE_TRUSTED_PROXY_RANGES]
+ : (trustedProxies.ranges ?? []);
+ if (!ranges.length) {
+ throw new Error("Caddy trusted proxies require at least one CIDR range");
+ }
+ for (const range of ranges) {
+ assertValidTrustedProxyRange(range);
+ }
+
+ const clientIpHeaders = normalizeClientIpHeaders(
+ trustedProxies.clientIpHeaders ??
+ (trustedProxies.source === "cloudflare"
+ ? CLOUDFLARE_CLIENT_IP_HEADERS
+ : DEFAULT_CLIENT_IP_HEADERS),
+ );
+
+ return {
+ trusted_proxies: {
+ source: "static",
+ ranges,
+ },
+ client_ip_headers: clientIpHeaders,
+ // Caddy's Server.trusted_proxies_strict field is an int, not a bool:
+ // emitting JSON `true` makes `caddy validate` reject the config when
+ // strict mode is enabled. Emit 1 and omit the field entirely otherwise.
+ ...(trustedProxies.strict === false
+ ? {}
+ : { trusted_proxies_strict: 1 }),
+ };
+};
+
+export const assertValidCaddyTrustedProxyConfig = (
+ trustedProxies: CaddyCompileOptions["trustedProxies"],
+) => {
+ createTrustedProxyServerOptions(trustedProxies);
+};
+
+const collectManualTlsCertificates = (routes: CaddyRouteIntent[]) => {
+ const seen = new Set();
+ const certificates = [];
+ for (const route of routes) {
+ if (!route.tlsCertificate) {
+ continue;
+ }
+ const key = `${route.tlsCertificate.certificate}\0${route.tlsCertificate.key}`;
+ if (seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ certificates.push({
+ certificate: route.tlsCertificate.certificate,
+ key: route.tlsCertificate.key,
+ });
+ }
+ return certificates;
+};
+
+const createTlsAppConfig = (
+ letsEncryptEmail: string | null | undefined,
+ manualCertificates: Array<{ certificate: string; key: string }>,
+) => {
+ const tlsApp: CaddyJsonObject = {};
+ if (manualCertificates.length) {
+ tlsApp.certificates = {
+ load_files: manualCertificates,
+ };
+ }
+ if (letsEncryptEmail) {
+ tlsApp.automation = {
+ policies: [
+ {
+ issuers: [
+ {
+ module: "acme",
+ email: letsEncryptEmail,
+ },
+ ],
+ },
+ ],
+ };
+ }
+ return Object.keys(tlsApp).length ? { tls: tlsApp } : {};
+};
+
+const createCaddyAccessLogConfig = (
+ accessLogs: CaddyCompileOptions["accessLogs"],
+) => {
+ if (!accessLogs?.enabled) {
+ return {};
+ }
+
+ return {
+ logging: {
+ logs: {
+ "dokploy-requests": {
+ writer: {
+ output: "file",
+ filename: accessLogs.filename || CADDY_ACCESS_LOG_CONTAINER_PATH,
+ },
+ encoder: {
+ format: "json",
+ },
+ include: ["http.log.access"],
+ },
+ },
+ },
+ };
+};
+
+const createCaddyServerLogConfig = (
+ accessLogs: CaddyCompileOptions["accessLogs"],
+) => (accessLogs?.enabled ? { logs: {} } : {});
+
+const createHeaderHandler = (route: CaddyRouteIntent) => {
+ const requestHeaders = route.transforms?.requestHeaders;
+ const responseHeaders = route.transforms?.responseHeaders;
+ const hasRequestHeaders =
+ requestHeaders && Object.keys(requestHeaders).length > 0;
+ const hasResponseHeaders =
+ responseHeaders && Object.keys(responseHeaders).length > 0;
+ if (!hasRequestHeaders && !hasResponseHeaders) {
+ return undefined;
+ }
+
+ return {
+ handler: "headers",
+ ...(hasRequestHeaders && {
+ request: {
+ set: normalizeHeaderValues(requestHeaders ?? {}),
+ },
+ }),
+ ...(hasResponseHeaders && {
+ response: {
+ set: normalizeHeaderValues(responseHeaders ?? {}),
+ },
+ }),
+ };
+};
+
+const createRewriteHandlers = (route: CaddyRouteIntent) => {
+ const handlers: CaddyJsonObject[] = [];
+ const stripPrefix = normalizePathPrefix(route.transforms?.stripPrefix);
+ const addPrefix = normalizePathPrefix(route.transforms?.addPrefix);
+
+ if (stripPrefix) {
+ handlers.push({
+ handler: "rewrite",
+ strip_path_prefix: stripPrefix,
+ });
+ }
+
+ if (addPrefix) {
+ handlers.push({
+ handler: "rewrite",
+ uri: `${addPrefix}{http.request.uri.path}`,
+ });
+ }
+
+ return handlers;
+};
+
+const createBasicAuthHandler = (route: CaddyRouteIntent) => {
+ if (!route.basicAuth?.length) {
+ return undefined;
+ }
+
+ return {
+ handler: "authentication",
+ providers: {
+ http_basic: {
+ accounts: route.basicAuth.map((account) => ({
+ username: account.username,
+ password: account.hash,
+ })),
+ },
+ },
+ };
+};
+
+const httpUrlPrefixRegex = /^https?:\/\//i;
+
+const stripUrlPath = (value: string) => value.split(/[/?#]/, 1)[0] ?? "";
+
+const getAuthorityPort = (authority: string) => {
+ const withoutCredentials = authority.includes("@")
+ ? authority.slice(authority.lastIndexOf("@") + 1)
+ : authority;
+
+ if (withoutCredentials.startsWith("[")) {
+ const closingBracketIndex = withoutCredentials.indexOf("]");
+ if (closingBracketIndex === -1) {
+ return { host: withoutCredentials, port: "" };
+ }
+ const host = withoutCredentials.slice(0, closingBracketIndex + 1);
+ const remainder = withoutCredentials.slice(closingBracketIndex + 1);
+ return {
+ host,
+ port: remainder.startsWith(":") ? remainder.slice(1) : "",
+ };
+ }
+
+ const separatorIndex = withoutCredentials.lastIndexOf(":");
+ if (separatorIndex === -1) {
+ return { host: withoutCredentials, port: "" };
+ }
+
+ return {
+ host: withoutCredentials.slice(0, separatorIndex),
+ port: withoutCredentials.slice(separatorIndex + 1),
+ };
+};
+
+const validateExplicitPort = (port: string) => {
+ if (!port) {
+ throw new Error("upstream must include an explicit port");
+ }
+ if (!/^\d+$/.test(port)) {
+ throw new Error(`upstream port "${port}" must be numeric`);
+ }
+ const portNumber = Number(port);
+ if (portNumber < 1 || portNumber > 65535) {
+ throw new Error(`upstream port "${port}" must be between 1 and 65535`);
+ }
+ return port;
+};
+
+export const parseCaddyUpstream = (upstream: string) => {
+ const trimmed = upstream.trim();
+ if (httpUrlPrefixRegex.test(trimmed)) {
+ const authority = stripUrlPath(trimmed.replace(httpUrlPrefixRegex, ""));
+ const { port } = getAuthorityPort(authority);
+ const explicitPort = validateExplicitPort(port);
+ const url = new URL(trimmed);
+ const scheme = url.protocol.replace(":", "");
+ if (scheme !== "http" && scheme !== "https") {
+ throw new Error(`unsupported upstream scheme "${scheme}"`);
+ }
+ return {
+ dial: `${url.hostname}:${explicitPort}`,
+ scheme,
+ };
+ }
+
+ const { host, port } = getAuthorityPort(trimmed);
+ const explicitPort = validateExplicitPort(port);
+ if (!host) {
+ throw new Error("upstream must include a host");
+ }
+ if (trimmed.includes("/") || trimmed.includes("?") || trimmed.includes("#")) {
+ throw new Error("raw upstream dials must be host:port only");
+ }
+ return {
+ dial: `${host}:${explicitPort}`,
+ scheme: "http",
+ };
+};
+
+const createReverseProxyHandler = (route: CaddyRouteIntent) => {
+ const parsedUpstreams = route.upstreams.map(parseCaddyUpstream);
+ const usesTlsTransport = parsedUpstreams.some(
+ (upstream) => upstream.scheme === "https",
+ );
+
+ return {
+ handler: "reverse_proxy",
+ upstreams: parsedUpstreams.map((upstream) => ({ dial: upstream.dial })),
+ ...(usesTlsTransport && {
+ transport: {
+ protocol: "http",
+ tls: {},
+ },
+ }),
+ };
+};
+
+const createProxyRoute = (route: CaddyRouteIntent) => {
+ const handlers = [
+ createHeaderHandler(route),
+ createBasicAuthHandler(route),
+ ...createRewriteHandlers(route),
+ createReverseProxyHandler(route),
+ ].filter(Boolean) as CaddyJsonObject[];
+
+ return {
+ match: createRouteMatcher(route),
+ handle: handlers,
+ terminal: true,
+ };
+};
+
+const createStaticResponseRoute = (route: CaddyRouteIntent) => {
+ const response = route.staticResponse;
+ const handlers = [
+ createHeaderHandler(route),
+ {
+ handler: "static_response",
+ status_code: response?.statusCode ?? 404,
+ ...(response?.body && { body: response.body }),
+ ...(response?.headers && {
+ headers: normalizeHeaderValues(response.headers),
+ }),
+ },
+ ].filter(Boolean) as CaddyJsonObject[];
+
+ return {
+ match: createRouteMatcher(route),
+ handle: handlers,
+ terminal: true,
+ };
+};
+
+const createForbiddenRoute = (route: CaddyRouteIntent) => {
+ return {
+ match: createRouteMatcherWithoutRemoteIp(route),
+ handle: [
+ {
+ handler: "static_response",
+ status_code: 403,
+ },
+ ],
+ terminal: true,
+ };
+};
+
+const createRedirectLocation = (route: CaddyRouteIntent) => {
+ const scheme = route.redirectScheme?.scheme || "https";
+ const port = route.redirectScheme?.port
+ ? `:${route.redirectScheme.port}`
+ : "";
+ return `${scheme}://{http.request.host}${port}{http.request.uri}`;
+};
+
+const createRedirectRoute = (route: CaddyRouteIntent) => {
+ return {
+ match: createRouteMatcher(route),
+ handle: [
+ {
+ handler: "static_response",
+ status_code: route.redirectScheme?.permanent === false ? 302 : 308,
+ headers: {
+ Location: [createRedirectLocation(route)],
+ },
+ },
+ ],
+ terminal: true,
+ };
+};
+
+const createHttpsRedirectRoute = (route: CaddyRouteIntent) => {
+ return createRedirectRoute({
+ ...route,
+ allowedRemoteIps: null,
+ redirectScheme: { scheme: "https", permanent: true },
+ });
+};
+
+const createCaddyMetricsServer = () => ({
+ listen: [`:${CADDY_METRICS_PORT}`],
+ routes: [
+ {
+ handle: [
+ {
+ handler: "metrics",
+ },
+ ],
+ terminal: true,
+ },
+ ],
+});
+
+export const flattenCaddyFragments = (fragments: CaddyRouteFragment[] = []) => {
+ for (const fragment of fragments) {
+ validateCaddyRouteFragment(fragment);
+ }
+ return fragments.flatMap((fragment) => fragment.routes);
+};
+
+export const compileCaddyConfig = ({
+ fragments = [],
+ routes = [],
+ letsEncryptEmail,
+ trustedProxies,
+ accessLogs,
+}: CaddyCompileOptions = {}) => {
+ const allRoutes = sortCaddyRouteIntents([
+ ...flattenCaddyFragments(fragments),
+ ...routes,
+ ]);
+ for (const route of allRoutes) {
+ validateCaddyRouteIntent(route);
+ }
+
+ const httpRoutes = allRoutes.map((route) => {
+ if (route.redirectScheme) {
+ return createRedirectRoute(route);
+ }
+ if (route.staticResponse && !route.https) {
+ return createStaticResponseRoute(route);
+ }
+ return route.https
+ ? createHttpsRedirectRoute(route)
+ : createProxyRoute(route);
+ });
+ const httpsRoutes = allRoutes
+ .filter((route) => route.https && !route.redirectScheme)
+ .flatMap((route) => {
+ const proxyRoute = route.staticResponse
+ ? createStaticResponseRoute(route)
+ : createProxyRoute(route);
+ return route.allowedRemoteIps?.length
+ ? [proxyRoute, createForbiddenRoute(route)]
+ : [proxyRoute];
+ });
+ const trustedProxyServerOptions =
+ createTrustedProxyServerOptions(trustedProxies);
+ const manualTlsCertificates = collectManualTlsCertificates(allRoutes);
+
+ return {
+ admin: {
+ listen: "localhost:2019",
+ },
+ ...createCaddyAccessLogConfig(accessLogs),
+ apps: {
+ ...createTlsAppConfig(letsEncryptEmail, manualTlsCertificates),
+ http: {
+ servers: {
+ http: {
+ listen: [":80"],
+ ...createCaddyServerLogConfig(accessLogs),
+ ...trustedProxyServerOptions,
+ routes: httpRoutes,
+ },
+ https: {
+ listen: [":443"],
+ ...createCaddyServerLogConfig(accessLogs),
+ ...trustedProxyServerOptions,
+ routes: httpsRoutes,
+ },
+ metrics: createCaddyMetricsServer(),
+ },
+ },
+ },
+ };
+};
+
+const encodeBase64 = (content: string) =>
+ Buffer.from(content, "utf8").toString("base64");
+
+const remoteWriteFileAtomic = async (
+ serverId: string,
+ filePath: string,
+ content: string,
+) => {
+ const tempPath = `${filePath}.tmp-${Date.now()}`;
+ const command = [
+ `mkdir -p ${quote([path.posix.dirname(filePath)])}`,
+ `printf %s ${quote([encodeBase64(content)])} | base64 -d > ${quote([tempPath])}`,
+ `mv ${quote([tempPath])} ${quote([filePath])}`,
+ ].join(" && ");
+ await execAsyncRemote(serverId, command);
+};
+
+export const writeCaddyConfigContent = async (
+ content: string,
+ options: CaddyFragmentStoreOptions = {},
+) => {
+ const filePath = getCaddyActiveConfigPath(!!options.serverId);
+ if (options.serverId) {
+ await remoteWriteFileAtomic(options.serverId, filePath, content);
+ return;
+ }
+ mkdirSync(path.dirname(filePath), { recursive: true });
+ const tempPath = `${filePath}.tmp-${Date.now()}`;
+ writeFileSync(tempPath, content, "utf8");
+ renameSync(tempPath, filePath);
+};
+
+export const writeCaddyConfigFile = async (
+ config: CaddyJsonObject,
+ options: CaddyFragmentStoreOptions = {},
+) => {
+ await writeCaddyConfigContent(
+ `${JSON.stringify(config, null, 2)}\n`,
+ options,
+ );
+};
+
+export const writeCaddyRouteFragment = async (
+ fragment: CaddyRouteFragment,
+ options: CaddyFragmentStoreOptions = {},
+) => {
+ validateCaddyRouteFragment(fragment);
+ const filePath = getCaddyFragmentFilePath(fragment.id, !!options.serverId);
+ const content = `${JSON.stringify(fragment, null, 2)}\n`;
+ if (options.serverId) {
+ await remoteWriteFileAtomic(options.serverId, filePath, content);
+ return;
+ }
+ mkdirSync(path.dirname(filePath), { recursive: true });
+ writeFileSync(filePath, content, "utf8");
+};
+
+export const removeCaddyRouteFragment = async (
+ fragmentId: string,
+ options: CaddyFragmentStoreOptions = {},
+) => {
+ const filePath = getCaddyFragmentFilePath(fragmentId, !!options.serverId);
+ if (options.serverId) {
+ await execAsyncRemote(options.serverId, `rm -f ${quote([filePath])}`);
+ return;
+ }
+ rmSync(filePath, { force: true });
+};
+
+export const restoreCaddyRouteFragments = async (
+ fragments: CaddyRouteFragment[],
+ options: CaddyFragmentStoreOptions = {},
+) => {
+ const fragmentsPath = paths(!!options.serverId).CADDY_FRAGMENTS_PATH;
+ if (options.serverId) {
+ await execAsyncRemote(
+ options.serverId,
+ `rm -rf ${quote([fragmentsPath])} && mkdir -p ${quote([fragmentsPath])}`,
+ );
+ } else {
+ rmSync(fragmentsPath, { recursive: true, force: true });
+ mkdirSync(fragmentsPath, { recursive: true });
+ }
+
+ for (const fragment of fragments) {
+ await writeCaddyRouteFragment(fragment, options);
+ }
+};
+
+const parseFragmentContent = (fileName: string, content: string) => {
+ try {
+ const parsed = JSON.parse(content) as CaddyRouteFragment;
+ validateCaddyRouteFragment(parsed);
+ return parsed;
+ } catch (error) {
+ throw new Error(
+ `Invalid Caddy fragment ${fileName}: ${error instanceof Error ? error.message : "Unknown error"}`,
+ );
+ }
+};
+
+export const readCaddyRouteFragments = async (
+ options: CaddyFragmentStoreOptions = {},
+) => {
+ const fragmentsPath = paths(!!options.serverId).CADDY_FRAGMENTS_PATH;
+ if (options.serverId) {
+ const command = `if [ -d ${quote([fragmentsPath])} ]; then find ${quote([fragmentsPath])} -maxdepth 1 -type f -name '*.json' | sort | while IFS= read -r file; do printf '%s\n' "---DOKPLOY-CADDY-FILE:$file"; cat "$file"; printf '\n'; done; fi`;
+ const { stdout } = await execAsyncRemote(options.serverId, command);
+ const fragments: CaddyRouteFragment[] = [];
+ for (const block of stdout.split("---DOKPLOY-CADDY-FILE:")) {
+ if (!block.trim()) continue;
+ const newlineIndex = block.indexOf("\n");
+ if (newlineIndex === -1) continue;
+ const fileName = block.slice(0, newlineIndex).trim();
+ const content = block.slice(newlineIndex + 1).trim();
+ fragments.push(parseFragmentContent(fileName, content));
+ }
+ return fragments;
+ }
+
+ if (!existsSync(fragmentsPath)) {
+ return [];
+ }
+
+ return readdirSync(fragmentsPath)
+ .filter((fileName) => fileName.endsWith(".json"))
+ .sort()
+ .map((fileName) => {
+ const filePath = path.join(fragmentsPath, fileName);
+ return parseFragmentContent(fileName, readFileSync(filePath, "utf8"));
+ });
+};
+
+export const compileAndWriteCaddyConfig = async (
+ options: CaddyFragmentStoreOptions & {
+ letsEncryptEmail?: string | null;
+ trustedProxies?: CaddyCompileOptions["trustedProxies"];
+ accessLogs?: CaddyCompileOptions["accessLogs"];
+ } = {},
+) => {
+ const fragments = await readCaddyRouteFragments(options);
+ const config = compileCaddyConfig({
+ fragments,
+ letsEncryptEmail: options.letsEncryptEmail,
+ trustedProxies: options.trustedProxies,
+ accessLogs: options.accessLogs,
+ });
+ await writeCaddyConfigFile(config, options);
+ return config;
+};
+
+export const compileWriteAndValidateCaddyConfigSafely = async (
+ options: CaddyFragmentStoreOptions & {
+ letsEncryptEmail?: string | null;
+ trustedProxies?: CaddyCompileOptions["trustedProxies"];
+ accessLogs?: CaddyCompileOptions["accessLogs"];
+ } = {},
+) => {
+ const previousConfig = await readCaddyConfigFileIfExists(options);
+ const config = await compileAndWriteCaddyConfig(options);
+ try {
+ await validateCaddyConfigWithContainer(options.serverId);
+ } catch (error) {
+ try {
+ if (previousConfig) {
+ await writeCaddyConfigContent(previousConfig, options);
+ } else {
+ await writeCaddyConfigFile(compileCaddyConfig(), options);
+ }
+ await validateCaddyConfigWithContainer(options.serverId);
+ } catch (restoreError) {
+ if (error instanceof Error) {
+ (error as Error & { restoreError?: unknown }).restoreError =
+ restoreError;
+ }
+ console.error("Failed to restore Caddy config:", restoreError);
+ }
+ throw error;
+ }
+ return config;
+};
+
+export const readCaddyConfigFile = async (
+ options: CaddyFragmentStoreOptions = {},
+) => {
+ const filePath = getCaddyActiveConfigPath(!!options.serverId);
+ if (options.serverId) {
+ const { stdout } = await execAsyncRemote(
+ options.serverId,
+ `cat ${quote([filePath])}`,
+ );
+ return stdout;
+ }
+ return readFileSync(filePath, "utf8");
+};
+
+export const readCaddyConfigFileIfExists = async (
+ options: CaddyFragmentStoreOptions = {},
+) => {
+ const filePath = getCaddyActiveConfigPath(!!options.serverId);
+ if (options.serverId) {
+ const { stdout } = await execAsyncRemote(
+ options.serverId,
+ `if [ -f ${quote([filePath])} ]; then cat ${quote([filePath])}; fi`,
+ );
+ return stdout || null;
+ }
+ if (!existsSync(filePath)) {
+ return null;
+ }
+ return readFileSync(filePath, "utf8");
+};
+
+export const writeAndReloadCaddyConfigSafely = async (
+ config: CaddyJsonObject,
+ options: CaddyFragmentStoreOptions = {},
+) => {
+ const previousConfig = await readCaddyConfigFileIfExists(options);
+ await writeCaddyConfigFile(config, options);
+ try {
+ await reloadCaddyAfterValidation(options.serverId);
+ } catch (error) {
+ try {
+ if (previousConfig) {
+ await writeCaddyConfigContent(previousConfig, options);
+ } else {
+ await writeCaddyConfigFile(compileCaddyConfig(), options);
+ }
+ await reloadCaddyAfterValidation(options.serverId);
+ } catch (restoreError) {
+ if (error instanceof Error) {
+ (error as Error & { restoreError?: unknown }).restoreError =
+ restoreError;
+ }
+ console.error("Failed to restore Caddy config:", restoreError);
+ }
+ throw error;
+ }
+};
+
+// Deployments can run concurrently (buildsConcurrency > 1) and the active
+// Caddy config is compiled from the fragments directory, so an unsynchronized
+// read-compile-write can publish a config that misses a concurrently written
+// fragment, and restoreCaddyRouteFragments rewrites the whole directory from
+// a snapshot. Every fragment read-modify-write + compile sequence must hold
+// this per-server lock; all writers (including SSH writes to remote servers)
+// run in this process, so an in-process lock is sufficient.
+const caddyConfigLocks = new Map>();
+
+export const withCaddyConfigLock = async (
+ serverId: string | null | undefined,
+ task: () => Promise,
+): Promise => {
+ const key = serverId || "local";
+ const previous = caddyConfigLocks.get(key) ?? Promise.resolve();
+ const run = previous.then(task, task);
+ const tail = run.then(
+ () => undefined,
+ () => undefined,
+ );
+ caddyConfigLocks.set(key, tail);
+ try {
+ return await run;
+ } finally {
+ if (caddyConfigLocks.get(key) === tail) {
+ caddyConfigLocks.delete(key);
+ }
+ }
+};
+
+type CaddyCompileAndReloadOptions = CaddyFragmentStoreOptions & {
+ letsEncryptEmail?: string | null;
+ trustedProxies?: CaddyCompileOptions["trustedProxies"];
+ accessLogs?: CaddyCompileOptions["accessLogs"];
+};
+
+// Only call while holding withCaddyConfigLock for the same server; use
+// compileWriteAndReloadCaddyConfigSafely otherwise.
+export const compileWriteAndReloadCaddyConfigSafelyLockHeld = async (
+ options: CaddyCompileAndReloadOptions = {},
+) => {
+ const fragments = await readCaddyRouteFragments(options);
+ const config = compileCaddyConfig({
+ fragments,
+ letsEncryptEmail: options.letsEncryptEmail,
+ trustedProxies: options.trustedProxies,
+ accessLogs: options.accessLogs,
+ });
+ await writeAndReloadCaddyConfigSafely(config, options);
+ return config;
+};
+
+export const compileWriteAndReloadCaddyConfigSafely = async (
+ options: CaddyCompileAndReloadOptions = {},
+) =>
+ withCaddyConfigLock(options.serverId, () =>
+ compileWriteAndReloadCaddyConfigSafelyLockHeld(options),
+ );
+
+export const ensureDefaultCaddyConfig = async (
+ options: CaddyFragmentStoreOptions & {
+ letsEncryptEmail?: string | null;
+ trustedProxies?: CaddyCompileOptions["trustedProxies"];
+ accessLogs?: CaddyCompileOptions["accessLogs"];
+ } = {},
+) => {
+ const caddyPaths = paths(!!options.serverId);
+ if (options.serverId) {
+ await execAsyncRemote(
+ options.serverId,
+ `mkdir -p ${quote([
+ caddyPaths.MAIN_CADDY_PATH,
+ caddyPaths.CADDY_FRAGMENTS_PATH,
+ caddyPaths.CADDY_DATA_PATH,
+ caddyPaths.CADDY_CONFIG_DIR_PATH,
+ ])}`,
+ );
+ await compileAndWriteCaddyConfig(options);
+ return;
+ }
+
+ mkdirSync(caddyPaths.MAIN_CADDY_PATH, { recursive: true });
+ mkdirSync(caddyPaths.CADDY_FRAGMENTS_PATH, { recursive: true });
+ mkdirSync(caddyPaths.CADDY_DATA_PATH, { recursive: true });
+ mkdirSync(caddyPaths.CADDY_CONFIG_DIR_PATH, { recursive: true });
+ await compileAndWriteCaddyConfig(options);
+};
+
+const getCaddyExecTargetCommand = `
+if docker service inspect dokploy-caddy >/dev/null 2>&1; then
+ docker ps --filter "label=com.docker.swarm.service.name=dokploy-caddy" --format '{{.ID}}' | head -n 1
+else
+ echo dokploy-caddy
+fi`;
+
+const execInCaddy = async (serverId: string | undefined, command: string) => {
+ const { stdout } = serverId
+ ? await execAsyncRemote(serverId, getCaddyExecTargetCommand)
+ : await execAsync(getCaddyExecTargetCommand);
+ const target = stdout.trim();
+ if (!target) {
+ throw new Error("Caddy resource is not running");
+ }
+ const dockerExecCommand = `docker exec ${quote([target])} ${command}`;
+ if (serverId) {
+ return execAsyncRemote(serverId, dockerExecCommand);
+ }
+ return execAsync(dockerExecCommand);
+};
+
+export const validateCaddyConfigWithContainer = async (serverId?: string) => {
+ return execInCaddy(serverId, "caddy validate --config /etc/caddy/caddy.json");
+};
+
+export const validateCaddyConfigFileWithImage = async (
+ configPath: string,
+ serverId?: string,
+) => {
+ const imageName = `caddy:${CADDY_VERSION}`;
+ const validationRoot = path.posix.join(
+ path.posix.dirname(configPath),
+ ".validate-runtime",
+ );
+ const validationDataPath = path.posix.join(validationRoot, "data");
+ const validationConfigPath = path.posix.join(validationRoot, "config");
+ const { CERTIFICATES_PATH } = paths(!!serverId);
+ const mkdirCommand = `mkdir -p ${quote([
+ validationDataPath,
+ validationConfigPath,
+ CERTIFICATES_PATH,
+ ])}`;
+ const cleanupCommand = `rm -rf ${quote([validationRoot])}`;
+ const validateCommand = `docker run --rm --network none ${[
+ "-v",
+ `${configPath}:/etc/caddy/caddy.json:ro`,
+ "-v",
+ `${validationDataPath}:/data`,
+ "-v",
+ `${validationConfigPath}:/config`,
+ "-v",
+ `${CERTIFICATES_PATH}:${CERTIFICATES_PATH}:ro`,
+ imageName,
+ "caddy",
+ "validate",
+ "--config",
+ "/etc/caddy/caddy.json",
+ ]
+ .map((part) => quote([part]))
+ .join(" ")}`;
+ if (serverId) {
+ await execAsyncRemote(serverId, mkdirCommand);
+ try {
+ return await execAsyncRemote(serverId, validateCommand);
+ } finally {
+ await execAsyncRemote(serverId, cleanupCommand).catch(() => undefined);
+ }
+ }
+ await execAsync(mkdirCommand);
+ try {
+ return await execAsync(validateCommand);
+ } finally {
+ await execAsync(cleanupCommand).catch(() => undefined);
+ }
+};
+
+export const reloadCaddyWithContainer = async (serverId?: string) => {
+ const { stdout } = serverId
+ ? await execAsyncRemote(serverId, getCaddyExecTargetCommand)
+ : await execAsync(getCaddyExecTargetCommand);
+ const target = stdout.trim();
+ if (!target) {
+ throw new Error("Caddy resource is not running");
+ }
+ const dockerExecCommand = `docker exec -w /etc/caddy ${quote([
+ target,
+ ])} caddy reload --config /etc/caddy/caddy.json`;
+ if (serverId) {
+ return execAsyncRemote(serverId, dockerExecCommand);
+ }
+ return execAsync(dockerExecCommand);
+};
+
+export const reloadCaddyAfterValidation = async (serverId?: string) => {
+ await validateCaddyConfigWithContainer(serverId);
+ return reloadCaddyWithContainer(serverId);
+};
diff --git a/packages/server/src/utils/caddy/domain.ts b/packages/server/src/utils/caddy/domain.ts
new file mode 100644
index 0000000000..aa92b789fa
--- /dev/null
+++ b/packages/server/src/utils/caddy/domain.ts
@@ -0,0 +1,215 @@
+import * as path from "node:path";
+import { paths } from "@dokploy/server/constants";
+import { assertCertificatePathAvailableForServer } from "@dokploy/server/services/certificate";
+import type { Domain } from "@dokploy/server/services/domain";
+import { getCaddyCompileSettings } from "@dokploy/server/services/web-server-settings";
+import type { ApplicationNested } from "../builders";
+import {
+ compileWriteAndReloadCaddyConfigSafelyLockHeld,
+ readCaddyRouteFragments,
+ removeCaddyRouteFragment,
+ restoreCaddyRouteFragments,
+ withCaddyConfigLock,
+ writeCaddyRouteFragment,
+} from "./config";
+import type { CaddyRouteFragment, CaddyRouteIntent } from "./types";
+import { DOKPLOY_CADDY_NETWORK } from "./upstream-targets";
+
+const CADDY_FRAGMENT_VERSION = 1;
+
+const toPunycode = (host: string): string => {
+ try {
+ return new URL(`http://${host}`).hostname;
+ } catch {
+ return host;
+ }
+};
+
+export const getCaddyApplicationFragmentId = (
+ appName: string,
+ uniqueConfigKey: number,
+) => `application.${appName}.${uniqueConfigKey}`;
+
+const createCaddyRouteId = (appName: string, uniqueConfigKey: number) =>
+ `${appName}-route-${uniqueConfigKey}`;
+
+const assertSafeCertificatePath = (certificatePath: string) => {
+ if (
+ !/^[a-zA-Z0-9_.-]+$/.test(certificatePath) ||
+ certificatePath === "." ||
+ certificatePath === ".." ||
+ certificatePath.split(".").some((segment) => segment === "")
+ ) {
+ throw new Error(
+ `Invalid Caddy custom certificate path "${certificatePath}". Use an uploaded certificate from Dokploy.`,
+ );
+ }
+};
+
+export const getCaddyCustomCertificateFiles = (
+ serverId: string | null | undefined,
+ certificatePath: string,
+) => {
+ assertSafeCertificatePath(certificatePath);
+ const certDir = path.join(
+ paths(!!serverId).CERTIFICATES_PATH,
+ certificatePath,
+ );
+ return {
+ certificate: path.join(certDir, "chain.crt"),
+ key: path.join(certDir, "privkey.key"),
+ };
+};
+
+export const getUnsupportedCaddyDomainFieldMessages = (domain: Domain) => {
+ const messages: string[] = [];
+ if (domain.customEntrypoint) {
+ messages.push("custom entrypoints are not supported by Caddy routes");
+ }
+ if (
+ domain.https &&
+ domain.customCertResolver &&
+ domain.certificateType !== "custom"
+ ) {
+ messages.push("custom certificate resolvers are Traefik-specific");
+ }
+ if (
+ domain.https &&
+ domain.certificateType === "custom" &&
+ !domain.customCertResolver
+ ) {
+ messages.push("custom certificates require an uploaded certificate");
+ }
+ if (domain.middlewares?.length) {
+ messages.push("Traefik middlewares are not translated to Caddy yet");
+ }
+ return messages;
+};
+
+export const assertCaddyDomainSupported = (domain: Domain) => {
+ const messages = getUnsupportedCaddyDomainFieldMessages(domain);
+ if (messages.length) {
+ throw new Error(
+ `Domain "${domain.host}" uses unsupported Caddy fields: ${messages.join("; ")}`,
+ );
+ }
+};
+
+export const assertCaddyDomainCertificateAvailable = async (
+ serverId: string | null | undefined,
+ domain: Domain,
+ organizationId?: string | null,
+) => {
+ if (
+ domain.https &&
+ domain.certificateType === "custom" &&
+ domain.customCertResolver
+ ) {
+ await assertCertificatePathAvailableForServer(
+ domain.customCertResolver,
+ serverId,
+ organizationId,
+ );
+ }
+};
+
+export const createCaddyApplicationRouteIntent = (
+ app: ApplicationNested,
+ domain: Domain,
+): CaddyRouteIntent => {
+ assertCaddyDomainSupported(domain);
+ const publicPath = domain.path && domain.path !== "/" ? domain.path : null;
+ const internalPath =
+ domain.internalPath &&
+ domain.internalPath !== "/" &&
+ domain.internalPath !== domain.path
+ ? domain.internalPath
+ : null;
+
+ return {
+ id: createCaddyRouteId(app.appName, domain.uniqueConfigKey),
+ source: "dokploy-application",
+ hosts: [toPunycode(domain.host)],
+ pathPrefix: publicPath,
+ https: domain.https && !domain.customEntrypoint,
+ upstreams: [`http://${app.appName}:${domain.port || 80}`],
+ upstreamNetwork: DOKPLOY_CADDY_NETWORK,
+ tlsCertificate:
+ domain.https &&
+ domain.certificateType === "custom" &&
+ domain.customCertResolver
+ ? getCaddyCustomCertificateFiles(
+ app.serverId,
+ domain.customCertResolver,
+ )
+ : null,
+ transforms: {
+ stripPrefix: domain.stripPath ? publicPath : null,
+ addPrefix: internalPath,
+ },
+ };
+};
+
+export const createCaddyApplicationRouteFragment = (
+ app: ApplicationNested,
+ domain: Domain,
+): CaddyRouteFragment => ({
+ version: CADDY_FRAGMENT_VERSION,
+ id: getCaddyApplicationFragmentId(app.appName, domain.uniqueConfigKey),
+ source: "dokploy-application",
+ description: `Dokploy application route for ${app.appName}:${domain.uniqueConfigKey}`,
+ routes: [createCaddyApplicationRouteIntent(app, domain)],
+});
+
+export const manageCaddyDomain = async (
+ app: ApplicationNested,
+ domain: Domain,
+) => {
+ const serverId = app.serverId || undefined;
+ await assertCaddyDomainCertificateAvailable(
+ serverId,
+ domain,
+ app.environment?.project?.organizationId,
+ );
+ const options = { serverId };
+ await withCaddyConfigLock(serverId, async () => {
+ const previousFragments = await readCaddyRouteFragments(options);
+ try {
+ await writeCaddyRouteFragment(
+ createCaddyApplicationRouteFragment(app, domain),
+ options,
+ );
+ await compileWriteAndReloadCaddyConfigSafelyLockHeld({
+ serverId,
+ ...(await getCaddyCompileSettings(serverId)),
+ });
+ } catch (error) {
+ await restoreCaddyRouteFragments(previousFragments, options);
+ throw error;
+ }
+ });
+};
+
+export const removeCaddyDomain = async (
+ app: ApplicationNested,
+ uniqueConfigKey: number,
+) => {
+ const serverId = app.serverId || undefined;
+ const options = { serverId };
+ await withCaddyConfigLock(serverId, async () => {
+ const previousFragments = await readCaddyRouteFragments(options);
+ try {
+ await removeCaddyRouteFragment(
+ getCaddyApplicationFragmentId(app.appName, uniqueConfigKey),
+ options,
+ );
+ await compileWriteAndReloadCaddyConfigSafelyLockHeld({
+ serverId,
+ ...(await getCaddyCompileSettings(serverId)),
+ });
+ } catch (error) {
+ await restoreCaddyRouteFragments(previousFragments, options);
+ throw error;
+ }
+ });
+};
diff --git a/packages/server/src/utils/caddy/migration/apply.ts b/packages/server/src/utils/caddy/migration/apply.ts
new file mode 100644
index 0000000000..15493362aa
--- /dev/null
+++ b/packages/server/src/utils/caddy/migration/apply.ts
@@ -0,0 +1,464 @@
+import * as path from "node:path";
+import { paths } from "@dokploy/server/constants";
+import {
+ getDockerResourceSnapshot,
+ readEnvironmentVariables,
+ readPorts,
+ stopDockerResource,
+ waitForDockerResourceRunning,
+ writeCaddySetup,
+} from "@dokploy/server/services/settings";
+import {
+ getCaddyCompileSettings,
+ resolveWebServerProvider,
+ updateLocalWebServerProvider,
+ updateRemoteWebServerProvider,
+} from "@dokploy/server/services/web-server-settings";
+import { filterTraefikCarryOverPortsForCaddyMigration } from "@dokploy/server/setup/caddy-setup";
+import {
+ reloadCaddyAfterValidation,
+ validateCaddyConfigFileWithImage,
+ validateCaddyConfigWithContainer,
+} from "@dokploy/server/utils/caddy/config";
+import {
+ acquireCaddyMigrationOperationLock,
+ appendCaddyMigrationEvent,
+ copyMigrationFileInPlace,
+ copyMigrationPath,
+ loadCaddyMigrationReport,
+ migrationPathExists,
+ readMigrationTextFileIfExists,
+ removeMigrationPath,
+ writeCaddyMigrationReport,
+ writeMigrationTextFile,
+} from "./files";
+import { rollbackCaddyMigration } from "./rollback";
+import type {
+ CaddyMigrationBackupSummary,
+ CaddyMigrationFileBackup,
+ CaddyMigrationReport,
+ CaddyMigrationResourceSnapshot,
+} from "./types";
+import { runCaddyMigrationUpstreamPreflight } from "./upstream-preflight";
+
+const updateProviderToCaddy = async (serverId?: string | null) => {
+ if (serverId) {
+ await updateRemoteWebServerProvider(serverId, "caddy");
+ return;
+ }
+ await updateLocalWebServerProvider("caddy");
+};
+
+type CaddyMigrationCompileSettings = NonNullable<
+ CaddyMigrationReport["compileSettings"]
+>;
+
+const normalizeCompileSettings = (
+ settings: CaddyMigrationReport["compileSettings"],
+): CaddyMigrationCompileSettings => ({
+ letsEncryptEmail: settings?.letsEncryptEmail ?? null,
+ trustedProxies: settings?.trustedProxies ?? null,
+ accessLogs: settings?.accessLogs ?? null,
+});
+
+const compileSettingsChanged = (
+ prepared: CaddyMigrationReport["compileSettings"],
+ current: CaddyMigrationReport["compileSettings"],
+) =>
+ JSON.stringify(normalizeCompileSettings(prepared)) !==
+ JSON.stringify(normalizeCompileSettings(current));
+
+const createFileBackup = async (
+ label: string,
+ source: string,
+ backupPath: string,
+ serverId?: string | null,
+): Promise => {
+ const existed = await migrationPathExists(source, serverId);
+ if (existed) {
+ await copyMigrationPath(source, backupPath, serverId);
+ }
+ return { label, source, backupPath, existed };
+};
+
+const redactResourceSnapshot = (
+ snapshot: CaddyMigrationResourceSnapshot,
+): CaddyMigrationResourceSnapshot => ({
+ resourceName: snapshot.resourceName,
+ resourceType: snapshot.resourceType,
+ running: snapshot.running,
+ replicas: snapshot.replicas,
+ additionalPorts: snapshot.additionalPorts,
+});
+
+const createMigrationBackups = async (
+ report: CaddyMigrationReport,
+ serverId?: string,
+): Promise => {
+ const currentPaths = paths(!!serverId);
+ const backupRoot = report.artifactPaths.backupsDir;
+ const files: CaddyMigrationFileBackup[] = [];
+ files.push(
+ await createFileBackup(
+ "traefik-static",
+ path.posix.join(currentPaths.MAIN_TRAEFIK_PATH, "traefik.yml"),
+ path.posix.join(backupRoot, "traefik", "traefik.yml"),
+ serverId,
+ ),
+ );
+ files.push(
+ await createFileBackup(
+ "traefik-dynamic",
+ currentPaths.DYNAMIC_TRAEFIK_PATH,
+ path.posix.join(backupRoot, "traefik", "dynamic"),
+ serverId,
+ ),
+ );
+ files.push(
+ await createFileBackup(
+ "caddy-config",
+ currentPaths.CADDY_CONFIG_PATH,
+ path.posix.join(backupRoot, "caddy", "caddy.json"),
+ serverId,
+ ),
+ );
+ files.push(
+ await createFileBackup(
+ "caddy-fragments",
+ currentPaths.CADDY_FRAGMENTS_PATH,
+ path.posix.join(backupRoot, "caddy", "fragments"),
+ serverId,
+ ),
+ );
+
+ const [traefikResource, caddyResource] = await Promise.all([
+ getDockerResourceSnapshot("dokploy-traefik", serverId),
+ getDockerResourceSnapshot("dokploy-caddy", serverId),
+ ]);
+ const restoreSnapshotPath = path.posix.join(
+ backupRoot,
+ "resources.restore.json",
+ );
+ await writeMigrationTextFile(
+ restoreSnapshotPath,
+ `${JSON.stringify({ traefikResource, caddyResource }, null, 2)}\n`,
+ serverId,
+ );
+
+ return {
+ createdAt: new Date().toISOString(),
+ traefikResource: redactResourceSnapshot(traefikResource),
+ caddyResource: redactResourceSnapshot(caddyResource),
+ restoreSnapshotPath,
+ files,
+ };
+};
+
+const readResourceEnvAndPorts = async (
+ resourceName: string,
+ serverId?: string,
+) => {
+ try {
+ const [env, additionalPorts] = await Promise.all([
+ readEnvironmentVariables(resourceName, serverId),
+ readPorts(resourceName, serverId),
+ ]);
+ return { env, additionalPorts };
+ } catch {
+ return { env: "", additionalPorts: [] };
+ }
+};
+
+const validateAppliedCaddy = async (serverId?: string) => {
+ await waitForDockerResourceRunning("dokploy-caddy", serverId, {
+ retries: 30,
+ intervalMs: 1000,
+ });
+ await validateCaddyConfigWithContainer(serverId);
+};
+
+const getLetsEncryptEmailFromConfig = async (
+ caddyJsonPath: string,
+ serverId?: string,
+) => {
+ const content = await readMigrationTextFileIfExists(caddyJsonPath, serverId);
+ if (!content) return null;
+ try {
+ const config = JSON.parse(content) as {
+ apps?: {
+ tls?: {
+ automation?: {
+ policies?: Array<{
+ issuers?: Array<{ email?: string }>;
+ }>;
+ };
+ };
+ };
+ };
+ return (
+ config.apps?.tls?.automation?.policies?.[0]?.issuers?.[0]?.email ?? null
+ );
+ } catch {
+ return null;
+ }
+};
+
+const markFailed = async (
+ report: CaddyMigrationReport,
+ message: string,
+ serverId?: string,
+) => {
+ const warning = {
+ code: "apply-failed" as const,
+ message,
+ blocking: true,
+ };
+ return writeCaddyMigrationReport(
+ appendCaddyMigrationEvent(
+ {
+ ...report,
+ status: "failed",
+ warnings: [...report.warnings, warning],
+ summary: {
+ ...report.summary,
+ warnings: report.summary.warnings + 1,
+ blockingWarnings: report.summary.blockingWarnings + 1,
+ },
+ },
+ "apply_failed",
+ message,
+ ),
+ serverId,
+ );
+};
+
+export const applyCaddyMigration = async (input: {
+ migrationId: string;
+ serverId?: string;
+}) => {
+ const releaseLock = await acquireCaddyMigrationOperationLock(
+ input.migrationId,
+ input.serverId,
+ );
+ try {
+ return await applyCaddyMigrationUnlocked(input);
+ } finally {
+ await releaseLock();
+ }
+};
+
+const applyCaddyMigrationUnlocked = async (input: {
+ migrationId: string;
+ serverId?: string;
+}) => {
+ const serverId = input.serverId;
+ let report = await loadCaddyMigrationReport(input.migrationId, serverId);
+ if (report.summary.blockingWarnings > 0) {
+ throw new Error(
+ `Cannot apply Caddy migration ${input.migrationId}: report has ${report.summary.blockingWarnings} blocking warning(s)`,
+ );
+ }
+ if (report.validation.status !== "passed") {
+ throw new Error(
+ `Cannot apply Caddy migration ${input.migrationId}: draft validation did not pass`,
+ );
+ }
+ if (report.status !== "prepared") {
+ throw new Error(
+ `Cannot apply Caddy migration ${input.migrationId}: report status is ${report.status}`,
+ );
+ }
+ if (
+ !(await migrationPathExists(report.artifactPaths.caddyJson, serverId)) ||
+ !(await migrationPathExists(report.artifactPaths.fragmentsDir, serverId))
+ ) {
+ throw new Error(
+ `Cannot apply Caddy migration ${input.migrationId}: approved Caddy artifacts are missing`,
+ );
+ }
+ const provider = await resolveWebServerProvider(serverId);
+ if (provider !== "traefik") {
+ throw new Error(
+ `Cannot apply Caddy migration ${input.migrationId}: active provider is ${provider}`,
+ );
+ }
+ const currentCompileSettings = await getCaddyCompileSettings(serverId);
+ if (
+ report.compileSettings &&
+ compileSettingsChanged(report.compileSettings, currentCompileSettings)
+ ) {
+ const message = `Cannot apply Caddy migration ${input.migrationId}: Caddy compile settings changed after prepare. Prepare a fresh migration dry run before applying.`;
+ await markFailed(report, message, serverId);
+ throw new Error(message);
+ }
+
+ report = await writeCaddyMigrationReport(
+ appendCaddyMigrationEvent(
+ { ...report, status: "applying" },
+ "applying",
+ "Applying Caddy migration cutover",
+ ),
+ serverId,
+ );
+
+ const runtimePreflight = await runCaddyMigrationUpstreamPreflight(report, {
+ serverId,
+ });
+ report = await writeCaddyMigrationReport(
+ appendCaddyMigrationEvent(
+ { ...report, runtimePreflight },
+ "runtime_preflight",
+ `Runtime upstream preflight ${runtimePreflight.status}`,
+ ),
+ serverId,
+ );
+ if (runtimePreflight.status !== "passed") {
+ const failedChecks = runtimePreflight.checks.filter(
+ (check) => check.status === "failed",
+ );
+ const message =
+ runtimePreflight.status === "failed"
+ ? `Runtime upstream preflight failed for ${failedChecks.length} upstream check(s)`
+ : `Runtime upstream preflight did not pass: ${runtimePreflight.status}`;
+ await markFailed(report, message, serverId);
+ throw new Error(message);
+ }
+
+ try {
+ await validateCaddyConfigFileWithImage(
+ report.artifactPaths.caddyJson,
+ serverId,
+ );
+ } catch (error) {
+ const message = `Pre-stop Caddy runtime validation failed: ${
+ error instanceof Error ? error.message : "unknown error"
+ }`;
+ await markFailed(report, message, serverId);
+ throw error;
+ }
+ report = await writeCaddyMigrationReport(
+ appendCaddyMigrationEvent(
+ { ...report },
+ "pre_stop_caddy_validate",
+ "Prepared Caddy config validated with the runtime Caddy image before stopping Traefik",
+ ),
+ serverId,
+ );
+
+ try {
+ const backup = await createMigrationBackups(report, serverId);
+ report = await writeCaddyMigrationReport(
+ appendCaddyMigrationEvent(
+ { ...report, backup },
+ "backup_created",
+ "Backed up Traefik and Caddy configuration before cutover",
+ ),
+ serverId,
+ );
+
+ const currentPaths = paths(!!serverId);
+ await removeMigrationPath(currentPaths.CADDY_FRAGMENTS_PATH, serverId);
+ await copyMigrationPath(
+ report.artifactPaths.fragmentsDir,
+ currentPaths.CADDY_FRAGMENTS_PATH,
+ serverId,
+ );
+ await copyMigrationPath(
+ report.artifactPaths.caddyJson,
+ currentPaths.CADDY_CONFIG_PATH,
+ serverId,
+ );
+
+ const caddyRuntime = await readResourceEnvAndPorts(
+ "dokploy-caddy",
+ serverId,
+ );
+ const traefikRuntime = await readResourceEnvAndPorts(
+ "dokploy-traefik",
+ serverId,
+ );
+ const runtime = (await migrationPathExists(
+ currentPaths.CADDY_CONFIG_PATH,
+ serverId,
+ ))
+ ? {
+ env: caddyRuntime.env || undefined,
+ additionalPorts: caddyRuntime.additionalPorts.length
+ ? caddyRuntime.additionalPorts
+ : traefikRuntime.additionalPorts,
+ }
+ : {
+ env: undefined,
+ additionalPorts: traefikRuntime.additionalPorts,
+ };
+ const caddyAdditionalPorts = filterTraefikCarryOverPortsForCaddyMigration(
+ runtime.additionalPorts,
+ );
+ const droppedPorts = runtime.additionalPorts.filter(
+ (port) => !caddyAdditionalPorts.includes(port),
+ );
+ if (droppedPorts.length) {
+ report = await writeCaddyMigrationReport(
+ appendCaddyMigrationEvent(
+ { ...report },
+ "caddy_ports_filtered",
+ `Dropped Traefik-only additional port(s) for Caddy: ${droppedPorts
+ .map(
+ (port) =>
+ `${port.publishedPort}->${port.targetPort}/${port.protocol ?? "tcp"}`,
+ )
+ .join(", ")}`,
+ ),
+ serverId,
+ );
+ }
+
+ await stopDockerResource("dokploy-traefik", serverId);
+ await writeCaddySetup({
+ env: runtime.env ? runtime.env.split("\n").filter(Boolean) : undefined,
+ additionalPorts: caddyAdditionalPorts,
+ serverId,
+ letsEncryptEmail: await getLetsEncryptEmailFromConfig(
+ report.artifactPaths.caddyJson,
+ serverId,
+ ),
+ trustedProxies: currentCompileSettings.trustedProxies,
+ });
+ await copyMigrationFileInPlace(
+ report.artifactPaths.caddyJson,
+ currentPaths.CADDY_CONFIG_PATH,
+ serverId,
+ );
+ await reloadCaddyAfterValidation(serverId);
+ await validateAppliedCaddy(serverId);
+ await updateProviderToCaddy(serverId);
+
+ report = await writeCaddyMigrationReport(
+ appendCaddyMigrationEvent(
+ { ...report, status: "applied" },
+ "applied",
+ "Caddy migration applied successfully",
+ ),
+ serverId,
+ );
+ return report;
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Caddy migration apply failed";
+ report = await markFailed(report, message, serverId);
+ try {
+ await rollbackCaddyMigration({
+ migrationId: input.migrationId,
+ serverId,
+ skipOperationLock: true,
+ });
+ } catch (rollbackError) {
+ const rollbackMessage =
+ rollbackError instanceof Error
+ ? rollbackError.message
+ : "rollback failed";
+ throw new Error(`${message}; rollback also failed: ${rollbackMessage}`);
+ }
+ throw error;
+ }
+};
diff --git a/packages/server/src/utils/caddy/migration/compose-label-translator.ts b/packages/server/src/utils/caddy/migration/compose-label-translator.ts
new file mode 100644
index 0000000000..4a1d33583f
--- /dev/null
+++ b/packages/server/src/utils/caddy/migration/compose-label-translator.ts
@@ -0,0 +1,575 @@
+import type { Domain } from "@dokploy/server/services/domain";
+import { isDokployGeneratedTraefikLabel } from "../../docker/domain";
+import type { ListOrDict } from "../../docker/types";
+import type { HttpMiddleware } from "../../traefik/file-types";
+import type { CaddyRouteFragment, CaddyRouteIntent } from "../types";
+import {
+ defaultKnownTraefikFileMiddlewares,
+ translateTraefikMiddleware,
+} from "./dynamic-file-translator";
+import { parseTraefikRule } from "./traefik-rule-parser";
+import type { CaddyMigrationWarning, KnownTraefikMiddlewareMap } from "./types";
+
+interface ComposeLabelTranslatorOptions {
+ sourceFile?: string;
+ fragmentId?: string;
+ appName?: string;
+ serviceName?: string;
+ upstreamServiceName?: string;
+ upstreamNetwork?: string | null;
+ composeType?: "docker-compose" | "stack";
+ domains?: Domain[];
+ knownMiddlewares?: KnownTraefikMiddlewareMap;
+ fileMiddlewares?: Record;
+}
+
+interface ParsedRouterLabels {
+ rule?: string;
+ entryPoints?: string[];
+ middlewares?: string[];
+ service?: string;
+ priority?: number;
+ tls?: boolean;
+ tlsCertResolver?: string;
+}
+
+interface ParsedServiceLabels {
+ port?: number;
+ scheme?: string;
+}
+
+interface LabelClassification {
+ label: string;
+ dokployGenerated: boolean;
+}
+
+const CADDY_FRAGMENT_VERSION = 1;
+
+const toSafeId = (value: string) =>
+ value
+ .replace(/\.[^.]+$/, "")
+ .replace(/[^a-zA-Z0-9_.-]+/g, "-")
+ .replace(/^-+|-+$/g, "") || "compose-labels";
+
+const warning = (
+ message: string,
+ options: ComposeLabelTranslatorOptions & {
+ routerName?: string;
+ serviceName?: string;
+ middlewareName?: string;
+ label?: string;
+ code?: CaddyMigrationWarning["code"];
+ },
+): CaddyMigrationWarning => ({
+ code: options.code ?? "invalid-label",
+ message,
+ blocking: true,
+ source: options.sourceFile,
+ routerName: options.routerName,
+ serviceName: options.serviceName,
+ middlewareName: options.middlewareName,
+ label: options.label,
+});
+
+const labelsToStrings = (labels: ListOrDict | undefined): string[] => {
+ if (!labels) {
+ return [];
+ }
+ if (Array.isArray(labels)) {
+ return labels.map(String);
+ }
+ return Object.entries(labels).map(([key, value]) =>
+ value === null ? key : `${key}=${value}`,
+ );
+};
+
+const splitLabel = (label: string) => {
+ const separatorIndex = label.indexOf("=");
+ if (separatorIndex === -1) {
+ return { key: label, value: undefined };
+ }
+ return {
+ key: label.slice(0, separatorIndex),
+ value: label.slice(separatorIndex + 1),
+ };
+};
+
+const splitList = (value: string | undefined) =>
+ (value ?? "")
+ .split(",")
+ .map((item) => item.trim())
+ .filter(Boolean);
+
+const mergeTransforms = (
+ target: NonNullable,
+ next: NonNullable,
+) => {
+ target.requestHeaders = {
+ ...(target.requestHeaders ?? {}),
+ ...(next.requestHeaders ?? {}),
+ };
+ target.responseHeaders = {
+ ...(target.responseHeaders ?? {}),
+ ...(next.responseHeaders ?? {}),
+ };
+ if (next.stripPrefix) target.stripPrefix = next.stripPrefix;
+ if (next.addPrefix) target.addPrefix = next.addPrefix;
+};
+
+const mergeAllowedRemoteIps = (target: string[], next?: string[] | null) => {
+ if (!next?.length) {
+ return;
+ }
+ for (const range of next) {
+ if (!target.includes(range)) {
+ target.push(range);
+ }
+ }
+};
+
+const ensureMiddleware = (
+ middlewares: Record>,
+ name: string,
+) => {
+ middlewares[name] ??= {};
+ return middlewares[name];
+};
+
+const parseBoolean = (value: string | undefined) =>
+ value === undefined ? undefined : value === "true";
+
+const setMiddlewareLabel = (
+ middlewares: Record>,
+ name: string,
+ suffix: string,
+ value: string | undefined,
+) => {
+ const middleware = ensureMiddleware(middlewares, name);
+ const lowerSuffix = suffix.toLowerCase();
+ if (lowerSuffix === "stripprefix.prefixes") {
+ middleware.stripPrefix = { prefixes: splitList(value) };
+ return true;
+ }
+ if (lowerSuffix === "addprefix.prefix") {
+ middleware.addPrefix = { prefix: value };
+ return true;
+ }
+ if (lowerSuffix === "basicauth.users") {
+ middleware.basicAuth = {
+ ...(middleware.basicAuth as Record | undefined),
+ users: splitList(value),
+ };
+ return true;
+ }
+ if (lowerSuffix === "basicauth.usersfile") {
+ middleware.basicAuth = {
+ ...(middleware.basicAuth as Record | undefined),
+ usersFile: value,
+ };
+ return true;
+ }
+ if (lowerSuffix === "redirectscheme.scheme") {
+ middleware.redirectScheme = {
+ ...(middleware.redirectScheme as Record | undefined),
+ scheme: value,
+ };
+ return true;
+ }
+ if (lowerSuffix === "redirectscheme.permanent") {
+ middleware.redirectScheme = {
+ ...(middleware.redirectScheme as Record | undefined),
+ permanent: parseBoolean(value),
+ };
+ return true;
+ }
+ if (lowerSuffix === "redirectscheme.port") {
+ middleware.redirectScheme = {
+ ...(middleware.redirectScheme as Record | undefined),
+ port: value,
+ };
+ return true;
+ }
+ if (lowerSuffix === "chain.middlewares") {
+ middleware.chain = { middlewares: splitList(value) };
+ return true;
+ }
+ if (lowerSuffix.startsWith("ipallowlist.")) {
+ middleware.ipAllowList = {
+ ...(middleware.ipAllowList as Record | undefined),
+ [suffix.slice("ipallowlist.".length)]: lowerSuffix.endsWith("sourcerange")
+ ? splitList(value)
+ : value,
+ };
+ return true;
+ }
+ if (lowerSuffix.startsWith("ipwhitelist.")) {
+ middleware.ipWhiteList = {
+ ...(middleware.ipWhiteList as Record | undefined),
+ [suffix.slice("ipwhitelist.".length)]: lowerSuffix.endsWith("sourcerange")
+ ? splitList(value)
+ : value,
+ };
+ return true;
+ }
+ if (lowerSuffix.startsWith("headers.customresponseheaders.")) {
+ const header = suffix.slice("headers.customresponseheaders.".length);
+ middleware.headers = {
+ ...(middleware.headers as Record | undefined),
+ customResponseHeaders: {
+ ...((
+ middleware.headers as
+ | Record>
+ | undefined
+ )?.customResponseHeaders ?? {}),
+ [header]: value ?? "",
+ },
+ };
+ return true;
+ }
+ if (lowerSuffix.startsWith("headers.customrequestheaders.")) {
+ const header = suffix.slice("headers.customrequestheaders.".length);
+ middleware.headers = {
+ ...(middleware.headers as Record | undefined),
+ customRequestHeaders: {
+ ...((
+ middleware.headers as
+ | Record>
+ | undefined
+ )?.customRequestHeaders ?? {}),
+ [header]: value ?? "",
+ },
+ };
+ return true;
+ }
+ return false;
+};
+
+const parseComposeLabels = (
+ labels: string[],
+ options: ComposeLabelTranslatorOptions,
+) => {
+ const routers: Record = {};
+ const services: Record = {};
+ const middlewares: Record> = {};
+ const warnings: CaddyMigrationWarning[] = [];
+ const classifications: LabelClassification[] = [];
+
+ for (const label of labels) {
+ const { key, value } = splitLabel(label);
+ const dokployGenerated = isDokployGeneratedTraefikLabel(label, {
+ appName: options.appName,
+ domains: options.domains,
+ includeGenericLabels: true,
+ });
+ classifications.push({ label, dokployGenerated });
+
+ const routerMatch = key.match(/^traefik\.http\.routers\.([^.]+)\.(.+)$/);
+ if (routerMatch?.[1] && routerMatch[2]) {
+ const routerName = routerMatch[1];
+ const suffix = routerMatch[2];
+ routers[routerName] ??= {};
+ const lowerSuffix = suffix.toLowerCase();
+ if (lowerSuffix === "rule") routers[routerName].rule = value;
+ else if (lowerSuffix === "entrypoints") {
+ routers[routerName].entryPoints = splitList(value);
+ } else if (lowerSuffix === "middlewares") {
+ routers[routerName].middlewares = splitList(value);
+ } else if (lowerSuffix === "service") routers[routerName].service = value;
+ else if (lowerSuffix === "priority") {
+ const priority = Number(value);
+ if (Number.isFinite(priority)) routers[routerName].priority = priority;
+ } else if (lowerSuffix === "tls") {
+ routers[routerName].tls = parseBoolean(value) ?? value === "";
+ } else if (lowerSuffix === "tls.certresolver") {
+ routers[routerName].tlsCertResolver = value;
+ } else {
+ warnings.push(
+ warning(`Unsupported router label suffix "${suffix}"`, {
+ ...options,
+ routerName,
+ label,
+ code: "unsupported-router",
+ }),
+ );
+ }
+ continue;
+ }
+
+ const serviceMatch = key.match(/^traefik\.http\.services\.([^.]+)\.(.+)$/);
+ if (serviceMatch?.[1] && serviceMatch[2]) {
+ const serviceName = serviceMatch[1];
+ const suffix = serviceMatch[2];
+ services[serviceName] ??= {};
+ const lowerSuffix = suffix.toLowerCase();
+ if (lowerSuffix === "loadbalancer.server.port") {
+ const port = Number(value);
+ if (Number.isFinite(port)) services[serviceName].port = port;
+ } else if (lowerSuffix === "loadbalancer.server.scheme") {
+ services[serviceName].scheme = value;
+ } else {
+ warnings.push(
+ warning(`Unsupported service label suffix "${suffix}"`, {
+ ...options,
+ serviceName,
+ label,
+ code: "unsupported-service",
+ }),
+ );
+ }
+ continue;
+ }
+
+ const middlewareMatch = key.match(
+ /^traefik\.http\.middlewares\.([^.]+)\.(.+)$/,
+ );
+ if (middlewareMatch?.[1] && middlewareMatch[2]) {
+ const middlewareName = middlewareMatch[1];
+ const suffix = middlewareMatch[2];
+ if (!setMiddlewareLabel(middlewares, middlewareName, suffix, value)) {
+ warnings.push(
+ warning(`Unsupported middleware label suffix "${suffix}"`, {
+ ...options,
+ middlewareName,
+ label,
+ code: "unsupported-middleware",
+ }),
+ );
+ }
+ continue;
+ }
+
+ if (key.startsWith("traefik.http.")) {
+ warnings.push(
+ warning(`Unsupported Traefik HTTP label "${key}"`, {
+ ...options,
+ label,
+ code: "invalid-label",
+ }),
+ );
+ }
+ }
+
+ return { routers, services, middlewares, warnings, classifications };
+};
+
+const isSecurityMiddlewareWarning = (item: CaddyMigrationWarning) =>
+ item.code === "unsupported-security-middleware";
+
+const dedupeWarnings = (warnings: CaddyMigrationWarning[]) => {
+ const seen = new Set();
+ return warnings.filter((item) => {
+ const key = [
+ item.code,
+ item.source ?? "",
+ item.routerName ?? "",
+ item.serviceName ?? "",
+ item.middlewareName ?? "",
+ item.label ?? "",
+ item.message,
+ ].join("\0");
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+};
+
+const routerUsesHttps = (router: ParsedRouterLabels) =>
+ Boolean(router.tls) ||
+ Boolean(router.tlsCertResolver) ||
+ Boolean(
+ router.entryPoints?.includes("websecure") &&
+ !router.entryPoints?.includes("web"),
+ );
+
+const getRouterUpstreams = (
+ routerName: string,
+ router: ParsedRouterLabels,
+ services: Record,
+ options: ComposeLabelTranslatorOptions,
+) => {
+ const serviceName = router.service ?? routerName;
+ const service = services[serviceName] ?? services[routerName];
+ if (!service?.port) {
+ return {
+ upstreams: [],
+ warnings: [
+ warning(
+ `Router "${routerName}" references service "${serviceName}" without a migrated port label`,
+ {
+ ...options,
+ routerName,
+ serviceName,
+ code: "unresolved-service",
+ },
+ ),
+ ],
+ };
+ }
+ const scheme = service.scheme ?? "http";
+ const upstreamName =
+ options.upstreamServiceName ?? options.serviceName ?? serviceName;
+ return {
+ upstreams: [`${scheme}://${upstreamName}:${service.port}`],
+ warnings: [],
+ };
+};
+
+export const translateTraefikComposeLabelsToCaddyFragment = (
+ labelsInput: ListOrDict | undefined,
+ options: ComposeLabelTranslatorOptions = {},
+): {
+ fragment: CaddyRouteFragment;
+ routes: CaddyRouteIntent[];
+ warnings: CaddyMigrationWarning[];
+ classifications: LabelClassification[];
+} => {
+ const labels = labelsToStrings(labelsInput);
+ const initialParsed = parseComposeLabels(labels, options);
+ const generatedSecurityWarnings = initialParsed.classifications.some(
+ (item) => item.dokployGenerated,
+ )
+ ? translateParsedComposeRoutes(initialParsed, options).warnings.filter(
+ isSecurityMiddlewareWarning,
+ )
+ : [];
+ const hasManualLabels = initialParsed.classifications.some(
+ (item) => !item.dokployGenerated,
+ );
+ const hasGeneratedLabels = initialParsed.classifications.some(
+ (item) => item.dokployGenerated,
+ );
+ const labelsForTranslation =
+ hasManualLabels && hasGeneratedLabels
+ ? labels.filter(
+ (_, index) => !initialParsed.classifications[index]?.dokployGenerated,
+ )
+ : labels;
+ const parsed =
+ labelsForTranslation === labels
+ ? initialParsed
+ : parseComposeLabels(labelsForTranslation, options);
+ const warnings = dedupeWarnings([
+ ...parsed.warnings,
+ ...generatedSecurityWarnings,
+ ]);
+ const translatedRoutes = translateParsedComposeRoutes(parsed, options);
+ warnings.push(...translatedRoutes.warnings);
+ return {
+ ...translatedRoutes,
+ warnings: dedupeWarnings(warnings),
+ classifications: initialParsed.classifications,
+ };
+};
+
+const translateParsedComposeRoutes = (
+ parsed: ReturnType,
+ options: ComposeLabelTranslatorOptions,
+) => {
+ const warnings: CaddyMigrationWarning[] = [];
+ const routes: CaddyRouteIntent[] = [];
+ const middlewares = {
+ ...(options.fileMiddlewares ?? {}),
+ ...(parsed.middlewares as Record),
+ };
+
+ for (const [routerName, router] of Object.entries(parsed.routers)) {
+ if (!router.rule) {
+ warnings.push(
+ warning(`Router "${routerName}" is missing a rule label`, {
+ ...options,
+ routerName,
+ code: "unsupported-router",
+ }),
+ );
+ continue;
+ }
+
+ const rule = parseTraefikRule(router.rule, {
+ source: options.sourceFile,
+ routerName,
+ });
+ warnings.push(...rule.warnings);
+
+ const transforms = {};
+ const basicAuth: { username: string; hash: string }[] = [];
+ const allowedRemoteIps: string[] = [];
+ let redirectScheme: CaddyRouteIntent["redirectScheme"] = null;
+ for (const middlewareName of router.middlewares ?? []) {
+ const translated = translateTraefikMiddleware(
+ middlewareName,
+ middlewares,
+ {
+ sourceFile: options.sourceFile,
+ routerName,
+ knownMiddlewares: {
+ ...defaultKnownTraefikFileMiddlewares,
+ ...(options.knownMiddlewares ?? {}),
+ },
+ },
+ );
+ mergeTransforms(transforms, translated.transforms);
+ basicAuth.push(...translated.basicAuth);
+ mergeAllowedRemoteIps(allowedRemoteIps, translated.allowedRemoteIps);
+ if (translated.redirectScheme) {
+ redirectScheme = translated.redirectScheme;
+ }
+ warnings.push(...translated.warnings);
+ }
+
+ if (router.tlsCertResolver && router.tlsCertResolver !== "letsencrypt") {
+ warnings.push(
+ warning(
+ `Custom certResolver "${router.tlsCertResolver}" has no direct Caddy mapping`,
+ {
+ ...options,
+ routerName,
+ code: "unsupported-router",
+ },
+ ),
+ );
+ }
+
+ const upstreamResult = redirectScheme
+ ? { upstreams: [], warnings: [] }
+ : getRouterUpstreams(routerName, router, parsed.services, options);
+ warnings.push(...upstreamResult.warnings);
+
+ rule.matches.forEach((match, index) => {
+ routes.push({
+ id: `${toSafeId(options.sourceFile ?? options.appName ?? "compose")}-${routerName}${rule.matches.length > 1 ? `-${index + 1}` : ""}`,
+ source: "traefik-compose-label",
+ hosts: match.hosts,
+ pathPrefix: match.pathPrefix,
+ pathExact: match.pathExact,
+ https: redirectScheme ? false : routerUsesHttps(router),
+ priority: router.priority ?? null,
+ upstreams: upstreamResult.upstreams,
+ upstreamNetwork: options.upstreamNetwork,
+ transforms,
+ allowedRemoteIps: allowedRemoteIps.length ? allowedRemoteIps : null,
+ basicAuth: basicAuth.length ? basicAuth : null,
+ redirectScheme,
+ });
+ });
+ }
+
+ const fragment: CaddyRouteFragment = {
+ version: CADDY_FRAGMENT_VERSION,
+ id:
+ options.fragmentId ??
+ `migration.traefik-compose-label.${toSafeId(
+ options.sourceFile ?? options.appName ?? "compose",
+ )}`,
+ source: "traefik-compose-label",
+ description: options.sourceFile
+ ? `Migrated Traefik compose labels from ${options.sourceFile}`
+ : "Migrated Traefik compose labels",
+ routes,
+ };
+
+ return {
+ fragment,
+ routes,
+ warnings,
+ };
+};
diff --git a/packages/server/src/utils/caddy/migration/dynamic-file-translator.ts b/packages/server/src/utils/caddy/migration/dynamic-file-translator.ts
new file mode 100644
index 0000000000..cd75b2aa92
--- /dev/null
+++ b/packages/server/src/utils/caddy/migration/dynamic-file-translator.ts
@@ -0,0 +1,736 @@
+import { parse } from "yaml";
+import type { FileConfig, HttpMiddleware } from "../../traefik/file-types";
+import type {
+ CaddyHeaderMap,
+ CaddyRouteFragment,
+ CaddyRouteIntent,
+ CaddyRouteTransform,
+} from "../types";
+import { parseTraefikRule } from "./traefik-rule-parser";
+import type {
+ CaddyMiddlewareTranslation,
+ CaddyMigrationWarning,
+ KnownTraefikMiddlewareMap,
+ ResolvedCaddyMiddleware,
+} from "./types";
+
+interface DynamicTranslatorOptions {
+ sourceFile?: string;
+ fragmentId?: string;
+ knownMiddlewares?: KnownTraefikMiddlewareMap;
+ fileMiddlewares?: Record;
+}
+
+const CADDY_FRAGMENT_VERSION = 1;
+const httpUrlPrefixRegex = /^https?:\/\//i;
+
+export const defaultKnownTraefikFileMiddlewares: KnownTraefikMiddlewareMap = {
+ "redirect-to-https": {
+ redirectScheme: { scheme: "https", permanent: true },
+ },
+ "security-headers": {
+ transforms: {
+ responseHeaders: {
+ "X-Content-Type-Options": "nosniff",
+ "X-Frame-Options": "DENY",
+ },
+ },
+ },
+};
+
+const toSafeId = (value: string) =>
+ value
+ .replace(/\.[^.]+$/, "")
+ .replace(/[^a-zA-Z0-9_.-]+/g, "-")
+ .replace(/^-+|-+$/g, "") || "traefik-dynamic";
+
+const warning = (
+ message: string,
+ options: {
+ source?: string;
+ routerName?: string;
+ serviceName?: string;
+ middlewareName?: string;
+ code?: CaddyMigrationWarning["code"];
+ },
+): CaddyMigrationWarning => ({
+ code: options.code ?? "unsupported-middleware",
+ message,
+ blocking: true,
+ source: options.source,
+ routerName: options.routerName,
+ serviceName: options.serviceName,
+ middlewareName: options.middlewareName,
+});
+
+const asRecord = (value: unknown) =>
+ value && typeof value === "object" ? (value as Record) : {};
+
+const mergeHeaders = (
+ a?: CaddyHeaderMap | null,
+ b?: CaddyHeaderMap | null,
+) => ({
+ ...(a ?? {}),
+ ...(b ?? {}),
+});
+
+const mergeTransforms = (
+ target: CaddyRouteTransform,
+ next: CaddyRouteTransform = {},
+) => {
+ target.requestHeaders = mergeHeaders(
+ target.requestHeaders,
+ next.requestHeaders,
+ );
+ target.responseHeaders = mergeHeaders(
+ target.responseHeaders,
+ next.responseHeaders,
+ );
+ if (next.stripPrefix) {
+ target.stripPrefix = next.stripPrefix;
+ }
+ if (next.addPrefix) {
+ target.addPrefix = next.addPrefix;
+ }
+};
+
+const mergeAllowedRemoteIps = (target: string[], next?: string[] | null) => {
+ if (!next?.length) {
+ return;
+ }
+ for (const range of next) {
+ if (!target.includes(range)) {
+ target.push(range);
+ }
+ }
+};
+
+const normalizeMiddlewareName = (name: string) => name.replace(/@file$/, "");
+
+const knownMiddlewareToTranslation = (
+ middleware: Partial & {
+ responseHeaders?: CaddyHeaderMap;
+ requestHeaders?: CaddyHeaderMap;
+ },
+): ResolvedCaddyMiddleware => ({
+ transforms: {
+ ...(middleware.transforms ?? {}),
+ ...(middleware.requestHeaders || middleware.responseHeaders
+ ? {
+ requestHeaders: middleware.requestHeaders,
+ responseHeaders: middleware.responseHeaders,
+ }
+ : {}),
+ },
+ basicAuth: middleware.basicAuth ?? [],
+ allowedRemoteIps: middleware.allowedRemoteIps ?? null,
+ redirectScheme: middleware.redirectScheme ?? null,
+});
+
+const setResponseHeader = (
+ transforms: CaddyRouteTransform,
+ name: string,
+ value: string,
+) => {
+ transforms.responseHeaders = {
+ ...(transforms.responseHeaders ?? {}),
+ [name]: value,
+ };
+};
+
+const translateHeadersMiddleware = (headers: Record) => {
+ const transforms: CaddyRouteTransform = {};
+ const customRequestHeaders = asRecord(headers.customRequestHeaders);
+ const customResponseHeaders = asRecord(headers.customResponseHeaders);
+ if (Object.keys(customRequestHeaders).length) {
+ transforms.requestHeaders = customRequestHeaders as CaddyHeaderMap;
+ }
+ if (Object.keys(customResponseHeaders).length) {
+ transforms.responseHeaders = customResponseHeaders as CaddyHeaderMap;
+ }
+ if (headers.frameDeny === true) {
+ setResponseHeader(transforms, "X-Frame-Options", "DENY");
+ }
+ if (typeof headers.customFrameOptionsValue === "string") {
+ setResponseHeader(
+ transforms,
+ "X-Frame-Options",
+ headers.customFrameOptionsValue,
+ );
+ }
+ if (headers.contentTypeNosniff === true) {
+ setResponseHeader(transforms, "X-Content-Type-Options", "nosniff");
+ }
+ if (headers.browserXssFilter === true) {
+ setResponseHeader(transforms, "X-XSS-Protection", "1; mode=block");
+ }
+ if (typeof headers.customBrowserXSSValue === "string") {
+ setResponseHeader(
+ transforms,
+ "X-XSS-Protection",
+ headers.customBrowserXSSValue,
+ );
+ }
+ if (typeof headers.contentSecurityPolicy === "string") {
+ setResponseHeader(
+ transforms,
+ "Content-Security-Policy",
+ headers.contentSecurityPolicy,
+ );
+ }
+ if (typeof headers.referrerPolicy === "string") {
+ setResponseHeader(transforms, "Referrer-Policy", headers.referrerPolicy);
+ }
+ if (typeof headers.permissionsPolicy === "string") {
+ setResponseHeader(
+ transforms,
+ "Permissions-Policy",
+ headers.permissionsPolicy,
+ );
+ }
+ if (typeof headers.featurePolicy === "string") {
+ setResponseHeader(transforms, "Feature-Policy", headers.featurePolicy);
+ }
+ if (typeof headers.stsSeconds === "number" && headers.stsSeconds > 0) {
+ setResponseHeader(
+ transforms,
+ "Strict-Transport-Security",
+ [
+ `max-age=${headers.stsSeconds}`,
+ headers.stsIncludeSubdomains === true ? "includeSubDomains" : null,
+ headers.stsPreload === true ? "preload" : null,
+ ]
+ .filter(Boolean)
+ .join("; "),
+ );
+ }
+ return transforms;
+};
+
+const parseBasicAuthUsers = (users: unknown) => {
+ if (!Array.isArray(users)) {
+ return [];
+ }
+ return users.flatMap((user) => {
+ if (typeof user !== "string") {
+ return [];
+ }
+ const separatorIndex = user.indexOf(":");
+ if (separatorIndex === -1) {
+ return [];
+ }
+ return [
+ {
+ username: user.slice(0, separatorIndex),
+ hash: user.slice(separatorIndex + 1),
+ },
+ ];
+ });
+};
+
+const isLikelyCaddySupportedBasicAuthHash = (hash: string) =>
+ /^\$2[aby]\$/.test(hash);
+
+const findCaseInsensitiveValue = (
+ record: Record,
+ key: string,
+) => {
+ const lowerKey = key.toLowerCase();
+ const entry = Object.entries(record).find(
+ ([entryKey]) => entryKey.toLowerCase() === lowerKey,
+ );
+ return entry?.[1];
+};
+
+const getIpAllowRanges = (middleware: Record) => {
+ const ipConfig = asRecord(middleware.ipAllowList ?? middleware.ipWhiteList);
+ const sourceRange = findCaseInsensitiveValue(ipConfig, "sourceRange");
+ if (!Array.isArray(sourceRange)) {
+ return [];
+ }
+ return sourceRange
+ .map((item) => `${item}`.trim())
+ .filter((item) => item.length > 0);
+};
+
+const normalizeTraefikServerUrl = (value: string) => {
+ if (!httpUrlPrefixRegex.test(value)) {
+ return value;
+ }
+ try {
+ const url = new URL(value);
+ if (url.port) {
+ return value;
+ }
+ const scheme = url.protocol.replace(":", "");
+ const defaultPort = scheme === "https" ? "443" : "80";
+ return `${scheme}://${url.hostname}:${defaultPort}`;
+ } catch {
+ return value;
+ }
+};
+
+export const translateTraefikMiddleware = (
+ middlewareName: string,
+ middlewares: Record = {},
+ options: DynamicTranslatorOptions & {
+ routerName?: string;
+ seen?: Set;
+ } = {},
+): CaddyMiddlewareTranslation => {
+ const normalizedName = normalizeMiddlewareName(middlewareName);
+ const source = options.sourceFile;
+ const seen = options.seen ?? new Set();
+ if (seen.has(normalizedName)) {
+ return {
+ transforms: {},
+ basicAuth: [],
+ warnings: [
+ warning(`Middleware chain cycle at "${normalizedName}"`, {
+ source,
+ routerName: options.routerName,
+ middlewareName: normalizedName,
+ }),
+ ],
+ };
+ }
+
+ const knownMiddleware = {
+ ...defaultKnownTraefikFileMiddlewares,
+ ...(options.knownMiddlewares ?? {}),
+ }[normalizedName];
+ if (knownMiddleware && !middlewares[normalizedName]) {
+ return {
+ ...knownMiddlewareToTranslation(knownMiddleware),
+ warnings: [],
+ };
+ }
+
+ const middleware = middlewares[normalizedName];
+ if (!middleware) {
+ return {
+ transforms: {},
+ basicAuth: [],
+ warnings: [
+ warning(`Referenced middleware "${middlewareName}" was not found`, {
+ source,
+ routerName: options.routerName,
+ middlewareName,
+ code: "unresolved-middleware",
+ }),
+ ],
+ };
+ }
+
+ const middlewareRecord = middleware as Record;
+ const transforms: CaddyRouteTransform = {};
+ const basicAuth: { username: string; hash: string }[] = [];
+ const allowedRemoteIps: string[] = [];
+ const warnings: CaddyMigrationWarning[] = [];
+ let redirectScheme: CaddyMiddlewareTranslation["redirectScheme"] = null;
+
+ const securityMiddleware = middlewareRecord.ipAllowList
+ ? "ipAllowList"
+ : middlewareRecord.ipWhiteList
+ ? "ipWhiteList"
+ : null;
+ if (securityMiddleware) {
+ const ranges = getIpAllowRanges(middlewareRecord);
+ if (ranges.length) {
+ mergeAllowedRemoteIps(allowedRemoteIps, ranges);
+ } else {
+ warnings.push(
+ warning(
+ `Traefik middleware "${normalizedName}" uses ${securityMiddleware} without a migratable sourceRange`,
+ {
+ source,
+ routerName: options.routerName,
+ middlewareName: normalizedName,
+ code: "unsupported-security-middleware",
+ },
+ ),
+ );
+ }
+ } else if (middlewareRecord.headers) {
+ mergeTransforms(
+ transforms,
+ translateHeadersMiddleware(asRecord(middlewareRecord.headers)),
+ );
+ } else if (middlewareRecord.stripPrefix) {
+ const prefixes = asRecord(middlewareRecord.stripPrefix).prefixes;
+ if (Array.isArray(prefixes) && prefixes.length === 1) {
+ transforms.stripPrefix = `${prefixes[0]}`;
+ } else {
+ warnings.push(
+ warning("stripPrefix migration supports exactly one prefix", {
+ source,
+ routerName: options.routerName,
+ middlewareName: normalizedName,
+ }),
+ );
+ }
+ } else if (middlewareRecord.addPrefix) {
+ const prefix = asRecord(middlewareRecord.addPrefix).prefix;
+ if (typeof prefix === "string") {
+ transforms.addPrefix = prefix;
+ } else {
+ warnings.push(
+ warning("addPrefix middleware is missing a string prefix", {
+ source,
+ routerName: options.routerName,
+ middlewareName: normalizedName,
+ }),
+ );
+ }
+ } else if (middlewareRecord.basicAuth) {
+ const basicAuthConfig = asRecord(middlewareRecord.basicAuth);
+ for (const account of parseBasicAuthUsers(basicAuthConfig.users)) {
+ if (isLikelyCaddySupportedBasicAuthHash(account.hash)) {
+ basicAuth.push(account);
+ continue;
+ }
+ warnings.push(
+ warning(
+ `basicAuth user "${account.username}" uses a hash format that is not supported by the Caddy migration`,
+ {
+ source,
+ routerName: options.routerName,
+ middlewareName: normalizedName,
+ code: "unsupported-security-middleware",
+ },
+ ),
+ );
+ }
+ if (basicAuthConfig.usersFile) {
+ warnings.push(
+ warning("basicAuth usersFile cannot be inlined into Caddy migration", {
+ source,
+ routerName: options.routerName,
+ middlewareName: normalizedName,
+ }),
+ );
+ }
+ } else if (middlewareRecord.redirectScheme) {
+ const redirect = asRecord(middlewareRecord.redirectScheme);
+ redirectScheme = {
+ scheme: typeof redirect.scheme === "string" ? redirect.scheme : "https",
+ permanent: redirect.permanent !== false,
+ port: typeof redirect.port === "string" ? redirect.port : null,
+ };
+ } else if (middlewareRecord.chain) {
+ const chain = asRecord(middlewareRecord.chain).middlewares;
+ if (!Array.isArray(chain)) {
+ warnings.push(
+ warning("chain middleware is missing middlewares", {
+ source,
+ routerName: options.routerName,
+ middlewareName: normalizedName,
+ }),
+ );
+ } else {
+ const nextSeen = new Set([...seen, normalizedName]);
+ for (const chainedName of chain) {
+ const translated = translateTraefikMiddleware(
+ `${chainedName}`,
+ middlewares,
+ {
+ ...options,
+ seen: nextSeen,
+ },
+ );
+ mergeTransforms(transforms, translated.transforms);
+ basicAuth.push(...translated.basicAuth);
+ mergeAllowedRemoteIps(allowedRemoteIps, translated.allowedRemoteIps);
+ if (translated.redirectScheme) {
+ redirectScheme = translated.redirectScheme;
+ }
+ warnings.push(...translated.warnings);
+ }
+ }
+ } else {
+ warnings.push(
+ warning(`Unsupported Traefik middleware "${normalizedName}"`, {
+ source,
+ routerName: options.routerName,
+ middlewareName: normalizedName,
+ }),
+ );
+ }
+
+ return {
+ transforms,
+ basicAuth,
+ allowedRemoteIps: allowedRemoteIps.length ? allowedRemoteIps : null,
+ redirectScheme,
+ warnings,
+ };
+};
+
+const collectRouterMiddlewares = (
+ middlewareNames: string[] | undefined,
+ middlewares: Record,
+ options: DynamicTranslatorOptions & { routerName: string },
+) => {
+ const transforms: CaddyRouteTransform = {};
+ const basicAuth: { username: string; hash: string }[] = [];
+ const allowedRemoteIps: string[] = [];
+ const warnings: CaddyMigrationWarning[] = [];
+ let redirectScheme: CaddyMiddlewareTranslation["redirectScheme"] = null;
+
+ for (const middlewareName of middlewareNames ?? []) {
+ const translated = translateTraefikMiddleware(
+ middlewareName,
+ middlewares,
+ options,
+ );
+ mergeTransforms(transforms, translated.transforms);
+ basicAuth.push(...translated.basicAuth);
+ mergeAllowedRemoteIps(allowedRemoteIps, translated.allowedRemoteIps);
+ if (translated.redirectScheme) {
+ redirectScheme = translated.redirectScheme;
+ }
+ warnings.push(...translated.warnings);
+ }
+
+ return {
+ transforms,
+ basicAuth,
+ allowedRemoteIps: allowedRemoteIps.length ? allowedRemoteIps : null,
+ redirectScheme,
+ warnings,
+ };
+};
+
+const getServiceUpstreams = (
+ serviceName: string,
+ config: FileConfig,
+ options: DynamicTranslatorOptions & { routerName: string },
+) => {
+ const service = config.http?.services?.[normalizeMiddlewareName(serviceName)];
+ if (!service) {
+ return {
+ upstreams: [],
+ warnings: [
+ warning(`Referenced service "${serviceName}" was not found`, {
+ source: options.sourceFile,
+ routerName: options.routerName,
+ serviceName,
+ code: "unresolved-service",
+ }),
+ ],
+ };
+ }
+ const loadBalancer = (service as Record).loadBalancer;
+ if (!loadBalancer) {
+ return {
+ upstreams: [],
+ warnings: [
+ warning(`Service "${serviceName}" is not a loadBalancer service`, {
+ source: options.sourceFile,
+ routerName: options.routerName,
+ serviceName,
+ code: "unsupported-service",
+ }),
+ ],
+ };
+ }
+ const loadBalancerRecord = asRecord(loadBalancer);
+ const servers = loadBalancerRecord.servers;
+ const upstreams = Array.isArray(servers)
+ ? servers.flatMap((server) => {
+ const url = asRecord(server).url;
+ return typeof url === "string" ? [normalizeTraefikServerUrl(url)] : [];
+ })
+ : [];
+ const warnings: CaddyMigrationWarning[] = [];
+ if (!upstreams.length) {
+ warnings.push(
+ warning(`Service "${serviceName}" has no loadBalancer servers`, {
+ source: options.sourceFile,
+ routerName: options.routerName,
+ serviceName,
+ code: "unsupported-service",
+ }),
+ );
+ }
+ if (loadBalancerRecord.passHostHeader === false) {
+ warnings.push(
+ warning("passHostHeader=false has no direct migration mapping yet", {
+ source: options.sourceFile,
+ routerName: options.routerName,
+ serviceName,
+ code: "unsupported-service",
+ }),
+ );
+ }
+ for (const key of [
+ "sticky",
+ "healthCheck",
+ "responseForwarding",
+ "serversTransport",
+ ]) {
+ if (loadBalancerRecord[key]) {
+ warnings.push(
+ warning(`Service option "${key}" is not migrated yet`, {
+ source: options.sourceFile,
+ routerName: options.routerName,
+ serviceName,
+ code: "unsupported-service",
+ }),
+ );
+ }
+ }
+ return { upstreams, warnings };
+};
+
+const routerUsesHttps = (router: Record) => {
+ if (router.tls) {
+ return true;
+ }
+ const entryPoints = router.entryPoints;
+ return Array.isArray(entryPoints)
+ ? entryPoints.includes("websecure") && !entryPoints.includes("web")
+ : false;
+};
+
+const isTraefikInternalRouter = (
+ routerName: string,
+ router: Record,
+) => {
+ const service = typeof router.service === "string" ? router.service : "";
+ const entryPoints = Array.isArray(router.entryPoints)
+ ? router.entryPoints.map(String)
+ : [];
+ return (
+ service === "api@internal" ||
+ (routerName.includes("traefik-dashboard") &&
+ entryPoints.includes("traefik"))
+ );
+};
+
+export const translateTraefikDynamicConfigToCaddyFragment = (
+ input: string | FileConfig,
+ options: DynamicTranslatorOptions = {},
+): {
+ fragment: CaddyRouteFragment;
+ routes: CaddyRouteIntent[];
+ warnings: CaddyMigrationWarning[];
+} => {
+ let config: FileConfig;
+ try {
+ config = typeof input === "string" ? (parse(input) as FileConfig) : input;
+ } catch (error) {
+ const fragment: CaddyRouteFragment = {
+ version: CADDY_FRAGMENT_VERSION,
+ id: options.fragmentId ?? "migration.traefik-dynamic.invalid",
+ source: "traefik-dynamic-file",
+ routes: [],
+ };
+ return {
+ fragment,
+ routes: [],
+ warnings: [
+ warning(
+ error instanceof Error
+ ? error.message
+ : "Invalid Traefik dynamic config",
+ { source: options.sourceFile, code: "invalid-config" },
+ ),
+ ],
+ };
+ }
+
+ const routes: CaddyRouteIntent[] = [];
+ const warnings: CaddyMigrationWarning[] = [];
+ const mergedMiddlewares = {
+ ...(options.fileMiddlewares ?? {}),
+ ...(config.http?.middlewares ?? {}),
+ };
+ for (const [routerName, router] of Object.entries(
+ config.http?.routers ?? {},
+ )) {
+ const routerRecord = router as unknown as Record;
+ if (isTraefikInternalRouter(routerName, routerRecord)) {
+ warnings.push({
+ code: "unsupported-router",
+ message: `Skipped Traefik internal router "${routerName}"; Caddy migration does not expose api@internal/dashboard routes`,
+ blocking: false,
+ source: options.sourceFile,
+ routerName,
+ serviceName:
+ typeof routerRecord.service === "string"
+ ? routerRecord.service
+ : undefined,
+ });
+ continue;
+ }
+ const rule = typeof router.rule === "string" ? router.rule : "";
+ const parsedRule = parseTraefikRule(rule, {
+ source: options.sourceFile,
+ routerName,
+ });
+ warnings.push(...parsedRule.warnings);
+
+ const middlewareResult = collectRouterMiddlewares(
+ router.middlewares,
+ mergedMiddlewares,
+ { ...options, routerName },
+ );
+ warnings.push(...middlewareResult.warnings);
+
+ const serviceName = router.service;
+ const serviceResult = middlewareResult.redirectScheme
+ ? { upstreams: [], warnings: [] }
+ : getServiceUpstreams(serviceName, config, { ...options, routerName });
+ warnings.push(...serviceResult.warnings);
+
+ const tls = asRecord(routerRecord.tls);
+ if (tls.certResolver && tls.certResolver !== "letsencrypt") {
+ warnings.push(
+ warning(
+ `Custom certResolver "${tls.certResolver}" has no direct Caddy mapping`,
+ {
+ source: options.sourceFile,
+ routerName,
+ code: "unsupported-router",
+ },
+ ),
+ );
+ }
+
+ parsedRule.matches.forEach((match, index) => {
+ routes.push({
+ id: `${toSafeId(options.sourceFile ?? "dynamic")}-${routerName}${parsedRule.matches.length > 1 ? `-${index + 1}` : ""}`,
+ source: "traefik-dynamic-file",
+ hosts: match.hosts,
+ pathPrefix: match.pathPrefix,
+ pathExact: match.pathExact,
+ https: middlewareResult.redirectScheme
+ ? false
+ : routerUsesHttps(routerRecord),
+ priority: router.priority ?? null,
+ upstreams: serviceResult.upstreams,
+ transforms: middlewareResult.transforms,
+ allowedRemoteIps: middlewareResult.allowedRemoteIps,
+ basicAuth: middlewareResult.basicAuth.length
+ ? middlewareResult.basicAuth
+ : null,
+ redirectScheme: middlewareResult.redirectScheme,
+ });
+ });
+ }
+
+ const fragment: CaddyRouteFragment = {
+ version: CADDY_FRAGMENT_VERSION,
+ id:
+ options.fragmentId ??
+ `migration.traefik-dynamic.${toSafeId(options.sourceFile ?? "manual")}`,
+ source: "traefik-dynamic-file",
+ description: options.sourceFile
+ ? `Migrated Traefik dynamic file ${options.sourceFile}`
+ : "Migrated Traefik dynamic config",
+ routes,
+ };
+
+ return { fragment, routes, warnings };
+};
diff --git a/packages/server/src/utils/caddy/migration/files.ts b/packages/server/src/utils/caddy/migration/files.ts
new file mode 100644
index 0000000000..b5bcbc7cc6
--- /dev/null
+++ b/packages/server/src/utils/caddy/migration/files.ts
@@ -0,0 +1,335 @@
+import { randomUUID } from "node:crypto";
+import {
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ readFileSync,
+ renameSync,
+ rmSync,
+ statSync,
+ writeFileSync,
+} from "node:fs";
+import * as path from "node:path";
+import { paths } from "@dokploy/server/constants";
+import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
+import { quote } from "shell-quote";
+import type {
+ CaddyMigrationArtifactPaths,
+ CaddyMigrationReport,
+} from "./types";
+
+export const assertSafeMigrationId = (migrationId: string) => {
+ if (
+ !/^[a-zA-Z0-9_.-]+$/.test(migrationId) ||
+ migrationId === "." ||
+ migrationId === ".." ||
+ migrationId.split(".").some((segment) => segment === "")
+ ) {
+ throw new Error(
+ `Invalid Caddy migration id "${migrationId}". Use letters, numbers, dash, underscore, or dot only; dot path segments are not allowed.`,
+ );
+ }
+};
+
+const assertPosixPathWithinBase = (basePath: string, targetPath: string) => {
+ const resolvedBase = path.posix.resolve(basePath);
+ const resolvedTarget = path.posix.resolve(targetPath);
+ if (
+ resolvedTarget !== resolvedBase &&
+ !resolvedTarget.startsWith(`${resolvedBase}/`)
+ ) {
+ throw new Error(`Resolved path "${targetPath}" escapes "${basePath}"`);
+ }
+};
+
+export const createCaddyMigrationId = () =>
+ `caddy-${new Date()
+ .toISOString()
+ .replace(/[^0-9]/g, "")
+ .slice(0, 14)}-${randomUUID().slice(0, 8)}`;
+
+export const getCaddyMigrationArtifactPaths = (
+ migrationId: string,
+ serverId?: string | null,
+): CaddyMigrationArtifactPaths => {
+ assertSafeMigrationId(migrationId);
+ const migrationsPath = paths(!!serverId).CADDY_MIGRATIONS_PATH;
+ const root = path.posix.join(migrationsPath, migrationId);
+ assertPosixPathWithinBase(migrationsPath, root);
+ return {
+ root,
+ reportJson: path.posix.join(root, "report.json"),
+ reportMd: path.posix.join(root, "report.md"),
+ caddyJson: path.posix.join(root, "caddy.json"),
+ fragmentsDir: path.posix.join(root, "fragments"),
+ backupsDir: path.posix.join(root, "backups"),
+ };
+};
+
+const encodeBase64 = (content: string) =>
+ Buffer.from(content, "utf8").toString("base64");
+
+export const ensureMigrationDirectory = async (
+ dirPath: string,
+ serverId?: string | null,
+) => {
+ if (serverId) {
+ await execAsyncRemote(serverId, `mkdir -p ${quote([dirPath])}`);
+ return;
+ }
+ mkdirSync(dirPath, { recursive: true });
+};
+
+export const writeMigrationTextFile = async (
+ filePath: string,
+ content: string,
+ serverId?: string | null,
+) => {
+ if (serverId) {
+ const tempPath = `${filePath}.tmp-${Date.now()}`;
+ await execAsyncRemote(
+ serverId,
+ [
+ `mkdir -p ${quote([path.posix.dirname(filePath)])}`,
+ `printf %s ${quote([encodeBase64(content)])} | base64 -d > ${quote([
+ tempPath,
+ ])}`,
+ `mv ${quote([tempPath])} ${quote([filePath])}`,
+ ].join(" && "),
+ );
+ return;
+ }
+ mkdirSync(path.dirname(filePath), { recursive: true });
+ writeFileSync(filePath, content, "utf8");
+};
+
+export const readMigrationTextFileIfExists = async (
+ filePath: string,
+ serverId?: string | null,
+) => {
+ if (serverId) {
+ const { stdout } = await execAsyncRemote(
+ serverId,
+ `if [ -f ${quote([filePath])} ]; then cat ${quote([filePath])}; fi`,
+ );
+ return stdout || null;
+ }
+ if (!existsSync(filePath)) return null;
+ return readFileSync(filePath, "utf8");
+};
+
+export const readRequiredMigrationTextFile = async (
+ filePath: string,
+ serverId?: string | null,
+) => {
+ const content = await readMigrationTextFileIfExists(filePath, serverId);
+ if (content === null) {
+ throw new Error(`Migration file not found: ${filePath}`);
+ }
+ return content;
+};
+
+export const listMigrationFiles = async (
+ dirPath: string,
+ serverId?: string | null,
+ extensions: string[] = [],
+) => {
+ if (serverId) {
+ const extensionExpression = extensions.length
+ ? extensions.map((ext) => `-name '*${ext}'`).join(" -o ")
+ : "-type f";
+ const { stdout } = await execAsyncRemote(
+ serverId,
+ `if [ -d ${quote([dirPath])} ]; then find ${quote([
+ dirPath,
+ ])} -maxdepth 1 -type f \\( ${extensionExpression} \\) | sort; fi`,
+ );
+ return stdout
+ .split("\n")
+ .map((item) => item.trim())
+ .filter(Boolean);
+ }
+ if (!existsSync(dirPath)) return [];
+ return readdirSync(dirPath)
+ .filter((fileName) =>
+ extensions.length
+ ? extensions.some((ext) => fileName.endsWith(ext))
+ : true,
+ )
+ .sort()
+ .map((fileName) => path.join(dirPath, fileName));
+};
+
+export const migrationPathExists = async (
+ target: string,
+ serverId?: string | null,
+) => {
+ if (serverId) {
+ const { stdout } = await execAsyncRemote(
+ serverId,
+ `if [ -e ${quote([target])} ]; then echo yes; fi`,
+ );
+ return stdout.trim() === "yes";
+ }
+ return existsSync(target);
+};
+
+export const acquireCaddyMigrationOperationLock = async (
+ migrationId: string,
+ serverId?: string | null,
+) => {
+ const artifactPaths = getCaddyMigrationArtifactPaths(migrationId, serverId);
+ const lockPath = path.posix.join(artifactPaths.root, ".operation.lock");
+ const errorMessage = `Caddy migration ${migrationId} already has an apply or rollback operation in progress`;
+
+ if (serverId) {
+ try {
+ await execAsyncRemote(
+ serverId,
+ `mkdir ${quote([lockPath])} 2>/dev/null || { echo ${quote([
+ errorMessage,
+ ])} >&2; exit 70; }`,
+ );
+ } catch {
+ throw new Error(errorMessage);
+ }
+ } else {
+ try {
+ mkdirSync(lockPath);
+ } catch {
+ throw new Error(errorMessage);
+ }
+ }
+
+ let released = false;
+ return async () => {
+ if (released) return;
+ released = true;
+ await removeMigrationPath(lockPath, serverId);
+ };
+};
+
+const copyLocalPath = (source: string, destination: string) => {
+ const stats = statSync(source);
+ if (stats.isDirectory()) {
+ mkdirSync(destination, { recursive: true });
+ for (const entry of readdirSync(source)) {
+ copyLocalPath(path.join(source, entry), path.join(destination, entry));
+ }
+ return;
+ }
+ mkdirSync(path.dirname(destination), { recursive: true });
+ writeFileSync(destination, readFileSync(source));
+};
+
+export const copyMigrationPath = async (
+ source: string,
+ destination: string,
+ serverId?: string | null,
+) => {
+ const tempDestination = `${destination}.tmp-${Date.now()}`;
+ if (serverId) {
+ await execAsyncRemote(
+ serverId,
+ [
+ `test -e ${quote([source])}`,
+ `rm -rf ${quote([tempDestination])}`,
+ `mkdir -p ${quote([path.posix.dirname(destination)])}`,
+ `cp -a ${quote([source])} ${quote([tempDestination])}`,
+ `rm -rf ${quote([destination])}`,
+ `mv ${quote([tempDestination])} ${quote([destination])}`,
+ ].join(" && "),
+ );
+ return;
+ }
+ if (!existsSync(source)) {
+ throw new Error(`Migration source path not found: ${source}`);
+ }
+ rmSync(tempDestination, { force: true, recursive: true });
+ mkdirSync(path.dirname(destination), { recursive: true });
+ copyLocalPath(source, tempDestination);
+ rmSync(destination, { force: true, recursive: true });
+ renameSync(tempDestination, destination);
+};
+
+export const copyMigrationFileInPlace = async (
+ source: string,
+ destination: string,
+ serverId?: string | null,
+) => {
+ if (serverId) {
+ const copyInPlaceCommand = [
+ `test -f ${quote([source])}`,
+ `test -f ${quote([destination])}`,
+ `cp ${quote([source])} ${quote([destination])}`,
+ ].join(" && ");
+ try {
+ await execAsyncRemote(serverId, copyInPlaceCommand);
+ return;
+ } catch {
+ await copyMigrationPath(source, destination, serverId);
+ return;
+ }
+ }
+ if (
+ existsSync(source) &&
+ statSync(source).isFile() &&
+ existsSync(destination)
+ ) {
+ const destinationStats = statSync(destination);
+ if (destinationStats.isFile()) {
+ copyFileSync(source, destination);
+ return;
+ }
+ }
+ await copyMigrationPath(source, destination, serverId);
+};
+
+export const removeMigrationPath = async (
+ target: string,
+ serverId?: string | null,
+) => {
+ if (serverId) {
+ await execAsyncRemote(serverId, `rm -rf ${quote([target])}`);
+ return;
+ }
+ rmSync(target, { force: true, recursive: true });
+};
+
+export const loadCaddyMigrationReport = async (
+ migrationId: string,
+ serverId?: string | null,
+): Promise => {
+ const artifactPaths = getCaddyMigrationArtifactPaths(migrationId, serverId);
+ const content = await readRequiredMigrationTextFile(
+ artifactPaths.reportJson,
+ serverId,
+ );
+ return JSON.parse(content) as CaddyMigrationReport;
+};
+
+export const writeCaddyMigrationReport = async (
+ report: CaddyMigrationReport,
+ serverId?: string | null,
+) => {
+ const nextReport = {
+ ...report,
+ updatedAt: new Date().toISOString(),
+ };
+ await writeMigrationTextFile(
+ nextReport.artifactPaths.reportJson,
+ `${JSON.stringify(nextReport, null, 2)}\n`,
+ serverId,
+ );
+ return nextReport;
+};
+
+export const appendCaddyMigrationEvent = (
+ report: CaddyMigrationReport,
+ type: string,
+ message: string,
+): CaddyMigrationReport => ({
+ ...report,
+ events: [...report.events, { at: new Date().toISOString(), type, message }],
+});
diff --git a/packages/server/src/utils/caddy/migration/prepare.ts b/packages/server/src/utils/caddy/migration/prepare.ts
new file mode 100644
index 0000000000..18e74dcde8
--- /dev/null
+++ b/packages/server/src/utils/caddy/migration/prepare.ts
@@ -0,0 +1,1096 @@
+import { isIP } from "node:net";
+import * as path from "node:path";
+import { paths } from "@dokploy/server/constants";
+import { db } from "@dokploy/server/db";
+import { applications, compose } from "@dokploy/server/db/schema";
+import { assertCertificatePathAvailableForServer } from "@dokploy/server/services/certificate";
+import { getCaddyCompileSettings } from "@dokploy/server/services/web-server-settings";
+import { createCaddyComposeRouteFragment } from "@dokploy/server/utils/caddy/compose";
+import {
+ compileCaddyConfig,
+ parseCaddyUpstream,
+ readCaddyRouteFragments,
+} from "@dokploy/server/utils/caddy/config";
+import {
+ createCaddyApplicationRouteFragment,
+ getUnsupportedCaddyDomainFieldMessages,
+} from "@dokploy/server/utils/caddy/domain";
+import type { CaddyRouteFragment } from "@dokploy/server/utils/caddy/types";
+import {
+ DOKPLOY_CADDY_NETWORK,
+ getCaddyComposeNetworkAlias,
+ getCaddyComposeRuntimeTarget,
+} from "@dokploy/server/utils/caddy/upstream-targets";
+import {
+ loadDockerCompose,
+ loadDockerComposeRemote,
+} from "@dokploy/server/utils/docker/domain";
+import type {
+ ComposeSpecification,
+ ListOrDict,
+} from "@dokploy/server/utils/docker/types";
+import { getComposeContainer } from "@dokploy/server/utils/docker/utils";
+import { getRemoteDocker } from "@dokploy/server/utils/servers/remote-docker";
+import type {
+ FileConfig,
+ HttpMiddleware,
+} from "@dokploy/server/utils/traefik/file-types";
+import { eq, isNull } from "drizzle-orm";
+import { parse } from "yaml";
+import { translateTraefikComposeLabelsToCaddyFragment } from "./compose-label-translator";
+import { translateTraefikDynamicConfigToCaddyFragment } from "./dynamic-file-translator";
+import {
+ createCaddyMigrationId,
+ ensureMigrationDirectory,
+ getCaddyMigrationArtifactPaths,
+ listMigrationFiles,
+ readMigrationTextFileIfExists,
+ writeCaddyMigrationReport,
+ writeMigrationTextFile,
+} from "./files";
+import type { CaddyMigrationReport, CaddyMigrationWarning } from "./types";
+
+const CADDY_FRAGMENT_VERSION = 1;
+
+const warning = (
+ message: string,
+ options: Partial = {},
+): CaddyMigrationWarning => ({
+ code: options.code ?? "missing-input",
+ message,
+ blocking: options.blocking ?? false,
+ source: options.source,
+ routerName: options.routerName,
+ serviceName: options.serviceName,
+ middlewareName: options.middlewareName,
+ label: options.label,
+});
+
+const safeFragmentIdPart = (value: string) =>
+ value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "source";
+
+const addFragment = (
+ fragments: CaddyRouteFragment[],
+ fragment: CaddyRouteFragment,
+) => {
+ if (!fragment.routes.length) return;
+ const existing = new Set(fragments.map((item) => item.id));
+ let nextFragment = fragment;
+ let index = 2;
+ while (existing.has(nextFragment.id)) {
+ nextFragment = { ...fragment, id: `${fragment.id}.${index}` };
+ index++;
+ }
+ fragments.push(nextFragment);
+};
+
+const labelsToStrings = (labels: ListOrDict | undefined) => {
+ if (!labels) return [];
+ if (Array.isArray(labels)) return labels.map(String);
+ return Object.entries(labels).map(([key, value]) =>
+ value === null ? key : `${key}=${value}`,
+ );
+};
+
+const labelsHaveTraefikHttp = (labels: ListOrDict | undefined) =>
+ labelsToStrings(labels).some((label) => label.startsWith("traefik.http."));
+
+const isSecurityMiddlewareWarning = (item: CaddyMigrationWarning) =>
+ item.code === "unsupported-security-middleware";
+
+const hasTraefikHttpLabelMap = (labels: Record | undefined) =>
+ labels
+ ? Object.keys(labels).some((label) => label.startsWith("traefik.http."))
+ : false;
+
+const getTraefikNetworkLabel = (labels: Record | undefined) =>
+ labels?.["traefik.swarm.network"] ??
+ labels?.["traefik.docker.network"] ??
+ DOKPLOY_CADDY_NETWORK;
+
+const splitDialHost = (dial: string) => {
+ const index = dial.lastIndexOf(":");
+ return index === -1 ? dial : dial.slice(0, index);
+};
+
+const isDockerLocalHost = (host: string) => !isIP(host) && !host.includes(".");
+
+const getRunningTaskCounts = (tasks: any[]) => {
+ const counts = new Map();
+ for (const task of tasks) {
+ const serviceId = task.ServiceID;
+ if (
+ typeof serviceId !== "string" ||
+ task.DesiredState !== "running" ||
+ task.Status?.State !== "running"
+ ) {
+ continue;
+ }
+ counts.set(serviceId, (counts.get(serviceId) ?? 0) + 1);
+ }
+ return counts;
+};
+
+const getRunningServiceTasks = (
+ service: any,
+ runningTaskCounts?: Map,
+) => {
+ if (runningTaskCounts && typeof service.ID === "string") {
+ return runningTaskCounts.get(service.ID) ?? 0;
+ }
+ const runningTasks = service.ServiceStatus?.RunningTasks;
+ if (typeof runningTasks === "number") return runningTasks;
+ const replicas = service.Spec?.Mode?.Replicated?.Replicas;
+ return typeof replicas === "number" ? replicas : 1;
+};
+
+const getPrimaryContainerName = (container: any) => {
+ const names = Array.isArray(container.Names) ? container.Names : [];
+ return names[0]?.replace(/^\//, "") ?? container.Names?.[0] ?? null;
+};
+
+const addContainerNetworkAliases = (hosts: Set, container: any) => {
+ const networks = container.NetworkSettings?.Networks ?? {};
+ for (const network of Object.values(networks) as any[]) {
+ for (const alias of network?.Aliases ?? []) {
+ if (
+ typeof alias === "string" &&
+ alias &&
+ !/^[a-f0-9]{12,64}$/.test(alias)
+ ) {
+ hosts.add(alias);
+ }
+ }
+ }
+};
+
+const inspectContainer = async (docker: any, container: any) => {
+ const containerId = container.Id ?? container.ID;
+ if (typeof containerId !== "string" || !containerId) return null;
+ if (typeof docker.getContainer !== "function") return null;
+ try {
+ return await docker.getContainer(containerId).inspect();
+ } catch {
+ return null;
+ }
+};
+
+const getLiveDockerTraefikFragments = async (
+ serverId: string | undefined,
+ fileMiddlewares: Record,
+) => {
+ const docker = await getRemoteDocker(serverId);
+ const hosts = new Set();
+ const fragments: CaddyRouteFragment[] = [];
+ const warnings: CaddyMigrationWarning[] = [];
+
+ const [services, containers, tasks] = await Promise.all([
+ docker.listServices().catch(() => []),
+ docker.listContainers().catch(() => []),
+ typeof docker.listTasks === "function"
+ ? docker.listTasks().catch(() => null)
+ : Promise.resolve(null),
+ ]);
+ const runningTaskCounts = Array.isArray(tasks)
+ ? getRunningTaskCounts(tasks)
+ : undefined;
+
+ for (const service of services as any[]) {
+ const serviceName = service.Spec?.Name;
+ if (typeof serviceName !== "string" || !serviceName) continue;
+ if (getRunningServiceTasks(service, runningTaskCounts) <= 0) continue;
+ hosts.add(serviceName);
+ hosts.add(`tasks.${serviceName}`);
+ const labels = service.Spec?.Labels as Record | undefined;
+ if (!hasTraefikHttpLabelMap(labels)) continue;
+ const translated = translateTraefikComposeLabelsToCaddyFragment(labels, {
+ sourceFile: `docker-service/${serviceName}`,
+ appName: serviceName,
+ serviceName,
+ upstreamServiceName: serviceName,
+ upstreamNetwork: getTraefikNetworkLabel(labels),
+ composeType: "stack",
+ fileMiddlewares,
+ });
+ warnings.push(...translated.warnings.filter(isSecurityMiddlewareWarning));
+ addFragment(fragments, {
+ ...translated.fragment,
+ id: `migration.traefik-docker-label.service.${safeFragmentIdPart(
+ serviceName,
+ )}`,
+ });
+ }
+
+ for (const container of containers as any[]) {
+ const containerName = getPrimaryContainerName(container);
+ if (!containerName) continue;
+ hosts.add(containerName);
+ for (const name of container.Names ?? []) {
+ if (typeof name === "string") hosts.add(name.replace(/^\//, ""));
+ }
+ addContainerNetworkAliases(hosts, container);
+ const inspectedContainer = await inspectContainer(docker, container);
+ if (inspectedContainer) {
+ addContainerNetworkAliases(hosts, inspectedContainer);
+ }
+ const labels = container.Labels as Record | undefined;
+ if (
+ !hasTraefikHttpLabelMap(labels) ||
+ labels?.["com.docker.swarm.service.name"]
+ ) {
+ continue;
+ }
+ const translated = translateTraefikComposeLabelsToCaddyFragment(labels, {
+ sourceFile: `docker-container/${containerName}`,
+ appName: containerName,
+ serviceName: containerName,
+ upstreamServiceName: containerName,
+ upstreamNetwork: getTraefikNetworkLabel(labels),
+ fileMiddlewares,
+ });
+ warnings.push(...translated.warnings.filter(isSecurityMiddlewareWarning));
+ addFragment(fragments, {
+ ...translated.fragment,
+ id: `migration.traefik-docker-label.container.${safeFragmentIdPart(
+ containerName,
+ )}`,
+ });
+ }
+
+ return {
+ fragments,
+ warnings,
+ hosts,
+ canPrune:
+ hosts.has("dokploy") ||
+ hosts.has("dokploy-traefik") ||
+ fragments.length > 0,
+ };
+};
+
+const getLocalOrRemoteComposeSpec = async (
+ composeEntity: typeof compose.$inferSelect,
+) => {
+ const loaded = composeEntity.serverId
+ ? await loadDockerComposeRemote(composeEntity)
+ : await loadDockerCompose(composeEntity);
+ if (loaded) return loaded;
+ if (composeEntity.composeFile?.trim()) {
+ return parse(composeEntity.composeFile, {
+ maxAliasCount: 10000,
+ }) as ComposeSpecification;
+ }
+ return null;
+};
+
+const resolveFinalComposeServiceName = (
+ composeEntity: typeof compose.$inferSelect,
+ composeSpec: ComposeSpecification | null,
+ serviceName: string,
+) => {
+ if (!composeSpec?.services) {
+ return composeEntity.randomize || composeEntity.isolatedDeployment
+ ? null
+ : serviceName;
+ }
+ if (composeSpec.services[serviceName]) {
+ return serviceName;
+ }
+ const suffix = composeEntity.randomize
+ ? composeEntity.suffix
+ : composeEntity.isolatedDeployment
+ ? composeEntity.suffix || composeEntity.appName
+ : null;
+ if (suffix && composeSpec.services[`${serviceName}-${suffix}`]) {
+ return `${serviceName}-${suffix}`;
+ }
+ return null;
+};
+
+const getServiceNetworkAliases = (
+ composeSpec: ComposeSpecification | null,
+ serviceName: string,
+ network: string,
+) => {
+ const networks = composeSpec?.services?.[serviceName]?.networks;
+ if (!networks || Array.isArray(networks)) return [];
+ const attachment = networks[network];
+ return attachment?.aliases ?? [];
+};
+
+const getComposeServiceContainerName = async (
+ composeEntity: typeof compose.$inferSelect,
+ finalServiceName: string,
+) => {
+ try {
+ const container = await getComposeContainer(
+ composeEntity as never,
+ finalServiceName,
+ );
+ return container?.Names?.[0]?.replace(/^\//, "") ?? null;
+ } catch {
+ return null;
+ }
+};
+
+const resolveMigrationComposeUpstreamTarget = async (
+ composeEntity: typeof compose.$inferSelect,
+ composeSpec: ComposeSpecification | null,
+ finalServiceName: string,
+) => {
+ const runtimeTarget = getCaddyComposeRuntimeTarget(
+ composeEntity,
+ finalServiceName,
+ );
+
+ if (
+ composeEntity.composeType === "stack" ||
+ composeEntity.isolatedDeployment
+ ) {
+ return runtimeTarget;
+ }
+
+ const expectedAlias = getCaddyComposeNetworkAlias(
+ composeEntity.appName,
+ finalServiceName,
+ );
+ if (
+ getServiceNetworkAliases(
+ composeSpec,
+ finalServiceName,
+ DOKPLOY_CADDY_NETWORK,
+ ).includes(expectedAlias)
+ ) {
+ return runtimeTarget;
+ }
+
+ const containerName =
+ composeSpec?.services?.[finalServiceName]?.container_name;
+ if (typeof containerName === "string" && containerName.trim()) {
+ return {
+ host: containerName.trim(),
+ network: DOKPLOY_CADDY_NETWORK,
+ };
+ }
+
+ const runningContainerName = await getComposeServiceContainerName(
+ composeEntity,
+ finalServiceName,
+ );
+ if (runningContainerName) {
+ return {
+ host: runningContainerName,
+ network: DOKPLOY_CADDY_NETWORK,
+ };
+ }
+
+ return runtimeTarget;
+};
+
+const routeCoverageKey = (route: CaddyRouteFragment["routes"][number]) =>
+ route.hosts
+ .map((host) =>
+ [
+ host,
+ route.pathPrefix ?? "",
+ route.pathExact ?? "",
+ route.https ? "https" : "http",
+ ].join("\0"),
+ )
+ .sort();
+
+type MigrationRoute = CaddyRouteFragment["routes"][number];
+
+const pathMatchesOverlap = (
+ left: Pick,
+ right: Pick,
+) => {
+ const leftExact = left.pathExact ?? null;
+ const rightExact = right.pathExact ?? null;
+ const leftPrefix = left.pathPrefix ?? null;
+ const rightPrefix = right.pathPrefix ?? null;
+
+ if (!leftExact && !leftPrefix) return true;
+ if (!rightExact && !rightPrefix) return true;
+ if (leftExact && rightExact) return leftExact === rightExact;
+ if (leftExact && rightPrefix) return leftExact.startsWith(rightPrefix);
+ if (leftPrefix && rightExact) return rightExact.startsWith(leftPrefix);
+ if (leftPrefix && rightPrefix) {
+ return (
+ leftPrefix.startsWith(rightPrefix) || rightPrefix.startsWith(leftPrefix)
+ );
+ }
+ return false;
+};
+
+const routesOverlap = (left: MigrationRoute, right: MigrationRoute) => {
+ if (left.https !== right.https) return false;
+ if (!left.hosts.some((host) => right.hosts.includes(host))) return false;
+ return pathMatchesOverlap(left, right);
+};
+
+const warnOnManualFragmentConflicts = (
+ fragments: CaddyRouteFragment[],
+ warnings: CaddyMigrationWarning[],
+) => {
+ const manualFragments = fragments.filter(
+ (fragment) => fragment.source === "manual",
+ );
+ const generatedFragments = fragments.filter(
+ (fragment) => fragment.source !== "manual",
+ );
+ const seen = new Set();
+
+ for (const manualFragment of manualFragments) {
+ for (const manualRoute of manualFragment.routes) {
+ for (const generatedFragment of generatedFragments) {
+ for (const generatedRoute of generatedFragment.routes) {
+ if (!routesOverlap(manualRoute, generatedRoute)) continue;
+ const key = [
+ manualFragment.id,
+ manualRoute.id,
+ generatedFragment.id,
+ generatedRoute.id,
+ ].join("\0");
+ if (seen.has(key)) continue;
+ seen.add(key);
+ warnings.push(
+ warning(
+ `Manual Caddy route "${manualRoute.id}" overlaps generated migration route "${generatedRoute.id}" for at least one host/path`,
+ {
+ code: "conflicting-manual-fragment",
+ source: manualFragment.id,
+ blocking: true,
+ },
+ ),
+ );
+ }
+ }
+ }
+ }
+};
+
+const hasAvailableUploadedCertificate = async (
+ certificatePath: string,
+ serverId: string | null | undefined,
+ organizationId?: string | null,
+) => {
+ try {
+ await assertCertificatePathAvailableForServer(
+ certificatePath,
+ serverId,
+ organizationId,
+ );
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+const warnIfCustomCertificateMissing = async (
+ domain: {
+ host: string;
+ https: boolean;
+ certificateType: string;
+ customCertResolver?: string | null;
+ },
+ source: string,
+ serverId: string | null | undefined,
+ warnings: CaddyMigrationWarning[],
+ serviceName?: string | null,
+ organizationId?: string | null,
+) => {
+ if (
+ !domain.https ||
+ domain.certificateType !== "custom" ||
+ !domain.customCertResolver
+ ) {
+ return false;
+ }
+ if (
+ await hasAvailableUploadedCertificate(
+ domain.customCertResolver,
+ serverId,
+ organizationId,
+ )
+ ) {
+ return false;
+ }
+
+ warnings.push(
+ warning(
+ `Domain "${domain.host}" references custom certificate "${domain.customCertResolver}" that is not an uploaded certificate with readable files for this server and organization`,
+ {
+ blocking: true,
+ code: "missing-certificate",
+ source,
+ serviceName: serviceName ?? undefined,
+ },
+ ),
+ );
+ return true;
+};
+
+const reconcileDbFallbackRoutes = (
+ fragments: CaddyRouteFragment[],
+ warnings: CaddyMigrationWarning[],
+) => {
+ const migratedCoverage = new Set();
+ for (const fragment of fragments) {
+ if (
+ fragment.source !== "traefik-compose-label" &&
+ fragment.source !== "traefik-dynamic-file"
+ ) {
+ continue;
+ }
+ for (const route of fragment.routes) {
+ if (route.redirectScheme || !route.upstreams.length) continue;
+ for (const key of routeCoverageKey(route)) {
+ migratedCoverage.add(key);
+ }
+ }
+ }
+
+ const reconciled: CaddyRouteFragment[] = [];
+ for (const fragment of fragments) {
+ if (
+ fragment.source !== "dokploy-compose" &&
+ fragment.source !== "dokploy-application"
+ ) {
+ reconciled.push(fragment);
+ continue;
+ }
+
+ const nextRoutes = fragment.routes.filter((route) => {
+ const shadowed = routeCoverageKey(route).some((key) =>
+ migratedCoverage.has(key),
+ );
+ if (shadowed) {
+ warnings.push(
+ warning(
+ `Dropped DB fallback route "${route.id}" because a migrated Traefik route covers the same host/path`,
+ {
+ code: "shadowed-route",
+ source: fragment.id,
+ blocking: false,
+ },
+ ),
+ );
+ }
+ return !shadowed;
+ });
+
+ if (nextRoutes.length) {
+ reconciled.push({ ...fragment, routes: nextRoutes });
+ }
+ }
+
+ return reconciled;
+};
+
+const pruneUnreachableDockerUpstreams = (
+ fragments: CaddyRouteFragment[],
+ warnings: CaddyMigrationWarning[],
+ reachableDockerHosts: Set,
+) => {
+ const reconciled: CaddyRouteFragment[] = [];
+ for (const fragment of fragments) {
+ const nextRoutes: CaddyRouteFragment["routes"] = [];
+ for (const route of fragment.routes) {
+ if (route.redirectScheme || !route.upstreams.length) {
+ nextRoutes.push(route);
+ continue;
+ }
+ const upstreams = route.upstreams.filter((upstream) => {
+ let host = "";
+ try {
+ host = splitDialHost(parseCaddyUpstream(upstream).dial);
+ } catch {
+ return true;
+ }
+ if (!isDockerLocalHost(host) || reachableDockerHosts.has(host)) {
+ return true;
+ }
+ warnings.push(
+ warning(
+ `Route "${route.id}" upstream "${upstream}" did not match a running Docker service, container, or network alias for "${host}"`,
+ {
+ code: "unreachable-upstream",
+ source: fragment.id,
+ blocking: true,
+ },
+ ),
+ );
+ return true;
+ });
+ if (upstreams.length) {
+ nextRoutes.push({ ...route, upstreams });
+ }
+ }
+ if (nextRoutes.length) {
+ reconciled.push({ ...fragment, routes: nextRoutes });
+ }
+ }
+ return reconciled;
+};
+
+const readTraefikStaticConfig = async (serverId?: string) => {
+ const configPath = path.posix.join(
+ paths(!!serverId).MAIN_TRAEFIK_PATH,
+ "traefik.yml",
+ );
+ const content = await readMigrationTextFileIfExists(configPath, serverId);
+ return { path: configPath, content };
+};
+
+const renderReportMarkdown = (report: CaddyMigrationReport) => {
+ const blocking = report.warnings.filter((item) => item.blocking);
+ return [
+ `# Caddy Migration ${report.migrationId}`,
+ "",
+ `Status: **${report.status}**`,
+ `Server: ${report.serverId ?? "local"}`,
+ `Created: ${report.createdAt}`,
+ "",
+ "## Summary",
+ `- Fragments: ${report.summary.fragments}`,
+ `- Routes: ${report.summary.routes}`,
+ `- Warnings: ${report.summary.warnings}`,
+ `- Blocking warnings: ${report.summary.blockingWarnings}`,
+ `- Validation: ${report.validation.status}${report.validation.message ? ` (${report.validation.message})` : ""}`,
+ "",
+ "## Inputs",
+ `- Traefik static config: ${report.inputs.traefikStaticConfigFound ? report.inputs.traefikStaticConfigPath : "not found"}`,
+ `- Dynamic files: ${report.inputs.dynamicFiles.length}`,
+ `- DB application domains: ${report.inputs.dbApplicationDomains}`,
+ `- DB compose domains: ${report.inputs.dbComposeDomains}`,
+ `- Compose files scanned: ${report.inputs.composeFilesScanned.length}`,
+ `- Compose files skipped: ${report.inputs.composeFilesSkipped.length}`,
+ "",
+ "## Blocking warnings",
+ blocking.length
+ ? blocking
+ .map(
+ (item) =>
+ `- [${item.code}] ${item.message}${item.source ? ` (${item.source})` : ""}`,
+ )
+ .join("\n")
+ : "None",
+ "",
+ "## All warnings",
+ report.warnings.length
+ ? report.warnings
+ .map(
+ (item) =>
+ `- ${item.blocking ? "BLOCKING" : "info"} [${item.code}] ${item.message}${item.source ? ` (${item.source})` : ""}`,
+ )
+ .join("\n")
+ : "None",
+ "",
+ ].join("\n");
+};
+
+export const prepareCaddyMigration = async (
+ input: { serverId?: string } = {},
+) => {
+ const serverId = input.serverId;
+ const migrationId = createCaddyMigrationId();
+ const artifactPaths = getCaddyMigrationArtifactPaths(migrationId, serverId);
+ await ensureMigrationDirectory(artifactPaths.fragmentsDir, serverId);
+ await ensureMigrationDirectory(artifactPaths.backupsDir, serverId);
+
+ const createdAt = new Date().toISOString();
+ let fragments: CaddyRouteFragment[] = [];
+ const warnings: CaddyMigrationWarning[] = [];
+ const composeFilesScanned: string[] = [];
+ const composeFilesSkipped: Array<{ path: string; reason: string }> = [];
+ const fileMiddlewares: Record = {};
+ let reachableDockerHosts = new Set();
+ let canPruneUnreachableDockerUpstreams = false;
+
+ try {
+ const existingManualFragments = (
+ await readCaddyRouteFragments({ serverId })
+ ).filter((fragment) => fragment.source === "manual");
+ for (const fragment of existingManualFragments) {
+ addFragment(fragments, fragment);
+ }
+ } catch (error) {
+ warnings.push(
+ warning(
+ `Unable to read existing manual Caddy fragments: ${
+ error instanceof Error ? error.message : "unknown error"
+ }`,
+ {
+ code: "missing-input",
+ blocking: false,
+ },
+ ),
+ );
+ }
+
+ const staticConfig = await readTraefikStaticConfig(serverId);
+ const dynamicFileContents: Array<{
+ file: string;
+ content: string;
+ config?: FileConfig;
+ }> = [];
+ if (!staticConfig.content) {
+ warnings.push(
+ warning("Traefik static config was not found", {
+ source: staticConfig.path,
+ }),
+ );
+ }
+
+ const dynamicFiles = await listMigrationFiles(
+ paths(!!serverId).DYNAMIC_TRAEFIK_PATH,
+ serverId,
+ [".yml", ".yaml"],
+ );
+ for (const dynamicFile of dynamicFiles) {
+ const content = await readMigrationTextFileIfExists(dynamicFile, serverId);
+ if (!content) continue;
+ try {
+ const config = parse(content) as FileConfig;
+ Object.assign(fileMiddlewares, config.http?.middlewares ?? {});
+ dynamicFileContents.push({ file: dynamicFile, content, config });
+ } catch {
+ dynamicFileContents.push({ file: dynamicFile, content });
+ }
+ }
+ for (const dynamicFile of dynamicFileContents) {
+ const translated = translateTraefikDynamicConfigToCaddyFragment(
+ dynamicFile.config ?? dynamicFile.content,
+ {
+ sourceFile: path.posix.basename(dynamicFile.file),
+ fileMiddlewares,
+ },
+ );
+ warnings.push(...translated.warnings);
+ addFragment(fragments, translated.fragment);
+ }
+ try {
+ const liveDockerLabels = await getLiveDockerTraefikFragments(
+ serverId,
+ fileMiddlewares,
+ );
+ reachableDockerHosts = liveDockerLabels.hosts;
+ canPruneUnreachableDockerUpstreams = liveDockerLabels.canPrune;
+ warnings.push(...liveDockerLabels.warnings);
+ for (const fragment of liveDockerLabels.fragments) {
+ addFragment(fragments, fragment);
+ }
+ } catch (error) {
+ warnings.push(
+ warning(
+ `Unable to inspect live Docker Traefik labels: ${
+ error instanceof Error ? error.message : "unknown error"
+ }`,
+ {
+ code: "missing-input",
+ blocking: false,
+ },
+ ),
+ );
+ }
+
+ const appWhere = serverId
+ ? eq(applications.serverId, serverId)
+ : isNull(applications.serverId);
+ const applicationRows = await db.query.applications.findMany({
+ where: appWhere,
+ with: { domains: true, environment: { with: { project: true } } },
+ });
+ let applicationDomainCount = 0;
+ for (const app of applicationRows) {
+ for (const domain of app.domains ?? []) {
+ applicationDomainCount++;
+ const unsupportedFields = getUnsupportedCaddyDomainFieldMessages(domain);
+ if (unsupportedFields.length) {
+ warnings.push(
+ warning(
+ `Application domain "${domain.host}" uses unsupported Caddy fields: ${unsupportedFields.join("; ")}`,
+ {
+ blocking: true,
+ code: "unsupported-domain-field",
+ source: app.appName,
+ },
+ ),
+ );
+ continue;
+ }
+ if (
+ await warnIfCustomCertificateMissing(
+ domain,
+ app.appName,
+ app.serverId,
+ warnings,
+ undefined,
+ app.environment?.project?.organizationId,
+ )
+ ) {
+ continue;
+ }
+ addFragment(
+ fragments,
+ createCaddyApplicationRouteFragment(app as never, domain),
+ );
+ }
+ }
+
+ const composeWhere = serverId
+ ? eq(compose.serverId, serverId)
+ : isNull(compose.serverId);
+ const composeRows = await db.query.compose.findMany({
+ where: composeWhere,
+ with: { domains: true, environment: { with: { project: true } } },
+ });
+ let composeDomainCount = 0;
+ for (const composeEntity of composeRows) {
+ let composeSpec: ComposeSpecification | null = null;
+ try {
+ composeSpec = await getLocalOrRemoteComposeSpec(composeEntity);
+ } catch (error) {
+ composeFilesSkipped.push({
+ path: composeEntity.appName,
+ reason:
+ error instanceof Error
+ ? error.message
+ : "Unable to read compose file",
+ });
+ }
+
+ for (const domain of composeEntity.domains ?? []) {
+ composeDomainCount++;
+ const unsupportedFields = getUnsupportedCaddyDomainFieldMessages(domain);
+ if (unsupportedFields.length) {
+ warnings.push(
+ warning(
+ `Compose domain "${domain.host}" uses unsupported Caddy fields: ${unsupportedFields.join("; ")}`,
+ {
+ blocking: true,
+ code: "unsupported-domain-field",
+ source: composeEntity.appName,
+ serviceName: domain.serviceName ?? undefined,
+ },
+ ),
+ );
+ continue;
+ }
+ if (
+ await warnIfCustomCertificateMissing(
+ domain,
+ composeEntity.appName,
+ composeEntity.serverId,
+ warnings,
+ domain.serviceName,
+ composeEntity.environment?.project?.organizationId,
+ )
+ ) {
+ continue;
+ }
+ if (!domain.serviceName) {
+ warnings.push(
+ warning(`Compose domain "${domain.host}" is missing a service name`, {
+ blocking: true,
+ source: composeEntity.appName,
+ serviceName: domain.serviceName ?? undefined,
+ }),
+ );
+ continue;
+ }
+ const finalServiceName = resolveFinalComposeServiceName(
+ composeEntity,
+ composeSpec,
+ domain.serviceName,
+ );
+ if (!finalServiceName) {
+ warnings.push(
+ warning(
+ `Compose domain "${domain.host}" references service "${domain.serviceName}" that could not be verified in the compose file`,
+ {
+ blocking: true,
+ source: composeEntity.appName,
+ serviceName: domain.serviceName,
+ },
+ ),
+ );
+ continue;
+ }
+ const upstreamTarget = await resolveMigrationComposeUpstreamTarget(
+ composeEntity,
+ composeSpec,
+ finalServiceName,
+ );
+ addFragment(
+ fragments,
+ createCaddyComposeRouteFragment(
+ composeEntity,
+ domain,
+ finalServiceName,
+ {
+ upstreamServiceName: upstreamTarget.host,
+ upstreamNetwork: upstreamTarget.network,
+ },
+ ),
+ );
+ }
+
+ if (!composeSpec?.services) {
+ composeFilesSkipped.push({
+ path: composeEntity.appName,
+ reason: "Compose file not found or has no services",
+ });
+ continue;
+ }
+
+ composeFilesScanned.push(composeEntity.appName);
+ for (const [serviceName, service] of Object.entries(composeSpec.services)) {
+ const labelSources: Array<{
+ labels: ListOrDict | undefined;
+ suffix: string;
+ }> = [
+ { labels: service.labels, suffix: "labels" },
+ { labels: service.deploy?.labels, suffix: "deploy.labels" },
+ ].filter((labelSource) => labelsHaveTraefikHttp(labelSource.labels));
+ if (!labelSources.length) continue;
+
+ const labelFinalServiceName =
+ resolveFinalComposeServiceName(
+ composeEntity,
+ composeSpec,
+ serviceName,
+ ) ?? serviceName;
+ const upstreamTarget = await resolveMigrationComposeUpstreamTarget(
+ composeEntity,
+ composeSpec,
+ labelFinalServiceName,
+ );
+ for (const labelSource of labelSources) {
+ const translated = translateTraefikComposeLabelsToCaddyFragment(
+ labelSource.labels,
+ {
+ sourceFile: `${composeEntity.appName}/${serviceName}/${labelSource.suffix}`,
+ appName: composeEntity.appName,
+ domains: composeEntity.domains ?? [],
+ serviceName,
+ upstreamServiceName: upstreamTarget.host,
+ upstreamNetwork: upstreamTarget.network,
+ composeType: composeEntity.composeType,
+ fileMiddlewares,
+ },
+ );
+ warnings.push(
+ ...translated.warnings.filter(
+ (item) =>
+ !translated.classifications.every(
+ (classification) => classification.dokployGenerated,
+ ) || isSecurityMiddlewareWarning(item),
+ ),
+ );
+ addFragment(fragments, {
+ ...translated.fragment,
+ id: `migration.traefik-compose-label.${safeFragmentIdPart(
+ `${composeEntity.appName}.${serviceName}.${labelSource.suffix}`,
+ )}`,
+ });
+ }
+ }
+ }
+
+ fragments = reconcileDbFallbackRoutes(fragments, warnings);
+ if (canPruneUnreachableDockerUpstreams && reachableDockerHosts.size) {
+ fragments = pruneUnreachableDockerUpstreams(
+ fragments,
+ warnings,
+ reachableDockerHosts,
+ );
+ }
+ warnOnManualFragmentConflicts(fragments, warnings);
+
+ const compileSettings = await getCaddyCompileSettings(serverId);
+ let config: ReturnType;
+ let validation: CaddyMigrationReport["validation"];
+ try {
+ config = compileCaddyConfig({
+ fragments,
+ ...compileSettings,
+ });
+ validation = {
+ status: "passed",
+ message:
+ "Draft config compiled successfully; container validation runs during apply",
+ };
+ } catch (error) {
+ config = compileCaddyConfig();
+ const message =
+ error instanceof Error ? error.message : "Draft Caddy config is invalid";
+ warnings.push(
+ warning(message, {
+ code: "validation-failed",
+ blocking: true,
+ }),
+ );
+ validation = { status: "failed", message };
+ }
+
+ for (const fragment of fragments) {
+ await writeMigrationTextFile(
+ path.posix.join(artifactPaths.fragmentsDir, `${fragment.id}.json`),
+ `${JSON.stringify(fragment, null, 2)}\n`,
+ serverId,
+ );
+ }
+ await writeMigrationTextFile(
+ artifactPaths.caddyJson,
+ `${JSON.stringify(config, null, 2)}\n`,
+ serverId,
+ );
+
+ const report: CaddyMigrationReport = {
+ migrationId,
+ serverId: serverId ?? null,
+ createdAt,
+ updatedAt: createdAt,
+ status: "prepared",
+ sourceProvider: "traefik",
+ targetProvider: "caddy",
+ artifactPaths,
+ inputs: {
+ traefikStaticConfigPath: staticConfig.path,
+ traefikStaticConfigFound: Boolean(staticConfig.content),
+ dynamicFiles,
+ dbApplicationDomains: applicationDomainCount,
+ dbComposeDomains: composeDomainCount,
+ composeFilesScanned,
+ composeFilesSkipped,
+ },
+ summary: {
+ fragments: fragments.length,
+ routes: fragments.reduce(
+ (total, fragment) => total + fragment.routes.length,
+ 0,
+ ),
+ warnings: warnings.length,
+ blockingWarnings: warnings.filter((item) => item.blocking).length,
+ },
+ validation,
+ compileSettings,
+ warnings,
+ events: [
+ {
+ at: createdAt,
+ type: "prepared",
+ message: "Dry-run Caddy migration artifacts generated",
+ },
+ ],
+ };
+ await writeCaddyMigrationReport(report, serverId);
+ await writeMigrationTextFile(
+ artifactPaths.reportMd,
+ renderReportMarkdown(report),
+ serverId,
+ );
+ return report;
+};
diff --git a/packages/server/src/utils/caddy/migration/rollback.ts b/packages/server/src/utils/caddy/migration/rollback.ts
new file mode 100644
index 0000000000..f43519b8a1
--- /dev/null
+++ b/packages/server/src/utils/caddy/migration/rollback.ts
@@ -0,0 +1,238 @@
+import * as path from "node:path";
+import { paths } from "@dokploy/server/constants";
+import {
+ ensureTraefikRunningFromSnapshot,
+ stopDockerResource,
+} from "@dokploy/server/services/settings";
+import {
+ updateLocalWebServerProvider,
+ updateRemoteWebServerProvider,
+} from "@dokploy/server/services/web-server-settings";
+import {
+ acquireCaddyMigrationOperationLock,
+ appendCaddyMigrationEvent,
+ copyMigrationPath,
+ getCaddyMigrationArtifactPaths,
+ loadCaddyMigrationReport,
+ migrationPathExists,
+ readMigrationTextFileIfExists,
+ removeMigrationPath,
+ writeCaddyMigrationReport,
+} from "./files";
+import type {
+ CaddyMigrationReport,
+ CaddyMigrationResourceSnapshot,
+} from "./types";
+
+const updateProviderToTraefik = async (serverId?: string | null) => {
+ if (serverId) {
+ await updateRemoteWebServerProvider(serverId, "traefik");
+ return;
+ }
+ await updateLocalWebServerProvider("traefik");
+};
+
+const restorePathFromBackup = async (
+ backupSource: string,
+ liveDestination: string,
+ existed?: boolean,
+ serverId?: string | null,
+) => {
+ if (existed === true) {
+ if (!(await migrationPathExists(backupSource, serverId))) {
+ throw new Error(
+ `Cannot restore ${liveDestination}: backup path is missing at ${backupSource}`,
+ );
+ }
+ await copyMigrationPath(backupSource, liveDestination, serverId);
+ return;
+ }
+ if (existed === false) {
+ await removeMigrationPath(liveDestination, serverId);
+ }
+};
+
+const loadRestoreSnapshots = async (
+ report: CaddyMigrationReport,
+ serverId?: string | null,
+): Promise<{
+ traefikResource?: CaddyMigrationResourceSnapshot;
+ caddyResource?: CaddyMigrationResourceSnapshot;
+}> => {
+ const restoreSnapshotPath = report.backup?.restoreSnapshotPath;
+ if (!restoreSnapshotPath) {
+ return {
+ traefikResource: report.backup?.traefikResource,
+ caddyResource: report.backup?.caddyResource,
+ };
+ }
+ const content = await readMigrationTextFileIfExists(
+ restoreSnapshotPath,
+ serverId,
+ );
+ if (!content) {
+ return {
+ traefikResource: report.backup?.traefikResource,
+ caddyResource: report.backup?.caddyResource,
+ };
+ }
+ return JSON.parse(content) as {
+ traefikResource?: CaddyMigrationResourceSnapshot;
+ caddyResource?: CaddyMigrationResourceSnapshot;
+ };
+};
+
+export const rollbackCaddyMigration = async (input: {
+ migrationId: string;
+ serverId?: string;
+ skipOperationLock?: boolean;
+}) => {
+ const releaseLock = input.skipOperationLock
+ ? null
+ : await acquireCaddyMigrationOperationLock(
+ input.migrationId,
+ input.serverId,
+ );
+ try {
+ return await rollbackCaddyMigrationUnlocked(input);
+ } finally {
+ await releaseLock?.();
+ }
+};
+
+const rollbackCaddyMigrationUnlocked = async (input: {
+ migrationId: string;
+ serverId?: string;
+}) => {
+ const serverId = input.serverId;
+ let report = await loadCaddyMigrationReport(input.migrationId, serverId);
+ if (!report.backup) {
+ throw new Error(
+ `Cannot roll back Caddy migration ${input.migrationId}: no backup metadata is available`,
+ );
+ }
+ report = await writeCaddyMigrationReport(
+ appendCaddyMigrationEvent(
+ { ...report, status: "rolling_back" },
+ "rolling_back",
+ "Rolling back Caddy migration to Traefik",
+ ),
+ serverId,
+ );
+
+ try {
+ await stopDockerResource("dokploy-caddy", serverId);
+
+ const caddyPaths = paths(!!serverId);
+ const backup = report.backup;
+ if (!backup) {
+ throw new Error(
+ `Cannot roll back Caddy migration ${input.migrationId}: no backup metadata is available`,
+ );
+ }
+ const backupRoot = path.posix.join(
+ report.artifactPaths.backupsDir,
+ "caddy",
+ );
+ const backupFiles = backup.files ?? [];
+ const backedUpCaddyConfig = backupFiles.find(
+ (item) => item.label === "caddy-config",
+ );
+ const backedUpCaddyFragments = backupFiles.find(
+ (item) => item.label === "caddy-fragments",
+ );
+ const backedUpTraefikStatic = backupFiles.find(
+ (item) => item.label === "traefik-static",
+ );
+ const backedUpTraefikDynamic = backupFiles.find(
+ (item) => item.label === "traefik-dynamic",
+ );
+
+ await restorePathFromBackup(
+ backedUpCaddyConfig?.backupPath ??
+ path.posix.join(backupRoot, "caddy.json"),
+ caddyPaths.CADDY_CONFIG_PATH,
+ backedUpCaddyConfig?.existed,
+ serverId,
+ );
+ await restorePathFromBackup(
+ backedUpCaddyFragments?.backupPath ??
+ path.posix.join(backupRoot, "fragments"),
+ caddyPaths.CADDY_FRAGMENTS_PATH,
+ backedUpCaddyFragments?.existed,
+ serverId,
+ );
+ await restorePathFromBackup(
+ backedUpTraefikStatic?.backupPath ??
+ path.posix.join(
+ report.artifactPaths.backupsDir,
+ "traefik",
+ "traefik.yml",
+ ),
+ path.posix.join(caddyPaths.MAIN_TRAEFIK_PATH, "traefik.yml"),
+ backedUpTraefikStatic?.existed,
+ serverId,
+ );
+ await restorePathFromBackup(
+ backedUpTraefikDynamic?.backupPath ??
+ path.posix.join(report.artifactPaths.backupsDir, "traefik", "dynamic"),
+ caddyPaths.DYNAMIC_TRAEFIK_PATH,
+ backedUpTraefikDynamic?.existed,
+ serverId,
+ );
+
+ const restoreSnapshots = await loadRestoreSnapshots(report, serverId);
+ await ensureTraefikRunningFromSnapshot(
+ restoreSnapshots.traefikResource ?? backup.traefikResource,
+ serverId,
+ );
+ await updateProviderToTraefik(serverId);
+
+ report = await writeCaddyMigrationReport(
+ appendCaddyMigrationEvent(
+ { ...report, status: "rolled_back" },
+ "rolled_back",
+ "Rollback completed; Traefik is the active provider",
+ ),
+ serverId,
+ );
+ return report;
+ } catch (error) {
+ const message =
+ error instanceof Error
+ ? error.message
+ : "Caddy migration rollback failed";
+ const failedReport: CaddyMigrationReport = {
+ ...report,
+ status: "failed",
+ warnings: [
+ ...report.warnings,
+ {
+ code: "rollback-failed",
+ message,
+ blocking: true,
+ },
+ ],
+ summary: {
+ ...report.summary,
+ warnings: report.summary.warnings + 1,
+ blockingWarnings: report.summary.blockingWarnings + 1,
+ },
+ };
+ await writeCaddyMigrationReport(
+ appendCaddyMigrationEvent(failedReport, "rollback_failed", message),
+ serverId,
+ );
+ throw error;
+ }
+};
+
+export const getCaddyMigrationReport = async (input: {
+ migrationId: string;
+ serverId?: string;
+}) => loadCaddyMigrationReport(input.migrationId, input.serverId);
+
+export const getCaddyMigrationPaths = (input: {
+ migrationId: string;
+ serverId?: string;
+}) => getCaddyMigrationArtifactPaths(input.migrationId, input.serverId);
diff --git a/packages/server/src/utils/caddy/migration/traefik-rule-parser.ts b/packages/server/src/utils/caddy/migration/traefik-rule-parser.ts
new file mode 100644
index 0000000000..a4b923d55e
--- /dev/null
+++ b/packages/server/src/utils/caddy/migration/traefik-rule-parser.ts
@@ -0,0 +1,337 @@
+import type { CaddyMigrationWarning, TraefikRuleMatch } from "./types";
+
+interface ParseOptions {
+ source?: string;
+ routerName?: string;
+}
+
+type TokenType =
+ | "identifier"
+ | "string"
+ | "and"
+ | "or"
+ | "lparen"
+ | "rparen"
+ | "comma";
+
+interface Token {
+ type: TokenType;
+ value: string;
+}
+
+type RuleNode =
+ | { type: "matcher"; name: string; args: string[] }
+ | { type: "and" | "or"; left: RuleNode; right: RuleNode };
+
+const warning = (
+ message: string,
+ options: ParseOptions,
+ code: CaddyMigrationWarning["code"] = "unsupported-rule",
+): CaddyMigrationWarning => ({
+ code,
+ message,
+ blocking: true,
+ source: options.source,
+ routerName: options.routerName,
+});
+
+const tokenizeRule = (rule: string, options: ParseOptions) => {
+ const tokens: Token[] = [];
+ let index = 0;
+
+ while (index < rule.length) {
+ const char = rule[index] ?? "";
+ if (/\s/.test(char)) {
+ index += 1;
+ continue;
+ }
+ if (rule.startsWith("&&", index)) {
+ tokens.push({ type: "and", value: "&&" });
+ index += 2;
+ continue;
+ }
+ if (rule.startsWith("||", index)) {
+ tokens.push({ type: "or", value: "||" });
+ index += 2;
+ continue;
+ }
+ if (char === "(") {
+ tokens.push({ type: "lparen", value: char });
+ index += 1;
+ continue;
+ }
+ if (char === ")") {
+ tokens.push({ type: "rparen", value: char });
+ index += 1;
+ continue;
+ }
+ if (char === ",") {
+ tokens.push({ type: "comma", value: char });
+ index += 1;
+ continue;
+ }
+ if (char === "`" || char === "'" || char === '"') {
+ const quote = char;
+ let value = "";
+ index += 1;
+ while (index < rule.length && rule[index] !== quote) {
+ value += rule[index] ?? "";
+ index += 1;
+ }
+ if (rule[index] !== quote) {
+ throw new Error(
+ warning("Unterminated string in Traefik rule", options).message,
+ );
+ }
+ index += 1;
+ tokens.push({ type: "string", value });
+ continue;
+ }
+ if (/[A-Za-z]/.test(char)) {
+ let value = "";
+ while (index < rule.length && /[A-Za-z0-9_]/.test(rule[index] ?? "")) {
+ value += rule[index] ?? "";
+ index += 1;
+ }
+ tokens.push({ type: "identifier", value });
+ continue;
+ }
+ throw new Error(
+ warning(`Unsupported token "${char}" in Traefik rule`, options).message,
+ );
+ }
+
+ return tokens;
+};
+
+class RuleParser {
+ private index = 0;
+
+ constructor(
+ private readonly tokens: Token[],
+ private readonly options: ParseOptions,
+ ) {}
+
+ parse() {
+ const expression = this.parseOr();
+ if (this.peek()) {
+ throw new Error(
+ warning(
+ `Unexpected token "${this.peek()?.value}" in Traefik rule`,
+ this.options,
+ ).message,
+ );
+ }
+ return expression;
+ }
+
+ private peek() {
+ return this.tokens[this.index];
+ }
+
+ private consume(type?: TokenType) {
+ const token = this.tokens[this.index];
+ if (!token || (type && token.type !== type)) {
+ throw new Error(
+ warning(`Expected ${type ?? "token"} in Traefik rule`, this.options)
+ .message,
+ );
+ }
+ this.index += 1;
+ return token;
+ }
+
+ private parseOr(): RuleNode {
+ let node = this.parseAnd();
+ while (this.peek()?.type === "or") {
+ this.consume("or");
+ node = { type: "or", left: node, right: this.parseAnd() };
+ }
+ return node;
+ }
+
+ private parseAnd(): RuleNode {
+ let node = this.parsePrimary();
+ while (this.peek()?.type === "and") {
+ this.consume("and");
+ node = { type: "and", left: node, right: this.parsePrimary() };
+ }
+ return node;
+ }
+
+ private parsePrimary(): RuleNode {
+ const token = this.peek();
+ if (token?.type === "lparen") {
+ this.consume("lparen");
+ const node = this.parseOr();
+ this.consume("rparen");
+ return node;
+ }
+ if (token?.type !== "identifier") {
+ throw new Error(
+ warning("Expected Traefik matcher in rule", this.options).message,
+ );
+ }
+
+ const name = this.consume("identifier").value;
+ this.consume("lparen");
+ const args: string[] = [];
+ while (this.peek()?.type !== "rparen") {
+ args.push(this.consume("string").value);
+ if (this.peek()?.type === "comma") {
+ this.consume("comma");
+ } else if (this.peek()?.type !== "rparen") {
+ throw new Error(
+ warning(`Expected comma in ${name} matcher`, this.options).message,
+ );
+ }
+ }
+ this.consume("rparen");
+ return { type: "matcher", name, args };
+ }
+}
+
+const mergeMatches = (
+ left: TraefikRuleMatch,
+ right: TraefikRuleMatch,
+ warnings: CaddyMigrationWarning[],
+ options: ParseOptions,
+): TraefikRuleMatch | null => {
+ const hosts = [...new Set([...left.hosts, ...right.hosts])];
+ const pathPrefix = left.pathPrefix ?? right.pathPrefix ?? null;
+ const pathExact = left.pathExact ?? right.pathExact ?? null;
+
+ if (
+ left.pathPrefix &&
+ right.pathPrefix &&
+ left.pathPrefix !== right.pathPrefix
+ ) {
+ warnings.push(
+ warning(
+ `Multiple PathPrefix matchers cannot be represented as one Caddy route: ${left.pathPrefix}, ${right.pathPrefix}`,
+ options,
+ ),
+ );
+ return null;
+ }
+ if (left.pathExact && right.pathExact && left.pathExact !== right.pathExact) {
+ warnings.push(
+ warning(
+ `Multiple Path matchers cannot be represented as one Caddy route: ${left.pathExact}, ${right.pathExact}`,
+ options,
+ ),
+ );
+ return null;
+ }
+ if (pathPrefix && pathExact) {
+ warnings.push(
+ warning(
+ `Combined Path and PathPrefix matchers cannot be represented as one Caddy route: ${pathExact}, ${pathPrefix}`,
+ options,
+ ),
+ );
+ return null;
+ }
+
+ return { hosts, pathPrefix, pathExact };
+};
+
+const expandRuleNode = (
+ node: RuleNode,
+ warnings: CaddyMigrationWarning[],
+ options: ParseOptions,
+): TraefikRuleMatch[] => {
+ if (node.type === "matcher") {
+ if (node.name === "Host") {
+ return [{ hosts: [...new Set(node.args)] }];
+ }
+ if (node.name === "PathPrefix") {
+ return node.args.map((pathPrefix) => ({ hosts: [], pathPrefix }));
+ }
+ if (node.name === "Path") {
+ return node.args.map((pathExact) => ({ hosts: [], pathExact }));
+ }
+ warnings.push(
+ warning(
+ `Unsupported Traefik matcher "${node.name}" in rule`,
+ options,
+ "unsupported-matcher",
+ ),
+ );
+ return [];
+ }
+
+ const left = expandRuleNode(node.left, warnings, options);
+ const right = expandRuleNode(node.right, warnings, options);
+ if (node.type === "or") {
+ return [...left, ...right];
+ }
+
+ const merged: TraefikRuleMatch[] = [];
+ for (const leftMatch of left) {
+ for (const rightMatch of right) {
+ const match = mergeMatches(leftMatch, rightMatch, warnings, options);
+ if (match) {
+ merged.push(match);
+ }
+ }
+ }
+ return merged;
+};
+
+const normalizeMatches = (
+ matches: TraefikRuleMatch[],
+ warnings: CaddyMigrationWarning[],
+ options: ParseOptions,
+) => {
+ const grouped = new Map();
+ for (const match of matches) {
+ if (!match.hosts.length) {
+ warnings.push(
+ warning("Traefik rule did not include a Host matcher", options),
+ );
+ continue;
+ }
+
+ const key = `${match.pathPrefix ?? ""}\u0000${match.pathExact ?? ""}`;
+ const existing = grouped.get(key);
+ if (existing) {
+ existing.hosts = [...new Set([...existing.hosts, ...match.hosts])];
+ } else {
+ grouped.set(key, {
+ ...match,
+ hosts: [...new Set(match.hosts)],
+ });
+ }
+ }
+ return [...grouped.values()];
+};
+
+export const parseTraefikRule = (
+ rule: string,
+ options: ParseOptions = {},
+): { matches: TraefikRuleMatch[]; warnings: CaddyMigrationWarning[] } => {
+ const warnings: CaddyMigrationWarning[] = [];
+ try {
+ const tokens = tokenizeRule(rule, options);
+ const ast = new RuleParser(tokens, options).parse();
+ const matches = normalizeMatches(
+ expandRuleNode(ast, warnings, options),
+ warnings,
+ options,
+ );
+ return { matches, warnings };
+ } catch (error) {
+ return {
+ matches: [],
+ warnings: [
+ warning(
+ error instanceof Error
+ ? error.message
+ : "Failed to parse Traefik rule",
+ options,
+ ),
+ ],
+ };
+ }
+};
diff --git a/packages/server/src/utils/caddy/migration/types.ts b/packages/server/src/utils/caddy/migration/types.ts
new file mode 100644
index 0000000000..d2fe158122
--- /dev/null
+++ b/packages/server/src/utils/caddy/migration/types.ts
@@ -0,0 +1,200 @@
+import type {
+ CaddyCompileOptions,
+ CaddyHeaderMap,
+ CaddyRouteRedirectScheme,
+ CaddyRouteTransform,
+} from "../types";
+
+export type CaddyMigrationWarningCode =
+ | "unsupported-rule"
+ | "unsupported-matcher"
+ | "unsupported-router"
+ | "unsupported-service"
+ | "unsupported-middleware"
+ | "unsupported-security-middleware"
+ | "unsupported-domain-field"
+ | "unresolved-middleware"
+ | "unresolved-service"
+ | "shadowed-route"
+ | "conflicting-manual-fragment"
+ | "unreachable-upstream"
+ | "invalid-label"
+ | "invalid-config"
+ | "missing-input"
+ | "missing-certificate"
+ | "validation-failed"
+ | "health-check-failed"
+ | "backup-failed"
+ | "apply-failed"
+ | "rollback-failed";
+
+export interface CaddyMigrationWarning {
+ code: CaddyMigrationWarningCode;
+ message: string;
+ blocking: boolean;
+ source?: string;
+ routerName?: string;
+ serviceName?: string;
+ middlewareName?: string;
+ label?: string;
+}
+
+export interface CaddyMiddlewareTranslation {
+ transforms: CaddyRouteTransform;
+ basicAuth: { username: string; hash: string }[];
+ allowedRemoteIps?: string[] | null;
+ redirectScheme?: CaddyRouteRedirectScheme | null;
+ warnings: CaddyMigrationWarning[];
+}
+
+export type ResolvedCaddyMiddleware = Omit<
+ CaddyMiddlewareTranslation,
+ "warnings"
+>;
+
+export type KnownTraefikMiddlewareMap = Record<
+ string,
+ Partial & {
+ transforms?: CaddyRouteTransform;
+ basicAuth?: { username: string; hash: string }[];
+ responseHeaders?: CaddyHeaderMap;
+ requestHeaders?: CaddyHeaderMap;
+ }
+>;
+
+export interface TraefikRuleMatch {
+ hosts: string[];
+ pathPrefix?: string | null;
+ pathExact?: string | null;
+}
+
+export type CaddyMigrationStatus =
+ | "prepared"
+ | "applying"
+ | "applied"
+ | "failed"
+ | "rolling_back"
+ | "rolled_back";
+
+export interface CaddyMigrationArtifactPaths {
+ root: string;
+ reportJson: string;
+ reportMd: string;
+ caddyJson: string;
+ fragmentsDir: string;
+ backupsDir: string;
+}
+
+export interface CaddyMigrationFileBackup {
+ label: string;
+ source: string;
+ backupPath: string;
+ existed: boolean;
+}
+
+export interface CaddyMigrationResourceSnapshot {
+ resourceName: string;
+ resourceType: "service" | "standalone" | "unknown";
+ running: boolean;
+ replicas?: number;
+ env?: string;
+ additionalPorts?: {
+ targetPort: number;
+ publishedPort: number;
+ protocol?: string;
+ }[];
+ image?: string;
+ binds?: string[];
+ mounts?: Array>;
+ networks?: Array>;
+ labels?: Record;
+ containerLabels?: Record;
+ placement?: Record;
+ endpointPorts?: Array>;
+ restartPolicy?: Record;
+}
+
+export interface CaddyMigrationBackupSummary {
+ createdAt: string;
+ traefikResource?: CaddyMigrationResourceSnapshot;
+ caddyResource?: CaddyMigrationResourceSnapshot;
+ restoreSnapshotPath?: string;
+ files?: CaddyMigrationFileBackup[];
+}
+
+export interface CaddyMigrationRuntimePreflightRoute {
+ routeId: string;
+ routeHosts: string[];
+ source: string;
+ sourceFragment?: string;
+ upstream: string;
+ normalizedHost: string;
+ normalizedPort: number;
+ network: string;
+}
+
+export interface CaddyMigrationRuntimePreflightCheck {
+ dial: string;
+ host: string;
+ port: number;
+ network: string;
+ status: "passed" | "failed";
+ reason?: string;
+ routes: CaddyMigrationRuntimePreflightRoute[];
+}
+
+export interface CaddyMigrationRuntimePreflight {
+ status: "passed" | "failed" | "skipped";
+ checkedAt?: string;
+ network: string;
+ networks?: string[];
+ probeMode?: "standalone" | "service";
+ probeImage: string;
+ checks: CaddyMigrationRuntimePreflightCheck[];
+}
+
+export interface CaddyMigrationReport {
+ migrationId: string;
+ serverId: string | null;
+ createdAt: string;
+ updatedAt: string;
+ status: CaddyMigrationStatus;
+ sourceProvider: "traefik";
+ targetProvider: "caddy";
+ artifactPaths: CaddyMigrationArtifactPaths;
+ inputs: {
+ traefikStaticConfigPath: string;
+ traefikStaticConfigFound: boolean;
+ dynamicFiles: string[];
+ dbApplicationDomains: number;
+ dbComposeDomains: number;
+ composeFilesScanned: string[];
+ composeFilesSkipped: Array<{ path: string; reason: string }>;
+ };
+ summary: {
+ fragments: number;
+ routes: number;
+ warnings: number;
+ blockingWarnings: number;
+ };
+ validation: {
+ status: "passed" | "failed" | "skipped";
+ message?: string;
+ };
+ compileSettings?: Pick<
+ CaddyCompileOptions,
+ "letsEncryptEmail" | "trustedProxies" | "accessLogs"
+ >;
+ runtimePreflight?: CaddyMigrationRuntimePreflight;
+ warnings: CaddyMigrationWarning[];
+ backup?: CaddyMigrationBackupSummary;
+ events: Array<{
+ at: string;
+ type: string;
+ message: string;
+ }>;
+}
+
+export const createMigrationWarning = (
+ warning: CaddyMigrationWarning,
+): CaddyMigrationWarning => warning;
diff --git a/packages/server/src/utils/caddy/migration/upstream-preflight.ts b/packages/server/src/utils/caddy/migration/upstream-preflight.ts
new file mode 100644
index 0000000000..1c2693e830
--- /dev/null
+++ b/packages/server/src/utils/caddy/migration/upstream-preflight.ts
@@ -0,0 +1,336 @@
+import {
+ execAsync,
+ execAsyncRemote,
+} from "@dokploy/server/utils/process/execAsync";
+import { quote } from "shell-quote";
+import { parseCaddyUpstream } from "../config";
+import type { CaddyRouteFragment } from "../types";
+import { DOKPLOY_CADDY_NETWORK } from "../upstream-targets";
+import { listMigrationFiles, readRequiredMigrationTextFile } from "./files";
+import type {
+ CaddyMigrationReport,
+ CaddyMigrationRuntimePreflight,
+ CaddyMigrationRuntimePreflightCheck,
+ CaddyMigrationRuntimePreflightRoute,
+} from "./types";
+
+const PROBE_IMAGE = "busybox:1.36";
+const DEFAULT_PROBE_NETWORK = DOKPLOY_CADDY_NETWORK;
+type ProbeMode = "standalone" | "service";
+
+const runCommand = async (command: string, serverId?: string) => {
+ if (serverId) {
+ return execAsyncRemote(serverId, command);
+ }
+ return execAsync(command);
+};
+
+const splitDial = (dial: string) => {
+ const separatorIndex = dial.lastIndexOf(":");
+ if (separatorIndex === -1) {
+ throw new Error("normalized upstream dial is missing a port");
+ }
+ const host = dial.slice(0, separatorIndex);
+ const port = Number(dial.slice(separatorIndex + 1));
+ if (!host || !Number.isInteger(port)) {
+ throw new Error("normalized upstream dial is invalid");
+ }
+ return { host, port };
+};
+
+const routeRefsFromFragment = (
+ fragment: CaddyRouteFragment,
+): {
+ refs: CaddyMigrationRuntimePreflightRoute[];
+ invalidChecks: CaddyMigrationRuntimePreflightCheck[];
+} => {
+ const refs: CaddyMigrationRuntimePreflightRoute[] = [];
+ const invalidChecks: CaddyMigrationRuntimePreflightCheck[] = [];
+ for (const route of fragment.routes) {
+ if (route.redirectScheme) continue;
+ for (const upstream of route.upstreams) {
+ let host = "";
+ let port = 0;
+ try {
+ const parsed = parseCaddyUpstream(upstream);
+ const normalized = splitDial(parsed.dial);
+ host = normalized.host;
+ port = normalized.port;
+ } catch (error) {
+ invalidChecks.push({
+ dial: upstream,
+ host: "",
+ port: 0,
+ status: "failed",
+ reason:
+ error instanceof Error ? error.message : "Invalid upstream format",
+ routes: [
+ {
+ routeId: route.id,
+ routeHosts: route.hosts,
+ source: route.source,
+ sourceFragment: fragment.id,
+ upstream,
+ normalizedHost: "",
+ normalizedPort: 0,
+ network: route.upstreamNetwork ?? DEFAULT_PROBE_NETWORK,
+ },
+ ],
+ network: route.upstreamNetwork ?? DEFAULT_PROBE_NETWORK,
+ });
+ continue;
+ }
+ refs.push({
+ routeId: route.id,
+ routeHosts: route.hosts,
+ source: route.source,
+ sourceFragment: fragment.id,
+ upstream,
+ normalizedHost: host,
+ normalizedPort: port,
+ network: route.upstreamNetwork ?? DEFAULT_PROBE_NETWORK,
+ });
+ }
+ }
+ return { refs, invalidChecks };
+};
+
+const readPreflightInputs = async (
+ report: CaddyMigrationReport,
+ serverId?: string,
+) => {
+ const refs: CaddyMigrationRuntimePreflightRoute[] = [];
+ const invalidChecks: CaddyMigrationRuntimePreflightCheck[] = [];
+ const fragmentFiles = await listMigrationFiles(
+ report.artifactPaths.fragmentsDir,
+ serverId,
+ [".json"],
+ );
+ for (const fragmentFile of fragmentFiles) {
+ const content = await readRequiredMigrationTextFile(fragmentFile, serverId);
+ const fragment = JSON.parse(content) as CaddyRouteFragment;
+ const parsed = routeRefsFromFragment(fragment);
+ refs.push(...parsed.refs);
+ invalidChecks.push(...parsed.invalidChecks);
+ }
+ return { refs, invalidChecks };
+};
+
+const probeScript = [
+ 'if nc -z -w 3 "$1" "$2" >/dev/null 2>&1; then echo passed; exit 0; fi',
+ 'if ! nslookup "$1" >/dev/null 2>&1; then echo dns_failed; exit 10; fi',
+ "echo tcp_failed",
+ "exit 11",
+].join("; ");
+
+const parseProbeFailure = (error: unknown, output: string) => {
+ if (output.includes("dns_failed")) {
+ return "DNS resolution failed";
+ }
+ if (output.includes("tcp_failed")) {
+ return "TCP connection failed";
+ }
+ if (output) {
+ return output.split("\n").slice(-3).join("; ");
+ }
+ return error instanceof Error ? error.message : "upstream probe failed";
+};
+
+const shouldFallbackToServiceProbe = (reason: string) =>
+ [
+ /not manually attachable/i,
+ /not attachable/i,
+ /network .* not found/i,
+ /could not find network/i,
+ /only supported for user defined networks/i,
+ /error response from daemon/i,
+ ].some((pattern) => pattern.test(reason));
+
+const probeStandaloneUpstream = async (
+ host: string,
+ port: number,
+ network: string,
+ serverId?: string,
+) => {
+ const command = [
+ "docker run --rm",
+ `--network ${quote([network])}`,
+ quote([PROBE_IMAGE]),
+ "sh -c",
+ quote([probeScript]),
+ "sh",
+ quote([host]),
+ quote([String(port)]),
+ ].join(" ");
+
+ try {
+ const { stdout } = await runCommand(command, serverId);
+ return stdout.trim() === "passed"
+ ? null
+ : stdout.trim() || "upstream probe did not report success";
+ } catch (error) {
+ const output =
+ `${(error as { stdout?: string }).stdout ?? ""}\n${(error as { stderr?: string }).stderr ?? ""}`.trim();
+ return parseProbeFailure(error, output);
+ }
+};
+
+const probeServiceUpstream = async (
+ host: string,
+ port: number,
+ network: string,
+ serverId?: string,
+) => {
+ const serviceName = `dokploy-caddy-preflight-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
+ const command = `
+set -e
+SERVICE_NAME=${quote([serviceName])}
+docker service rm "$SERVICE_NAME" >/dev/null 2>&1 || true
+trap 'docker service rm "$SERVICE_NAME" >/dev/null 2>&1 || true' EXIT
+docker service create --detach=true --name "$SERVICE_NAME" --restart-condition none --network ${quote([network])} ${quote([PROBE_IMAGE])} sh -c ${quote([probeScript])} sh ${quote([host])} ${quote([String(port)])} >/dev/null
+for i in $(seq 1 30); do
+ LOGS=$(docker service logs --raw "$SERVICE_NAME" 2>&1 || true)
+ if echo "$LOGS" | grep -Eq '(^|[[:space:]])(passed|dns_failed|tcp_failed)([[:space:]]|$)'; then
+ echo "$LOGS"
+ exit 0
+ fi
+ STATE=$(docker service ps "$SERVICE_NAME" --no-trunc --format '{{.CurrentState}} {{.Error}}' 2>&1 | head -n 1 || true)
+ if echo "$STATE" | grep -Eq 'Failed|Rejected|Complete|Shutdown'; then
+ echo "$LOGS"
+ echo "$STATE"
+ exit 0
+ fi
+ sleep 1
+done
+docker service logs --raw "$SERVICE_NAME" 2>&1 || true
+exit 12
+`;
+
+ try {
+ const { stdout } = await runCommand(command, serverId);
+ return stdout.trim().includes("passed")
+ ? null
+ : parseProbeFailure(new Error("upstream service probe failed"), stdout);
+ } catch (error) {
+ const output =
+ `${(error as { stdout?: string }).stdout ?? ""}\n${(error as { stderr?: string }).stderr ?? ""}`.trim();
+ return parseProbeFailure(error, output);
+ }
+};
+
+const probeUpstream = async (
+ host: string,
+ port: number,
+ network: string,
+ serverId?: string,
+): Promise<{ failureReason: string | null; probeMode: ProbeMode }> => {
+ const standaloneFailure = await probeStandaloneUpstream(
+ host,
+ port,
+ network,
+ serverId,
+ );
+ if (!standaloneFailure) {
+ return { failureReason: null, probeMode: "standalone" };
+ }
+ if (!shouldFallbackToServiceProbe(standaloneFailure)) {
+ return { failureReason: standaloneFailure, probeMode: "standalone" };
+ }
+
+ const serviceFailure = await probeServiceUpstream(
+ host,
+ port,
+ network,
+ serverId,
+ );
+ if (!serviceFailure) {
+ return { failureReason: null, probeMode: "service" };
+ }
+ return {
+ failureReason: `standalone probe failed (${standaloneFailure}); service probe failed (${serviceFailure})`,
+ probeMode: "service",
+ };
+};
+
+export const runCaddyMigrationUpstreamPreflight = async (
+ report: CaddyMigrationReport,
+ input: { serverId?: string } = {},
+): Promise => {
+ const grouped = new Map();
+ let refs: CaddyMigrationRuntimePreflightRoute[] = [];
+ let invalidChecks: CaddyMigrationRuntimePreflightCheck[] = [];
+ try {
+ const inputs = await readPreflightInputs(report, input.serverId);
+ refs = inputs.refs;
+ invalidChecks = inputs.invalidChecks;
+ } catch (error) {
+ return {
+ status: "failed",
+ checkedAt: new Date().toISOString(),
+ network: DEFAULT_PROBE_NETWORK,
+ networks: [DEFAULT_PROBE_NETWORK],
+ probeImage: PROBE_IMAGE,
+ checks: [
+ {
+ dial: "fragment-read",
+ host: "",
+ port: 0,
+ network: DEFAULT_PROBE_NETWORK,
+ status: "failed",
+ reason:
+ error instanceof Error
+ ? error.message
+ : "Unable to read Caddy fragments",
+ routes: [],
+ },
+ ],
+ };
+ }
+
+ for (const ref of refs) {
+ const dial = `${ref.normalizedHost}:${ref.normalizedPort}`;
+ const groupKey = `${ref.network}\0${dial}`;
+ grouped.set(groupKey, {
+ dial,
+ host: ref.normalizedHost,
+ port: ref.normalizedPort,
+ network: ref.network,
+ status: "passed",
+ routes: [...(grouped.get(groupKey)?.routes ?? []), ref],
+ });
+ }
+
+ const checks = [...invalidChecks, ...grouped.values()];
+ const probeableChecks = checks.filter((item) => item.port > 0);
+ let probeMode: ProbeMode = "standalone";
+ for (const check of probeableChecks) {
+ const probeResult = await probeUpstream(
+ check.host,
+ check.port,
+ check.network,
+ input.serverId,
+ );
+ if (probeResult.probeMode === "service") {
+ probeMode = "service";
+ }
+ const { failureReason } = probeResult;
+ if (failureReason) {
+ check.status = "failed";
+ check.reason = failureReason;
+ }
+ }
+ const networks = [...new Set(checks.map((check) => check.network))].sort();
+
+ return {
+ status: checks.some((check) => check.status === "failed")
+ ? "failed"
+ : "passed",
+ checkedAt: new Date().toISOString(),
+ network:
+ networks.length === 1 ? (networks[0] ?? DEFAULT_PROBE_NETWORK) : "mixed",
+ networks,
+ probeMode,
+ probeImage: PROBE_IMAGE,
+ checks,
+ };
+};
diff --git a/packages/server/src/utils/caddy/types.ts b/packages/server/src/utils/caddy/types.ts
new file mode 100644
index 0000000000..dbee1a0c5b
--- /dev/null
+++ b/packages/server/src/utils/caddy/types.ts
@@ -0,0 +1,97 @@
+export type CaddyRouteSource =
+ | "dokploy-application"
+ | "dokploy-compose"
+ | "dokploy-dashboard"
+ | "manual"
+ | "traefik-dynamic-file"
+ | "traefik-compose-label";
+
+export type CaddyHeaderMap = Record;
+
+export interface CaddyRouteTransform {
+ stripPrefix?: string | null;
+ addPrefix?: string | null;
+ requestHeaders?: CaddyHeaderMap | null;
+ responseHeaders?: CaddyHeaderMap | null;
+}
+
+export interface CaddyRouteRedirectScheme {
+ scheme: string;
+ permanent?: boolean | null;
+ port?: string | null;
+}
+
+export interface CaddyStaticResponse {
+ statusCode: number;
+ body?: string | null;
+ headers?: CaddyHeaderMap | null;
+}
+
+export interface CaddyTlsCertificateFile {
+ certificate: string;
+ key: string;
+}
+
+export interface CaddyBasicAuthAccount {
+ username: string;
+ hash: string;
+}
+
+export interface CaddyRouteIntent {
+ id: string;
+ source: CaddyRouteSource;
+ hosts: string[];
+ pathPrefix?: string | null;
+ pathExact?: string | null;
+ allowedRemoteIps?: string[] | null;
+ https?: boolean;
+ priority?: number | null;
+ upstreams: string[];
+ upstreamNetwork?: string | null;
+ transforms?: CaddyRouteTransform | null;
+ basicAuth?: CaddyBasicAuthAccount[] | null;
+ tlsCertificate?: CaddyTlsCertificateFile | null;
+ redirectScheme?: CaddyRouteRedirectScheme | null;
+ staticResponse?: CaddyStaticResponse | null;
+}
+
+export interface CaddyRouteFragment {
+ version: 1;
+ id: string;
+ source: CaddyRouteSource;
+ description?: string;
+ routes: CaddyRouteIntent[];
+}
+
+export interface CaddyTrustedProxyConfig {
+ source: "static" | "cloudflare";
+ ranges?: string[] | null;
+ clientIpHeaders?: string[] | null;
+ strict?: boolean | null;
+}
+
+export interface CaddyTrustedProxySettings {
+ mode: "disabled" | "cloudflare" | "static";
+ ranges?: string[] | null;
+ clientIpHeaders?: string[] | null;
+ strict?: boolean | null;
+}
+
+export interface CaddyAccessLogConfig {
+ enabled: boolean;
+ filename?: string | null;
+}
+
+export interface CaddyCompileOptions {
+ fragments?: CaddyRouteFragment[];
+ routes?: CaddyRouteIntent[];
+ letsEncryptEmail?: string | null;
+ trustedProxies?: CaddyTrustedProxyConfig | null;
+ accessLogs?: CaddyAccessLogConfig | null;
+}
+
+export type CaddyJsonObject = Record;
+
+export interface CaddyFragmentStoreOptions {
+ serverId?: string;
+}
diff --git a/packages/server/src/utils/caddy/upstream-targets.ts b/packages/server/src/utils/caddy/upstream-targets.ts
new file mode 100644
index 0000000000..94a6063fe9
--- /dev/null
+++ b/packages/server/src/utils/caddy/upstream-targets.ts
@@ -0,0 +1,34 @@
+import type { Compose } from "@dokploy/server/services/compose";
+
+export const DOKPLOY_CADDY_NETWORK = "dokploy-network";
+
+export const getCaddyComposeNetworkAlias = (
+ appName: string,
+ finalServiceName: string,
+) => `${appName}-${finalServiceName}`;
+
+export const getCaddyComposeRuntimeTarget = (
+ compose: Pick,
+ finalServiceName: string,
+) => {
+ if (compose.composeType === "stack") {
+ return {
+ host: `${compose.appName}_${finalServiceName}`,
+ network: compose.isolatedDeployment
+ ? compose.appName
+ : DOKPLOY_CADDY_NETWORK,
+ };
+ }
+
+ if (compose.isolatedDeployment) {
+ return {
+ host: finalServiceName,
+ network: compose.appName,
+ };
+ }
+
+ return {
+ host: getCaddyComposeNetworkAlias(compose.appName, finalServiceName),
+ network: DOKPLOY_CADDY_NETWORK,
+ };
+};
diff --git a/packages/server/src/utils/caddy/web-server.ts b/packages/server/src/utils/caddy/web-server.ts
new file mode 100644
index 0000000000..141c6c4242
--- /dev/null
+++ b/packages/server/src/utils/caddy/web-server.ts
@@ -0,0 +1,105 @@
+import type { webServerSettings } from "@dokploy/server/db/schema/web-server-settings";
+import { localWebServerSettingsToCaddyCompileSettings } from "@dokploy/server/services/web-server-settings";
+import {
+ compileWriteAndReloadCaddyConfigSafelyLockHeld,
+ readCaddyRouteFragments,
+ removeCaddyRouteFragment,
+ withCaddyConfigLock,
+ writeCaddyRouteFragment,
+} from "./config";
+import type { CaddyRouteFragment, CaddyRouteIntent } from "./types";
+import { DOKPLOY_CADDY_NETWORK } from "./upstream-targets";
+
+const CADDY_FRAGMENT_VERSION = 1;
+const DASHBOARD_FRAGMENT_ID = "dashboard.dokploy";
+
+const isSameFragment = (
+ first: CaddyRouteFragment | undefined,
+ second: CaddyRouteFragment | undefined,
+) => JSON.stringify(first) === JSON.stringify(second);
+
+const toPunycode = (host: string): string => {
+ try {
+ return new URL(`http://${host}`).hostname;
+ } catch {
+ return host;
+ }
+};
+
+export const createCaddyDashboardRouteIntent = (
+ settings: typeof webServerSettings.$inferSelect,
+ host: string,
+): CaddyRouteIntent => ({
+ id: "dokploy-dashboard",
+ source: "dokploy-dashboard",
+ hosts: [toPunycode(host)],
+ pathPrefix: "/",
+ https: !!settings.https,
+ upstreams: [`http://dokploy:${process.env.PORT || 3000}`],
+ upstreamNetwork: DOKPLOY_CADDY_NETWORK,
+});
+
+export const createCaddyDashboardRouteFragment = (
+ settings: typeof webServerSettings.$inferSelect,
+ host: string,
+): CaddyRouteFragment => ({
+ version: CADDY_FRAGMENT_VERSION,
+ id: DASHBOARD_FRAGMENT_ID,
+ source: "dokploy-dashboard",
+ description: "Dokploy dashboard route",
+ routes: [createCaddyDashboardRouteIntent(settings, host)],
+});
+
+export const updateServerCaddy = async (
+ settings: typeof webServerSettings.$inferSelect,
+ newHost: string | null,
+) => {
+ const compileSettings =
+ localWebServerSettingsToCaddyCompileSettings(settings);
+ await withCaddyConfigLock(undefined, async () => {
+ const previousDashboardFragment = (await readCaddyRouteFragments()).find(
+ (fragment) => fragment.id === DASHBOARD_FRAGMENT_ID,
+ );
+ let writtenDashboardFragment: CaddyRouteFragment | undefined;
+ try {
+ if (newHost) {
+ writtenDashboardFragment = createCaddyDashboardRouteFragment(
+ settings,
+ newHost,
+ );
+ await writeCaddyRouteFragment(writtenDashboardFragment);
+ } else {
+ await removeCaddyRouteFragment(DASHBOARD_FRAGMENT_ID);
+ }
+
+ await compileWriteAndReloadCaddyConfigSafelyLockHeld(compileSettings);
+ } catch (error) {
+ const currentDashboardFragment = (await readCaddyRouteFragments()).find(
+ (fragment) => fragment.id === DASHBOARD_FRAGMENT_ID,
+ );
+ const shouldRestorePrevious = newHost
+ ? isSameFragment(currentDashboardFragment, writtenDashboardFragment)
+ : !currentDashboardFragment;
+
+ if (shouldRestorePrevious) {
+ if (previousDashboardFragment) {
+ await writeCaddyRouteFragment(previousDashboardFragment);
+ } else {
+ await removeCaddyRouteFragment(DASHBOARD_FRAGMENT_ID);
+ }
+ } else {
+ try {
+ await compileWriteAndReloadCaddyConfigSafelyLockHeld(
+ compileSettings,
+ );
+ } catch (resyncError) {
+ if (error instanceof Error) {
+ (error as Error & { resyncError?: unknown }).resyncError =
+ resyncError;
+ }
+ }
+ }
+ throw error;
+ }
+ });
+};
diff --git a/packages/server/src/utils/cluster/upload.ts b/packages/server/src/utils/cluster/upload.ts
index b4235c767a..0968afc7bd 100644
--- a/packages/server/src/utils/cluster/upload.ts
+++ b/packages/server/src/utils/cluster/upload.ts
@@ -1,8 +1,8 @@
import { findAllDeploymentsByApplicationId } from "@dokploy/server/services/deployment";
import {
findRegistryByIdWithCredentials,
- safeDockerLoginCommand,
type Registry,
+ safeDockerLoginCommand,
} from "@dokploy/server/services/registry";
import { createRollback } from "@dokploy/server/services/rollbacks";
import type { ApplicationNested } from "../builders";
diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts
index 8094f1df2a..79cbfe3637 100644
--- a/packages/server/src/utils/docker/domain.ts
+++ b/packages/server/src/utils/docker/domain.ts
@@ -3,7 +3,11 @@ import { join } from "node:path";
import { paths } from "@dokploy/server/constants";
import type { Compose } from "@dokploy/server/services/compose";
import type { Domain } from "@dokploy/server/services/domain";
+import { resolveWebServerProvider } from "@dokploy/server/services/web-server-settings";
import { parse, stringify } from "yaml";
+import { writeCaddyComposeRouteFragments } from "../caddy/compose";
+import { assertCaddyDomainSupported } from "../caddy/domain";
+import { getCaddyComposeNetworkAlias } from "../caddy/upstream-targets";
import { execAsyncRemote } from "../process/execAsync";
import { cloneBitbucketRepository } from "../providers/bitbucket";
import { cloneGitRepository } from "../providers/git";
@@ -11,11 +15,13 @@ import { cloneGiteaRepository } from "../providers/gitea";
import { cloneGithubRepository } from "../providers/github";
import { cloneGitlabRepository } from "../providers/gitlab";
import { getCreateComposeFileCommand } from "../providers/raw";
+import type { WebServerProvider } from "../web-server/providers";
import { randomizeDeployableSpecificationFile } from "./collision";
import { randomizeSpecificationFile } from "./compose";
import type {
ComposeSpecification,
DefinitionsService,
+ ListOrDict,
PropertiesNetworks,
} from "./types";
import { encodeBase64 } from "./utils";
@@ -106,12 +112,187 @@ export const readComposeFile = async (compose: Compose) => {
return null;
};
+export type CaddyComposeRouteTarget = {
+ domain: Domain;
+ finalServiceName: string;
+};
+
+const loadComposeSpecification = async (compose: Compose) => {
+ if (compose.serverId) {
+ return loadDockerComposeRemote(compose);
+ }
+ return loadDockerCompose(compose);
+};
+
+const finalizeComposeSpecification = (
+ compose: Compose,
+ composeSpec: ComposeSpecification,
+) => {
+ let result = composeSpec;
+
+ if (compose.isolatedDeployment) {
+ result = randomizeDeployableSpecificationFile(
+ result,
+ compose.isolatedDeploymentsVolume,
+ compose.suffix || compose.appName,
+ );
+ } else if (compose.randomize) {
+ result = randomizeSpecificationFile(result, compose.suffix);
+ }
+
+ return result;
+};
+
+const resolveFinalServiceName = (
+ compose: Compose,
+ composeSpec: ComposeSpecification,
+ serviceName: string,
+) => {
+ if (composeSpec.services?.[serviceName]) {
+ return serviceName;
+ }
+
+ const suffix = compose.randomize
+ ? compose.suffix
+ : compose.isolatedDeployment
+ ? compose.suffix || compose.appName
+ : null;
+ if (suffix) {
+ const suffixedServiceName = `${serviceName}-${suffix}`;
+ if (composeSpec.services?.[suffixedServiceName]) {
+ return suffixedServiceName;
+ }
+ }
+
+ return serviceName;
+};
+
+const addDomainToComposeForCaddyWithRoutes = async (
+ compose: Compose,
+ domains: Domain[],
+): Promise<{
+ composeSpec: ComposeSpecification | null;
+ caddyRouteTargets: CaddyComposeRouteTarget[];
+}> => {
+ const result = await loadComposeSpecification(compose);
+ if (!result) {
+ return { composeSpec: null, caddyRouteTargets: [] };
+ }
+
+ for (const domain of domains) {
+ assertCaddyDomainSupported(domain);
+ }
+
+ const finalizedCompose = finalizeComposeSpecification(compose, result);
+ const caddyRouteTargets: CaddyComposeRouteTarget[] = [];
+
+ for (const domain of domains) {
+ const { serviceName } = domain;
+ if (!serviceName) {
+ throw new Error(`Domain "${domain.host}" is missing a service name`);
+ }
+
+ const finalServiceName = resolveFinalServiceName(
+ compose,
+ finalizedCompose,
+ serviceName,
+ );
+ if (!finalizedCompose.services?.[finalServiceName]) {
+ throw new Error(
+ `Domain "${domain.host}" is attached to service "${serviceName}" which does not exist in the compose`,
+ );
+ }
+
+ const service = finalizedCompose.services[finalServiceName];
+ if (compose.composeType === "docker-compose") {
+ if (service.labels) {
+ service.labels = removeDokployGeneratedTraefikLabels(service.labels, {
+ appName: compose.appName,
+ domains,
+ });
+ }
+ } else if (service.deploy?.labels) {
+ service.deploy.labels = removeDokployGeneratedTraefikLabels(
+ service.deploy.labels,
+ {
+ appName: compose.appName,
+ domains,
+ },
+ );
+ }
+
+ if (!compose.isolatedDeployment) {
+ service.networks = addDokployNetworkToService(service.networks, {
+ aliases:
+ compose.composeType === "docker-compose"
+ ? [getCaddyComposeNetworkAlias(compose.appName, finalServiceName)]
+ : undefined,
+ });
+ }
+
+ caddyRouteTargets.push({ domain, finalServiceName });
+ }
+
+ if (!compose.isolatedDeployment) {
+ finalizedCompose.networks = addDokployNetworkToRoot(
+ finalizedCompose.networks,
+ );
+ }
+
+ return { composeSpec: finalizedCompose, caddyRouteTargets };
+};
+
+export const getCaddyComposeRouteTargetsForWebServer = async (
+ compose: Compose,
+ domains: Domain[],
+ provider?: WebServerProvider,
+) => {
+ const resolvedProvider =
+ provider ?? (await resolveWebServerProvider(compose.serverId));
+ if (resolvedProvider !== "caddy") {
+ return null;
+ }
+
+ return (await addDomainToComposeForCaddyWithRoutes(compose, domains))
+ .caddyRouteTargets;
+};
+
+export const writeCaddyComposeRoutesForTargets = async (
+ compose: Compose,
+ caddyRouteTargets: CaddyComposeRouteTarget[],
+ options: {
+ organizationId?: string | null;
+ } = {},
+) => {
+ await writeCaddyComposeRouteFragments(compose, caddyRouteTargets, options);
+};
+
+export const addDomainToComposeForWebServer = async (
+ compose: Compose,
+ domains: Domain[],
+ provider?: WebServerProvider,
+) => {
+ const resolvedProvider =
+ provider ?? (await resolveWebServerProvider(compose.serverId));
+ if (resolvedProvider === "caddy") {
+ return (await addDomainToComposeForCaddyWithRoutes(compose, domains))
+ .composeSpec;
+ }
+
+ return addDomainToCompose(compose, domains);
+};
+
export const writeDomainsToCompose = async (
compose: Compose,
domains: Domain[],
) => {
try {
- const composeConverted = await addDomainToCompose(compose, domains);
+ const provider = await resolveWebServerProvider(compose.serverId);
+ const composeConverted =
+ provider === "caddy"
+ ? (await addDomainToComposeForCaddyWithRoutes(compose, domains))
+ .composeSpec
+ : await addDomainToCompose(compose, domains);
const path = getComposePath(compose);
if (!composeConverted) {
@@ -347,8 +528,156 @@ export const createDomainLabels = (
return labels;
};
+export type DokployTraefikLabelClassifierContext = {
+ appName?: string;
+ domains?: Pick[];
+ includeGenericLabels?: boolean;
+};
+
+const getLabelKey = (label: string) => label.split("=")[0] ?? label;
+
+const getLabelValue = (label: string) => {
+ const separatorIndex = label.indexOf("=");
+ return separatorIndex === -1 ? undefined : label.slice(separatorIndex + 1);
+};
+
+const getDomainEntrypoints = (
+ domain: Pick,
+) => {
+ if (domain.customEntrypoint) {
+ return [domain.customEntrypoint];
+ }
+ return domain.https ? ["web", "websecure"] : ["web"];
+};
+
+const isGenericDokployTraefikLabel = (label: string) => {
+ const key = getLabelKey(label);
+ const value = getLabelValue(label);
+
+ if (key === "traefik.enable") {
+ return value === undefined || value === "true";
+ }
+
+ if (key === "traefik.docker.network" || key === "traefik.swarm.network") {
+ return value === undefined || value === "dokploy-network";
+ }
+
+ return false;
+};
+
+const isDomainSpecificDokployTraefikLabel = (
+ label: string,
+ context: DokployTraefikLabelClassifierContext = {},
+) => {
+ const key = getLabelKey(label);
+ const { appName, domains } = context;
+
+ if (appName && domains) {
+ for (const domain of domains) {
+ for (const entrypoint of getDomainEntrypoints(domain)) {
+ const routerName = `${appName}-${domain.uniqueConfigKey}-${entrypoint}`;
+ if (
+ key.startsWith(`traefik.http.routers.${routerName}.`) ||
+ key.startsWith(`traefik.http.services.${routerName}.`)
+ ) {
+ return true;
+ }
+ }
+
+ if (
+ key.startsWith(
+ `traefik.http.middlewares.stripprefix-${appName}-${domain.uniqueConfigKey}.`,
+ ) ||
+ key.startsWith(
+ `traefik.http.middlewares.addprefix-${appName}-${domain.uniqueConfigKey}.`,
+ )
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ if (appName) {
+ const appRouterPattern = new RegExp(
+ `^traefik\\.http\\.(routers|services)\\.${appName.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}.+-(web|websecure)\\.`,
+ );
+ if (appRouterPattern.test(key)) {
+ return true;
+ }
+ }
+
+ return (
+ /^traefik\.http\.(routers|services)\.[^.]+-\d+-[^.]+\./.test(key) ||
+ /^traefik\.http\.middlewares\.(stripprefix|addprefix)-.+-\d+\./.test(key)
+ );
+};
+
+export const isDokployGeneratedTraefikLabel = (
+ label: string,
+ context: DokployTraefikLabelClassifierContext = {},
+) => {
+ return (
+ isDomainSpecificDokployTraefikLabel(label, context) ||
+ (context.includeGenericLabels !== false &&
+ isGenericDokployTraefikLabel(label))
+ );
+};
+
+const stringifyLabelObjectEntry = (
+ key: string,
+ value: string | number | boolean | null,
+) => (value === null ? key : `${key}=${value}`);
+
+export const removeDokployGeneratedTraefikLabels = (
+ labels: ListOrDict | undefined,
+ context: DokployTraefikLabelClassifierContext = {},
+): ListOrDict | undefined => {
+ if (!labels) {
+ return labels;
+ }
+
+ if (Array.isArray(labels)) {
+ const removedSpecificLabel = labels.some((label) =>
+ isDomainSpecificDokployTraefikLabel(label, context),
+ );
+ return labels.filter((label) => {
+ if (isDomainSpecificDokployTraefikLabel(label, context)) {
+ return false;
+ }
+ if (removedSpecificLabel && isGenericDokployTraefikLabel(label)) {
+ return false;
+ }
+ return true;
+ });
+ }
+
+ const entries = Object.entries(labels);
+ const removedSpecificLabel = entries.some(([key, value]) =>
+ isDomainSpecificDokployTraefikLabel(
+ stringifyLabelObjectEntry(key, value),
+ context,
+ ),
+ );
+
+ return Object.fromEntries(
+ entries.filter(([key, value]) => {
+ const label = stringifyLabelObjectEntry(key, value);
+ if (isDomainSpecificDokployTraefikLabel(label, context)) {
+ return false;
+ }
+ if (removedSpecificLabel && isGenericDokployTraefikLabel(label)) {
+ return false;
+ }
+ return true;
+ }),
+ );
+};
+
export const addDokployNetworkToService = (
networkService: DefinitionsService["networks"],
+ options: { aliases?: string[] } = {},
) => {
let networks = networkService;
const network = "dokploy-network";
@@ -364,6 +693,17 @@ export const addDokployNetworkToService = (
if (!networks.includes(defaultNetwork)) {
networks.push(defaultNetwork);
}
+ if (options.aliases?.length) {
+ const nextNetworks: Record = {};
+ for (const item of networks) {
+ nextNetworks[item] = {};
+ }
+ nextNetworks[network] = {
+ ...nextNetworks[network],
+ aliases: [...new Set(options.aliases)],
+ };
+ networks = nextNetworks;
+ }
} else if (networks && typeof networks === "object") {
if (!(network in networks)) {
networks[network] = {};
@@ -371,6 +711,17 @@ export const addDokployNetworkToService = (
if (!(defaultNetwork in networks)) {
networks[defaultNetwork] = {};
}
+ if (options.aliases?.length) {
+ const current = networks[network];
+ const currentAliases =
+ current && typeof current === "object" && "aliases" in current
+ ? ((current.aliases as string[] | undefined) ?? [])
+ : [];
+ networks[network] = {
+ ...(current && typeof current === "object" ? current : {}),
+ aliases: [...new Set([...currentAliases, ...options.aliases])],
+ };
+ }
}
return networks;
diff --git a/packages/server/src/utils/docker/utils.ts b/packages/server/src/utils/docker/utils.ts
index 8065b7dd93..fffbc286f9 100644
--- a/packages/server/src/utils/docker/utils.ts
+++ b/packages/server/src/utils/docker/utils.ts
@@ -16,6 +16,7 @@ import type { RedisNested } from "../databases/redis";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { spawnAsync } from "../process/spawnAsync";
import { getRemoteDocker } from "../servers/remote-docker";
+import type { WebServerProvider } from "../web-server/providers";
interface RegistryAuth {
username: string;
@@ -788,7 +789,7 @@ export const getComposeContainer = async (
}
};
-type ServiceHealthStatus = {
+export type ServiceHealthStatus = {
status: "healthy" | "unhealthy";
message?: string;
};
@@ -944,10 +945,11 @@ export const checkRedisHealth = async (): Promise => {
}
};
-export const checkTraefikHealth = async (): Promise => {
- // Traefik can run as a standalone container or a swarm service
+const checkDockerResourceHealth = async (
+ resourceName: string,
+): Promise => {
try {
- const container = docker.getContainer("dokploy-traefik");
+ const container = docker.getContainer(resourceName);
const info = await container.inspect();
if (!info.State.Running) {
return {
@@ -958,6 +960,21 @@ export const checkTraefikHealth = async (): Promise => {
return { status: "healthy" };
} catch {
// Not a standalone container, check as swarm service
- return checkSwarmServiceRunning("dokploy-traefik");
+ return checkSwarmServiceRunning(resourceName);
}
};
+
+export const checkTraefikHealth = async (): Promise => {
+ return checkDockerResourceHealth("dokploy-traefik");
+};
+
+export const checkWebServerHealth = async (
+ provider: WebServerProvider,
+): Promise => {
+ const resourceName =
+ provider === "caddy" ? "dokploy-caddy" : "dokploy-traefik";
+ return {
+ provider,
+ ...(await checkDockerResourceHealth(resourceName)),
+ };
+};
diff --git a/packages/server/src/utils/web-server/domain.ts b/packages/server/src/utils/web-server/domain.ts
new file mode 100644
index 0000000000..19066863d8
--- /dev/null
+++ b/packages/server/src/utils/web-server/domain.ts
@@ -0,0 +1,31 @@
+import type { Domain } from "@dokploy/server/services/domain";
+import { resolveWebServerProvider } from "@dokploy/server/services/web-server-settings";
+import type { ApplicationNested } from "../builders";
+import { manageCaddyDomain, removeCaddyDomain } from "../caddy/domain";
+import { manageDomain, removeDomain } from "../traefik/domain";
+
+export const manageWebServerDomain = async (
+ app: ApplicationNested,
+ domain: Domain,
+) => {
+ const provider = await resolveWebServerProvider(app.serverId);
+
+ if (provider === "caddy") {
+ return manageCaddyDomain(app, domain);
+ }
+
+ return manageDomain(app, domain);
+};
+
+export const removeWebServerDomain = async (
+ app: ApplicationNested,
+ uniqueConfigKey: number,
+) => {
+ const provider = await resolveWebServerProvider(app.serverId);
+
+ if (provider === "caddy") {
+ return removeCaddyDomain(app, uniqueConfigKey);
+ }
+
+ return removeDomain(app, uniqueConfigKey);
+};
diff --git a/packages/server/src/utils/web-server/paths.ts b/packages/server/src/utils/web-server/paths.ts
new file mode 100644
index 0000000000..1d113bf030
--- /dev/null
+++ b/packages/server/src/utils/web-server/paths.ts
@@ -0,0 +1,29 @@
+import { paths } from "@dokploy/server/constants";
+import type { WebServerProvider } from "./providers";
+
+export const getWebServerPaths = (
+ provider: WebServerProvider,
+ isServer = false,
+) => {
+ const resolvedPaths = paths(isServer);
+
+ if (provider === "caddy") {
+ return {
+ provider,
+ basePath: resolvedPaths.MAIN_CADDY_PATH,
+ activeConfigPath: resolvedPaths.CADDY_CONFIG_PATH,
+ fragmentsPath: resolvedPaths.CADDY_FRAGMENTS_PATH,
+ dataPath: resolvedPaths.CADDY_DATA_PATH,
+ configDirPath: resolvedPaths.CADDY_CONFIG_DIR_PATH,
+ migrationsPath: resolvedPaths.CADDY_MIGRATIONS_PATH,
+ };
+ }
+
+ return {
+ provider,
+ basePath: resolvedPaths.MAIN_TRAEFIK_PATH,
+ activeConfigPath: `${resolvedPaths.MAIN_TRAEFIK_PATH}/traefik.yml`,
+ fragmentsPath: resolvedPaths.DYNAMIC_TRAEFIK_PATH,
+ certificatesPath: resolvedPaths.CERTIFICATES_PATH,
+ };
+};
diff --git a/packages/server/src/utils/web-server/providers.ts b/packages/server/src/utils/web-server/providers.ts
new file mode 100644
index 0000000000..e5c29623a3
--- /dev/null
+++ b/packages/server/src/utils/web-server/providers.ts
@@ -0,0 +1,20 @@
+export const WEB_SERVER_PROVIDERS = ["traefik", "caddy"] as const;
+
+export type WebServerProvider = (typeof WEB_SERVER_PROVIDERS)[number];
+
+export const DEFAULT_WEB_SERVER_PROVIDER: WebServerProvider = "traefik";
+
+export const isWebServerProvider = (
+ provider: unknown,
+): provider is WebServerProvider =>
+ typeof provider === "string" &&
+ WEB_SERVER_PROVIDERS.includes(provider as WebServerProvider);
+
+export const normalizeWebServerProvider = (
+ provider: unknown,
+): WebServerProvider =>
+ isWebServerProvider(provider) ? provider : DEFAULT_WEB_SERVER_PROVIDER;
+
+export const getWebServerResourceName = (provider: WebServerProvider) => {
+ return provider === "caddy" ? "dokploy-caddy" : "dokploy-traefik";
+};