This commit is contained in:
Jay Simons 2026-09-11 13:11:53 -04:00 committed by GitHub
commit 5a8920bc6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 921 additions and 201 deletions

4
.gitignore vendored
View File

@ -46,4 +46,6 @@ yarn-error.log*
.db
.playwright-*
.credentials
.credentials
.cursor/handoff.md
.env.production

View File

@ -7,6 +7,17 @@ vi.mock("@dokploy/server/utils/docker/domain", () => ({
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
}));
vi.mock("@dokploy/server/services/registry", () => ({
findRegistryByIdWithCredentials: vi.fn().mockResolvedValue({
registryUrl: "registry.example.com",
username: "user",
password: "pass",
}),
safeDockerLoginCommand: vi
.fn()
.mockReturnValue('echo "docker login stub"'),
}));
const baseCompose = {
appName: "my-app",
sourceType: "raw",
@ -17,6 +28,8 @@ const baseCompose = {
randomize: false,
suffix: "",
serverId: null,
buildServerId: null,
buildRegistryId: null,
env: "",
mounts: [],
domains: [],
@ -49,4 +62,20 @@ describe("getBuildComposeCommand registry auth (#4401)", () => {
expect(command).toContain("compose -p my-app");
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
});
it("uses prebuilt pull/up when buildServerId is set", async () => {
const command = await getBuildComposeCommand(
{
...baseCompose,
composeType: "docker-compose",
buildServerId: "build-server-1",
buildRegistryId: "registry-1",
},
{ prebuilt: true },
);
expect(command).toContain("pull");
expect(command).toContain("--no-build");
expect(command).toContain("docker login stub");
});
});

View File

@ -0,0 +1,86 @@
import {
applyBuildRegistryImages,
hasBuildableServices,
} from "@dokploy/server/utils/docker/domain";
import type { ComposeSpecification } from "@dokploy/server/utils/docker/types";
import type { Registry } from "@dokploy/server/services/registry";
import { describe, expect, it } from "vitest";
const registry = {
registryId: "reg-1",
registryName: "Test Registry",
registryUrl: "registry.example.com",
username: "user",
password: "pass",
imagePrefix: "myorg",
registryType: "cloud" as const,
organizationId: "org-1",
createdAt: new Date().toISOString(),
};
describe("applyBuildRegistryImages", () => {
it("rewrites image tags for services with build:", () => {
const spec: ComposeSpecification = {
services: {
api: {
build: "./api",
image: "local-api:latest",
},
db: {
image: "postgres:16",
},
},
};
const result = applyBuildRegistryImages(spec, registry, "my-compose");
expect(result.services?.api?.image).toBe(
"registry.example.com/myorg/my-compose-api:latest",
);
expect(result.services?.api?.build).toBe("./api");
expect(result.services?.db?.image).toBe("postgres:16");
});
it("lowercases and sanitizes service names in image tags", () => {
const spec: ComposeSpecification = {
services: {
"MyService": {
build: ".",
},
},
};
const result = applyBuildRegistryImages(
spec,
registry,
"App-Name",
);
expect(result.services?.MyService?.image).toBe(
"registry.example.com/myorg/app-name-myservice:latest",
);
});
});
describe("hasBuildableServices", () => {
it("returns true when at least one service has build:", () => {
expect(
hasBuildableServices({
services: {
web: { image: "nginx" },
api: { build: "." },
},
}),
).toBe(true);
});
it("returns false when no service has build:", () => {
expect(
hasBuildableServices({
services: {
db: { image: "postgres:16" },
},
}),
).toBe(false);
});
});

View File

@ -0,0 +1,38 @@
import { createCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it } from "vitest";
const base = {
composeType: "docker-compose" as const,
appName: "my-app",
sourceType: "raw" as const,
command: "",
composePath: "docker-compose.yml",
buildServerId: null,
} as unknown as Parameters<typeof createCommand>[0];
describe("createCommand prebuilt remote build", () => {
it("uses --build when not prebuilt", () => {
const command = createCommand(base, { prebuilt: false });
expect(command).toContain("up -d --build --remove-orphans");
expect(command).not.toContain("--no-build");
});
it("uses pull and --no-build when prebuilt", () => {
const command = createCommand(base, { prebuilt: true });
// First segment is prefixed with `docker ` at exec time; chained segment
// must already include `docker compose` or it runs as bare `compose`.
expect(command).toMatch(
/^compose -p .+ pull && docker compose -p .+ up -d --no-build --remove-orphans$/,
);
expect(command).not.toContain("--build");
});
it("keeps stack deploy with registry auth when prebuilt", () => {
const command = createCommand(
{ ...base, composeType: "stack" },
{ prebuilt: true },
);
expect(command).toContain("stack deploy");
expect(command).toContain("--with-registry-auth");
});
});

View File

