Compare commits

..

6 Commits

Author SHA1 Message Date
Dokploy Bot 8c2d4d5c19 chore: release v0.30.2 2026-08-18 21:32:24 +00:00
Mauricio Siu fa3cab8bcc Merge pull request #5125 from Dokploy/fix/issue-5096-stack-env-quotes
fix: don't quote .env values for stack deploys
(cherry picked from commit 31ddd7fe1a)

[skip ci]
2026-08-18 21:22:27 +00:00
Narciso E. Núñez Arias 8f42dced38 Merge pull request #5079 from NoiceHax/fix/issue-4074
fix(github-webhook): apply preview limit only to new previews

(cherry picked from commit fc6f4d9af8)

[skip ci]
2026-08-18 21:21:20 +00:00
Dokploy Bot 7a0efc7aa4 chore: release v0.30.1 2026-08-18 20:53:40 +00:00
Mauricio Siu 65c5ccc7b2 Merge pull request #5118 from andwati/fix/tag-assignment-on-project-create
fix: assign tags created from the project dialog
(cherry picked from commit cf6f5e39d2)

[skip ci]
2026-08-18 20:22:27 +00:00
Mauricio Siu 5d1731c54f Merge pull request #5121 from Dokploy/fix/issue-5120-switch-thumb-position
fix: correct switch thumb translate-x for checked state
(cherry picked from commit e15b38be38)

