Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
104 changes: 104 additions & 0 deletions apps/dokploy/__test__/env/environment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,3 +642,107 @@ SPECIAL=café résumé naïve
expect(resolved[2]).toContain("café");
});
});

describe("prepareEnvironmentVariables (cross-service references)", () => {
const serviceFqdns = {
backend: "https://api.example.com",
database: "http://db.internal.example.com",
};

it("resolves ${{service.<name>.fqdn}} from the provided map", () => {
const serviceEnv = `
API_URL=\${{service.backend.fqdn}}
DB_URL=\${{service.database.fqdn}}
PORT=4000
`;

const resolved = prepareEnvironmentVariables(
serviceEnv,
"",
"",
serviceFqdns,
);

expect(resolved).toEqual([
"API_URL=https://api.example.com",
"DB_URL=http://db.internal.example.com",
"PORT=4000",
]);
});

it("composes service references with surrounding text", () => {
const serviceEnv = `
HEALTHCHECK=\${{service.backend.fqdn}}/health
`;

const resolved = prepareEnvironmentVariables(
serviceEnv,
"",
"",
serviceFqdns,
);

expect(resolved).toEqual(["HEALTHCHECK=https://api.example.com/health"]);
});

it("resolves service references alongside project, environment and self refs", () => {
const serviceEnv = `
ENVIRONMENT=\${{project.ENVIRONMENT}}
NODE_ENV=\${{environment.NODE_ENV}}
API_URL=\${{service.backend.fqdn}}
REGION=eu-west-1
SELF=\${{REGION}}
`;

const resolved = prepareEnvironmentVariables(
serviceEnv,
projectEnv,
environmentEnv,
serviceFqdns,
);

expect(resolved).toEqual([
"ENVIRONMENT=staging",
"NODE_ENV=development",
"API_URL=https://api.example.com",
"REGION=eu-west-1",
"SELF=eu-west-1",
]);
});

it("throws when the referenced service is not in the map", () => {
const serviceEnv = `
API_URL=\${{service.unknown.fqdn}}
`;

expect(() =>
prepareEnvironmentVariables(serviceEnv, "", "", serviceFqdns),
).toThrow("Invalid service reference: service.unknown.fqdn");
});

it("throws when no service map is provided", () => {
const serviceEnv = `
API_URL=\${{service.backend.fqdn}}
`;

expect(() => prepareEnvironmentVariables(serviceEnv, "", "")).toThrow(
"Invalid service reference: service.backend.fqdn",
);
});

it("does not affect env vars without service references", () => {
const serviceEnv = `
NODE_ENV=production
PORT=3000
`;

const resolved = prepareEnvironmentVariables(
serviceEnv,
"",
"",
serviceFqdns,
);

expect(resolved).toEqual(["NODE_ENV=production", "PORT=3000"]);
});
});
31 changes: 31 additions & 0 deletions packages/server/src/utils/builders/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { db } from "@dokploy/server/db";
import { applications } from "@dokploy/server/db/schema";
import { findRegistryByIdWithCredentials } from "@dokploy/server/services/registry";
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { eq } from "drizzle-orm";
import { getRegistryTag, uploadImageRemoteCommand } from "../cluster/upload";
import {
calculateResources,
Expand Down Expand Up @@ -75,6 +78,32 @@ export const getBuildCommand = async (application: ApplicationNested) => {
return command;
};

/**
* Builds a map of `serviceName -> public URL` for every other application in the
* same environment that has a domain, so env vars can reference a sibling's URL
* via `${{service.<name>.fqdn}}`. Skips the DB query entirely when the env has no
* such reference. When a service has multiple domains, its first domain is used.
*/
export const buildServiceFqdnMap = async (
application: ApplicationNested,
): Promise<Record<string, string>> => {
if (!application.env?.includes("${{service.")) {
return {};
}
const siblings = await db.query.applications.findMany({
where: eq(applications.environmentId, application.environmentId),
with: { domains: true },
});
const map: Record<string, string> = {};
for (const sibling of siblings) {
const domain = sibling.domains?.[0];
if (domain?.host) {
map[sibling.name] = `${domain.https ? "https" : "http"}://${domain.host}`;
}
}
return map;
};

export const mechanizeDockerContainer = async (
application: ApplicationNested,
) => {
Expand Down Expand Up @@ -116,10 +145,12 @@ export const mechanizeDockerContainer = async (

const bindsMount = generateBindMounts(mounts);
const filesMount = generateFileMounts(appName, application);
const serviceFqdns = await buildServiceFqdnMap(application);
const envVariables = prepareEnvironmentVariables(
env,
application.environment.project.env,
application.environment.env,
serviceFqdns,
);

const image = await getImageName(application);
Expand Down
15 changes: 15 additions & 0 deletions packages/server/src/utils/docker/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ export const prepareEnvironmentVariables = (
serviceEnv: string | null,
projectEnv?: string | null,
environmentEnv?: string | null,
serviceFqdns?: Record<string, string> | null,
) => {
const projectVars = parse(projectEnv ?? "");
const environmentVars = parse(environmentEnv ?? "");
Expand Down Expand Up @@ -436,6 +437,20 @@ export const prepareEnvironmentVariables = (
);
}

// Replace cross-service references: ${{service.<name>.fqdn}}
// Resolves to the public URL of another service in the same environment.
// The name->fqdn map is precomputed by the caller (it requires a DB
// lookup); this keeps the function pure and synchronous.
resolvedValue = resolvedValue.replace(
/\$\{\{service\.(.*?)\.fqdn\}\}/g,
(_, name) => {
if (serviceFqdns && serviceFqdns[name] !== undefined) {
return serviceFqdns[name];
}
throw new Error(`Invalid service reference: service.${name}.fqdn`);
},
);

// Replace self-references (service variables)
resolvedValue = resolvedValue.replace(/\$\{\{(.*?)\}\}/g, (_, ref) => {
if (serviceVars[ref] !== undefined) {
Expand Down