@ -34,9 +34,17 @@ import {
} from "@/components/ui/select";
import { api } from "@/utils/api";
interface Props {
type ApplicationProps = {
serviceType: "application";
applicationId: string;
}
};
type ComposeProps = {
serviceType: "compose";
composeId: string;
};
type Props = ApplicationProps | ComposeProps;
const schema = z
.object({
@ -45,13 +53,11 @@ const schema = z
})
.refine(
(data) => {
// Both empty/none is valid
const buildServerIsNone =
!data.buildServerId || data.buildServerId === "none";
const buildRegistryIsNone =
!data.buildRegistryId || data.buildRegistryId === "none";
// Both should be either filled or empty
if (buildServerIsNone && buildRegistryIsNone) return true;
if (!buildServerIsNone && !buildRegistryIsNone) return true;
@ -60,21 +66,42 @@ const schema = z
{
message:
"Both Build Server and Build Registry must be selected together, or both set to None",
path: ["buildServerId"], // Show error on buildServerId field
path: ["buildServerId"],
},
);
type Schema = z.infer<typeof schema>;
export const ShowBuildServer = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery(
{ applicationId },
{ enabled: !!applicationId },
export const ShowBuildServer = (props: Props) => {
const isCompose = props.serviceType === "compose";
const serviceId = isCompose ? props.composeId : props.applicationId;
const applicationQuery = api.application.one.useQuery(
{ applicationId: props.serviceType === "application" ? props.applicationId : "" },
{ enabled: props.serviceType === "application" && !!props.applicationId },
);
const composeQuery = api.compose.one.useQuery(
{ composeId: props.serviceType === "compose" ? props.composeId : "" },
{ enabled: props.serviceType === "compose" && !!props.composeId },
);
const data =
props.serviceType === "application"
? applicationQuery.data
: composeQuery.data;
const refetch =
props.serviceType === "application"
? applicationQuery.refetch
: composeQuery.refetch;
const { data: buildServers } = api.server.buildServers.useQuery();
const { data: registries } = api.registry.all.useQuery();
const { mutateAsync, isPending } = api.application.update.useMutation();
const updateApplication = api.application.update.useMutation();
const updateCompose = api.compose.update.useMutation();
const isPending = isCompose
? updateCompose.isPending
: updateApplication.isPending;
const form = useForm<Schema>({
defaultValues: {
@ -94,24 +121,34 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
}, [form, form.reset, data]);
const onSubmit = async (formData: Schema) => {
await mutateAsync({
applicationId,
buildServerId:
formData?.buildServerId === "none" || !formData?.buildServerId
? null
: formData?.buildServerId,
buildRegistryId:
formData?.buildRegistryId === "none" || !formData?.buildRegistryId
? null
: formData?.buildRegistryId,
})
.then(async () => {
toast.success("Build Server Settings Updated");
await refetch();
})
.catch(() => {
toast.error("Error updating build server settings");
});
const buildServerId =
formData?.buildServerId === "none" || !formData?.buildServerId
? null
: formData?.buildServerId;
const buildRegistryId =
formData?.buildRegistryId === "none" || !formData?.buildRegistryId
? null
: formData?.buildRegistryId;
try {
if (isCompose) {
await updateCompose.mutateAsync({
composeId: serviceId,
buildServerId,
buildRegistryId,
});
} else {
await updateApplication.mutateAsync({
applicationId: serviceId,
buildServerId,
buildRegistryId,
});
}
toast.success("Build Server Settings Updated");
await refetch();
} catch {
toast.error("Error updating build server settings");
}
};
return (
@ -122,7 +159,8 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
<div>
<CardTitle className="text-xl">Build Server</CardTitle>
<CardDescription>
Configure a dedicated server for building your application.
Configure a dedicated server for building your{" "}
{isCompose ? "compose service" : "application"}.
</CardDescription>
</div>
</div>
@ -130,10 +168,19 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info">
Build servers offload the build process from your deployment servers.
Select a build server and registry to use for building your
application.
Select a build server and registry to use for building your service.
</AlertBlock>
{isCompose ? (
<AlertBlock type="info">
For compose services, only services with a{" "}
<code className="text-xs">build:</code> section are built on the
build server and pushed to the registry. Image-only services are
unchanged. Custom deploy commands should not include{" "}
<code className="text-xs">--build</code> when using a build server.
</AlertBlock>
) : null}
<AlertBlock type="info">
📊 <strong>Important:</strong> Once the build finishes, you'll need to
wait a few seconds for the deployment server to download the image.
@ -175,7 +222,6 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
<Select
onValueChange={(value) => {
field.onChange(value);
// If setting to "none", also reset build registry to "none"
if (value === "none") {
form.setValue("buildRegistryId", "none");
}
@ -215,7 +261,7 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
</Select>
<FormDescription>
Select a build server to handle the build process for this
application.
service.
</FormDescription>
<FormMessage />
</FormItem>
@ -231,7 +277,6 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
<Select
onValueChange={(value) => {
field.onChange(value);
// If setting to "none", also reset build server to "none"
if (value === "none") {
form.setValue("buildServerId", "none");
}

View File

@ -136,7 +136,10 @@ export function ShowDeploymentsTable() {
d.compose?.server?.name ??
"";
const buildServerName =
d.buildServer?.name ?? d.application?.buildServer?.name ?? "";
d.buildServer?.name ??
d.application?.buildServer?.name ??
d.compose?.buildServer?.name ??
"";
if (!info) return false;
return (
info.name.toLowerCase().includes(q) ||
@ -296,10 +299,14 @@ export function ShowDeploymentsTable() {
d.compose?.server?.serverType ??
null;
const buildServerName =
d.buildServer?.name ?? d.application?.buildServer?.name ?? null;
d.buildServer?.name ??
d.application?.buildServer?.name ??
d.compose?.buildServer?.name ??
null;
const buildServerType =
d.buildServer?.serverType ??
d.application?.buildServer?.serverType ??
d.compose?.buildServer?.serverType ??
null;
const showBuild =
buildServerName != null && buildServerName !== serverName;

View File

@ -0,0 +1,4 @@
ALTER TABLE "compose" ADD COLUMN "buildServerId" text;--> statement-breakpoint
ALTER TABLE "compose" ADD COLUMN "buildRegistryId" text;--> statement-breakpoint
ALTER TABLE "compose" ADD CONSTRAINT "compose_buildServerId_server_serverId_fk" FOREIGN KEY ("buildServerId") REFERENCES "public"."server"("serverId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "compose" ADD CONSTRAINT "compose_buildRegistryId_registry_registryId_fk" FOREIGN KEY ("buildRegistryId") REFERENCES "public"."registry"("registryId") ON DELETE set null ON UPDATE no action;

View File

@ -1,6 +1,6 @@
{
"id": "92a9010e-bc5f-4c9f-b97e-f2867a602e90",
"prevId": "633b82a2-0321-4ef4-ae09-7a8861830873",
"id": "bf16a3c7-9d5d-4c2f-83ce-f87c7878dd23",
"prevId": "c9ad226a-7a8f-4c3b-8f04-fef6bccf9bfb",
"version": "7",
"dialect": "postgresql",
"tables": {
@ -2690,6 +2690,13 @@
"notNull": true,
"default": false
},
"pullImages": {
"name": "pullImages",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"triggerType": {
"name": "triggerType",
"type": "triggerType",
@ -2760,6 +2767,18 @@
"primaryKey": false,
"notNull": false
},
"buildServerId": {
"name": "buildServerId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"buildRegistryId": {
"name": "buildRegistryId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"serviceNetworks": {
"name": "serviceNetworks",
"type": "jsonb",
@ -2860,6 +2879,32 @@
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"compose_buildServerId_server_serverId_fk": {
"name": "compose_buildServerId_server_serverId_fk",
"tableFrom": "compose",
"tableTo": "server",
"columnsFrom": [
"buildServerId"
],
"columnsTo": [
"serverId"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"compose_buildRegistryId_registry_registryId_fk": {
"name": "compose_buildRegistryId_registry_registryId_fk",
"tableFrom": "compose",
"tableTo": "registry",
"columnsFrom": [
"buildRegistryId"
],
"columnsTo": [
"registryId"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
@ -7983,6 +8028,13 @@
"primaryKey": false,
"notNull": true
},
"domain_verified": {
"name": "domain_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
@ -8351,6 +8403,12 @@
"primaryKey": false,
"notNull": false,
"default": "ARRAY[]::text[]"
},
"onboardingCompletedAt": {
"name": "onboardingCompletedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
@ -8797,7 +8855,7 @@
"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"
"default": "'{\"appName\":null,\"appDescription\":null,\"logoUrl\":null,\"faviconUrl\":null,\"customCss\":null,\"loginLogoUrl\":null,\"supportUrl\":null,\"docsUrl\":null,\"errorPageTitle\":null,\"errorPageDescription\":null,\"footerText\":null,\"ogImageUrl\":null}'::jsonb"
},
"remoteServersOnly": {
"name": "remoteServersOnly",
@ -8946,7 +9004,10 @@
"schema": "public",
"values": [
"cloudflare",
"route53"
"route53",
"porkbun",
"infomaniak",
"ovh"
]
},
"public.domainType": {
@ -9126,6 +9187,7 @@
"hashicorp",
"infisical",
"aws",
"aws-parameter-store",
"doppler",
"azure",
"scaleway",
@ -9143,4 +9205,4 @@
"schemas": {},
"tables": {}
}
}
}

View File

@ -1380,6 +1380,13 @@
"when": 1788995453103,
"tag": "0196_worthless_ravenous",
"breakpoints": true
},
{
"idx": 197,
"version": "7",
"when": 1787888847584,
"tag": "0197_sharp_arclight",
"breakpoints": true
}
]
}
}

View File

@ -425,7 +425,10 @@ const Service = (
id={applicationId}
type="application"
/>
<ShowBuildServer applicationId={applicationId} />
<ShowBuildServer
serviceType="application"
applicationId={applicationId}
/>
<ShowResources id={applicationId} type="application" />
<ShowVolumes id={applicationId} type="application" />
<AssignNetworks id={applicationId} type="application" />

View File

@ -21,6 +21,7 @@ import { ShowIconSettings } from "@/components/dashboard/application/icon/show-i
import { ShowPatches } from "@/components/dashboard/application/patches/show-patches";
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
import { ShowBuildServer } from "@/components/dashboard/application/advanced/show-build-server";
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
import { FreshVolumes } from "@/components/dashboard/compose/advanced/fresh-volumes";
@ -439,6 +440,10 @@ const Service = (
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<AddCommandCompose composeId={composeId} />
<ShowBuildServer
serviceType="compose"
composeId={composeId}
/>
<ShowVolumes id={composeId} type="compose" />
<ShowImport composeId={composeId} />
<AssignComposeNetworks composeId={composeId} />

View File

@ -15,6 +15,7 @@ import {
findDomainsByComposeId,
findEnvironmentById,
findProjectById,
findRegistryById,
findServerById,
getAccessibleServerIds,
getComposeContainer,
@ -197,7 +198,40 @@ export const composeRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const updated = await updateCompose(input.composeId, input);
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this compose",
});
}
if (input.buildServerId) {
const accessibleIds = await getAccessibleServerIds(ctx.session);
if (!accessibleIds.has(input.buildServerId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this build server",
});
}
}
if (input.buildRegistryId) {
const reg = await findRegistryById(input.buildRegistryId);
if (reg.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this build registry",
});
}
}
const { composeId, ...rest } = input;
const updated = await updateCompose(composeId, rest);
await audit(ctx, {
action: "update",
resourceType: "compose",
@ -293,7 +327,10 @@ export const composeRouter = createTRPCRouter({
deployment: ["create"],
});
const compose = await findComposeById(input.composeId);
await clearOldDeployments(compose.appName, compose.serverId);
await clearOldDeployments(
compose.appName,
compose.buildServerId || compose.serverId,
);
await audit(ctx, {
action: "update",
resourceType: "compose",
@ -309,7 +346,10 @@ export const composeRouter = createTRPCRouter({
deployment: ["cancel"],
});
const compose = await findComposeById(input.composeId);
await killDockerBuild("compose", compose.serverId);
await killDockerBuild(
"compose",
compose.buildServerId || compose.serverId,
);
}),
loadServices: protectedProcedure

View File

@ -265,6 +265,7 @@ export const deploymentRouter = createTRPCRouter({
const command = `tail -n ${input.tail} "${deployment.logPath}" 2>/dev/null || echo ""`;
const serverId =
deployment.buildServerId ||
deployment.serverId ||
deployment.schedule?.serverId ||
deployment.application?.serverId ||

View File

@ -61,22 +61,6 @@
"typescript",
"drizzle-kit"
]
},
"onlyBuiltDependencies": [
"@scarf/scarf",
"@tree-sitter-grammars/tree-sitter-yaml",
"bcrypt",
"better-sqlite3",
"core-js-pure",
"cpu-features",
"esbuild",
"msgpackr-extract",
"node-pty",
"protobufjs",
"sharp",
"ssh2",
"tree-sitter",
"tree-sitter-json"
]
}
}
}

View File

@ -20,6 +20,7 @@ import { github } from "./github";
import { gitlab } from "./gitlab";
import { mounts } from "./mount";
import { patch } from "./patch";
import { registry } from "./registry";
import { schedules } from "./schedule";
import { server } from "./server";
import { applicationStatus, triggerType } from "./shared";
@ -123,6 +124,15 @@ export const compose = pgTable("compose", {
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
buildServerId: text("buildServerId").references(() => server.serverId, {
onDelete: "set null",
}),
buildRegistryId: text("buildRegistryId").references(
() => registry.registryId,
{
onDelete: "set null",
},
),
serviceNetworks: jsonb("serviceNetworks")
.$type<
Array<{
@ -165,6 +175,17 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
server: one(server, {
fields: [compose.serverId],
references: [server.serverId],
relationName: "composeServer",
}),
buildServer: one(server, {
fields: [compose.buildServerId],
references: [server.serverId],
relationName: "composeBuildServer",
}),
buildRegistry: one(registry, {
fields: [compose.buildRegistryId],
references: [registry.registryId],
relationName: "composeBuildRegistry",
}),
backups: many(backups),
schedules: many(schedules),

View File

@ -5,6 +5,7 @@ import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
import { applications } from "./application";
import { compose } from "./compose";
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
* database instance for multiple projects.
@ -42,6 +43,9 @@ export const registryRelations = relations(registry, ({ many }) => ({
rollbackApplications: many(applications, {
relationName: "applicationRollbackRegistry",
}),
buildComposes: many(compose, {
relationName: "composeBuildRegistry",
}),
}));
// Registry usernames should NOT be lowercased.

View File

@ -118,7 +118,12 @@ export const serverRelations = relations(server, ({ one, many }) => ({
buildApplications: many(applications, {
relationName: "applicationBuildServer",
}),
compose: many(compose),
compose: many(compose, {
relationName: "composeServer",
}),
buildCompose: many(compose, {
relationName: "composeBuildServer",
}),
libsql: many(libsql),
redis: many(redis),
mariadb: many(mariadb),

View File

@ -1,4 +1,5 @@
import { join } from "node:path";
import { promises as fsPromises } from "node:fs";
import { dirname, join } from "node:path";
import { paths } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import {
@ -7,7 +8,7 @@ import {
cleanAppName,
compose,
} from "@dokploy/server/db/schema";
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
import { getBuildComposeCommand, getComposeRemoteBuildCommand } from "@dokploy/server/utils/builders/compose";
import { randomizeSpecificationFile } from "@dokploy/server/utils/docker/compose";
import {
cloneCompose,
@ -144,6 +145,8 @@ export const findComposeById = async (composeId: string) => {
},
},
server: true,
buildServer: true,
buildRegistry: { columns: { password: false } },
backups: {
with: {
destination: {
@ -225,6 +228,139 @@ export const updateCompose = async (
return composeResult[0];
};
type ComposeEntity = Awaited<ReturnType<typeof findComposeById>> & {
type: "compose";
};
const ensureLogDirectory = async (
serverId: string | null,
logPath: string,
) => {
const logDir = dirname(logPath);
if (serverId) {
await execAsyncRemote(serverId, `mkdir -p ${quote([logDir])}`);
return;
}
await fsPromises.mkdir(logDir, { recursive: true });
};
const runWithLog = async (
serverId: string | null,
command: string,
logPath: string,
) => {
await ensureLogDirectory(serverId, logPath);
const commandWithLog = `(${command}) >> ${quote([logPath])} 2>&1`;
if (serverId) {
await execAsyncRemote(serverId, commandWithLog);
} else {
await execAsync(commandWithLog);
}
};
const appendDeployLogsIfSplit = async (
deployTarget: string | null,
deployLogPath: string,
logTarget: string | null,
mainLogPath: string,
) => {
if (logTarget === deployTarget) return;
await appendLogFromTarget(
deployTarget,
deployLogPath,
logTarget,
mainLogPath,
"Deploy phase",
);
};
const appendLogFromTarget = async (
fromServerId: string | null,
fromLogPath: string,
toServerId: string | null,
toLogPath: string,
sectionLabel: string,
) => {
let content = "";
if (fromServerId) {
const result = await execAsyncRemote(
fromServerId,
`cat ${quote([fromLogPath])} 2>/dev/null || true`,
);
content = result.stdout;
} else {
const result = await execAsync(
`cat ${quote([fromLogPath])} 2>/dev/null || true`,
);
content = result.stdout;
}
if (!content.trim()) return;
const section = `\n=== ${sectionLabel} ===\n${content}`;
const encoded = encodeBase64(section);
const appendCommand = `echo "${encoded}" | base64 -d >> ${quote([toLogPath])}`;
if (toServerId) {
await execAsyncRemote(toServerId, appendCommand);
} else {
await execAsync(appendCommand);
}
};
const cloneComposeSource = async (
compose: ComposeEntity,
serverId: string | null,
) => {
const entity = { ...compose, serverId, type: "compose" as const };
let command = "set -e;";
if (compose.sourceType === "github") {
command += await cloneGithubRepository(entity);
} else if (compose.sourceType === "gitlab") {
command += await cloneGitlabRepository(entity);
} else if (compose.sourceType === "bitbucket") {
command += await cloneBitbucketRepository(entity);
} else if (compose.sourceType === "git") {
command += await cloneGitRepository(entity);
} else if (compose.sourceType === "gitea") {
command += await cloneGiteaRepository(entity);
} else if (compose.sourceType === "raw") {
command += getCreateComposeFileCommand(entity);
}
return command;
};
const applyComposePatches = async (
compose: ComposeEntity,
serverId: string | null,
) => {
if (compose.sourceType === "raw") {
return "";
}
return generateApplyPatchesCommand({
id: compose.composeId,
type: "compose",
serverId,
});
};
const cloneAndPatchOnTarget = async (
compose: ComposeEntity,
targetServerId: string | null,
logPath: string,
options?: { skipClone?: boolean },
) => {
if (!options?.skipClone) {
const command = await cloneComposeSource(compose, targetServerId);
await runWithLog(targetServerId, command, logPath);
}
const patchCommand = await applyComposePatches(compose, targetServerId);
if (patchCommand) {
await runWithLog(targetServerId, `set -e;${patchCommand}`, logPath);
}
};
export const deployCompose = async ({
composeId,
titleLog = "Manual deployment",
@ -237,6 +373,10 @@ export const deployCompose = async ({
freshVolumes?: boolean;
}) => {
const compose = await findComposeById(composeId);
const buildTarget = compose.buildServerId ?? null;
const deployTarget = compose.serverId ?? null;
const logTarget = buildTarget ?? deployTarget;
const usesRemoteBuild = Boolean(buildTarget && compose.buildRegistryId);
const buildLink = `${await getDokployUrl()}/dashboard/project/${
compose.environment.projectId
@ -247,65 +387,64 @@ export const deployCompose = async ({
description: descriptionLog,
});
const entity = {
...compose,
type: "compose" as const,
};
const logErrorToTarget = async (error: unknown) => {
let command = "";
if (!(error instanceof ExecError)) {
const message = error instanceof Error ? error.message : String(error);
const encodedMessage = encodeBase64(message);
command += `echo "${encodedMessage}" | base64 -d >> ${quote([deployment.logPath])};`;
}
command += `echo "\nError occurred ❌, check the logs for details." >> ${quote([deployment.logPath])};`;
if (logTarget) {
await execAsyncRemote(logTarget, command);
} else {
await execAsync(command);
}
};
const deployLogPath =
logTarget !== deployTarget
? `${deployment.logPath}.deploy`
: deployment.logPath;
try {
const entity = {
...compose,
type: "compose" as const,
};
let command = "set -e;";
if (compose.sourceType === "github") {
command += await cloneGithubRepository(entity);
} else if (compose.sourceType === "gitlab") {
command += await cloneGitlabRepository(entity);
} else if (compose.sourceType === "bitbucket") {
command += await cloneBitbucketRepository(entity);
} else if (compose.sourceType === "git") {
command += await cloneGitRepository(entity);
} else if (compose.sourceType === "gitea") {
command += await cloneGiteaRepository(entity);
} else if (compose.sourceType === "raw") {
command += getCreateComposeFileCommand(entity);
if (usesRemoteBuild && buildTarget) {
await cloneAndPatchOnTarget(entity, buildTarget, deployment.logPath);
const buildCommand = await getComposeRemoteBuildCommand(
entity,
buildTarget,
);
await runWithLog(buildTarget, buildCommand, deployment.logPath);
}
let commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, commandWithLog);
} else {
await execAsync(commandWithLog);
}
if (compose.sourceType !== "raw") {
command = "set -e;";
command += await generateApplyPatchesCommand({
id: compose.composeId,
type: "compose",
serverId: compose.serverId,
});
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, commandWithLog);
} else {
await execAsync(commandWithLog);
}
if (usesRemoteBuild && deployTarget !== buildTarget) {
await cloneAndPatchOnTarget(entity, deployTarget, deployLogPath);
} else if (!usesRemoteBuild) {
await cloneAndPatchOnTarget(entity, deployTarget, deployment.logPath);
}
if (freshVolumes && compose.composeType === "docker-compose") {
const downCommand = `set -e; env -i PATH="$PATH" docker compose -p ${compose.appName} down --volumes 2>&1 || true;`;
const downWithLog = `(${downCommand}) >> ${deployment.logPath} 2>&1`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, downWithLog);
} else {
await execAsync(downWithLog);
}
await runWithLog(deployTarget, downCommand, deployLogPath);
}
command = "set -e;";
command += await getBuildComposeCommand(entity);
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, commandWithLog);
} else {
await execAsync(commandWithLog);
}
const deployCommand = await getBuildComposeCommand(entity, {
prebuilt: usesRemoteBuild,
targetServerId: deployTarget,
});
await runWithLog(deployTarget, deployCommand, deployLogPath);
await appendDeployLogsIfSplit(
deployTarget,
deployLogPath,
logTarget,
deployment.logPath,
);
await updateDeploymentStatus(deployment.deploymentId, "done");
await updateCompose(composeId, {
@ -322,21 +461,17 @@ export const deployCompose = async ({
environmentName: compose.environment.name,
});
} catch (error) {
let command = "";
// Only log details for non-ExecError errors
if (!(error instanceof ExecError)) {
const message = error instanceof Error ? error.message : String(error);
const encodedMessage = encodeBase64(message);
command += `echo "${encodedMessage}" | base64 -d >> "${deployment.logPath}";`;
}
command += `echo "\nError occurred ❌, check the logs for details." >> ${deployment.logPath};`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, command);
} else {
await execAsync(command);
try {
await appendDeployLogsIfSplit(
deployTarget,
deployLogPath,
logTarget,
deployment.logPath,
);
} catch {
// Best effort: deploy logs may not exist yet.
}
await logErrorToTarget(error);
await updateDeploymentStatus(deployment.deploymentId, "error");
await updateCompose(composeId, {
composeStatus: "error",
@ -379,6 +514,10 @@ export const rebuildCompose = async ({
freshVolumes?: boolean;
}) => {
const compose = await findComposeById(composeId);
const buildTarget = compose.buildServerId ?? null;
const deployTarget = compose.serverId ?? null;
const logTarget = buildTarget ?? deployTarget;
const usesRemoteBuild = Boolean(buildTarget && compose.buildRegistryId);
const deployment = await createDeploymentCompose({
composeId: composeId,
@ -386,70 +525,95 @@ export const rebuildCompose = async ({
description: descriptionLog,
});
const entity = {
...compose,
type: "compose" as const,
};
const deployLogPath =
logTarget !== deployTarget
? `${deployment.logPath}.deploy`
: deployment.logPath;
try {
let command = "set -e;";
if (compose.sourceType === "raw") {
command += getCreateComposeFileCommand(compose);
}
let commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, commandWithLog);
} else {
await execAsync(commandWithLog);
}
if (compose.sourceType !== "raw") {
command = "set -e;";
command += await generateApplyPatchesCommand({
id: compose.composeId,
type: "compose",
serverId: compose.serverId,
});
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, commandWithLog);
} else {
await execAsync(commandWithLog);
if (usesRemoteBuild && buildTarget) {
if (compose.sourceType === "raw") {
const rawCommand = getCreateComposeFileCommand(entity);
await runWithLog(buildTarget, rawCommand, deployment.logPath);
}
await cloneAndPatchOnTarget(entity, buildTarget, deployment.logPath, {
skipClone: true,
});
const buildCommand = await getComposeRemoteBuildCommand(
entity,
buildTarget,
);
await runWithLog(buildTarget, buildCommand, deployment.logPath);
}
if (usesRemoteBuild && deployTarget !== buildTarget) {
if (compose.sourceType === "raw") {
const rawCommand = getCreateComposeFileCommand(entity);
await runWithLog(deployTarget, rawCommand, deployLogPath);
}
await cloneAndPatchOnTarget(entity, deployTarget, deployLogPath, {
skipClone: true,
});
} else if (!usesRemoteBuild) {
if (compose.sourceType === "raw") {
const rawCommand = getCreateComposeFileCommand(entity);
await runWithLog(deployTarget, rawCommand, deployment.logPath);
}
await cloneAndPatchOnTarget(entity, deployTarget, deployment.logPath, {
skipClone: true,
});
}
if (freshVolumes && compose.composeType === "docker-compose") {
const downCommand = `set -e; env -i PATH="$PATH" docker compose -p ${compose.appName} down --volumes 2>&1 || true;`;
const downWithLog = `(${downCommand}) >> ${deployment.logPath} 2>&1`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, downWithLog);
} else {
await execAsync(downWithLog);
}
await runWithLog(deployTarget, downCommand, deployLogPath);
}
command = "set -e;";
command += await getBuildComposeCommand(compose);
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, commandWithLog);
} else {
await execAsync(commandWithLog);
}
const deployCommand = await getBuildComposeCommand(entity, {
prebuilt: usesRemoteBuild,
targetServerId: deployTarget,
});
await runWithLog(deployTarget, deployCommand, deployLogPath);
await appendDeployLogsIfSplit(
deployTarget,
deployLogPath,
logTarget,
deployment.logPath,
);
await updateDeploymentStatus(deployment.deploymentId, "done");
await updateCompose(composeId, {
composeStatus: "done",
});
} catch (error) {
try {
await appendDeployLogsIfSplit(
deployTarget,
deployLogPath,
logTarget,
deployment.logPath,
);
} catch {
// Best effort: deploy logs may not exist yet.
}
let command = "";
// Only log details for non-ExecError errors
if (!(error instanceof ExecError)) {
const message = error instanceof Error ? error.message : String(error);
const encodedMessage = encodeBase64(message);
command += `echo "${encodedMessage}" | base64 -d >> "${deployment.logPath}";`;
command += `echo "${encodedMessage}" | base64 -d >> ${quote([deployment.logPath])};`;
}
command += `echo "\nError occurred ❌, check the logs for details." >> ${deployment.logPath};`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, command);
command += `echo "\nError occurred ❌, check the logs for details." >> ${quote([deployment.logPath])};`;
if (logTarget) {
await execAsyncRemote(logTarget, command);
} else {
await execAsync(command);
}

View File

@ -293,20 +293,22 @@ export const createDeploymentCompose = async (
await removeLastTenDeployments(
deployment.composeId,
"compose",
compose.serverId,
compose.buildServerId || compose.serverId,
);
try {
const { LOGS_PATH } = paths(!!compose.serverId);
const logServerId = compose.buildServerId || compose.serverId;
const { LOGS_PATH } = paths(!!logServerId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${compose.appName}-${formattedDateTime}.log`;
const logFilePath = path.join(LOGS_PATH, compose.appName, fileName);
if (compose.serverId) {
const server = await findServerById(compose.serverId);
if (logServerId) {
const server = await findServerById(logServerId);
const command = `
mkdir -p ${LOGS_PATH}/${compose.appName};
echo "Initializing deployment\n" >> ${logFilePath};
echo "Initializing deployment" >> ${logFilePath};
echo "Building on ${compose.buildServerId ? "Build Server" : logServerId ? "Deploy Server" : "Dokploy Server"}" >> ${logFilePath};
`;
await execAsyncRemote(server.serverId, command);
@ -326,6 +328,9 @@ echo "Initializing deployment\n" >> ${logFilePath};
status: "running",
logPath: logFilePath,
startedAt: new Date().toISOString(),
...(compose.buildServerId && {
buildServerId: compose.buildServerId,
}),
})
.returning();
if (deploymentCreate.length === 0 || !deploymentCreate[0]) {
@ -629,8 +634,9 @@ export const removeDeployment = async (deploymentId: string) => {
const logPath = path.join(deployment.logPath);
if (logPath && logPath !== ".") {
const command = `rm -f ${logPath};`;
if (deployment.serverId) {
await execAsyncRemote(deployment.serverId, command);
const logServerId = deployment.buildServerId || deployment.serverId;
if (logServerId) {
await execAsyncRemote(logServerId, command);
} else {
await execAsync(command);
}
@ -781,10 +787,11 @@ export const removeDeploymentsByPreviewDeploymentId = async (
export const removeDeploymentsByComposeId = async (compose: Compose) => {
const { appName } = compose;
const { LOGS_PATH } = paths(!!compose.serverId);
const logServerId = compose.buildServerId || compose.serverId;
const { LOGS_PATH } = paths(!!logServerId);
const logsPath = path.join(LOGS_PATH, appName);
if (compose.serverId) {
await execAsyncRemote(compose.serverId, `rm -rf ${logsPath}`);
if (logServerId) {
await execAsyncRemote(logServerId, `rm -rf ${logsPath}`);
} else {
await removeDirectoryIfExistsContent(logsPath);
}
@ -847,6 +854,9 @@ const centralizedDeploymentsWith = {
server: {
columns: { serverId: true, name: true, serverType: true },
},
buildServer: {
columns: { serverId: true, name: true, serverType: true },
},
},
},
server: {

View File

@ -103,7 +103,9 @@ export const haveActiveServices = async (serverId: string) => {
columns: { serverId: true },
with: {
applications: { columns: { applicationId: true } },
buildApplications: { columns: { applicationId: true } },
compose: { columns: { composeId: true } },
buildCompose: { columns: { composeId: true } },
libsql: { columns: { libsqlId: true } },
mariadb: { columns: { mariadbId: true } },
mongo: { columns: { mongoId: true } },
@ -119,7 +121,9 @@ export const haveActiveServices = async (serverId: string) => {
const total =
currentServer?.applications?.length +
currentServer?.buildApplications?.length +
currentServer?.compose?.length +
currentServer?.buildCompose?.length +
currentServer?.libsql?.length +
currentServer?.mariadb?.length +
currentServer?.mongo?.length +

View File

@ -1,9 +1,17 @@
import { dirname, join } from "node:path";
import { paths } from "@dokploy/server/constants";
import {
findRegistryByIdWithCredentials,
safeDockerLoginCommand,
} from "@dokploy/server/services/registry";
import type { InferResultType } from "@dokploy/server/types/with";
import boxen from "boxen";
import { quote } from "shell-quote";
import { writeDomainsToCompose } from "../docker/domain";
import {
addDomainToCompose,
hasBuildableServices,
writeDomainsToCompose,
} from "../docker/domain";
import {
encodeBase64,
getEnvironmentVariablesObject,
@ -14,31 +22,65 @@ import { withResolvedVaultRefs } from "../vault";
export type ComposeNested = InferResultType<
"compose",
{ environment: { with: { project: true } }; mounts: true; domains: true }
{
environment: { with: { project: true } };
mounts: true;
domains: true;
buildRegistry: { columns: { password: false } };
}
>;
export const getBuildComposeCommand = async (rawCompose: ComposeNested) => {
type CreateCommandOptions = {
prebuilt?: boolean;
projectPath?: string;
};
export const getBuildComposeCommand = async (
rawCompose: ComposeNested,
options?: { prebuilt?: boolean; targetServerId?: string | null },
) => {
const compose = await withResolvedVaultRefs(rawCompose);
const { COMPOSE_PATH } = paths(!!compose.serverId);
const targetServerId = options?.targetServerId ?? compose.serverId;
const isRemote = !!targetServerId;
const { COMPOSE_PATH } = paths(isRemote);
const { sourceType, appName, mounts, composeType, domains } = compose;
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const command = createCommand(
compose,
mounts.length > 0 ? projectPath : undefined,
);
const prebuilt = options?.prebuilt ?? Boolean(compose.buildServerId);
const command = createCommand(compose, {
prebuilt,
projectPath: mounts.length > 0 ? projectPath : undefined,
});
const envCommand = compose.createEnvFile
? getCreateEnvFileCommand(compose)
? getCreateEnvFileCommand(compose, targetServerId)
: "";
const exportEnvCommand = getExportEnvCommand(compose);
const newCompose = await writeDomainsToCompose(compose, domains);
const buildRegistry =
prebuilt && compose.buildRegistryId
? await findRegistryByIdWithCredentials(compose.buildRegistryId)
: undefined;
const registryLoginCommand = buildRegistry
? `${safeDockerLoginCommand(
buildRegistry.registryUrl,
buildRegistry.username,
buildRegistry.password,
)} || { echo "❌ Registry login failed"; exit 1; }`
: "";
const newCompose = await writeDomainsToCompose(compose, domains, {
buildRegistry,
loadServerId: targetServerId,
});
const logContent = `
App Name: ${appName}
Build Compose 🐳
Detected: ${mounts.length} mounts 📂
Command: docker ${command}
Source Type: docker ${sourceType}
Compose Type: ${composeType} `;
Compose Type: ${composeType}
Remote Build: ${prebuilt ? "enabled (pre-built images)" : "disabled"}`;
const logBox = boxen(logContent, {
padding: {
@ -61,6 +103,8 @@ Compose Type: ${composeType} ✅`;
cd "${projectPath}";
${registryLoginCommand}
${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` : ""}
@ -75,6 +119,82 @@ Compose Type: ${composeType} ✅`;
return bashCommand;
};
export const getComposeRemoteBuildCommand = async (
rawCompose: ComposeNested,
buildServerId: string,
) => {
const compose = await withResolvedVaultRefs(rawCompose);
if (!compose.buildRegistryId) {
throw new Error("Build registry is required for remote compose builds");
}
const buildRegistry = await findRegistryByIdWithCredentials(
compose.buildRegistryId,
);
const { COMPOSE_PATH } = paths(true);
const { sourceType, appName, composeType } = compose;
const path =
sourceType === "raw" ? "docker-compose.yml" : compose.composePath;
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const loadCompose = { ...compose, serverId: buildServerId };
const composeSpec = await addDomainToCompose(loadCompose, []);
if (!composeSpec || !hasBuildableServices(composeSpec)) {
return `
echo " No services with build: defined — skipping remote build phase";
`;
}
const composeWrite = await writeDomainsToCompose(compose, [], {
buildRegistry,
loadServerId: buildServerId,
});
const envCommand = compose.createEnvFile
? getCreateEnvFileCommand(compose, buildServerId)
: "";
const loginCommand = `${safeDockerLoginCommand(
buildRegistry.registryUrl,
buildRegistry.username,
buildRegistry.password,
)} || { echo "❌ Registry login failed"; exit 1; }`;
const logContent = `
App Name: ${appName}
Remote Build Compose 🐳
Build Server: ${buildServerId}
Compose Type: ${composeType} `;
const logBox = boxen(logContent, {
padding: { left: 1, right: 1, bottom: 1 },
width: 80,
borderStyle: "double",
});
return `
set -e
{
echo "${logBox}";
${composeWrite}
${envCommand}
cd "${projectPath}";
${loginCommand}
env -i PATH="$PATH" HOME="$HOME" docker compose -p ${quote([appName])} -f ${quote([path])} build 2>&1 || { echo "Error: ❌ Docker compose build failed"; exit 1; }
env -i PATH="$PATH" HOME="$HOME" docker compose -p ${quote([appName])} -f ${quote([path])} push 2>&1 || { echo "Error: ❌ Docker compose push failed"; exit 1; }
echo "Remote Compose Build & Push: ✅";
} || {
echo "Error: ❌ Remote build script execution failed";
exit 1
}
`;
};
// Shell control characters that must never appear in a user-provided compose
// command: they would let it break out of the `docker ${command}` invocation
// into arbitrary host commands. A normal docker compose CLI line never needs them.
@ -122,7 +242,14 @@ const sanitizeCommand = (command: string) => {
return restCommand.join(" ");
};
export const createCommand = (compose: ComposeNested, projectPath?: string) => {
export const createCommand = (
compose: ComposeNested,
options?: CreateCommandOptions | string,
) => {
const { prebuilt, projectPath } =
typeof options === "string"
? { projectPath: options }
: (options ?? {});
const { composeType, appName, sourceType } = compose;
if (compose.command) {
return `${sanitizeCommand(compose.command)}`;
@ -139,8 +266,12 @@ export const createCommand = (compose: ComposeNested, projectPath?: string) => {
const envFileFlag = compose.createEnvFile
? `--env-file ${quote([join(dirname(compose.composePath || "docker-compose.yml"), ".env")])} `
: "";
const pullFlag = compose.pullImages ? " --pull always" : "";
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} up -d --build --remove-orphans${pullFlag}`;
if (prebuilt) {
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} pull && docker compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} up -d --no-build --remove-orphans`;
} else {
const pullFlag = compose.pullImages ? " --pull always" : "";
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} up -d --build --remove-orphans${pullFlag}`;
}
} else if (composeType === "stack") {
command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`;
}
@ -148,8 +279,11 @@ export const createCommand = (compose: ComposeNested, projectPath?: string) => {
return command;
};
export const getCreateEnvFileCommand = (compose: ComposeNested) => {
const { COMPOSE_PATH } = paths(!!compose.serverId);
export const getCreateEnvFileCommand = (
compose: ComposeNested,
targetServerId?: string | null,
) => {
const { COMPOSE_PATH } = paths(!!(targetServerId ?? compose.serverId));
const { env, composePath, appName } = compose;
const composeFilePath =
join(COMPOSE_PATH, appName, "code", composePath) ||

View File

@ -4,11 +4,13 @@ import { paths } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import { network, patch } from "@dokploy/server/db/schema";
import type { Compose } from "@dokploy/server/services/compose";
import type { Registry } from "@dokploy/server/services/registry";
import type { Domain } from "@dokploy/server/services/domain";
import { eq, inArray } from "drizzle-orm";
import { quote } from "shell-quote";
import { parse, stringify } from "yaml";
import { execAsyncRemote } from "../process/execAsync";
import { getRegistryTag } from "../cluster/upload";
import { cloneBitbucketRepository } from "../providers/bitbucket";
import { cloneGitRepository } from "../providers/git";
import { cloneGiteaRepository } from "../providers/gitea";
@ -110,12 +112,48 @@ export const readComposeFile = async (compose: Compose) => {
return null;
};
const sanitizeComposeServiceName = (serviceName: string): string =>
serviceName.toLowerCase().replace(/[^a-z0-9._-]/g, "-");
export const applyBuildRegistryImages = (
spec: ComposeSpecification,
registry: Registry,
appName: string,
): ComposeSpecification => {
const result = structuredClone(spec);
if (!result.services) return result;
for (const [serviceName, service] of Object.entries(result.services)) {
if (service?.build) {
const localImage = `${sanitizeComposeServiceName(appName)}-${sanitizeComposeServiceName(serviceName)}:latest`;
service.image = getRegistryTag(registry, localImage);
}
}
return result;
};
export const hasBuildableServices = (
spec: ComposeSpecification | null,
): boolean => {
if (!spec?.services) return false;
return Object.values(spec.services).some((service) => Boolean(service?.build));
};
export const writeDomainsToCompose = async (
compose: Compose,
domains: Domain[],
options?: {
buildRegistry?: Registry;
loadServerId?: string | null;
},
) => {
try {
const composeConverted = await addDomainToCompose(compose, domains);
const loadCompose =
options?.loadServerId !== undefined
? { ...compose, serverId: options.loadServerId }
: compose;
let composeConverted = await addDomainToCompose(loadCompose, domains);
const path = getComposePath(compose);
if (!composeConverted) {
@ -125,6 +163,14 @@ exit 1;
`;
}
if (options?.buildRegistry) {
composeConverted = applyBuildRegistryImages(
composeConverted,
options.buildRegistry,
compose.appName,
);
}
const composeString = stringify(composeConverted, { lineWidth: 1000 });
const encodedContent = encodeBase64(composeString);
return `echo "${encodedContent}" | base64 -d > "${path}";`;

View File

@ -4,6 +4,25 @@ packages:
- "apps/schedules"
- "packages/server"
onlyBuiltDependencies:
- "@prisma/client"
- "@prisma/engines"
- "@scarf/scarf"
- "@tree-sitter-grammars/tree-sitter-yaml"
- bcrypt
- better-sqlite3
- core-js-pure
- cpu-features
- esbuild
- msgpackr-extract
- node-pty
- prisma
- protobufjs
- sharp
- ssh2
- tree-sitter
- tree-sitter-json
# Supply-chain hardening: refuse package versions younger than N days,
# so newly-published malicious versions get caught/yanked before we install them.
# Disabled for now — kept breaking builds on unrelated fresh transitive deps