Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ WORKDIR /app
# Set production
ENV NODE_ENV=production

RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync git-lfs wireguard-tools iptables && git lfs install && rm -rf /var/lib/apt/lists/*

# Nomad CLI — the deploy pipeline runs `nomad job run` to submit jobs to the
# control plane's own Nomad. (Remote-server deploys use that server's own CLI.)
Expand Down
2 changes: 1 addition & 1 deletion apps/dokploy/__test__/nomad/nomad-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe("nomad builder — compose → HCL (live)", () => {
// getBuildNomadCommand embeds the HCL as base64 in the deploy script.
const match = cmd.match(/echo "([A-Za-z0-9+/=]+)" \| base64 -d/);
expect(match).not.toBeNull();
const hcl = Buffer.from(match![1], "base64").toString("utf8");
const hcl = Buffer.from(match?.[1] ?? "", "base64").toString("utf8");

// Print it so the translation is visible when running the test.
console.log("\n===== generated Nomad HCL =====\n" + hcl + "\n===============================\n");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, Terminal } from "lucide-react";
import { Loader2, Network, Terminal } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
Expand Down Expand Up @@ -73,6 +73,34 @@ export const NomadSettings = ({ serverId }: Props) => {
setIsBootstrapping(true);
};

const [isJoining, setIsJoining] = useState(false);
const [joinLogs, setJoinLogs] = useState<string>("");

api.nomad.joinCluster.useSubscription(
{ serverId },
{
enabled: isJoining,
onData(log) {
if (log === "JOIN_DONE") {
setIsJoining(false);
toast.success("Server joined the Nomad cluster");
refetch();
return;
}
setJoinLogs((prev) => prev + log);
},
onError(error) {
setIsJoining(false);
toast.error(error.message || "Cluster join failed");
},
},
);

const startJoin = () => {
setJoinLogs("");
setIsJoining(true);
};

const form = useForm<NomadFormValues>({
resolver: zodResolver(nomadSchema),
values: {
Expand Down Expand Up @@ -107,20 +135,35 @@ export const NomadSettings = ({ serverId }: Props) => {
Configure Nomad cluster connection for deploying services.
</CardDescription>
</div>
<Button
type="button"
variant="secondary"
onClick={startBootstrap}
disabled={isBootstrapping}
title="Install Docker + Consul + Nomad + CNI on this server over SSH"
>
{isBootstrapping ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Terminal className="mr-2 h-4 w-4" />
)}
{isBootstrapping ? "Bootstrapping…" : "Bootstrap Nomad"}
</Button>
<div className="flex flex-col gap-2">
<Button
type="button"
variant="secondary"
onClick={startBootstrap}
disabled={isBootstrapping || isJoining}
title="Install a standalone Docker + Consul + Nomad + CNI on this server over SSH"
>
{isBootstrapping ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Terminal className="mr-2 h-4 w-4" />
)}
{isBootstrapping ? "Bootstrapping…" : "Bootstrap Nomad"}
</Button>
<Button
type="button"
onClick={startJoin}
disabled={isBootstrapping || isJoining}
title="Install Nomad on this server and join it to the control plane's cluster over WireGuard"
>
{isJoining ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Network className="mr-2 h-4 w-4" />
)}
{isJoining ? "Joining cluster…" : "Join cluster"}
</Button>
</div>
</CardHeader>
<CardContent>
<Form {...form}>
Expand Down Expand Up @@ -208,6 +251,11 @@ export const NomadSettings = ({ serverId }: Props) => {
{bootstrapLogs || "Starting bootstrap…"}
</pre>
)}
{(isJoining || joinLogs) && (
<pre className="mt-4 max-h-[400px] overflow-auto whitespace-pre-wrap rounded-lg bg-black p-4 font-mono text-xs text-green-400">
{joinLogs || "Joining cluster…"}
</pre>
)}
</CardContent>
</Card>
);
Expand Down
104 changes: 103 additions & 1 deletion apps/dokploy/server/api/routers/nomad.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { findServerById, updateServerById } from "@nomploy/server";
import { getNomadBootstrapCommand } from "@nomploy/server/setup/nomad-bootstrap";
import { execAsyncRemote } from "@nomploy/server/utils/process/execAsync";
import { getClusterWorkerJoinCommand } from "@nomploy/server/setup/nomad-cluster";
import {
execAsync,
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";

// Cluster state written by the installer's hub setup and updated as nodes join.
const CLUSTER_FILE = "/etc/nomploy/cluster.json";
interface ClusterState {
hubPublicKey: string;
gossipKey: string;
hubWgIp: string;
hubEndpoint: string;
overlayCidr?: string;
peers: { wgIp: string; publicKey: string; serverId: string; name: string }[];
}

// 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 || "";
Expand Down Expand Up @@ -316,4 +332,90 @@ export const nomadRouter = createTRPCRouter({
});
});
}),

// Join a server to the Nomad cluster over the WireGuard mesh: install +
// configure it as a Consul/Nomad client, then register its WireGuard peer on
// the hub (this control plane). Streams progress.
joinCluster: 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" });
}

return observable<string>((emit) => {
(async () => {
try {
if (!existsSync(CLUSTER_FILE)) {
emit.next(
"❌ Cluster not initialized on the control plane (missing /etc/nomploy/cluster.json).\n",
);
emit.complete();
return;
}
const cluster: ClusterState = JSON.parse(
readFileSync(CLUSTER_FILE, "utf8"),
);
cluster.peers = cluster.peers || [];

// Allocate the next free overlay IP (hub keeps .1).
const prefix = cluster.hubWgIp.replace(/\.\d+$/, "");
const used = new Set([
cluster.hubWgIp,
...cluster.peers.map((p) => p.wgIp),
]);
let n = 2;
while (used.has(`${prefix}.${n}`)) n++;
const wgIp = `${prefix}.${n}`;
emit.next(`Assigning overlay IP ${wgIp} to "${server.name}"\n`);

const script = getClusterWorkerJoinCommand({
hubPublicKey: cluster.hubPublicKey,
hubEndpoint: cluster.hubEndpoint,
gossipKey: cluster.gossipKey,
workerWgIp: wgIp,
hubWgIp: cluster.hubWgIp,
overlayCidr: cluster.overlayCidr,
});

let pubkey = "";
await execAsyncRemote(input.serverId, script, (log) => {
emit.next(log);
const cap = log.match(/WORKER_WG_PUBKEY=(\S+)/)?.[1];
if (cap) pubkey = cap.trim();
});
if (!pubkey) {
emit.next("\n❌ Did not receive the worker's WireGuard key\n");
emit.complete();
return;
}

emit.next(`\nRegistering WireGuard peer on the hub (${wgIp})\n`);
await execAsync(
`wg set wg0 peer ${pubkey} allowed-ips ${wgIp}/32 && wg-quick save wg0`,
);

cluster.peers.push({
wgIp,
publicKey: pubkey,
serverId: input.serverId,
name: server.name,
});
writeFileSync(CLUSTER_FILE, JSON.stringify(cluster, null, 2));
await updateServerById(input.serverId, {
nomadAddress: `http://${wgIp}:4646`,
});

emit.next("JOIN_DONE");
emit.complete();
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Cluster join failed";
emit.next(`\n❌ ${message}\n`);
emit.complete();
}
})();
});
}),
});
71 changes: 59 additions & 12 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -92,34 +92,67 @@ echo 1 | $SUDO tee /proc/sys/net/bridge/bridge-nf-call-iptables >/dev/null 2>&1
# ── Consul + Nomad config (single node: server + client) ─────────────────────
$SUDO mkdir -p /etc/consul.d /opt/consul /etc/nomad.d /opt/nomad

# Single-node all-in-one: bind everything to 127.0.0.1. This avoids the
# multiple-private-IP ambiguity ({{ GetPrivateIP }} can pick the docker bridge)
# and keeps Nomad/Consul internal — only Traefik is exposed publicly.
$SUDO tee /etc/consul.d/consul.hcl >/dev/null <<'CONSULHCL'
# ── WireGuard hub (cluster overlay 10.10.0.0/24) ─────────────────────────────
# The control plane is the WireGuard hub; Consul/Nomad servers bind to the hub's
# overlay IP so worker nodes can join over an encrypted mesh. Consul/Nomad HTTP
# APIs still answer on 127.0.0.1 for the local app. Endpoint workers dial defaults
# to this host's public IP (open UDP 51820); override with NOMPLOY_WG_ENDPOINT.
$SUDO mkdir -p /etc/nomploy
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get install -y wireguard wireguard-tools >/dev/null 2>&1 || true
else
$SUDO yum install -y wireguard-tools >/dev/null 2>&1 || true
fi
$SUDO mkdir -p /etc/wireguard && $SUDO chmod 700 /etc/wireguard
if [ ! -s /etc/wireguard/hub_priv ]; then
$SUDO sh -c 'wg genkey > /etc/wireguard/hub_priv && chmod 600 /etc/wireguard/hub_priv && wg pubkey < /etc/wireguard/hub_priv > /etc/wireguard/hub_pub'
fi
HUB_PUB="$($SUDO cat /etc/wireguard/hub_pub)"
if [ ! -f /etc/wireguard/wg0.conf ]; then
$SUDO tee /etc/wireguard/wg0.conf >/dev/null <<WGCONF
[Interface]
Address = 10.10.0.1/24
ListenPort = 51820
PrivateKey = $($SUDO cat /etc/wireguard/hub_priv)
PostUp = sysctl -w net.ipv4.ip_forward=1; iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT
WGCONF
$SUDO chmod 600 /etc/wireguard/wg0.conf
fi
ip link show wg0 >/dev/null 2>&1 || $SUDO wg-quick up wg0 || true
$SUDO systemctl enable wg-quick@wg0 >/dev/null 2>&1 || true

# Shared gossip encryption key (Consul + Nomad).
[ -s /etc/nomploy/gossip.key ] || consul keygen | $SUDO tee /etc/nomploy/gossip.key >/dev/null
GOSSIP="$($SUDO cat /etc/nomploy/gossip.key)"
WG_ENDPOINT="${NOMPLOY_WG_ENDPOINT:-$(ip route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}'):51820}"

$SUDO tee /etc/consul.d/consul.hcl >/dev/null <<CONSULHCL
data_dir = "/opt/consul"
bind_addr = "127.0.0.1"
client_addr = "127.0.0.1"
bind_addr = "10.10.0.1"
client_addr = "0.0.0.0"
datacenter = "dc1"
server = true
bootstrap_expect = 1
encrypt = "$GOSSIP"
ui_config { enabled = true }
CONSULHCL

$SUDO tee /etc/nomad.d/nomad.hcl >/dev/null <<'NOMADHCL'
$SUDO tee /etc/nomad.d/nomad.hcl >/dev/null <<NOMADHCL
data_dir = "/opt/nomad"
bind_addr = "127.0.0.1"
bind_addr = "0.0.0.0"
datacenter = "dc1"

# Explicit advertise: Nomad refuses to default a server's advertise to localhost.
advertise {
http = "127.0.0.1"
rpc = "127.0.0.1"
serf = "127.0.0.1"
http = "10.10.0.1"
rpc = "10.10.0.1"
serf = "10.10.0.1"
}

server {
enabled = true
bootstrap_expect = 1
encrypt = "$GOSSIP"
}

client {
Expand All @@ -140,6 +173,18 @@ plugin "docker" {
}
NOMADHCL

# Cluster descriptor read by the app to add worker nodes (nomad.joinCluster).
$SUDO tee /etc/nomploy/cluster.json >/dev/null <<CJSON
{
"hubPublicKey": "$HUB_PUB",
"gossipKey": "$GOSSIP",
"hubWgIp": "10.10.0.1",
"hubEndpoint": "$WG_ENDPOINT",
"overlayCidr": "10.10.0.0/24",
"peers": []
}
CJSON

# The Nomad docker plugin above sets auth.config = /root/.docker/config.json.
# If that file is missing, the docker driver fails to pull EVERY image (even
# public ones). Create an empty auth config so public pulls work; `docker login`
Expand Down Expand Up @@ -272,8 +317,10 @@ $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 \
--cap-add NET_ADMIN \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /etc/nomploy:/etc/nomploy \
-v /etc/wireguard:/etc/wireguard \
-e NODE_ENV=production \
-e PORT="$NOMPLOY_PORT" \
-e DATABASE_URL="postgresql://nomploy:${POSTGRES_PASSWORD}@127.0.0.1:5432/nomploy" \
Expand Down
Loading
Loading