diff --git a/apps/dokploy/components/dashboard/settings/web-server.tsx b/apps/dokploy/components/dashboard/settings/web-server.tsx index 10631c63e..97ca2d410 100644 --- a/apps/dokploy/components/dashboard/settings/web-server.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server.tsx @@ -15,6 +15,7 @@ import { ShowTraefikActions } from "./servers/actions/show-traefik-actions"; import { ToggleDockerCleanup } from "./servers/actions/toggle-docker-cleanup"; import { ShowWebServerBackups } from "./web-server/show-web-server-backups"; import { UpdateServer } from "./web-server/update-server"; +import { UpgradeTraefik } from "./web-server/upgrade-traefik"; export const WebServer = () => { const { data: webServerSettings } = @@ -51,6 +52,8 @@ export const WebServer = () => { + +
Server IP: {webServerSettings?.serverIp} diff --git a/apps/dokploy/components/dashboard/settings/web-server/upgrade-traefik.tsx b/apps/dokploy/components/dashboard/settings/web-server/upgrade-traefik.tsx new file mode 100644 index 000000000..a06de5ceb --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/web-server/upgrade-traefik.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api } from "@/utils/api"; + +const VERSION_RE = /^\d+\.\d+\.\d+$/; + +interface Props { + serverId?: string; +} + +export const UpgradeTraefik = ({ serverId }: Props) => { + const { data, refetch } = api.settings.getTraefikVersion.useQuery({ + serverId, + }); + const { mutateAsync: upgradeTraefik, isPending } = + api.settings.upgradeTraefik.useMutation(); + + const [version, setVersion] = useState(""); + + // Prefill the input with the current version once it loads. + useEffect(() => { + if (data?.version) { + setVersion(data.version); + } + }, [data?.version]); + + const isValid = VERSION_RE.test(version.trim()); + const isUnchanged = version.trim() === data?.version; + + return ( + + + Traefik Version + + Current version: {data?.version ?? "..."}. Upgrade the Traefik image + used for ingress. + + + +
+ + setVersion(e.target.value)} + /> +
+ + + Upgrading recreates Traefik and briefly interrupts ingress while + the new image is pulled and the container is recreated. + +

+ Are you sure you want to upgrade Traefik to {version.trim()}? +

+
+ } + onClick={async () => { + try { + await upgradeTraefik({ version: version.trim(), serverId }); + toast.success( + "Traefik upgrade started. Ingress may briefly be interrupted.", + ); + refetch(); + } catch (error) { + toast.error( + (error as Error)?.message || "Failed to upgrade Traefik.", + ); + } + }} + type="default" + disabled={!isValid || isUnchanged || isPending} + > + + + + + ); +}; + +export default UpgradeTraefik; diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index 6c1d80522..d562e9da0 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -50,6 +50,12 @@ import { } from "@nomploy/server"; import { db } from "@nomploy/server/db"; import { checkPermission } from "@nomploy/server/services/permission"; +import { deployTraefikHaSystemJob } from "@nomploy/server/setup/traefik-ha"; +import { + getTraefikVersion, + initializeStandaloneTraefik, + setTraefikVersion, +} from "@nomploy/server/setup/traefik-setup"; import { TRPCError } from "@trpc/server"; import { eq, sql } from "drizzle-orm"; import { scheduledJobs, scheduleJob } from "node-schedule"; @@ -164,6 +170,37 @@ export const settingsRouter = createTRPCRouter({ }); return true; }), + getTraefikVersion: adminProcedure + .input(apiServerSchema) + .query(({ input }) => { + return { version: getTraefikVersion(input?.serverId) }; + }), + upgradeTraefik: adminProcedure + .input( + z.object({ + version: z.string().regex(/^\d+\.\d+\.\d+$/), + serverId: z.string().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + setTraefikVersion(input.version, input.serverId); + // Recreate on the new image in the background so the request returns + // immediately (the pull + recreate briefly interrupts ingress). Then + // best-effort re-register the HA pool so LB nodes pull the new image too — + // tolerate failure when HA isn't enabled / no LB nodes exist. + void (async () => { + await initializeStandaloneTraefik({ serverId: input.serverId }); + await deployTraefikHaSystemJob().catch(() => {}); + })().catch((err) => { + console.error("upgradeTraefik background:", err); + }); + await audit(ctx, { + action: "update", + resourceType: "settings", + resourceName: "traefik-version", + }); + return true; + }), toggleDashboard: adminProcedure .input(apiEnableDashboard) .mutation(async ({ input, ctx }) => { diff --git a/packages/server/src/setup/traefik-ha.ts b/packages/server/src/setup/traefik-ha.ts index 974f6ba86..416f2a356 100644 --- a/packages/server/src/setup/traefik-ha.ts +++ b/packages/server/src/setup/traefik-ha.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { paths } from "../constants"; import { encodeBase64 } from "../utils/docker/utils"; import { execAsync } from "../utils/process/execAsync"; -import { TRAEFIK_VERSION } from "./traefik-setup"; +import { getTraefikVersion } from "./traefik-setup"; const JOB_NAME = "nomploy-traefik-ha"; const CONSUL_ADDR = "http://127.0.0.1:8500"; @@ -81,7 +81,7 @@ export const generateTraefikHaJob = (opts: { driver = "docker" config { - image = "traefik:v${TRAEFIK_VERSION}" + image = "traefik:v${getTraefikVersion()}" network_mode = "host" args = ["--configFile=/local/traefik.yml"] } diff --git a/packages/server/src/setup/traefik-setup.ts b/packages/server/src/setup/traefik-setup.ts index 424a0e5ef..1df382714 100644 --- a/packages/server/src/setup/traefik-setup.ts +++ b/packages/server/src/setup/traefik-setup.ts @@ -2,6 +2,7 @@ import { chmodSync, existsSync, mkdirSync, + readFileSync, rmSync, statSync, writeFileSync, @@ -22,6 +23,52 @@ export const TRAEFIK_HTTP3_PORT = Number.parseInt(process.env.TRAEFIK_HTTP3_PORT!, 10) || 443; export const TRAEFIK_VERSION = process.env.TRAEFIK_VERSION || "3.6.7"; +// A semver like "3.6.7" — the only shape we accept for a Traefik image tag. +const TRAEFIK_VERSION_RE = /^\d+\.\d+\.\d+$/; + +// The stored version lives next to the main Traefik config so a recreate always +// picks it up. Falls back to the env/default when the file is missing/invalid. +const traefikVersionFile = (serverId?: string): string => { + const { MAIN_TRAEFIK_PATH } = paths(!!serverId); + return path.join(MAIN_TRAEFIK_PATH, "traefik-version"); +}; + +/** + * Resolve the Traefik version to run. Reads the stored `traefik-version` file + * (set via the panel's upgrade action); falls back to `process.env.TRAEFIK_VERSION` + * or the pinned default when the file is missing or malformed. + */ +export const getTraefikVersion = (serverId?: string): string => { + try { + const stored = readFileSync(traefikVersionFile(serverId), "utf8").trim(); + if (TRAEFIK_VERSION_RE.test(stored)) { + return stored; + } + } catch { + // fall through to the env/default + } + return TRAEFIK_VERSION; +}; + +/** + * Persist the Traefik version to run. Validates the semver shape (throws on bad + * input) and writes it to the `traefik-version` file so subsequent recreates and + * HA-pool deploys use it. + */ +export const setTraefikVersion = (version: string, serverId?: string): void => { + const trimmed = version.trim(); + if (!TRAEFIK_VERSION_RE.test(trimmed)) { + throw new Error(`Invalid Traefik version: ${version}`); + } + const { MAIN_TRAEFIK_PATH } = paths(!!serverId); + mkdirSync(MAIN_TRAEFIK_PATH, { recursive: true }); + writeFileSync( + path.join(MAIN_TRAEFIK_PATH, "traefik-version"), + trimmed, + "utf8", + ); +}; + export interface TraefikOptions { env?: string[]; serverId?: string; @@ -42,7 +89,7 @@ export const initializeStandaloneTraefik = async ({ skipPull, }: TraefikOptions = {}) => { const { MAIN_TRAEFIK_PATH, DYNAMIC_TRAEFIK_PATH } = paths(!!serverId); - const imageName = `traefik:v${TRAEFIK_VERSION}`; + const imageName = `traefik:v${getTraefikVersion(serverId)}`; const containerName = "nomploy-traefik"; // Host networking (matching install.sh): Traefik binds 80/443/8080 (via its