diff --git a/apps/dokploy/__test__/traefik/standalone-traefik-static-ip.test.ts b/apps/dokploy/__test__/traefik/standalone-traefik-static-ip.test.ts new file mode 100644 index 00000000000..f8d9437dd85 --- /dev/null +++ b/apps/dokploy/__test__/traefik/standalone-traefik-static-ip.test.ts @@ -0,0 +1,518 @@ +import { + initializeStandaloneTraefik, + initializeTraefikService, + TRAEFIK_NETWORK_NAME, + validateTraefikStaticIp, +} from "@dokploy/server/setup/traefik-setup"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ getRemoteDocker: vi.fn() })); + +vi.mock("@dokploy/server/db", () => ({ db: {} })); + +vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({ + getRemoteDocker: mocks.getRemoteDocker, +})); + +interface FakeNetwork { + driver?: "bridge" | "overlay"; + subnets: string[]; + containers: { Name: string; IPv4Address: string }[]; +} + +interface FakeOptions { + oldRunning?: boolean | null; + oldEnv?: string[]; + network?: FakeNetwork; +} + +const fakeDocker = ({ + oldRunning = true, + oldEnv = [], + network = { subnets: ["10.0.1.0/24"], containers: [] }, +}: FakeOptions = {}) => { + const log: string[] = []; + const containers: { + id: string; + name: string; + running: boolean; + env: string[]; + }[] = []; + if (oldRunning !== null) { + containers.push({ + id: "old", + name: "dokploy-traefik", + running: oldRunning, + env: oldEnv, + }); + } + let created = 0; + let createdWith: Record | undefined; + + const find = (ref: string) => { + const found = containers.find((c) => c.id === ref || c.name === ref); + if (!found) throw new Error(`No such container: ${ref}`); + return found; + }; + + const container = (ref: string) => ({ + inspect: async () => { + const c = find(ref); + return { + Id: c.id, + State: { Running: c.running, StartedAt: "steady" }, + Config: { Env: c.env }, + }; + }, + rename: async ({ name }: { name: string }) => { + const c = find(ref); + log.push(`rename ${c.id} -> ${name}`); + c.name = name; + }, + stop: async () => { + const c = find(ref); + log.push(`stop ${c.id}`); + c.running = false; + }, + start: async () => { + const c = find(ref); + log.push(`start ${c.id}`); + c.running = true; + }, + remove: async () => { + const c = find(ref); + log.push(`remove ${c.id}`); + containers.splice(containers.indexOf(c), 1); + }, + logs: async () => Buffer.from(""), + }); + + const docker = { + getImage: () => ({ + inspect: async () => ({}), + }), + pull: async () => "stream", + modem: { + followProgress: ( + _stream: unknown, + done: (error: Error | null, events: unknown[]) => void, + ) => { + done(null, []); + }, + }, + getContainer: (ref: string) => container(ref), + createContainer: async (settings: Record) => { + const name = settings.name as string; + if (containers.some((c) => c.name === name)) { + throw new Error(`Conflict: ${name} is already in use`); + } + createdWith = settings; + created += 1; + const id = created === 1 ? "new" : `new${created}`; + containers.push({ + id, + name, + running: false, + env: (settings.Env as string[] | undefined) ?? [], + }); + log.push(`create ${id}`); + return container(id); + }, + getNetwork: (name: string) => ({ + inspect: async () => ({ + Name: name, + Driver: network.driver ?? "bridge", + IPAM: { + Driver: "default", + Config: network.subnets.map((Subnet) => ({ Subnet })), + }, + Containers: Object.fromEntries( + network.containers.map((entry, index) => [ + String(index), + { + Name: entry.Name, + IPv4Address: entry.IPv4Address, + IPv6Address: "", + EndpointID: `endpoint-${index}`, + MacAddress: `mac-${index}`, + }, + ]), + ), + }), + }), + }; + + return { + docker, + log, + containers, + createdWith: () => createdWith, + }; +}; + +const recreate = async ( + fake: ReturnType, + options: Parameters[0] = {}, +) => { + mocks.getRemoteDocker.mockResolvedValue(fake.docker); + const result = initializeStandaloneTraefik(options).then( + () => null, + (error: Error) => error, + ); + await vi.runAllTimersAsync(); + return result; +}; + +const endpointOf = (fake: ReturnType) => { + const created = fake.createdWith() as + | { + NetworkingConfig: { + EndpointsConfig: Record< + string, + { IPAMConfig?: { IPv4Address?: string } } + >; + }; + } + | undefined; + return created?.NetworkingConfig.EndpointsConfig[TRAEFIK_NETWORK_NAME]; +}; + +const envOf = (fake: ReturnType) => + (fake.createdWith() as { Env?: string[] } | undefined)?.Env; + +describe("initializeStandaloneTraefik with TRAEFIK_STATIC_IP", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("pins the container to the configured address", async () => { + const fake = fakeDocker(); + + const error = await recreate(fake, { + env: ["TRAEFIK_STATIC_IP=10.0.1.45"], + }); + + expect(error).toBeNull(); + expect(endpointOf(fake)).toEqual({ + IPAMConfig: { IPv4Address: "10.0.1.45" }, + }); + }); + + it("leaves the endpoint untouched when no address is configured", async () => { + const fake = fakeDocker(); + + const error = await recreate(fake, { env: [] }); + + expect(error).toBeNull(); + expect(endpointOf(fake)).toEqual({}); + }); + + it("inherits the address from the running container when no env is passed", async () => { + const fake = fakeDocker({ oldEnv: ["TRAEFIK_STATIC_IP=10.0.1.45"] }); + + const error = await recreate(fake); + + expect(error).toBeNull(); + expect(endpointOf(fake)).toEqual({ + IPAMConfig: { IPv4Address: "10.0.1.45" }, + }); + }); + + it("keeps the address over two consecutive recreates", async () => { + const fake = fakeDocker({ oldEnv: ["TRAEFIK_STATIC_IP=10.0.1.45"] }); + + const first = await recreate(fake); + expect(first).toBeNull(); + expect(endpointOf(fake)).toEqual({ + IPAMConfig: { IPv4Address: "10.0.1.45" }, + }); + // The replacement container has to keep the setting, otherwise the next + // recreate would pin it once and drop it afterwards. + expect(envOf(fake)).toEqual(["TRAEFIK_STATIC_IP=10.0.1.45"]); + + const second = await recreate(fake); + expect(second).toBeNull(); + expect(endpointOf(fake)).toEqual({ + IPAMConfig: { IPv4Address: "10.0.1.45" }, + }); + expect(envOf(fake)).toEqual(["TRAEFIK_STATIC_IP=10.0.1.45"]); + }); + + it("accepts an address in a network with several IPv4 subnets", async () => { + const fake = fakeDocker({ + network: { subnets: ["10.0.1.0/24", "10.0.2.0/24"], containers: [] }, + }); + + const error = await recreate(fake, { + env: ["TRAEFIK_STATIC_IP=10.0.2.45"], + }); + + expect(error).toBeNull(); + expect(endpointOf(fake)).toEqual({ + IPAMConfig: { IPv4Address: "10.0.2.45" }, + }); + }); + + it("accepts an address on a dual-stack network with the IPv6 subnet first", async () => { + const fake = fakeDocker({ + network: { + subnets: ["fd00::/64", "10.0.1.0/24"], + containers: [], + }, + }); + + const error = await recreate(fake, { + env: ["TRAEFIK_STATIC_IP=10.0.1.45"], + }); + + expect(error).toBeNull(); + expect(endpointOf(fake)).toEqual({ + IPAMConfig: { IPv4Address: "10.0.1.45" }, + }); + }); + + it("does not treat the Traefik containers as a conflict", async () => { + const fake = fakeDocker({ + network: { + subnets: ["10.0.1.0/24"], + containers: [ + { Name: "dokploy-traefik", IPv4Address: "10.0.1.45/24" }, + { + Name: "dokploy-traefik-previous", + IPv4Address: "10.0.1.45/24", + }, + ], + }, + }); + + const error = await recreate(fake, { + env: ["TRAEFIK_STATIC_IP=10.0.1.45"], + }); + + expect(error).toBeNull(); + expect(endpointOf(fake)).toEqual({ + IPAMConfig: { IPv4Address: "10.0.1.45" }, + }); + }); + + it("reports an invalid address and leaves the running container alone", async () => { + const fake = fakeDocker(); + + const error = await recreate(fake, { + env: ["TRAEFIK_STATIC_IP=not-an-address"], + }); + + expect(error?.message).toContain( + 'TRAEFIK_STATIC_IP "not-an-address" is not a valid IPv4 address', + ); + expect(fake.log).toEqual([]); + expect(fake.createdWith()).toBeUndefined(); + }); + + it("reports an address outside the network subnets", async () => { + const fake = fakeDocker(); + + const error = await recreate(fake, { + env: ["TRAEFIK_STATIC_IP=10.9.9.9"], + }); + + expect(error?.message).toContain( + 'TRAEFIK_STATIC_IP "10.9.9.9" is not inside the "dokploy-network" network (10.0.1.0/24)', + ); + expect(fake.createdWith()).toBeUndefined(); + }); + + it("reports an address that another container already uses", async () => { + const fake = fakeDocker({ + network: { + subnets: ["10.0.1.0/24"], + containers: [{ Name: "some-app", IPv4Address: "10.0.1.45/24" }], + }, + }); + + const error = await recreate(fake, { + env: ["TRAEFIK_STATIC_IP=10.0.1.45"], + }); + + expect(error?.message).toContain( + 'TRAEFIK_STATIC_IP "10.0.1.45" is already used by the container "some-app"', + ); + expect(fake.createdWith()).toBeUndefined(); + }); + + it("explains the overlay caveat when the address is taken", async () => { + const fake = fakeDocker({ + network: { + driver: "overlay", + subnets: ["10.0.1.0/24"], + containers: [{ Name: "some-task.1", IPv4Address: "10.0.1.45/24" }], + }, + }); + + const error = await recreate(fake, { + env: ["TRAEFIK_STATIC_IP=10.0.1.45"], + }); + + expect(error?.message).toContain( + "On an overlay network a swarm task can be given the same address after a reboot", + ); + }); + + it("warns on an overlay network that a task can take the address", async () => { + const log = vi.spyOn(console, "log"); + const fake = fakeDocker({ + network: { + driver: "overlay", + subnets: ["10.0.1.0/24"], + containers: [], + }, + }); + + const error = await recreate(fake, { + env: ["TRAEFIK_STATIC_IP=10.0.1.45"], + }); + + expect(error).toBeNull(); + expect(log).toHaveBeenCalledWith( + expect.stringContaining("is an overlay network"), + ); + }); + + it("does not warn on a bridge network", async () => { + const log = vi.spyOn(console, "log"); + const fake = fakeDocker({ + network: { + driver: "bridge", + subnets: ["10.0.1.0/24"], + containers: [], + }, + }); + + const error = await recreate(fake, { + env: ["TRAEFIK_STATIC_IP=10.0.1.45"], + }); + + expect(error).toBeNull(); + expect(log).not.toHaveBeenCalledWith( + expect.stringContaining("is an overlay network"), + ); + }); +}); + +describe("the TRAEFIK_STATIC_IP process default", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it("applies it on the startup path, which passes no env", async () => { + vi.stubEnv("TRAEFIK_STATIC_IP", "10.0.1.99"); + vi.resetModules(); + const { initializeStandaloneTraefik } = await import( + "@dokploy/server/setup/traefik-setup" + ); + const fake = fakeDocker({ oldEnv: [] }); + + mocks.getRemoteDocker.mockResolvedValue(fake.docker); + const result = initializeStandaloneTraefik().then( + () => null, + (error: Error) => error, + ); + await vi.runAllTimersAsync(); + + expect(await result).toBeNull(); + expect(endpointOf(fake)).toEqual({ + IPAMConfig: { IPv4Address: "10.0.1.99" }, + }); + }); + + it("does not apply it when an env is given without the key", async () => { + vi.stubEnv("TRAEFIK_STATIC_IP", "10.0.1.99"); + vi.resetModules(); + const { initializeStandaloneTraefik } = await import( + "@dokploy/server/setup/traefik-setup" + ); + const fake = fakeDocker(); + + mocks.getRemoteDocker.mockResolvedValue(fake.docker); + const result = initializeStandaloneTraefik({ env: [] }).then( + () => null, + (error: Error) => error, + ); + await vi.runAllTimersAsync(); + + expect(await result).toBeNull(); + expect(endpointOf(fake)).toEqual({}); + }); +}); + +describe("validateTraefikStaticIp", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("accepts a valid address", async () => { + mocks.getRemoteDocker.mockResolvedValue(fakeDocker().docker); + + await expect( + validateTraefikStaticIp({ env: ["TRAEFIK_STATIC_IP=10.0.1.45"] }), + ).resolves.toBeUndefined(); + }); + + it("does nothing when no address is configured", async () => { + mocks.getRemoteDocker.mockResolvedValue(fakeDocker().docker); + + await expect(validateTraefikStaticIp({ env: [] })).resolves.toBeUndefined(); + }); + + it("reports a wrong address before it is stored", async () => { + mocks.getRemoteDocker.mockResolvedValue(fakeDocker().docker); + + await expect( + validateTraefikStaticIp({ env: ["TRAEFIK_STATIC_IP=10.9.9.9"] }), + ).rejects.toThrow( + 'TRAEFIK_STATIC_IP "10.9.9.9" is not inside the "dokploy-network" network (10.0.1.0/24)', + ); + }); +}); + +describe("initializeTraefikService with TRAEFIK_STATIC_IP", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("rejects the setting instead of ignoring it", async () => { + mocks.getRemoteDocker.mockResolvedValue(fakeDocker().docker); + + await expect( + initializeTraefikService({ env: ["TRAEFIK_STATIC_IP=10.0.1.45"] }), + ).rejects.toThrow("TRAEFIK_STATIC_IP is not supported in swarm mode"); + }); +}); diff --git a/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx b/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx index cb615a76e35..4324af48d46 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx @@ -140,6 +140,7 @@ TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_HTTP_CHALLENGE_PRETTY=true TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_HTTP_CHALLENGE_ENTRYPOINT=web TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_HTTP_CHALLENGE_DNS_CHALLENGE=true TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_HTTP_CHALLENGE_DNS_PROVIDER=cloudflare +TRAEFIK_STATIC_IP=10.0.1.45 `} {...field} /> diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index e2051495a19..3c7c18be907 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -46,6 +46,7 @@ import { updateServerById, updateServerTraefik, updateWebServerSettings, + validateTraefikStaticIp, writeConfig, writeMainConfig, writeTraefikConfigInPath, @@ -826,6 +827,14 @@ export const settingsRouter = createTRPCRouter({ const envs = prepareEnvironmentVariables(input.env); const ports = await readPorts("dokploy-traefik", input?.serverId); + // Check the pinned address before storing it: the recreate runs in + // the background, so a wrong value would only be logged and the + // editor would report a successful update. + await validateTraefikStaticIp({ + env: envs, + serverId: input.serverId, + }); + // Run in background so the request returns immediately; client polls /api/health. void writeTraefikSetup({ env: envs, diff --git a/packages/server/src/setup/traefik-setup.ts b/packages/server/src/setup/traefik-setup.ts index 988f8e9145b..32456758e39 100644 --- a/packages/server/src/setup/traefik-setup.ts +++ b/packages/server/src/setup/traefik-setup.ts @@ -7,10 +7,13 @@ import { writeFileSync, } from "node:fs"; import path from "node:path"; +import type Dockerode from "dockerode"; import type { Container, ContainerCreateOptions, + ContainerInspectInfo, CreateServiceOptions, + NetworkContainer, } from "dockerode"; import { stringify } from "yaml"; import { paths } from "../constants"; @@ -26,6 +29,16 @@ export const TRAEFIK_PORT = export const TRAEFIK_HTTP3_PORT = Number.parseInt(process.env.TRAEFIK_HTTP3_PORT!, 10) || 443; export const TRAEFIK_VERSION = process.env.TRAEFIK_VERSION || "3.7.16"; +// Operators can pin the Traefik container to a fixed address on the +// dokploy-network, so that setups which point at it (split-DNS, firewall +// rules, NAT targets, monitoring) keep working across recreates. +export const TRAEFIK_STATIC_IP = process.env.TRAEFIK_STATIC_IP || ""; +export const TRAEFIK_NETWORK_NAME = "dokploy-network"; + +const STATIC_IP_ENV_KEY = "TRAEFIK_STATIC_IP"; +// The Traefik containers keep the address across a recreate, so they must not +// be reported as a conflict with themselves. +const TRAEFIK_CONTAINER_NAMES = ["dokploy-traefik", "dokploy-traefik-previous"]; export interface TraefikOptions { env?: string[]; @@ -40,6 +53,133 @@ export interface TraefikOptions { // Overlapping recreates of one server would remove each other's containers. const recreateQueue = new Map>(); +export const readEnvValue = (env: string[] | undefined, key: string) => { + if (!env) { + return ""; + } + const prefix = `${key}=`; + const entry = env.find((item) => item.startsWith(prefix)); + return entry ? entry.slice(prefix.length).trim() : ""; +}; + +export const hasEnvValue = (env: string[] | undefined, key: string) => + Boolean(env?.some((item) => item.startsWith(`${key}=`))); + +export const isValidIPv4Address = (value: string) => { + const parts = value.split("."); + if (parts.length !== 4) { + return false; + } + return parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255); +}; + +const ipv4ToNumber = (value: string) => + value.split(".").reduce((total, part) => total * 256 + Number(part), 0); + +export const isAddressInSubnet = (address: string, subnet: string) => { + const [network, prefixRaw] = subnet.split("/"); + const prefix = Number(prefixRaw); + if ( + !network || + !isValidIPv4Address(network) || + !Number.isInteger(prefix) || + prefix < 0 || + prefix > 32 + ) { + return false; + } + const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; + return (ipv4ToNumber(address) & mask) === (ipv4ToNumber(network) & mask); +}; + +// The env passed by the caller is the source of truth: it is the "Traefik Env" +// setting, so an operator can add or remove the address there. Callers that +// don't pass an env (the startup path) inherit it from the running container, +// so that a recreate keeps the settings instead of dropping them - otherwise +// the address would be gone on the next recreate. +const resolveTraefikEnv = async ( + docker: Dockerode, + env: string[] | undefined, +): Promise => { + if (env) { + return env; + } + let current: ContainerInspectInfo | null = null; + try { + current = await docker.getContainer("dokploy-traefik").inspect(); + } catch { + current = null; + } + return current?.Config?.Env; +}; + +// A wrong address fails with Docker's "invalid endpoint settings", which says +// nothing about the cause. Check it upfront and report what is wrong. +const assertStaticIpIsUsable = async (docker: Dockerode, address: string) => { + if (!isValidIPv4Address(address)) { + throw new Error( + `${STATIC_IP_ENV_KEY} "${address}" is not a valid IPv4 address`, + ); + } + const network = await docker.getNetwork(TRAEFIK_NETWORK_NAME).inspect(); + // A network can have several subnets, and an IPv6 one can come first. + const ipamConfig: { Subnet?: string }[] = network.IPAM?.Config ?? []; + const subnets = ipamConfig + .map((config) => config.Subnet) + .filter((subnet): subnet is string => Boolean(subnet)) + .filter((subnet) => isValidIPv4Address(subnet.split("/")[0] ?? "")); + if (subnets.length === 0) { + throw new Error( + `Could not read the IPv4 subnet of the "${TRAEFIK_NETWORK_NAME}" network`, + ); + } + if (!subnets.some((subnet) => isAddressInSubnet(address, subnet))) { + throw new Error( + `${STATIC_IP_ENV_KEY} "${address}" is not inside the "${TRAEFIK_NETWORK_NAME}" network (${subnets.join(", ")})`, + ); + } + // On an overlay network the swarm assigns addresses from the same pool, so + // the address can be taken by a task after a reboot without anyone + // changing the setting. + const overlayHint = + network.Driver === "overlay" + ? " On an overlay network a swarm task can be given the same address after a reboot." + : ""; + const containers: Record = network.Containers ?? {}; + const conflict = Object.values(containers).find( + (container) => + container.IPv4Address?.split("/")[0] === address && + !TRAEFIK_CONTAINER_NAMES.includes(container.Name), + ); + if (conflict) { + throw new Error( + `${STATIC_IP_ENV_KEY} "${address}" is already used by the container "${conflict.Name}".${overlayHint}`, + ); + } + if (network.Driver === "overlay") { + console.log( + `Warning: "${TRAEFIK_NETWORK_NAME}" is an overlay network. A swarm task can be given the address "${address}" after a reboot, and Traefik would then not start. Use an address outside the pool of the network, or a smaller pool.`, + ); + } +}; + +// Checks the address without touching the container, so the editor can report +// a wrong value instead of storing it. +export const validateTraefikStaticIp = async ({ + env, + serverId, +}: { + env: string[]; + serverId?: string; +}) => { + const address = readEnvValue(env, STATIC_IP_ENV_KEY); + if (!address) { + return; + } + const docker = await getRemoteDocker(serverId); + await assertStaticIpIsUsable(docker, address); +}; + export const initializeStandaloneTraefik = (options: TraefikOptions = {}) => { const key = options.serverId ?? "local"; const run = (recreateQueue.get(key) ?? Promise.resolve()).then(() => @@ -67,6 +207,24 @@ const recreateStandaloneTraefik = async ({ const imageName = `traefik:v${TRAEFIK_VERSION}`; const containerName = "dokploy-traefik"; + const docker = await getRemoteDocker(serverId); + // Restores a container left behind by an interrupted recreate before the + // env is read, so the settings can be inherited from it. + await restoreInterruptedTraefik(serverId); + const resolvedEnv = await resolveTraefikEnv(docker, env); + // The process default only applies to callers that don't pass an env at + // all: an env is the operator's choice, and for a remote server the + // address of the local Dokploy process would be the wrong one. + const staticIp = hasEnvValue(resolvedEnv, STATIC_IP_ENV_KEY) + ? readEnvValue(resolvedEnv, STATIC_IP_ENV_KEY) + : env === undefined + ? TRAEFIK_STATIC_IP + : ""; + if (staticIp) { + await assertStaticIpIsUsable(docker, staticIp); + } + await ensureTraefikImage(serverId); + const exposedPorts: Record = { [`${TRAEFIK_PORT}/tcp`]: {}, [`${TRAEFIK_SSL_PORT}/tcp`]: {}, @@ -101,7 +259,9 @@ const recreateStandaloneTraefik = async ({ Image: imageName, NetworkingConfig: { EndpointsConfig: { - "dokploy-network": {}, + [TRAEFIK_NETWORK_NAME]: staticIp + ? { IPAMConfig: { IPv4Address: staticIp } } + : {}, }, }, ExposedPorts: exposedPorts, @@ -116,13 +276,9 @@ const recreateStandaloneTraefik = async ({ ], PortBindings: portBindings, }, - Env: env, + Env: resolvedEnv, }; - const docker = await getRemoteDocker(serverId); - await restoreInterruptedTraefik(serverId); - await ensureTraefikImage(serverId); - // Kept until the new container stays up, so it can be restored on failure. const currentInfo = await docker .getContainer(containerName) @@ -274,6 +430,16 @@ export const initializeTraefikService = async ({ const imageName = `traefik:v${TRAEFIK_VERSION}`; const appName = "dokploy-traefik"; + // Swarm assigns an address per task, so there is nothing to pin. Failing + // loudly is better than silently ignoring the setting. + const staticIp = + env === undefined + ? TRAEFIK_STATIC_IP + : readEnvValue(env, STATIC_IP_ENV_KEY); + if (staticIp) { + throw new Error(`${STATIC_IP_ENV_KEY} is not supported in swarm mode`); + } + const settings: CreateServiceOptions = { Name: appName, TaskTemplate: {