[skip ci]
2026-08-18 20:21:25 +00:00
14 changed files with 217 additions and 414 deletions
+1 -4
View File
@@ -31,7 +31,7 @@ WORKDIR /app
# Set production
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y tini curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
# Copy only the necessary files
COPY --from=build /prod/dokploy/.next ./.next
@@ -69,8 +69,5 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
# tini reaps HEALTHCHECK child processes that Node (as PID 1) leaves defunct.
ENTRYPOINT ["/usr/bin/tini", "--"]
# Ejecutar node directamente: pnpm como wrapper queda residente (~100MB RSS)
CMD ["sh", "-c", "node -r dotenv/config dist/wait-for-postgres.mjs && node -r dotenv/config dist/migration.mjs && exec node -r dotenv/config dist/server.mjs"]
@@ -1,144 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getCreateEnvFileCommand } from "@dokploy/server/utils/builders/compose";
import { afterAll, describe, expect, it } from "vitest";
// Real end-to-end: the same Environment Settings text must reach a *running*
// container with identical values under docker-compose and under swarm stack.
// Covers https://github.com/Dokploy/dokploy/issues/5095 and /5096.
// exactly what a user types in the Environment Settings tab
const uiEnv = [
"SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0",
"PASSWORD=mysecret#",
"MIDDLE=sec#ret",
"SHEBANG=#!secret",
"DOLLARS=pa$$word",
"SPACED=hello world",
'JSON={"a":1}',
"COMMENTED=value # a real comment",
'QUOTED_HASH="abc#de"',
].join("\n");
const expected: Record<string, string> = {
SENTRY_DSN: "https://examplePublicKey@o0.ingest.sentry.io/0",
PASSWORD: "mysecret#",
MIDDLE: "sec#ret",
SHEBANG: "#!secret",
DOLLARS: "pa$$word",
SPACED: "hello world",
JSON: '{"a":1}',
COMMENTED: "value",
QUOTED_HASH: "abc#de",
};
const keys = Object.keys(expected);
const composeApp = `env-delivery-c${process.pid}`;
const stackApp = `env-delivery-s${process.pid}`;
const pathFor = (app: string) =>
join(process.cwd(), ".docker", "compose", app, "code");
const sh = (cmd: string, args: string[], cwd?: string) =>
execFileSync(cmd, args, { cwd, encoding: "utf8" });
const setup = (
app: string,
composeType: "docker-compose" | "stack",
svc: string,
) => {
const codePath = pathFor(app);
mkdirSync(codePath, { recursive: true });
sh("bash", [
"-c",
getCreateEnvFileCommand({
appName: app,
composePath: "docker-compose.yml",
composeType,
env: uiEnv,
randomize: false,
suffix: "",
serverId: null,
environment: { project: { env: "" }, env: "" },
} as Parameters<typeof getCreateEnvFileCommand>[0]),
]);
writeFileSync(join(codePath, "docker-compose.yml"), svc);
return codePath;
};
// `printenv` per key, NUL-delimited so values keep their spaces and #
const dumpScript = keys.map((k) => `printf '%s\\0' "$${k}"`).join("; ");
const parseDump = (out: string) =>
Object.fromEntries(
out
.split("\0")
.slice(0, keys.length)
.map((v, i) => [keys[i] as string, v]),
);
afterAll(() => {
try {
sh("docker", ["stack", "rm", stackApp]);
} catch {}
try {
sh("docker", ["compose", "down", "--remove-orphans"], pathFor(composeApp));
} catch {}
for (const app of [composeApp, stackApp]) {
rmSync(join(process.cwd(), ".docker", "compose", app), {
force: true,
recursive: true,
});
}
});
describe("env delivery into a running container", () => {
it("docker-compose: values survive compose-go interpolation", () => {
const codePath = setup(
composeApp,
"docker-compose",
"services:\n app:\n image: busybox\n env_file: .env\n",
);
const out = sh(
"docker",
["compose", "run", "--rm", "-T", "app", "sh", "-c", dumpScript],
codePath,
);
expect(parseDump(out)).toEqual(expected);
}, 120000);
it("swarm stack: values survive the literal env-file parser", () => {
const codePath = setup(
stackApp,
"stack",
"services:\n app:\n image: busybox\n command: sleep 300\n env_file: .env\n",
);
sh(
"docker",
["stack", "deploy", "-c", "docker-compose.yml", stackApp],
codePath,
);
let containerId = "";
for (let i = 0; i < 60 && !containerId; i++) {
containerId = (
sh("docker", [
"ps",
"--filter",
`label=com.docker.swarm.service.name=${stackApp}_app`,
"--format",
"{{.ID}}",
]).split("\n")[0] ?? ""
).trim();
if (!containerId) execFileSync("sleep", ["1"]);
}
expect(containerId, "swarm task never started").toBeTruthy();
const out = sh("docker", ["exec", containerId, "sh", "-c", dumpScript]);
expect(parseDump(out)).toEqual(expected);
}, 180000);
});
@@ -1,79 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getCreateEnvFileCommand } from "@dokploy/server/utils/builders/compose";
import { prepareEnvironmentVariablesForFile } from "@dokploy/server/utils/docker/utils";
import { afterEach, describe, expect, it } from "vitest";
// https://github.com/Dokploy/dokploy/issues/5096 — compose-go interpolates the
// generated .env, docker stack deploy reads it literally; quoting cannot serve both.
const appName = `env-file-compose-type-${process.pid}`;
const projectPath = join(process.cwd(), ".docker", "compose", appName);
const codePath = join(projectPath, "code");
afterEach(() => {
rmSync(projectPath, { force: true, recursive: true });
});
const buildEnvFile = (composeType: "docker-compose" | "stack", env: string) => {
mkdirSync(codePath, { recursive: true });
const command = getCreateEnvFileCommand({
appName,
composePath: "docker-compose.yml",
composeType,
env,
randomize: false,
suffix: "",
serverId: null,
environment: { project: { env: "" }, env: "" },
} as Parameters<typeof getCreateEnvFileCommand>[0]);
execFileSync("bash", ["-c", command]);
return readFileSync(join(codePath, ".env"), "utf8");
};
describe("prepareEnvironmentVariablesForFile", () => {
const env = "DSN=https://key@o0.io/0\nPASSWORD=pa$$word";
it("quotes and escapes by default, for the docker-compose .env", () => {
expect(prepareEnvironmentVariablesForFile(env, "", "")).toEqual([
'DSN="https://key@o0.io/0"',
'PASSWORD="pa\\$\\$word"',
]);
});
it("writes values untouched when raw", () => {
expect(prepareEnvironmentVariablesForFile(env, "", "", true)).toEqual([
"DSN=https://key@o0.io/0",
"PASSWORD=pa$$word",
]);
});
});
describe("getCreateEnvFileCommand", () => {
it("writes a stack .env that docker reads back without the wrapping quotes", () => {
const contents = buildEnvFile("stack", "DSN=https://key@o0.io/0");
expect(contents).toContain("DSN=https://key@o0.io/0");
expect(contents).not.toContain('DSN="');
writeFileSync(
join(codePath, "docker-compose.yml"),
"services:\n test:\n image: busybox\n env_file: .env\n",
);
const rendered = execFileSync(
"docker",
["stack", "config", "-c", "docker-compose.yml"],
{ cwd: codePath, encoding: "utf8" },
);
expect(rendered).toContain("DSN: https://key@o0.io/0");
// #5096 symptom: quotes surviving into the value
expect(rendered).not.toContain("DSN: '\"");
}, 60000);
it("keeps quoting the docker-compose .env so $ survives interpolation", () => {
const contents = buildEnvFile("docker-compose", "PASSWORD=pa$$word");
expect(contents).toContain('PASSWORD="pa\\$\\$word"');
});
});
@@ -34,13 +34,13 @@ const cases: Record<string, string> = {
MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----",
};
// How each value must be typed in the UI so the env input parser resolves it
// to the raw string in `cases` above.
// How each value must be typed in the UI so the (unchanged) dotenv input
// parser resolves it to the raw string in `cases` above.
const inputEncoding: Record<string, string> = {
PASSWORD: "pa$$word",
SPECIAL: `'!"#$%&/()=?'`,
NESTED_JSON: '{"nested":{"a":1}}',
MAIL_PASSWORD: "abc#de",
MAIL_PASSWORD: `"abc#de"`,
TRAILING_BACKSLASH: "trailing\\",
QUOTE_INSIDE: 'she said "hi"',
APOSTROPHE: "it's a test",
@@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({
queueAdd: vi.fn(),
verify: vi.fn(),
shouldDeploy: vi.fn(),
createPreviewDeployment: vi.fn(),
findPreviewDeploymentByApplicationId: vi.fn(),
}));
vi.mock("drizzle-orm", () => ({
@@ -64,10 +66,11 @@ vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
shouldDeploy: mocks.shouldDeploy,
checkUserRepositoryPermissions: vi.fn(),
createPreviewDeployment: vi.fn(),
createPreviewDeployment: mocks.createPreviewDeployment,
createSecurityBlockedComment: vi.fn(),
findGithubById: vi.fn(),
findPreviewDeploymentByApplicationId: vi.fn(),
findPreviewDeploymentByApplicationId:
mocks.findPreviewDeploymentByApplicationId,
findPreviewDeploymentsByPullRequestId: vi.fn(),
getBitbucketHeaders: vi.fn(() => ({})),
removePreviewDeployment: vi.fn(),
@@ -321,3 +324,157 @@ describe("GitHub app webhook auto-deploy", () => {
expect(res.json).toHaveBeenCalledWith({ message: "No apps to deploy" });
});
});
describe("GitHub app webhook preview deployments", () => {
const createApplication = (
overrides: Record<string, unknown> = {},
): Record<string, unknown> => ({
applicationId: "application-id",
name: "my-app",
serverId: null,
previewLabels: [],
previewLimit: 3,
previewDeployments: [],
previewRequireCollaboratorPermissions: false,
...overrides,
});
const createPreviewDeployments = (total: number) =>
Array.from({ length: total }, (_, index) => ({
previewDeploymentId: `existing-preview-${index}`,
}));
const createPullRequestRequest = (action: string) =>
({
headers: {
"x-hub-signature-256": "sha256=test-signature",
"x-github-event": "pull_request",
},
body: {
installation: {
id: 12345,
},
action,
pull_request: {
id: 987,
number: 42,
title: "feat: add preview",
html_url: "https://github.com/agentHits/dokploy/pull/42",
labels: [],
user: {
login: "agentHits",
},
head: {
ref: "feature",
sha: "abc123",
},
base: {
ref: "main",
},
},
repository: {
name: "dokploy",
owner: {
login: "agentHits",
},
},
},
}) as unknown as NextApiRequest;
beforeEach(() => {
vi.clearAllMocks();
mocks.githubFindFirst.mockResolvedValue({
githubId: "github-provider-id",
githubInstallationId: 12345,
githubWebhookSecret: "webhook-secret",
});
mocks.verify.mockResolvedValue(true);
mocks.queueAdd.mockResolvedValue({ id: "job-id" });
mocks.createPreviewDeployment.mockResolvedValue({
previewDeploymentId: "new-preview-id",
});
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue(undefined);
});
it("redeploys an existing preview even when the limit is reached", async () => {
mocks.applicationsFindMany.mockResolvedValue([
createApplication({
previewLimit: 2,
previewDeployments: createPreviewDeployments(3),
}),
]);
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue({
previewDeploymentId: "existing-preview-0",
});
const res = createResponse();
await handler(createPullRequestRequest("synchronize"), res);
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
expect(mocks.queueAdd).toHaveBeenCalledWith(
"deployments",
expect.objectContaining({
applicationId: "application-id",
applicationType: "application-preview",
previewDeploymentId: "existing-preview-0",
type: "deploy",
}),
expect.objectContaining({
removeOnComplete: true,
removeOnFail: true,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
});
it("does not create a new preview once the limit is reached", async () => {
mocks.applicationsFindMany.mockResolvedValue([
createApplication({
previewLimit: 2,
previewDeployments: createPreviewDeployments(2),
}),
]);
const res = createResponse();
await handler(createPullRequestRequest("opened"), res);
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
expect(mocks.queueAdd).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
});
it("falls back to the default limit when none is configured", async () => {
mocks.applicationsFindMany.mockResolvedValue([
createApplication({
previewLimit: null,
previewDeployments: createPreviewDeployments(2),
}),
]);
const res = createResponse();
await handler(createPullRequestRequest("opened"), res);
expect(mocks.createPreviewDeployment).toHaveBeenCalledWith(
expect.objectContaining({
applicationId: "application-id",
branch: "feature",
pullRequestId: 987,
pullRequestNumber: 42,
}),
);
expect(mocks.queueAdd).toHaveBeenCalledWith(
"deployments",
expect.objectContaining({
applicationId: "application-id",
applicationType: "application-preview",
previewDeploymentId: "new-preview-id",
type: "deploy",
}),
expect.objectContaining({
removeOnComplete: true,
removeOnFail: true,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
});
});
-139
View File
@@ -1,139 +0,0 @@
import { prepareEnvironmentVariables } from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
// https://github.com/Dokploy/dokploy/issues/5095 — expectations calibrated against docker compose v2
describe("prepareEnvironmentVariables (# in values)", () => {
it("preserves # when it is not an inline comment", () => {
const serviceEnv = [
"TRAILING=secret#",
"MIDDLE=sec#ret",
"MULTI=a#b#c",
"LEADING=#!secret",
"NO_SPACE=value# not a comment",
"CONN=user=admin;password=b#c",
].join("\n");
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
"TRAILING=secret#",
"MIDDLE=sec#ret",
"MULTI=a#b#c",
"LEADING=#!secret",
"NO_SPACE=value# not a comment",
"CONN=user=admin;password=b#c",
]);
});
it("strips an inline comment only after one or more spaces", () => {
const serviceEnv = [
"ONE_SPACE=value # comment",
"TWO_SPACES=value # comment",
"TRAILING_WS=value ",
].join("\n");
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
"ONE_SPACE=value",
"TWO_SPACES=value",
"TRAILING_WS=value",
]);
});
it("does not treat a tab-preceded # as a comment", () => {
const serviceEnv = "WITH_TAB=value\t# not a comment";
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
"WITH_TAB=value\t# not a comment",
]);
});
it("treats a bare `KEY= # ...` as an empty value", () => {
// deliberate divergence: compose reads "# this is a comment" here
const serviceEnv = "ONLY_HASH= # this is a comment";
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
"ONLY_HASH=",
]);
});
it("preserves # inside quoted values and still strips a trailing comment", () => {
const serviceEnv = [
'DOUBLE="val#ue"',
"SINGLE='val#ue'",
'DOUBLE_THEN_COMMENT="a # b" # comment',
"SINGLE_THEN_COMMENT='c # d' # comment",
].join("\n");
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
"DOUBLE=val#ue",
"SINGLE=val#ue",
"DOUBLE_THEN_COMMENT=a # b",
"SINGLE_THEN_COMMENT=c # d",
]);
});
it("still skips full-line comments", () => {
const serviceEnv = [
"# leading comment",
"FIRST=one",
" # indented comment",
"SECOND=two",
].join("\n");
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
"FIRST=one",
"SECOND=two",
]);
});
it("preserves # through project, environment and self references", () => {
const projectEnv = "DB_PASS=secret#end";
const environmentEnv = "API_KEY=key#123";
const serviceEnv = [
"DATABASE_URL=postgres://user:${{project.DB_PASS}}@db:5432/app",
"TOKEN=${{environment.API_KEY}}",
"LOCAL_SECRET=self#value",
"COPY=${{LOCAL_SECRET}}",
].join("\n");
expect(
prepareEnvironmentVariables(serviceEnv, projectEnv, environmentEnv),
).toEqual([
"DATABASE_URL=postgres://user:secret#end@db:5432/app",
"TOKEN=key#123",
"LOCAL_SECRET=self#value",
"COPY=self#value",
]);
});
});
describe("prepareEnvironmentVariables (dotenv parity)", () => {
it("keeps the behaviour dotenv had for everything except #", () => {
const serviceEnv = [
"export EXPORTED=value",
'ESCAPED="line1\\nline2"',
"EMPTY=",
'EMPTY_DOUBLE=""',
"EMPTY_SINGLE=''",
"DOLLARS=pa$$word",
'SPACED_QUOTED= "padded" ',
].join("\n");
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
"EXPORTED=value",
"ESCAPED=line1\nline2",
"EMPTY=",
"EMPTY_DOUBLE=",
"EMPTY_SINGLE=",
"DOLLARS=pa$$word",
"SPACED_QUOTED=padded",
]);
});
it("supports multiline double-quoted values", () => {
const serviceEnv = 'MULTILINE="line1\nline2"';
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
"MULTILINE=line1\nline2",
]);
});
});
@@ -53,9 +53,14 @@ type Tag = z.infer<typeof TagSchema>;
interface HandleTagProps {
tagId?: string;
/**
* Called with the new tag's id after it is created, so callers embedding
* this dialog (e.g. the tag selector) can select the tag right away.
*/
onCreated?: (tagId: string) => void;
}
export const HandleTag = ({ tagId }: HandleTagProps) => {
export const HandleTag = ({ tagId, onCreated }: HandleTagProps) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const colorInputRef = useRef<HTMLInputElement>(null);
@@ -101,8 +106,11 @@ export const HandleTag = ({ tagId }: HandleTagProps) => {
color: data.color,
tagId: tagId || "",
})
.then(async () => {
.then(async (result) => {
await utils.tag.all.invalidate();
if (!tagId && result?.tagId) {
onCreated?.(result.tagId);
}
toast.success(tagId ? "Tag Updated" : "Tag Created");
setIsOpen(false);
form.reset();
@@ -139,9 +147,18 @@ export const HandleTag = ({ tagId }: HandleTagProps) => {
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
{/*
* This dialog can be rendered inside another form (e.g. the tag
* selector in the project dialog). React propagates events through the
* React tree, not the DOM tree, so without stopPropagation submitting
* this form would also submit the outer one.
*/}
<form
id="hook-form-tag"
onSubmit={form.handleSubmit(onSubmit)}
onSubmit={(e) => {
e.stopPropagation();
form.handleSubmit(onSubmit)(e);
}}
className="grid w-full gap-4"
>
<FormField
@@ -147,7 +147,13 @@ export function TagSelector({
})}
</CommandGroup>
<div className="flex items-center justify-center p-2 border-t">
<HandleTag />
<HandleTag
onCreated={(tagId) => {
if (!selectedTags.includes(tagId)) {
onTagsChange([...selectedTags, tagId]);
}
}}
/>
</div>
</CommandList>
</Command>
+1 -1
View File
@@ -22,7 +22,7 @@ function Switch({
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[18px] group-data-[size=sm]/switch:data-checked:translate-x-[10px] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.30.0",
"version": "v0.30.2",
"private": true,
"license": "Apache-2.0",
"type": "module",
+9 -4
View File
@@ -484,10 +484,6 @@ export default async function handler(
if (!hasLabel) continue;
}
const previewLimit = app?.previewLimit || 0;
if (app?.previewDeployments?.length > previewLimit) {
continue;
}
const previewDeploymentResult =
await findPreviewDeploymentByApplicationId(app.applicationId, prId);
@@ -495,6 +491,15 @@ export default async function handler(
previewDeploymentResult?.previewDeploymentId || "";
if (!previewDeploymentResult && shouldCreateDeployment) {
// The limit only applies to new previews, existing ones must
// still be redeployed when the pull request is updated.
const previewLimit = app?.previewLimit ?? 3;
if ((app?.previewDeployments?.length ?? 0) >= previewLimit) {
console.warn(
`⚠️ Preview deployment limit (${previewLimit}) reached for ${app.name}, skipping preview for pull request #${prNumber}`,
);
continue;
}
const previewDeployment = await createPreviewDeployment({
applicationId: app.applicationId as string,
branch: prBranch,
-11
View File
@@ -177,17 +177,6 @@ else
OS_VERSION=$(grep -w "VERSION_ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"')
fi
# Ubuntu 26.04 (resolute) ships a docker-ce repo that has no 28.5.0, so the
# default pin is absent from apt-cache madison and get.docker.com aborts before
# installing Docker. Repin to a version present on every architecture Docker
# ships resolute for: amd64/arm64/armhf start at 29.3.1, but s390x only carries
# 29.4.0-29.4.2, so 29.4.2 is the newest version common to all of them. This
# must run after OS_VERSION is resolved and before the banner so the reported
# Docker version stays accurate.
if [ "$OS_TYPE" = "ubuntu" ] && [ "$OS_VERSION" = "26.04" ]; then
DOCKER_VERSION=29.4.2
fi
if [ "$OS_TYPE" = 'amzn' ]; then
$SUDO_CMD dnf install -y findutils >/dev/null
fi
+13 -6
View File
@@ -7,6 +7,7 @@ import { writeDomainsToCompose } from "../docker/domain";
import {
encodeBase64,
getEnvironmentVariablesObject,
prepareEnvironmentVariables,
prepareEnvironmentVariablesForFile,
} from "../docker/utils";
import { withResolvedVaultRefs } from "../vault";
@@ -157,12 +158,18 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
envContent += `\nCOMPOSE_PREFIX=${compose.suffix}`;
}
const envFileContent = prepareEnvironmentVariablesForFile(
envContent,
compose.environment.project.env,
compose.environment.env,
// docker stack deploy reads env_file literally, no unquoting
compose.composeType === "stack",
const envFileContent = (
compose.composeType === "stack"
? prepareEnvironmentVariables(
envContent,
compose.environment.project.env,
compose.environment.env,
)
: prepareEnvironmentVariablesForFile(
envContent,
compose.environment.project.env,
compose.environment.env,
)
).join("\n");
const encodedContent = encodeBase64(envFileContent);
+3 -16
View File
@@ -456,16 +456,6 @@ export const removeService = async (
}
};
// dotenv ends an unquoted value at the first `#`; compose only does so after a space
const HASH_PLACEHOLDER = "\u0000dokploy-hash\u0000";
const parseEnvVariables = (src: string): Record<string, string> =>
Object.fromEntries(
Object.entries(parse(src.replace(/(?<! )#/g, HASH_PLACEHOLDER))).map(
([key, value]) => [key, value.replaceAll(HASH_PLACEHOLDER, "#")],
),
);
export const prepareEnvironmentVariables = (
serviceEnv: string | null,
projectEnv?: string | null,
@@ -478,9 +468,9 @@ export const prepareEnvironmentVariables = (
);
}
}
const projectVars = parseEnvVariables(projectEnv ?? "");
const environmentVars = parseEnvVariables(environmentEnv ?? "");
const serviceVars = parseEnvVariables(serviceEnv ?? "");
const projectVars = parse(projectEnv ?? "");
const environmentVars = parse(environmentEnv ?? "");
const serviceVars = parse(serviceEnv ?? "");
const resolvedVars = Object.entries(serviceVars).map(([key, value]) => {
let resolvedValue = value;
@@ -546,7 +536,6 @@ export const prepareEnvironmentVariablesForFile = (
serviceEnv: string | null,
projectEnv?: string | null,
environmentEnv?: string | null,
rawValues = false,
): string[] => {
const envVars = prepareEnvironmentVariables(
serviceEnv,
@@ -554,8 +543,6 @@ export const prepareEnvironmentVariablesForFile = (
environmentEnv,
);
if (rawValues) return envVars;
return envVars.map((pair) => {
const [key, value] = parseEnvironmentKeyValuePair(pair);
const escapedValue = value