Skip to content
Closed
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
3 changes: 3 additions & 0 deletions apps/dokploy/components/dashboard/settings/web-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } =
Expand Down Expand Up @@ -51,6 +52,8 @@ export const WebServer = () => {
<UpdateServer />
</div>

<UpgradeTraefik />

<div className="flex items-center flex-wrap justify-between gap-4">
<span className="text-sm text-muted-foreground flex items-center gap-1.5">
Server IP: {webServerSettings?.serverIp}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-lg">Traefik Version</CardTitle>
<CardDescription>
Current version: {data?.version ?? "..."}. Upgrade the Traefik image
used for ingress.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="flex w-full flex-col gap-2 sm:max-w-xs">
<Label htmlFor="traefik-version">Target version</Label>
<Input
id="traefik-version"
placeholder="3.6.7"
value={version}
onChange={(e) => setVersion(e.target.value)}
/>
</div>
<DialogAction
title="Upgrade Traefik"
description={
<div className="space-y-4">
<AlertBlock type="warning">
Upgrading recreates Traefik and briefly interrupts ingress while
the new image is pulled and the container is recreated.
</AlertBlock>
<p>
Are you sure you want to upgrade Traefik to {version.trim()}?
</p>
</div>
}
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}
>
<Button
variant="secondary"
isLoading={isPending}
disabled={!isValid || isUnchanged || isPending}
>
Upgrade
</Button>
</DialogAction>
</CardContent>
</Card>
);
};

export default UpgradeTraefik;
37 changes: 37 additions & 0 deletions apps/dokploy/server/api/routers/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 }) => {
Expand Down
4 changes: 2 additions & 2 deletions packages/server/src/setup/traefik-ha.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"]
}
Expand Down
49 changes: 48 additions & 1 deletion packages/server/src/setup/traefik-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
Expand All @@ -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;
Expand All @@ -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
Expand Down
Loading