Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2c80fdf
feat: add caddy web server provider
masonjames Jun 1, 2026
f8c6d78
chore: format caddy provider changes
masonjames Jun 1, 2026
a86a412
fix: fail closed on runtime migration errors
masonjames Jun 1, 2026
94164bf
feat: harden caddy provider domain flows
masonjames Jun 2, 2026
82a758e
fix: block missing caddy custom cert migrations
masonjames Jun 2, 2026
df469d9
fix: harden caddy certificate and migration guards
masonjames Jun 2, 2026
7e78bbd
fix: relax domain custom cert validation
masonjames Jun 2, 2026
bbbe7ce
chore: polish caddy settings copy
masonjames Jun 2, 2026
c145d1a
docs: record caddy pr hygiene audit
masonjames Jun 2, 2026
c4ac4d0
fix: refresh caddy routes on compose domain import cleanup
masonjames Jun 2, 2026
d14bea9
fix: make web server health provider aware
masonjames Jun 2, 2026
55e003c
test: prove caddy custom certificate success paths
masonjames Jun 2, 2026
2e618b6
test: prove caddy trusted proxy settings rollback
masonjames Jun 2, 2026
877a249
test: prove caddy dashboard stays local only
masonjames Jun 2, 2026
6d4fbeb
test: prove caddy domain delete preflight failures
masonjames Jun 2, 2026
5ce0f8c
fix: harden caddy reload and admin ports
masonjames Jun 2, 2026
ceec308
fix: refresh caddy app config cache
masonjames Jun 2, 2026
4537236
fix: harden caddy web server review gaps
masonjames Jun 2, 2026
2f8a69f
fix: harden caddy request analytics
masonjames Jun 2, 2026
7c80ad6
fix: neutralize web server file permission copy
masonjames Jun 2, 2026
057a1c7
fix: neutralize web server file nav copy
masonjames Jun 2, 2026
fd2a971
fix: harden caddy request analytics review gaps
masonjames Jun 2, 2026
1e4985b
fix: cover caddy domain modal contracts
masonjames Jun 2, 2026
b92b680
fix: prove caddy modal and request log review gaps
masonjames Jun 2, 2026
dee7df5
docs: record caddy read-only proof refresh
masonjames Jun 2, 2026
6dc39dd
fix: neutralize web server account copy
masonjames Jun 2, 2026
a9bcee1
fix: close provider-neutral review gaps
masonjames Jun 2, 2026
3de974b
docs: record exact-head caddy image gate
masonjames Jun 2, 2026
98f6a18
fix: make app web server config provider-neutral
masonjames Jun 2, 2026
01b4533
docs: record exact-head caddy live validation
masonjames Jun 2, 2026
4141b3d
fix: harden caddy dashboard route updates
masonjames Jun 2, 2026
1fffd19
fix: guard caddy dashboard toggles
masonjames Jun 2, 2026
14dcc22
fix: harden caddy dashboard rollback
masonjames Jun 2, 2026
83d15ce
fix: tighten caddy certificate and env handling
masonjames Jun 2, 2026
7baf056
fix: resync caddy dashboard after concurrent reload failure
masonjames Jun 2, 2026
8d24ce5
docs: record rebased caddy PR validation
masonjames Jun 2, 2026
6929bba
fix: polish caddy web server UI
masonjames Jun 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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/);
},
);
241 changes: 241 additions & 0 deletions apps/dokploy/__test__/caddy/application/domain-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import { beforeEach, expect, test, vi } from "vitest";

const txInsertMock = 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: vi.fn().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("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();
});
Loading