diff --git a/apps/dokploy/components/dashboard/nomad/allocations/show-nomad-allocations.tsx b/apps/dokploy/components/dashboard/nomad/allocations/show-nomad-allocations.tsx
index 0e62161ff..0b6814a4d 100644
--- a/apps/dokploy/components/dashboard/nomad/allocations/show-nomad-allocations.tsx
+++ b/apps/dokploy/components/dashboard/nomad/allocations/show-nomad-allocations.tsx
@@ -20,16 +20,17 @@ import { api } from "@/utils/api";
interface Props {
appName: string;
+ serverId?: string;
}
-export const ShowNomadAllocations = ({ appName }: Props) => {
+export const ShowNomadAllocations = ({ appName, serverId }: Props) => {
const {
data: allocs,
isLoading,
isError,
refetch,
} = api.nomad.getJobAllocations.useQuery(
- { jobId: appName },
+ { jobId: appName, serverId },
{ enabled: !!appName, refetchInterval: 10000 },
);
@@ -69,14 +70,20 @@ export const ShowNomadAllocations = ({ appName }: Props) => {
)}
{allocs?.map((alloc: any) => (
-
+
))}
);
};
-const AllocationRow = ({ alloc }: { alloc: any }) => {
+const AllocationRow = ({
+ alloc,
+ serverId,
+}: {
+ alloc: any;
+ serverId?: string;
+}) => {
const [open, setOpen] = useState(false);
const [logType, setLogType] = useState<"stdout" | "stderr">("stdout");
@@ -122,6 +129,7 @@ const AllocationRow = ({ alloc }: { alloc: any }) => {
allocId={alloc.ID}
taskName={taskName}
logType={logType}
+ serverId={serverId}
/>
)}
@@ -135,16 +143,21 @@ const AllocLogViewer = ({
allocId,
taskName,
logType,
+ serverId,
}: {
allocId: string;
taskName: string;
logType: "stdout" | "stderr";
+ serverId?: string;
}) => {
- const { data: logs, isLoading, refetch } =
- api.nomad.getAllocationLogs.useQuery(
- { allocId, taskName, logType },
- { refetchInterval: 5000 },
- );
+ const {
+ data: logs,
+ isLoading,
+ refetch,
+ } = api.nomad.getAllocationLogs.useQuery(
+ { allocId, taskName, logType, serverId },
+ { refetchInterval: 5000 },
+ );
return (
diff --git a/apps/dokploy/components/dashboard/nomad/jobs/show-nomad-jobs.tsx b/apps/dokploy/components/dashboard/nomad/jobs/show-nomad-jobs.tsx
index b3822b8e2..243ecc024 100644
--- a/apps/dokploy/components/dashboard/nomad/jobs/show-nomad-jobs.tsx
+++ b/apps/dokploy/components/dashboard/nomad/jobs/show-nomad-jobs.tsx
@@ -1,4 +1,11 @@
-import { AlertTriangle, Loader2, Play, RefreshCw, Square, Trash2 } from "lucide-react";
+import {
+ AlertTriangle,
+ Loader2,
+ Play,
+ RefreshCw,
+ Square,
+ Trash2,
+} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
@@ -26,13 +33,13 @@ import {
} from "@/components/ui/table";
import { api } from "@/utils/api";
-export const ShowNomadJobs = () => {
+export const ShowNomadJobs = ({ serverId }: { serverId?: string }) => {
const {
data: jobs,
isLoading,
isError,
refetch,
- } = api.nomad.getJobs.useQuery();
+ } = api.nomad.getJobs.useQuery({ serverId });
const stopJob = api.nomad.stopJob.useMutation({
onSuccess: () => {
@@ -95,7 +102,9 @@ export const ShowNomadJobs = () => {
Object.entries(job.JobSummary.Summary).map(
([group, summary]: [string, any]) => (
- {group}: {summary.Running}/{summary.Running + summary.Starting + summary.Queued} running
+ {group}: {summary.Running}/
+ {summary.Running + summary.Starting + summary.Queued}{" "}
+ running
),
)}
@@ -110,15 +119,24 @@ export const ShowNomadJobs = () => {
- Stop job "{job.Name}"?
+
+ Stop job "{job.Name}"?
+
- This will stop all allocations for this job. You can restart it later.
+ This will stop all allocations for this job. You can
+ restart it later.
Cancel
stopJob.mutate({ jobId: job.ID, purge: false })}
+ onClick={() =>
+ stopJob.mutate({
+ jobId: job.ID,
+ purge: false,
+ serverId,
+ })
+ }
>
Stop
@@ -134,16 +152,25 @@ export const ShowNomadJobs = () => {
- Purge job "{job.Name}"?
+
+ Purge job "{job.Name}"?
+
- This will permanently remove the job and all its history. This cannot be undone.
+ This will permanently remove the job and all its
+ history. This cannot be undone.
Cancel
stopJob.mutate({ jobId: job.ID, purge: true })}
+ onClick={() =>
+ stopJob.mutate({
+ jobId: job.ID,
+ purge: true,
+ serverId,
+ })
+ }
>
Purge
@@ -156,7 +183,10 @@ export const ShowNomadJobs = () => {
))}
{(!jobs || jobs.length === 0) && (
-
+
No jobs running
diff --git a/apps/dokploy/components/dashboard/nomad/logs/show-nomad-logs.tsx b/apps/dokploy/components/dashboard/nomad/logs/show-nomad-logs.tsx
index b33ebd516..c667ed3b4 100644
--- a/apps/dokploy/components/dashboard/nomad/logs/show-nomad-logs.tsx
+++ b/apps/dokploy/components/dashboard/nomad/logs/show-nomad-logs.tsx
@@ -12,7 +12,7 @@ import {
} from "@/components/ui/select";
import { api } from "@/utils/api";
-export const ShowNomadLogs = () => {
+export const ShowNomadLogs = ({ serverId }: { serverId?: string }) => {
const [selectedAlloc, setSelectedAlloc] = useState(null);
const [selectedTask, setSelectedTask] = useState("");
const [logType, setLogType] = useState<"stdout" | "stderr">("stdout");
@@ -22,9 +22,10 @@ export const ShowNomadLogs = () => {
isLoading,
isError,
refetch,
- } = api.nomad.getAllocations.useQuery();
+ } = api.nomad.getAllocations.useQuery({ serverId });
- const runningAllocs = allocs?.filter((a: any) => a.ClientStatus === "running") || [];
+ const runningAllocs =
+ allocs?.filter((a: any) => a.ClientStatus === "running") || [];
const currentAlloc = runningAllocs.find((a: any) => a.ID === selectedAlloc);
@@ -92,6 +93,7 @@ export const ShowNomadLogs = () => {
allocId={selectedAlloc}
taskName={selectedTask}
logType={logType}
+ serverId={serverId}
/>
)}
>
@@ -105,13 +107,19 @@ const LogViewer = ({
allocId,
taskName,
logType,
+ serverId,
}: {
allocId: string;
taskName: string;
logType: "stdout" | "stderr";
+ serverId?: string;
}) => {
- const { data: logs, isLoading, refetch } = api.nomad.getAllocationLogs.useQuery(
- { allocId, taskName, logType },
+ const {
+ data: logs,
+ isLoading,
+ refetch,
+ } = api.nomad.getAllocationLogs.useQuery(
+ { allocId, taskName, logType, serverId },
{ refetchInterval: 5000 },
);
diff --git a/apps/dokploy/components/dashboard/nomad/nodes/show-nomad-nodes.tsx b/apps/dokploy/components/dashboard/nomad/nodes/show-nomad-nodes.tsx
index fad0a3010..9d50da9f8 100644
--- a/apps/dokploy/components/dashboard/nomad/nodes/show-nomad-nodes.tsx
+++ b/apps/dokploy/components/dashboard/nomad/nodes/show-nomad-nodes.tsx
@@ -14,17 +14,19 @@ import {
} from "@/components/ui/table";
import { api } from "@/utils/api";
-export const ShowNomadNodes = () => {
+export const ShowNomadNodes = ({ serverId }: { serverId?: string }) => {
const {
data: nodes,
isLoading,
isError,
refetch,
- } = api.nomad.getNodes.useQuery();
+ } = api.nomad.getNodes.useQuery({ serverId });
- const { data: resources } = api.nomad.getClusterResources.useQuery();
+ const { data: resources } = api.nomad.getClusterResources.useQuery({
+ serverId,
+ });
- const { data: allocs } = api.nomad.getAllocations.useQuery();
+ const { data: allocs } = api.nomad.getAllocations.useQuery({ serverId });
if (isLoading) {
return (
@@ -67,18 +69,26 @@ export const ShowNomadNodes = () => {
{nodes?.map((node: any) => {
- const nodeAllocs = allocs?.filter(
- (a: any) => a.NodeID === node.ID && a.ClientStatus === "running",
- ) || [];
+ const nodeAllocs =
+ allocs?.filter(
+ (a: any) =>
+ a.NodeID === node.ID && a.ClientStatus === "running",
+ ) || [];
const cpuTotal = resources?.cpu.total || 1;
const memTotal = resources?.memory.total || 1;
- const cpuPercent = cpuTotal > 0
- ? Math.round((resources?.cpu.allocated || 0) / cpuTotal * 100)
- : 0;
- const memPercent = memTotal > 0
- ? Math.round((resources?.memory.allocated || 0) / memTotal * 100)
- : 0;
+ const cpuPercent =
+ cpuTotal > 0
+ ? Math.round(
+ ((resources?.cpu.allocated || 0) / cpuTotal) * 100,
+ )
+ : 0;
+ const memPercent =
+ memTotal > 0
+ ? Math.round(
+ ((resources?.memory.allocated || 0) / memTotal) * 100,
+ )
+ : 0;
return (
@@ -89,7 +99,11 @@ export const ShowNomadNodes = () => {
-
+
{node.Status}
@@ -98,13 +112,17 @@ export const ShowNomadNodes = () => {
-
{cpuPercent}%
+
+ {cpuPercent}%
+
-
{memPercent}%
+
+ {memPercent}%
+
@@ -112,7 +130,10 @@ export const ShowNomadNodes = () => {
})}
{(!nodes || nodes.length === 0) && (
-
+
No nodes found
diff --git a/apps/dokploy/components/dashboard/nomad/overview.tsx b/apps/dokploy/components/dashboard/nomad/overview.tsx
index 66807f35f..7d42cf7a5 100644
--- a/apps/dokploy/components/dashboard/nomad/overview.tsx
+++ b/apps/dokploy/components/dashboard/nomad/overview.tsx
@@ -1,11 +1,18 @@
-import { Cpu, HardDrive, Loader2, MemoryStick, Server, Container } from "lucide-react";
+import {
+ Container,
+ Cpu,
+ HardDrive,
+ Loader2,
+ MemoryStick,
+ Server,
+} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { api } from "@/utils/api";
-export const NomadOverview = () => {
+export const NomadOverview = ({ serverId }: { serverId?: string }) => {
const { data, isLoading } = api.nomad.getClusterResources.useQuery(
- undefined,
+ { serverId },
{ refetchInterval: 10000 },
);
@@ -17,12 +24,14 @@ export const NomadOverview = () => {
);
}
- const cpuPercent = data.cpu.total > 0
- ? Math.round((data.cpu.allocated / data.cpu.total) * 100)
- : 0;
- const memPercent = data.memory.total > 0
- ? Math.round((data.memory.allocated / data.memory.total) * 100)
- : 0;
+ const cpuPercent =
+ data.cpu.total > 0
+ ? Math.round((data.cpu.allocated / data.cpu.total) * 100)
+ : 0;
+ const memPercent =
+ data.memory.total > 0
+ ? Math.round((data.memory.allocated / data.memory.total) * 100)
+ : 0;
return (
@@ -49,7 +58,8 @@ export const NomadOverview = () => {
{memPercent}%
- {formatMB(data.memory.allocated)} / {formatMB(data.memory.total)} allocated
+ {formatMB(data.memory.allocated)} / {formatMB(data.memory.total)}{" "}
+ allocated
diff --git a/apps/dokploy/components/dashboard/settings/servers/nomad-settings-modal.tsx b/apps/dokploy/components/dashboard/settings/servers/nomad-settings-modal.tsx
new file mode 100644
index 000000000..96af646b4
--- /dev/null
+++ b/apps/dokploy/components/dashboard/settings/servers/nomad-settings-modal.tsx
@@ -0,0 +1,42 @@
+import { useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
+import { NomadSettings } from "./nomad-settings";
+
+interface Props {
+ serverId: string;
+}
+
+export const NomadSettingsModal = ({ serverId }: Props) => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/settings/servers/nomad-settings.tsx b/apps/dokploy/components/dashboard/settings/servers/nomad-settings.tsx
index d7e1055c4..391d5c856 100644
--- a/apps/dokploy/components/dashboard/settings/servers/nomad-settings.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/nomad-settings.tsx
@@ -1,4 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
+import { Loader2, Terminal } from "lucide-react";
+import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -43,6 +45,34 @@ export const NomadSettings = ({ serverId }: Props) => {
const { mutateAsync, isPending } = api.server.update.useMutation();
+ const [isBootstrapping, setIsBootstrapping] = useState(false);
+ const [bootstrapLogs, setBootstrapLogs] = useState
("");
+
+ api.nomad.bootstrapServer.useSubscription(
+ { serverId },
+ {
+ enabled: isBootstrapping,
+ onData(log) {
+ if (log === "BOOTSTRAP_DONE") {
+ setIsBootstrapping(false);
+ toast.success("Nomad bootstrapped on this server");
+ refetch();
+ return;
+ }
+ setBootstrapLogs((prev) => prev + log);
+ },
+ onError(error) {
+ setIsBootstrapping(false);
+ toast.error(error.message || "Bootstrap failed");
+ },
+ },
+ );
+
+ const startBootstrap = () => {
+ setBootstrapLogs("");
+ setIsBootstrapping(true);
+ };
+
const form = useForm({
resolver: zodResolver(nomadSchema),
values: {
@@ -70,11 +100,27 @@ export const NomadSettings = ({ serverId }: Props) => {
return (
-
- Nomad Configuration
-
- Configure Nomad cluster connection for deploying services.
-
+
+
+ Nomad Configuration
+
+ Configure Nomad cluster connection for deploying services.
+
+
+
+
+ {(isBootstrapping || bootstrapLogs) && (
+
+ {bootstrapLogs || "Starting bootstrap…"}
+
+ )}
);
diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx
index 832d04759..3df7105e9 100644
--- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx
@@ -41,6 +41,7 @@ import { api } from "@/utils/api";
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
import { TerminalModal } from "../web-server/terminal-modal";
import { ShowServerActions } from "./actions/show-server-actions";
+import { NomadSettingsModal } from "./nomad-settings-modal";
import { HandleServers } from "./handle-servers";
import { SetupServer } from "./setup-server";
import { ShowDockerContainersModal } from "./show-docker-containers-modal";
@@ -181,6 +182,9 @@ export const ShowServers = () => {
+
)}
diff --git a/apps/dokploy/pages/dashboard/nomad.tsx b/apps/dokploy/pages/dashboard/nomad.tsx
index 4751b099e..bede79894 100644
--- a/apps/dokploy/pages/dashboard/nomad.tsx
+++ b/apps/dokploy/pages/dashboard/nomad.tsx
@@ -1,18 +1,52 @@
import { IS_CLOUD } from "@nomploy/server/constants";
import { validateRequest } from "@nomploy/server/lib/auth";
import type { GetServerSidePropsContext } from "next";
-import type { ReactElement } from "react";
-import { DashboardLayout } from "@/components/layouts/dashboard-layout";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { NomadOverview } from "@/components/dashboard/nomad/overview";
+import { type ReactElement, useState } from "react";
import { ShowNomadJobs } from "@/components/dashboard/nomad/jobs/show-nomad-jobs";
-import { ShowNomadNodes } from "@/components/dashboard/nomad/nodes/show-nomad-nodes";
import { ShowNomadLogs } from "@/components/dashboard/nomad/logs/show-nomad-logs";
+import { ShowNomadNodes } from "@/components/dashboard/nomad/nodes/show-nomad-nodes";
+import { NomadOverview } from "@/components/dashboard/nomad/overview";
+import { DashboardLayout } from "@/components/layouts/dashboard-layout";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { api } from "@/utils/api";
+
+// Sentinel for the control plane's own local Nomad (no serverId sent).
+const LOCAL = "local";
const NomadDashboard = () => {
+ const [selected, setSelected] = useState(LOCAL);
+ const { data: servers } = api.server.all.useQuery();
+
+ const serverId = selected === LOCAL ? undefined : selected;
+
return (
-
+
+
Nomad
+
+
+
+
Jobs
@@ -20,13 +54,13 @@ const NomadDashboard = () => {
Logs
-
+
-
+
-
+
@@ -39,9 +73,7 @@ NomadDashboard.getLayout = (page: ReactElement) => {
return {page};
};
-export async function getServerSideProps(
- ctx: GetServerSidePropsContext,
-) {
+export async function getServerSideProps(ctx: GetServerSidePropsContext) {
if (IS_CLOUD) {
return {
redirect: { permanent: false, destination: "/dashboard/home" },
diff --git a/apps/dokploy/server/api/routers/nomad.ts b/apps/dokploy/server/api/routers/nomad.ts
index 211ea13a0..f93d44a7a 100644
--- a/apps/dokploy/server/api/routers/nomad.ts
+++ b/apps/dokploy/server/api/routers/nomad.ts
@@ -1,89 +1,182 @@
+import { findServerById, updateServerById } from "@nomploy/server";
+import { getNomadBootstrapCommand } from "@nomploy/server/setup/nomad-bootstrap";
+import { execAsyncRemote } from "@nomploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
+import { observable } from "@trpc/server/observable";
import { z } from "zod";
import { createTRPCRouter, withPermission } from "../trpc";
-const NOMAD_ADDRESS = process.env.NOMAD_ADDRESS || "http://127.0.0.1:4646";
-const NOMAD_TOKEN = process.env.NOMAD_TOKEN || "";
+// Control-plane-local Nomad (used when no serverId is given).
+const DEFAULT_ADDRESS = process.env.NOMAD_ADDRESS || "http://127.0.0.1:4646";
+const DEFAULT_TOKEN = process.env.NOMAD_TOKEN || "";
-const nomadFetch = async (path: string) => {
- const headers: Record = {
- "Content-Type": "application/json",
- };
- if (NOMAD_TOKEN) {
- headers["X-Nomad-Token"] = NOMAD_TOKEN;
+interface NomadConfig {
+ address: string;
+ token: string;
+ namespace: string;
+}
+
+/**
+ * Resolve which Nomad cluster a request targets.
+ * - With a serverId: use that server's stored connection (nomadAddress / nomadToken
+ * / nomadNamespace), after verifying the server belongs to the caller's org.
+ * - Without: fall back to the control plane's own local Nomad.
+ *
+ * NOTE: transport is plain HTTP here. The seam for a future SSH-tunnel is
+ * `nomadClient()` below — swap how the request is made without touching callers.
+ */
+const resolveNomad = async (
+ ctx: { session?: { activeOrganizationId?: string } | null },
+ serverId?: string,
+): Promise => {
+ if (!serverId) {
+ return {
+ address: DEFAULT_ADDRESS,
+ token: DEFAULT_TOKEN,
+ namespace: "default",
+ };
}
- const res = await fetch(`${NOMAD_ADDRESS}/v1${path}`, { headers });
- if (!res.ok) {
+ const server = await findServerById(serverId);
+ if (server.organizationId !== ctx.session?.activeOrganizationId) {
+ throw new TRPCError({ code: "UNAUTHORIZED" });
+ }
+ if (!server.nomadAddress) {
throw new TRPCError({
- code: "INTERNAL_SERVER_ERROR",
- message: `Nomad API error: ${res.status} ${res.statusText}`,
+ code: "BAD_REQUEST",
+ message:
+ "This server has no Nomad address configured. Set it in the server's Nomad settings.",
});
}
- return res.json();
+
+ return {
+ address: server.nomadAddress,
+ token: server.nomadToken ?? "",
+ namespace: server.nomadNamespace ?? "default",
+ };
+};
+
+const nomadClient = (cfg: NomadConfig) => {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ };
+ if (cfg.token) headers["X-Nomad-Token"] = cfg.token;
+
+ const url = (path: string) => {
+ const base = `${cfg.address.replace(/\/$/, "")}/v1${path}`;
+ return base;
+ };
+
+ return {
+ namespace: cfg.namespace,
+ async request(path: string, init?: RequestInit) {
+ return fetch(url(path), {
+ ...init,
+ headers: { ...headers, ...init?.headers },
+ });
+ },
+ async get(path: string) {
+ const res = await this.request(path);
+ if (!res.ok) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: `Nomad API error: ${res.status} ${res.statusText}`,
+ });
+ }
+ return res.json();
+ },
+ };
+};
+
+// Append a namespace query param to a path (for namespaced endpoints).
+const withNs = (path: string, namespace: string) => {
+ if (!namespace || namespace === "*") return path;
+ const sep = path.includes("?") ? "&" : "?";
+ return `${path}${sep}namespace=${encodeURIComponent(namespace)}`;
};
+const serverInput = z.object({ serverId: z.string().optional() });
+
export const nomadRouter = createTRPCRouter({
getJobs: withPermission("server", "read")
- .query(async () => {
- return await nomadFetch("/jobs");
+ .input(serverInput)
+ .query(async ({ input, ctx }) => {
+ const cfg = await resolveNomad(ctx, input.serverId);
+ return nomadClient(cfg).get(withNs("/jobs", cfg.namespace));
}),
getJob: withPermission("server", "read")
- .input(z.object({ jobId: z.string() }))
- .query(async ({ input }) => {
- return await nomadFetch(`/job/${input.jobId}`);
+ .input(serverInput.extend({ jobId: z.string() }))
+ .query(async ({ input, ctx }) => {
+ const cfg = await resolveNomad(ctx, input.serverId);
+ return nomadClient(cfg).get(withNs(`/job/${input.jobId}`, cfg.namespace));
}),
getJobAllocations: withPermission("server", "read")
- .input(z.object({ jobId: z.string() }))
- .query(async ({ input }) => {
- return await nomadFetch(`/job/${input.jobId}/allocations`);
+ .input(serverInput.extend({ jobId: z.string() }))
+ .query(async ({ input, ctx }) => {
+ const cfg = await resolveNomad(ctx, input.serverId);
+ return nomadClient(cfg).get(
+ withNs(`/job/${input.jobId}/allocations`, cfg.namespace),
+ );
}),
getJobScale: withPermission("server", "read")
- .input(z.object({ jobId: z.string() }))
- .query(async ({ input }) => {
- return await nomadFetch(`/job/${input.jobId}/scale`);
+ .input(serverInput.extend({ jobId: z.string() }))
+ .query(async ({ input, ctx }) => {
+ const cfg = await resolveNomad(ctx, input.serverId);
+ return nomadClient(cfg).get(
+ withNs(`/job/${input.jobId}/scale`, cfg.namespace),
+ );
}),
getAllocations: withPermission("server", "read")
- .query(async () => {
- return await nomadFetch("/allocations");
+ .input(serverInput)
+ .query(async ({ input, ctx }) => {
+ const cfg = await resolveNomad(ctx, input.serverId);
+ return nomadClient(cfg).get(withNs("/allocations", cfg.namespace));
}),
getAllocation: withPermission("server", "read")
- .input(z.object({ allocId: z.string() }))
- .query(async ({ input }) => {
- return await nomadFetch(`/allocation/${input.allocId}`);
+ .input(serverInput.extend({ allocId: z.string() }))
+ .query(async ({ input, ctx }) => {
+ const cfg = await resolveNomad(ctx, input.serverId);
+ return nomadClient(cfg).get(`/allocation/${input.allocId}`);
}),
getAllocationLogs: withPermission("server", "read")
- .input(z.object({
- allocId: z.string(),
- taskName: z.string(),
- logType: z.enum(["stdout", "stderr"]).default("stdout"),
- }))
- .query(async ({ input }) => {
- const res = await fetch(
- `${NOMAD_ADDRESS}/v1/client/fs/logs/${input.allocId}?task=${input.taskName}&type=${input.logType}&plain=true`,
- {
- headers: NOMAD_TOKEN ? { "X-Nomad-Token": NOMAD_TOKEN } : {},
- },
+ .input(
+ serverInput.extend({
+ allocId: z.string(),
+ taskName: z.string(),
+ logType: z.enum(["stdout", "stderr"]).default("stdout"),
+ }),
+ )
+ .query(async ({ input, ctx }) => {
+ const cfg = await resolveNomad(ctx, input.serverId);
+ const res = await nomadClient(cfg).request(
+ `/client/fs/logs/${input.allocId}?task=${input.taskName}&type=${input.logType}&plain=true`,
);
if (!res.ok) return "";
- return await res.text();
+ return res.text();
}),
getNodes: withPermission("server", "read")
- .query(async () => {
- return await nomadFetch("/nodes");
+ .input(serverInput)
+ .query(async ({ input, ctx }) => {
+ const cfg = await resolveNomad(ctx, input.serverId);
+ return nomadClient(cfg).get("/nodes");
}),
getClusterResources: withPermission("server", "read")
- .query(async () => {
- const nodes: any[] = await nomadFetch("/nodes");
- const allocs: any[] = await nomadFetch("/allocations?resources=true");
+ .input(serverInput)
+ .query(async ({ input, ctx }) => {
+ const cfg = await resolveNomad(ctx, input.serverId);
+ const client = nomadClient(cfg);
+ const nodes: any[] = await client.get("/nodes");
+ const allocs: any[] = await client.get(
+ withNs("/allocations?resources=true", cfg.namespace),
+ );
let totalCpu = 0;
let totalMemory = 0;
@@ -91,13 +184,12 @@ export const nomadRouter = createTRPCRouter({
let allocatedCpu = 0;
let allocatedMemory = 0;
let runningAllocs = 0;
- let totalAllocs = allocs.length;
+ const totalAllocs = allocs.length;
- // Get detailed node info for resources
for (const node of nodes) {
if (node.Status !== "ready") continue;
try {
- const detail: any = await nomadFetch(`/node/${node.ID}`);
+ const detail: any = await client.get(`/node/${node.ID}`);
const res = detail.NodeResources || {};
totalCpu += res.Cpu?.CpuShares || 0;
totalMemory += res.Memory?.MemoryMB || 0;
@@ -105,7 +197,6 @@ export const nomadRouter = createTRPCRouter({
} catch {}
}
- // Sum allocated resources from running allocations
for (const alloc of allocs) {
if (alloc.ClientStatus !== "running") continue;
runningAllocs++;
@@ -119,78 +210,110 @@ export const nomadRouter = createTRPCRouter({
return {
nodes: nodes.length,
nodesReady: nodes.filter((n: any) => n.Status === "ready").length,
- cpu: { total: totalCpu, allocated: allocatedCpu, free: totalCpu - allocatedCpu },
- memory: { total: totalMemory, allocated: allocatedMemory, free: totalMemory - allocatedMemory },
+ cpu: {
+ total: totalCpu,
+ allocated: allocatedCpu,
+ free: totalCpu - allocatedCpu,
+ },
+ memory: {
+ total: totalMemory,
+ allocated: allocatedMemory,
+ free: totalMemory - allocatedMemory,
+ },
disk: { total: totalDisk },
allocations: { running: runningAllocs, total: totalAllocs },
};
}),
getNode: withPermission("server", "read")
- .input(z.object({ nodeId: z.string() }))
- .query(async ({ input }) => {
- return await nomadFetch(`/node/${input.nodeId}`);
+ .input(serverInput.extend({ nodeId: z.string() }))
+ .query(async ({ input, ctx }) => {
+ const cfg = await resolveNomad(ctx, input.serverId);
+ return nomadClient(cfg).get(`/node/${input.nodeId}`);
}),
scaleJob: withPermission("server", "create")
- .input(z.object({
- jobId: z.string(),
- group: z.string(),
- count: z.number().min(0),
- }))
- .mutation(async ({ input }) => {
- const headers: Record = {
- "Content-Type": "application/json",
- };
- if (NOMAD_TOKEN) {
- headers["X-Nomad-Token"] = NOMAD_TOKEN;
- }
-
- const res = await fetch(
- `${NOMAD_ADDRESS}/v1/job/${input.jobId}/scale`,
+ .input(
+ serverInput.extend({
+ jobId: z.string(),
+ group: z.string(),
+ count: z.number().min(0),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const cfg = await resolveNomad(ctx, input.serverId);
+ const res = await nomadClient(cfg).request(
+ withNs(`/job/${input.jobId}/scale`, cfg.namespace),
{
method: "POST",
- headers,
body: JSON.stringify({
Count: input.count,
- Target: {
- Group: input.group,
- },
+ Target: { Group: input.group },
}),
},
);
-
if (!res.ok) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Scale failed: ${res.status}`,
});
}
- return await res.json();
+ return res.json();
}),
stopJob: withPermission("server", "create")
- .input(z.object({ jobId: z.string(), purge: z.boolean().default(false) }))
- .mutation(async ({ input }) => {
- const headers: Record = {};
- if (NOMAD_TOKEN) {
- headers["X-Nomad-Token"] = NOMAD_TOKEN;
- }
-
- const res = await fetch(
- `${NOMAD_ADDRESS}/v1/job/${input.jobId}?purge=${input.purge}`,
- {
- method: "DELETE",
- headers,
- },
+ .input(
+ serverInput.extend({
+ jobId: z.string(),
+ purge: z.boolean().default(false),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const cfg = await resolveNomad(ctx, input.serverId);
+ const res = await nomadClient(cfg).request(
+ withNs(`/job/${input.jobId}?purge=${input.purge}`, cfg.namespace),
+ { method: "DELETE" },
);
-
if (!res.ok) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Stop failed: ${res.status}`,
});
}
- return await res.json();
+ return res.json();
+ }),
+
+ // Install Docker + Consul + Nomad + CNI on a server over SSH, streaming logs.
+ bootstrapServer: withPermission("server", "create")
+ .input(z.object({ serverId: z.string() }))
+ .subscription(async ({ input, ctx }) => {
+ const server = await findServerById(input.serverId);
+ if (server.organizationId !== ctx.session?.activeOrganizationId) {
+ throw new TRPCError({ code: "UNAUTHORIZED" });
+ }
+
+ const command = getNomadBootstrapCommand({
+ datacenter: server.nomadNamespace || "dc1",
+ });
+
+ return observable((emit) => {
+ execAsyncRemote(input.serverId, command, (log) => emit.next(log))
+ .then(async () => {
+ // Point the control plane at this server's Nomad if not set yet.
+ if (!server.nomadAddress) {
+ const address = `http://${server.ipAddress}:4646`;
+ await updateServerById(input.serverId, { nomadAddress: address });
+ emit.next(`\nSaved Nomad address: ${address} ✅\n`);
+ }
+ emit.next("BOOTSTRAP_DONE");
+ emit.complete();
+ })
+ .catch((err: unknown) => {
+ const message =
+ err instanceof Error ? err.message : "Bootstrap failed";
+ emit.next(`\n❌ ${message}\n`);
+ emit.complete();
+ });
+ });
}),
});
diff --git a/install.sh b/install.sh
new file mode 100755
index 000000000..da79ec10a
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,195 @@
+#!/bin/sh
+# nomploy installer — sets up the nomploy control plane on a fresh Linux server.
+#
+# curl -sSL https://raw.githubusercontent.com/Nomploy/nomploy/main/install.sh | sh
+#
+# Installs: Docker, Consul, Nomad, CNI plugins, then runs Postgres, Redis,
+# Traefik and the nomploy app. Idempotent — safe to re-run.
+#
+# nomploy is a fork of Dokploy (Apache-2.0) that uses HashiCorp Nomad as the
+# orchestrator. See https://github.com/Nomploy/nomploy.
+
+set -e
+
+NOMPLOY_IMAGE="${NOMPLOY_IMAGE:-nomploy/nomploy:latest}"
+NOMPLOY_PORT="${NOMPLOY_PORT:-3000}"
+CNI_VERSION="${CNI_VERSION:-v1.5.1}"
+
+# ── privilege + platform detection ─────────────────────────────────────────
+if [ "$(id -u)" -eq 0 ]; then
+ SUDO=""
+else
+ if command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then
+ SUDO="sudo"
+ else
+ echo "Error: run as root or with passwordless sudo." >&2
+ exit 1
+ fi
+fi
+
+if [ ! -f /etc/os-release ]; then
+ echo "Error: unsupported OS (no /etc/os-release)." >&2
+ exit 1
+fi
+OS_TYPE="$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"')"
+ARCH="$(uname -m)"
+case "$ARCH" in
+ x86_64) CNI_ARCH=amd64 ;;
+ aarch64 | arm64) CNI_ARCH=arm64 ;;
+ *) echo "Error: unsupported architecture $ARCH." >&2; exit 1 ;;
+esac
+
+echo "==> Installing nomploy on ${OS_TYPE} (${ARCH})"
+
+# ── Docker ──────────────────────────────────────────────────────────────────
+if ! command -v docker >/dev/null 2>&1; then
+ echo "==> Installing Docker"
+ curl -fsSL https://get.docker.com | $SUDO sh
+ $SUDO systemctl enable --now docker
+else
+ echo "Docker already installed."
+fi
+
+# ── Consul + Nomad (HashiCorp repos) ─────────────────────────────────────────
+install_hashicorp_debian() {
+ export DEBIAN_FRONTEND=noninteractive
+ $SUDO apt-get update -y
+ $SUDO apt-get install -y curl gnupg lsb-release
+ curl -fsSL https://apt.releases.hashicorp.com/gpg \
+ | $SUDO gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
+ echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" \
+ | $SUDO tee /etc/apt/sources.list.d/hashicorp.list >/dev/null
+ $SUDO apt-get update -y
+ $SUDO apt-get install -y nomad consul
+}
+
+install_hashicorp_rhel() {
+ $SUDO yum install -y yum-utils
+ $SUDO yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
+ $SUDO yum -y install nomad consul
+}
+
+if ! command -v nomad >/dev/null 2>&1 || ! command -v consul >/dev/null 2>&1; then
+ echo "==> Installing Consul + Nomad"
+ case "$OS_TYPE" in
+ ubuntu | debian | raspbian | pop | linuxmint | zorin) install_hashicorp_debian ;;
+ centos | rhel | rocky | almalinux | fedora | amzn | ol) install_hashicorp_rhel ;;
+ *) echo "Error: unsupported OS $OS_TYPE for auto-install." >&2; exit 1 ;;
+ esac
+else
+ echo "Consul + Nomad already installed."
+fi
+
+# ── CNI plugins (Nomad bridge networking) ────────────────────────────────────
+if [ ! -f /opt/cni/bin/bridge ]; then
+ echo "==> Installing CNI plugins"
+ $SUDO mkdir -p /opt/cni/bin
+ curl -fsSL "https://github.com/containernetworking/plugins/releases/download/${CNI_VERSION}/cni-plugins-linux-${CNI_ARCH}-${CNI_VERSION}.tgz" \
+ | $SUDO tar -C /opt/cni/bin -xz
+fi
+echo 1 | $SUDO tee /proc/sys/net/bridge/bridge-nf-call-iptables >/dev/null 2>&1 || true
+
+# ── Consul + Nomad config (single node: server + client) ─────────────────────
+$SUDO mkdir -p /etc/consul.d /opt/consul /etc/nomad.d /opt/nomad
+
+$SUDO tee /etc/consul.d/consul.hcl >/dev/null <<'CONSULHCL'
+data_dir = "/opt/consul"
+bind_addr = "{{ GetPrivateIP }}"
+client_addr = "0.0.0.0"
+datacenter = "dc1"
+server = true
+bootstrap_expect = 1
+ui_config { enabled = true }
+CONSULHCL
+
+$SUDO tee /etc/nomad.d/nomad.hcl >/dev/null <<'NOMADHCL'
+data_dir = "/opt/nomad"
+bind_addr = "0.0.0.0"
+datacenter = "dc1"
+
+server {
+ enabled = true
+ bootstrap_expect = 1
+}
+
+client {
+ enabled = true
+}
+
+consul {
+ address = "127.0.0.1:8500"
+}
+
+plugin "docker" {
+ config {
+ allow_privileged = true
+ auth {
+ config = "/root/.docker/config.json"
+ }
+ }
+}
+NOMADHCL
+
+echo "==> Starting Consul + Nomad"
+$SUDO systemctl enable consul nomad
+$SUDO systemctl restart consul
+sleep 3
+$SUDO systemctl restart nomad
+
+echo "==> Waiting for Nomad API"
+i=0
+while [ "$i" -lt 30 ]; do
+ if curl -fsS http://127.0.0.1:4646/v1/agent/health >/dev/null 2>&1; then
+ echo "Nomad is up."
+ break
+ fi
+ i=$((i + 1))
+ sleep 2
+done
+
+# ── Datastores ────────────────────────────────────────────────────────────────
+POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-amukds4wi9001583845717ad2}"
+
+run_container() {
+ name="$1"; shift
+ if [ "$($SUDO docker ps -q -f name="^${name}$" -f status=running)" ]; then
+ echo "${name} already running."
+ return
+ fi
+ $SUDO docker rm -f "$name" >/dev/null 2>&1 || true
+ $SUDO docker run -d --name "$name" --restart unless-stopped "$@"
+}
+
+echo "==> Starting Postgres + Redis"
+run_container nomploy-postgres \
+ -e POSTGRES_USER=nomploy -e POSTGRES_DB=nomploy \
+ -e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \
+ -v nomploy-postgres:/var/lib/postgresql/data \
+ -p 127.0.0.1:5432:5432 postgres:16
+run_container nomploy-redis \
+ -v nomploy-redis:/data -p 127.0.0.1:6379:6379 redis:7
+
+# ── nomploy app ───────────────────────────────────────────────────────────────
+echo "==> Starting nomploy ($NOMPLOY_IMAGE)"
+$SUDO docker pull "$NOMPLOY_IMAGE"
+$SUDO docker rm -f nomploy >/dev/null 2>&1 || true
+$SUDO docker run -d --name nomploy --restart unless-stopped \
+ --network host \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -v /etc/nomploy:/etc/nomploy \
+ -e NODE_ENV=production \
+ -e PORT="$NOMPLOY_PORT" \
+ -e DATABASE_URL="postgresql://nomploy:${POSTGRES_PASSWORD}@127.0.0.1:5432/nomploy" \
+ -e REDIS_URL="redis://127.0.0.1:6379" \
+ -e NOMAD_ADDRESS="http://127.0.0.1:4646" \
+ -e CONSUL_ADDRESS="http://127.0.0.1:8500" \
+ "$NOMPLOY_IMAGE"
+
+IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
+echo ""
+echo "=============================================="
+echo " nomploy is starting."
+echo " Open: http://${IP:-}:${NOMPLOY_PORT}"
+echo " Nomad: http://${IP:-}:4646"
+echo " Consul: http://${IP:-}:8500"
+echo "=============================================="
diff --git a/packages/server/src/setup/nomad-bootstrap.ts b/packages/server/src/setup/nomad-bootstrap.ts
new file mode 100644
index 000000000..3f064febf
--- /dev/null
+++ b/packages/server/src/setup/nomad-bootstrap.ts
@@ -0,0 +1,190 @@
+/**
+ * nomploy — Nomad bootstrap.
+ *
+ * Generates a shell script (run over SSH on a target server, the same way
+ * Dokploy's `serverSetup` runs `installRequirements`) that installs and starts a
+ * single-node HashiCorp stack:
+ * - Docker (container runtime)
+ * - Consul (service discovery — Traefik reads services from its catalog)
+ * - Nomad (orchestrator, server + client on one node)
+ * - CNI plugins (required for Nomad bridge networking)
+ *
+ * After this runs, `setupNomad()` (nomad-setup.ts) can bring up Traefik, the
+ * databases, and the autoscaler, because Nomad/Consul/Docker are now present.
+ *
+ * Multi-node: bootstrap the first node as the server, then bootstrap additional
+ * nodes as clients with `serverMode:false` and `retryJoin` pointing at the
+ * server's private IP.
+ */
+
+export interface NomadBootstrapOptions {
+ /** Datacenter name Nomad/Consul register under. */
+ datacenter?: string;
+ /** Address to advertise/bind on (defaults to auto-detected private IP). */
+ bindAddr?: string;
+ /** Run as a Nomad+Consul server (true) or client-only node (false). */
+ serverMode?: boolean;
+ /** Server addresses a client node should join (private IPs). */
+ retryJoin?: string[];
+ /** Nomad/Consul version to pin, or "latest" (default). */
+ version?: string;
+}
+
+export const getNomadBootstrapCommand = (
+ opts: NomadBootstrapOptions = {},
+): string => {
+ const datacenter = opts.datacenter || "dc1";
+ const serverMode = opts.serverMode !== false;
+ const retryJoin = opts.retryJoin ?? [];
+ const bindExpr = opts.bindAddr
+ ? `"${opts.bindAddr}"`
+ : // GO template resolved by Consul/Nomad to the first private interface.
+ `"{{ GetPrivateIP }}"`;
+
+ // Consul retry_join list (client nodes point at the server).
+ const consulRetryJoin =
+ retryJoin.length > 0
+ ? `retry_join = [${retryJoin.map((h) => `"${h}"`).join(", ")}]`
+ : "";
+
+ return `
+set -e
+CURRENT_USER=$USER
+if [ "$EUID" -eq 0 ]; then
+ SUDO=""
+else
+ if sudo -n true 2>/dev/null; then
+ SUDO="sudo"
+ else
+ echo "Error: needs root or passwordless sudo. ❌"
+ echo " echo '$CURRENT_USER ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/$CURRENT_USER"
+ exit 1
+ fi
+fi
+
+OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"')
+ARCH=$(uname -m)
+case "$ARCH" in
+ x86_64) CNI_ARCH=amd64 ;;
+ aarch64|arm64) CNI_ARCH=arm64 ;;
+ *) echo "Unsupported arch: $ARCH ❌"; exit 1 ;;
+esac
+
+echo "==> Installing Nomad stack on $OS_TYPE ($ARCH)"
+
+# ── Docker ────────────────────────────────────────────────────────────────
+if ! command -v docker >/dev/null 2>&1; then
+ echo "==> Installing Docker"
+ curl -fsSL https://get.docker.com | $SUDO sh
+ $SUDO systemctl enable --now docker
+else
+ echo "Docker already installed ✅"
+fi
+
+# ── HashiCorp repo + Consul/Nomad ─────────────────────────────────────────
+install_hashicorp_debian() {
+ export DEBIAN_FRONTEND=noninteractive
+ $SUDO apt-get update -y
+ $SUDO apt-get install -y curl gnupg lsb-release
+ curl -fsSL https://apt.releases.hashicorp.com/gpg | $SUDO gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
+ echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | $SUDO tee /etc/apt/sources.list.d/hashicorp.list
+ $SUDO apt-get update -y
+ $SUDO apt-get install -y nomad consul
+}
+
+install_hashicorp_rhel() {
+ $SUDO yum install -y yum-utils
+ $SUDO yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
+ $SUDO yum -y install nomad consul
+}
+
+if ! command -v nomad >/dev/null 2>&1 || ! command -v consul >/dev/null 2>&1; then
+ case "$OS_TYPE" in
+ ubuntu|debian|raspbian|pop|linuxmint|zorin) install_hashicorp_debian ;;
+ centos|rhel|rocky|almalinux|fedora|amzn|ol) install_hashicorp_rhel ;;
+ *) echo "Unsupported OS for auto-install: $OS_TYPE. Install nomad+consul manually. ❌"; exit 1 ;;
+ esac
+else
+ echo "Nomad + Consul already installed ✅"
+fi
+
+# ── CNI plugins (needed for Nomad bridge networking) ──────────────────────
+if [ ! -f /opt/cni/bin/bridge ]; then
+ echo "==> Installing CNI plugins"
+ CNI_VERSION=v1.5.1
+ $SUDO mkdir -p /opt/cni/bin
+ curl -fsSL "https://github.com/containernetworking/plugins/releases/download/\${CNI_VERSION}/cni-plugins-linux-\${CNI_ARCH}-\${CNI_VERSION}.tgz" \\
+ | $SUDO tar -C /opt/cni/bin -xz
+else
+ echo "CNI plugins already present ✅"
+fi
+# Let bridged traffic traverse iptables (required by Nomad bridge mode).
+echo 1 | $SUDO tee /proc/sys/net/bridge/bridge-nf-call-iptables >/dev/null 2>&1 || true
+
+# ── Consul config ─────────────────────────────────────────────────────────
+$SUDO mkdir -p /etc/consul.d /opt/consul
+$SUDO tee /etc/consul.d/consul.hcl >/dev/null <<'CONSULHCL'
+data_dir = "/opt/consul"
+bind_addr = ${bindExpr}
+client_addr = "0.0.0.0"
+datacenter = "${datacenter}"
+${serverMode ? "server = true\nbootstrap_expect = 1\nui_config { enabled = true }" : "server = false"}
+${consulRetryJoin}
+CONSULHCL
+
+# ── Nomad config ──────────────────────────────────────────────────────────
+$SUDO mkdir -p /etc/nomad.d /opt/nomad
+$SUDO tee /etc/nomad.d/nomad.hcl >/dev/null <<'NOMADHCL'
+data_dir = "/opt/nomad"
+bind_addr = "0.0.0.0"
+datacenter = "${datacenter}"
+
+advertise {
+ http = ${bindExpr}
+ rpc = ${bindExpr}
+ serf = ${bindExpr}
+}
+
+${serverMode ? "server {\n enabled = true\n bootstrap_expect = 1\n}" : "server { enabled = false }"}
+
+client {
+ enabled = true
+ ${retryJoin.length > 0 ? `servers = [${retryJoin.map((h) => `"${h}"`).join(", ")}]` : ""}
+}
+
+consul {
+ address = "127.0.0.1:8500"
+}
+
+plugin "docker" {
+ config {
+ allow_privileged = true
+ # Lets Nomad pull from private registries you've logged into.
+ auth {
+ config = "/root/.docker/config.json"
+ }
+ }
+}
+NOMADHCL
+
+# ── Enable + start services ───────────────────────────────────────────────
+echo "==> Starting Consul and Nomad"
+$SUDO systemctl enable consul nomad
+$SUDO systemctl restart consul
+sleep 3
+$SUDO systemctl restart nomad
+
+# ── Wait for Nomad API ────────────────────────────────────────────────────
+echo "==> Waiting for Nomad API on :4646"
+for i in $(seq 1 30); do
+ if curl -fsS http://127.0.0.1:4646/v1/agent/health >/dev/null 2>&1; then
+ echo "Nomad is up ✅"
+ break
+ fi
+ sleep 2
+done
+
+nomad node status || true
+echo "==> Nomad bootstrap complete ✅"
+`;
+};