From 6435e65e07f112538cde3e9a4be63a8bfff0d7a0 Mon Sep 17 00:00:00 2001 From: Alessandro Caroli Date: Fri, 19 Jun 2026 13:57:22 +0200 Subject: [PATCH] feat: resolve ${{service..fqdn}} cross-service env references Allow an application's environment variables to reference another service's public URL within the same environment, e.g. API_URL=${{service.backend.fqdn}}. Resolves to the referenced service's first domain URL. Extends the existing ${{project.*}} / ${{environment.*}} resolver in prepareEnvironmentVariables with an optional precomputed name->fqdn map. mechanizeDockerContainer builds that map from sibling applications and their domains, skipping the DB lookup entirely when the env contains no ${{service.* reference. Adds tests covering resolution, composition, multi-ref, unknown-service and no-map error cases. Refs #2028 --- apps/dokploy/__test__/env/environment.test.ts | 104 ++++++++++++++++++ packages/server/src/utils/builders/index.ts | 31 ++++++ packages/server/src/utils/docker/utils.ts | 15 +++ 3 files changed, 150 insertions(+) diff --git a/apps/dokploy/__test__/env/environment.test.ts b/apps/dokploy/__test__/env/environment.test.ts index 24ef18b009..a0a7326394 100644 --- a/apps/dokploy/__test__/env/environment.test.ts +++ b/apps/dokploy/__test__/env/environment.test.ts @@ -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..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"]); + }); +}); diff --git a/packages/server/src/utils/builders/index.ts b/packages/server/src/utils/builders/index.ts index 82bbf4eff7..facad32fe7 100644 --- a/packages/server/src/utils/builders/index.ts +++ b/packages/server/src/utils/builders/index.ts @@ -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, @@ -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..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> => { + 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 = {}; + 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, ) => { @@ -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); diff --git a/packages/server/src/utils/docker/utils.ts b/packages/server/src/utils/docker/utils.ts index 8065b7dd93..0b28707247 100644 --- a/packages/server/src/utils/docker/utils.ts +++ b/packages/server/src/utils/docker/utils.ts @@ -400,6 +400,7 @@ export const prepareEnvironmentVariables = ( serviceEnv: string | null, projectEnv?: string | null, environmentEnv?: string | null, + serviceFqdns?: Record | null, ) => { const projectVars = parse(projectEnv ?? ""); const environmentVars = parse(environmentEnv ?? ""); @@ -436,6 +437,20 @@ export const prepareEnvironmentVariables = ( ); } + // Replace cross-service references: ${{service..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) {