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