diff --git a/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx b/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx
index f42210a..b59bad0 100644
--- a/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx
+++ b/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx
@@ -1,161 +1,10 @@
 import { SetBreadcrumbs } from "@/components/core/breadcrumb-data";
-import {
-	Card,
-	CardContent,
-	CardDescription,
-	CardHeader,
-	CardTitle,
-} from "@/components/ui/card";
 import { Skeleton } from "@/components/ui/skeleton";
 
-function ServerDetailsSkeleton() {
-	return (
-		<Card>
-			<CardHeader>
-				<CardTitle>Server Details</CardTitle>
-			</CardHeader>
-			<CardContent>
-				<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
-					{Array.from({ length: 9 }).map((_, index) => (
-						<div key={index} className="space-y-2">
-							<Skeleton className="h-3 w-24" />
-							<Skeleton className="h-4 w-32 max-w-full" />
-						</div>
-					))}
-				</div>
-			</CardContent>
-		</Card>
-	);
-}
-
-function HealthMetricSkeleton() {
-	return (
-		<div className="space-y-3">
-			<div className="flex items-center gap-2">
-				<Skeleton className="size-4 rounded" />
-				<Skeleton className="h-4 w-16" />
-			</div>
-			<Skeleton className="h-2 w-full rounded-full" />
-			<Skeleton className="h-3 w-10" />
-		</div>
-	);
-}
-
-function HealthStatusSkeleton() {
-	return (
-		<div className="flex items-start gap-3">
-			<Skeleton className="size-8 rounded-md" />
-			<div className="space-y-2">
-				<Skeleton className="h-4 w-24" />
-				<Skeleton className="h-3 w-16" />
-			</div>
-		</div>
-	);
-}
-
-function SystemHealthSkeleton() {
-	return (
-		<Card>
-			<CardHeader>
-				<CardTitle>System Health</CardTitle>
-			</CardHeader>
-			<CardContent className="space-y-4">
-				<div className="grid gap-4 sm:grid-cols-3">
-					<HealthMetricSkeleton />
-					<HealthMetricSkeleton />
-					<HealthMetricSkeleton />
-				</div>
-				<div className="border-t pt-4">
-					<div className="grid gap-4 sm:grid-cols-3">
-						<HealthStatusSkeleton />
-						<HealthStatusSkeleton />
-						<HealthStatusSkeleton />
-					</div>
-				</div>
-			</CardContent>
-		</Card>
-	);
-}
-
-function RunningServicesSkeleton() {
-	return (
-		<Card>
-			<CardHeader>
-				<CardTitle className="flex items-center gap-2">
-					<Skeleton className="size-5 rounded" />
-					<Skeleton className="h-5 w-36" />
-				</CardTitle>
-				<CardDescription>
-					<Skeleton className="h-4 w-48" />
-				</CardDescription>
-			</CardHeader>
-			<CardContent>
-				<div className="grid gap-3 md:grid-cols-2">
-					{Array.from({ length: 4 }).map((_, index) => (
-						<div
-							key={index}
-							className="flex items-center gap-2.5 rounded-lg border px-3 py-2.5"
-						>
-							<Skeleton className="size-8 shrink-0 rounded-md" />
-							<div className="min-w-0 flex-1 space-y-2">
-								<Skeleton className="h-4 w-32" />
-								<Skeleton className="h-3 w-44 max-w-full" />
-							</div>
-						</div>
-					))}
-				</div>
-			</CardContent>
-		</Card>
-	);
-}
-
-function AgentLogsSkeleton() {
-	return (
-		<div className="space-y-2">
-			<h3 className="text-sm font-medium">Agent Logs</h3>
-			<div className="flex h-[420px] flex-col overflow-hidden rounded-lg border bg-card">
-				<div className="flex items-center gap-2 border-b p-3">
-					<Skeleton className="h-9 w-64 max-w-full rounded-md" />
-					<Skeleton className="ml-auto size-8 rounded-md" />
-				</div>
-				<div className="space-y-2 p-4">
-					{Array.from({ length: 12 }).map((_, index) => (
-						<Skeleton
-							key={index}
-							className="h-3"
-							style={{ width: `${92 - (index % 5) * 9}%` }}
-						/>
-					))}
-				</div>
-			</div>
-		</div>
-	);
-}
-
-function DangerZoneSkeleton() {
-	return (
-		<Card className="border-destructive/30">
-			<CardHeader>
-				<CardTitle>
-					<Skeleton className="h-5 w-28" />
-				</CardTitle>
-				<CardDescription>
-					<Skeleton className="h-4 w-72 max-w-full" />
-				</CardDescription>
-			</CardHeader>
-			<CardContent>
-				<Skeleton className="h-9 w-32 rounded-md" />
-			</CardContent>
-		</Card>
-	);
-}
-
 export default function Loading() {
 	return (
 		<>
-			<SetBreadcrumbs
-				items={[{ label: "Dashboard", href: "/dashboard" }]}
-			/>
+			<SetBreadcrumbs items={[{ label: "Dashboard", href: "/dashboard" }]} />
 			<div
 				aria-hidden="true"
 				className="container max-w-7xl mx-auto px-4 py-6 space-y-6"
@@ -166,11 +15,10 @@ export default function Loading() {
 					<Skeleton className="h-5 w-16 rounded-md" />
 				</div>
 
-				<ServerDetailsSkeleton />
-				<SystemHealthSkeleton />
-				<RunningServicesSkeleton />
-				<AgentLogsSkeleton />
-				<DangerZoneSkeleton />
+				<Skeleton className="h-32 rounded-xl" />
+				<Skeleton className="h-40 rounded-xl" />
+				<Skeleton className="h-36 rounded-xl" />
+				<Skeleton className="h-[420px] rounded-lg" />
 			</div>
 			<div aria-live="polite" className="sr-only">
 				Loading server details
diff --git a/web/app/api/projects/[id]/services/[serviceId]/position/route.ts b/web/app/api/projects/[id]/services/[serviceId]/position/route.ts
new file mode 100644
index 0000000..771441d
--- /dev/null
+++ b/web/app/api/projects/[id]/services/[serviceId]/position/route.ts
@@ -0,0 +1,53 @@
+import { and, eq, isNull } from "drizzle-orm";
+import { headers } from "next/headers";
+import { z } from "zod";
+import { db } from "@/db";
+import { services } from "@/db/schema";
+import { auth } from "@/lib/auth";
+
+const positionSchema = z.object({
+	canvasX: z.number().int().min(0).max(10000),
+	canvasY: z.number().int().min(0).max(10000),
+});
+
+export async function PATCH(
+	request: Request,
+	{ params }: { params: Promise<{ id: string; serviceId: string }> },
+) {
+	const session = await auth.api.getSession({
+		headers: await headers(),
+	});
+
+	if (!session) {
+		return Response.json({ error: "Unauthorized" }, { status: 401 });
+	}
+
+	const { id: projectId, serviceId } = await params;
+	const parsed = positionSchema.safeParse(await request.json());
+
+	if (!parsed.success) {
+		return Response.json({ error: "Invalid position" }, { status: 400 });
+	}
+
+	const [service] = await db
+		.update(services)
+		.set(parsed.data)
+		.where(
+			and(
+				eq(services.id, serviceId),
+				eq(services.projectId, projectId),
+				isNull(services.deletedAt),
+			),
+		)
+		.returning({
+			id: services.id,
+			canvasX: services.canvasX,
+			canvasY: services.canvasY,
+		});
+
+	if (!service) {
+		return Response.json({ error: "Service not found" }, { status: 404 });
+	}
+
+	return Response.json(service);
+}
diff --git a/web/components/dashboard/dashboard-page-skeleton.tsx b/web/components/dashboard/dashboard-page-skeleton.tsx
index 9d4a521..1b43e4b 100644
--- a/web/components/dashboard/dashboard-page-skeleton.tsx
+++ b/web/components/dashboard/dashboard-page-skeleton.tsx
@@ -1,92 +1,21 @@
 import { Skeleton } from "@/components/ui/skeleton";
 
-function DashboardHeaderSkeleton() {
-	return (
-		<header className="border-b">
-			<div className="container max-w-full mx-auto px-4 h-14 flex items-center justify-between">
-				<div className="flex items-center gap-3">
-					<Skeleton className="size-6 rounded" />
-					<Skeleton className="h-4 w-28" />
-				</div>
-				<Skeleton className="size-8 rounded-md" />
-			</div>
-		</header>
-	);
-}
-
-function ProjectCardSkeleton() {
-	return (
-		<div className="min-h-[80px] rounded-lg border p-4">
-			<div className="flex h-full gap-3">
-				<Skeleton className="size-10 shrink-0 rounded-md" />
-				<div className="flex min-w-0 flex-1 flex-col justify-between">
-					<Skeleton className="h-4 w-2/3" />
-					<Skeleton className="h-3 w-20" />
-				</div>
-			</div>
-		</div>
-	);
-}
-
-function ServerRowSkeleton() {
-	return (
-		<div className="rounded-lg border p-4">
-			<div className="flex items-center justify-between gap-4">
-				<div className="flex min-w-0 flex-1 items-center gap-3">
-					<Skeleton className="size-10 shrink-0 rounded-md" />
-					<div className="min-w-0 flex-1 space-y-2">
-						<Skeleton className="h-4 w-40" />
-						<Skeleton className="h-3 w-56 max-w-full" />
-					</div>
-				</div>
-				<Skeleton className="hidden h-8 w-24 rounded-md sm:block" />
-			</div>
-		</div>
-	);
-}
-
 export function DashboardPageSkeleton() {
 	return (
 		<div className="min-h-screen bg-background">
-			<DashboardHeaderSkeleton />
+			<header className="border-b">
+				<div className="container max-w-full mx-auto px-4 h-14 flex items-center justify-between">
+					<Skeleton className="h-4 w-36" />
+					<Skeleton className="size-8 rounded-md" />
+				</div>
+			</header>
 			<main
 				aria-hidden="true"
-				className="container max-w-7xl mx-auto px-4 py-6 space-y-12"
+				className="container max-w-7xl mx-auto px-4 py-6 space-y-8"
 			>
-				<section className="grid gap-4 md:grid-cols-3">
-					<Skeleton className="h-24 rounded-lg" />
-					<Skeleton className="h-24 rounded-lg" />
-					<Skeleton className="h-24 rounded-lg" />
-				</section>
-
-				<section className="space-y-6">
-					<div className="flex items-center justify-between gap-4">
-						<div className="space-y-2">
-							<Skeleton className="h-5 w-24" />
-							<Skeleton className="h-4 w-44" />
-						</div>
-						<Skeleton className="h-9 w-28 rounded-md" />
-					</div>
-					<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
-						<ProjectCardSkeleton />
-						<ProjectCardSkeleton />
-						<ProjectCardSkeleton />
-					</div>
-				</section>
-
-				<section className="space-y-4">
-					<div className="flex items-center justify-between gap-4">
-						<div className="space-y-2">
-							<Skeleton className="h-5 w-20" />
-							<Skeleton className="h-4 w-36" />
-						</div>
-						<Skeleton className="h-9 w-28 rounded-md" />
-					</div>
-					<div className="space-y-3">
-						<ServerRowSkeleton />
-						<ServerRowSkeleton />
-					</div>
-				</section>
+				<Skeleton className="h-24 rounded-lg" />
+				<Skeleton className="h-48 rounded-xl" />
+				<Skeleton className="h-36 rounded-xl" />
 			</main>
 			<div aria-live="polite" className="sr-only">
 				Loading dashboard
diff --git a/web/components/service/service-canvas.tsx b/web/components/service/service-canvas.tsx
index 429b906..bb8fffd 100644
--- a/web/components/service/service-canvas.tsx
+++ b/web/components/service/service-canvas.tsx
@@ -14,7 +14,8 @@ import {
 } from "lucide-react";
 import Link from "next/link";
 import { useRouter } from "next/navigation";
-import { useMemo, useState } from "react";
+import type { AnchorHTMLAttributes, MouseEvent, PointerEvent } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 import useSWR from "swr";
 import { buttonVariants } from "@/components/ui/button";
 import { getStatusColorFromDeployments } from "@/components/ui/canvas-wrapper";
@@ -48,10 +49,93 @@ import {
 	CreateGitHubServiceDialog,
 } from "./create-service-dialog";
 
+type CanvasPosition = {
+	canvasX: number;
+	canvasY: number;
+};
+
+const SERVICE_CARD_WIDTH = 320;
+const SERVICE_CARD_HEIGHT = 150;
+const SERVICE_CARD_GAP_X = 56;
+const SERVICE_CARD_GAP_Y = 48;
+const DEFAULT_GRID_COLUMNS = 3;
+const DEFAULT_GRID_ROWS = 3;
+const CANVAS_WIDTH = 1320;
+const CANVAS_HEIGHT = 900;
+const MIN_CANVAS_SCALE = 0.5;
+const SNAP_GRID_SIZE = 24;
+
+function getCanvasScale() {
+	if (typeof window === "undefined") {
+		return 1;
+	}
+
+	const availableWidth = window.innerWidth - 96;
+	const availableHeight = window.innerHeight - 112;
+
+	return Math.max(
+		MIN_CANVAS_SCALE,
+		Math.min(1, availableWidth / CANVAS_WIDTH, availableHeight / CANVAS_HEIGHT),
+	);
+}
+
+function getDefaultServicePosition(index: number): CanvasPosition {
+	const gridWidth =
+		DEFAULT_GRID_COLUMNS * SERVICE_CARD_WIDTH +
+		(DEFAULT_GRID_COLUMNS - 1) * SERVICE_CARD_GAP_X;
+	const gridHeight =
+		DEFAULT_GRID_ROWS * SERVICE_CARD_HEIGHT +
+		(DEFAULT_GRID_ROWS - 1) * SERVICE_CARD_GAP_Y;
+	const gridStartX = (CANVAS_WIDTH - gridWidth) / 2;
+	const gridStartY = (CANVAS_HEIGHT - gridHeight) / 2;
+	const column = index % DEFAULT_GRID_COLUMNS;
+	const row = Math.floor(index / DEFAULT_GRID_COLUMNS);
+
+	return {
+		canvasX: gridStartX + column * (SERVICE_CARD_WIDTH + SERVICE_CARD_GAP_X),
+		canvasY: gridStartY + row * (SERVICE_CARD_HEIGHT + SERVICE_CARD_GAP_Y),
+	};
+}
+
+function clampPosition(position: CanvasPosition): CanvasPosition {
+	return {
+		canvasX: Math.max(
+			0,
+			Math.min(CANVAS_WIDTH - SERVICE_CARD_WIDTH, Math.round(position.canvasX)),
+		),
+		canvasY: Math.max(
+			0,
+			Math.min(
+				CANVAS_HEIGHT - SERVICE_CARD_HEIGHT,
+				Math.round(position.canvasY),
+			),
+		),
+	};
+}
+
+function snapPosition(position: CanvasPosition): CanvasPosition {
+	return clampPosition({
+		canvasX: Math.round(position.canvasX / SNAP_GRID_SIZE) * SNAP_GRID_SIZE,
+		canvasY: Math.round(position.canvasY / SNAP_GRID_SIZE) * SNAP_GRID_SIZE,
+	});
+}
+
+function getServicePosition(
+	service: ServiceWithDetails,
+	index: number,
+): CanvasPosition {
+	const fallback = getDefaultServicePosition(index);
+
+	return {
+		canvasX: service.canvasX ?? fallback.canvasX,
+		canvasY: service.canvasY ?? fallback.canvasY,
+	};
+}
+
 function ServiceCardSkeleton() {
 	return (
-		<div className="flex flex-col items-stretch gap-2 w-full md:w-70">
-			<div className="flex min-h-30 w-full flex-col p-3 rounded-xl border-2 border-slate-200 dark:border-slate-700 bg-slate-100/50 dark:bg-slate-800/50">
+		<div className="flex flex-col items-stretch gap-2 w-full md:w-80">
+			<div className="flex min-h-36 w-full flex-col p-4 rounded-xl border-2 border-slate-200 dark:border-slate-700 bg-slate-100/50 dark:bg-slate-800/50">
 				<div className="flex items-center gap-2">
 					<div className="flex-1 min-w-0">
 						<div className="flex items-center gap-1.5">
@@ -202,13 +286,17 @@ function ServiceCard({
 	projectSlug,
 	envName,
 	proxyDomain,
+	dragHandleProps,
 }: {
 	service: ServiceWithDetails;
 	projectSlug: string;
 	envName: string;
 	proxyDomain: string | null;
+	dragHandleProps?: AnchorHTMLAttributes<HTMLAnchorElement>;
 }) {
 	const colors = getStatusColorFromDeployments(service.deployments);
+	const { className: dragHandleClassName, ...linkProps } =
+		dragHandleProps ?? {};
 	const publicPorts = service.ports.filter((p) => p.isPublic && p.domain);
 	const tcpUdpPorts = service.ports.filter(
 		(p) =>
@@ -229,99 +317,249 @@ function ServiceCard({
 		hasInternalDns;
 
 	return (
-		<div className="flex flex-col items-stretch gap-2 w-full md:w-70">
+		<div className="flex flex-col items-stretch gap-2 w-full md:w-80">
 			<Link
+				{...linkProps}
 				href={`/dashboard/projects/${projectSlug}/${envName}/services/${service.id}`}
-				className={`
-          group relative flex min-h-30 w-full flex-col
-          p-3 rounded-xl border-2 ${colors.border} ${colors.bg}
-          hover:shadow-lg transition-shadow duration-200
-          cursor-pointer
-        `}
+				className={cn(
+					"group block w-full cursor-pointer rounded-xl transition-all duration-200 hover:shadow-lg hover:ring hover:ring-primary/25 dark:hover:ring-primary/55",
+					dragHandleClassName,
+				)}
 			>
-				<div className="flex items-center gap-2">
-					<div className="flex-1 min-w-0">
-						<div className="flex items-center gap-1.5">
-							<h3 className="font-semibold text-sm text-foreground truncate">
+				<div className="relative z-10 flex min-h-30 w-full flex-col rounded-xl border border-slate-200 dark:border-slate-700 bg-white/50 dark:bg-slate-900/50 backdrop-blur-sm px-2.5 py-2">
+					<div className="py-1">
+						<div className="flex items-center justify-between gap-2">
+							<h3 className="font-semibold text-[15px] text-foreground truncate">
 								{service.name}
 							</h3>
-							<span className={`relative flex h-2 w-2`}>
-								<span
-									className={`animate-ping absolute inline-flex h-full w-full rounded-full ${colors.dot} opacity-75`}
-								/>
-								<span
-									className={`relative inline-flex rounded-full h-2 w-2 ${colors.dot}`}
-								/>
-							</span>
+							<div className="flex items-center gap-1.5 shrink-0">
+								<span className="relative flex h-2.5 w-2.5">
+									{runningCount > 0 && (
+										<span
+											className={`animate-ping absolute inline-flex h-full w-full rounded-full ${colors.dot} opacity-75`}
+										/>
+									)}
+									<span
+										className={`relative inline-flex rounded-full h-2.5 w-2.5 ${colors.dot}`}
+									/>
+								</span>
+								<span className={`text-[13px] font-medium ${colors.text}`}>
+									{service.deployments.length > 0
+										? `${runningCount}/${service.deployments.length}`
+										: "Not deployed"}
+								</span>
+							</div>
 						</div>
 					</div>
-				</div>
 
-				{hasEndpoints && (
-					<div className="mt-2 space-y-1.5">
-						{publicPorts.map((port) => (
-							<div key={port.id} className="flex items-center gap-1.5 text-xs">
-								<Globe className="h-3 w-3 text-sky-500" />
-								<span className="text-sky-600 dark:text-sky-400 truncate">
-									{port.domain}
-								</span>
-							</div>
-						))}
-						{tcpUdpPorts.length > 0 &&
-							proxyDomain &&
-							tcpUdpPorts.map((port) => (
+					{hasEndpoints && (
+						<div className="mt-2 space-y-1.5">
+							{publicPorts.map((port) => (
 								<div
 									key={port.id}
-									className="flex items-center gap-1.5 text-xs"
+									className="border-l-2 border-sky-500 pl-2.5 py-0.5"
 								>
-									<Network className="h-3 w-3 text-violet-500" />
-									<span className="text-violet-600 dark:text-violet-400 font-mono truncate">
-										{port.protocol}://{proxyDomain}:{port.externalPort}
-									</span>
+									<div className="flex items-center gap-2 text-[13px] text-foreground">
+										<Globe className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+										<span className="truncate">{port.domain}</span>
+									</div>
 								</div>
 							))}
-						{hasInternalDns && (
-							<div className="flex items-center gap-1.5 text-xs">
-								<Lock className="h-3 w-3 text-slate-500" />
-								<span className="text-slate-600 dark:text-slate-400 truncate">
-									{service.hostname || service.name}.internal
-								</span>
-							</div>
-						)}
-					</div>
-				)}
+							{tcpUdpPorts.length > 0 &&
+								proxyDomain &&
+								tcpUdpPorts.map((port) => (
+									<div
+										key={port.id}
+										className="border-l-2 border-violet-500 pl-2.5 py-0.5"
+									>
+										<div className="flex items-center gap-2 text-[13px] text-foreground font-mono">
+											<Network className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+											<span className="truncate">
+												{port.protocol}://{proxyDomain}:{port.externalPort}
+											</span>
+										</div>
+									</div>
+								))}
+							{hasInternalDns && (
+								<div className="border-l-2 border-slate-300 dark:border-slate-600 pl-2.5 py-0.5">
+									<div className="flex items-center gap-2 text-[13px] text-foreground">
+										<Lock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+										<span className="truncate">
+											{service.hostname || service.name}.internal
+										</span>
+									</div>
+								</div>
+							)}
+						</div>
+					)}
+				</div>
 
 				{service.volumes && service.volumes.length > 0 && (
-					<div className="mt-2 space-y-1">
+					<div className="-mt-2 rounded-b-xl border border-slate-200 dark:border-slate-700 bg-white/50 dark:bg-slate-900/50 backdrop-blur-sm px-3 pt-4 pb-2.5 space-y-1.5">
 						{service.volumes.map((volume) => (
 							<div
 								key={volume.id}
-								className="flex items-center gap-2 text-xs text-muted-foreground"
+								className="flex items-center gap-2.5 text-muted-foreground"
 							>
-								<HardDrive className="h-3.5 w-3.5" />
-								<span>{volume.name}</span>
+								<HardDrive className="h-4 w-4 shrink-0" />
+								<span className="text-sm font-medium truncate">
+									{volume.name}
+								</span>
 							</div>
 						))}
 					</div>
 				)}
+			</Link>
+		</div>
+	);
+}
 
-				{service.deployments.length > 0 && (
-					<div className="mt-auto pt-2">
-						<div className="flex items-center justify-between">
-							<span className="text-xs text-muted-foreground">Replicas</span>
-							<span className={`text-sm font-medium ${colors.text}`}>
-								{runningCount}/{service.deployments.length}
-							</span>
-						</div>
-					</div>
-				)}
+function DraggableServiceCard({
+	service,
+	index,
+	projectSlug,
+	envName,
+	proxyDomain,
+	canvasScale,
+	onPositionChange,
+}: {
+	service: ServiceWithDetails;
+	index: number;
+	projectSlug: string;
+	envName: string;
+	proxyDomain: string | null;
+	canvasScale: number;
+	onPositionChange: (serviceId: string, position: CanvasPosition) => void;
+}) {
+	const [dragPosition, setDragPosition] = useState<CanvasPosition | null>(null);
+	const dragRef = useRef<{
+		pointerId: number;
+		startX: number;
+		startY: number;
+		origin: CanvasPosition;
+		moved: boolean;
+	} | null>(null);
+	const suppressClickRef = useRef(false);
+	const position = dragPosition ?? getServicePosition(service, index);
 
-				{service.deployments.length === 0 && (
-					<div className="mt-auto pt-2">
-						<span className="text-xs text-muted-foreground">Not deployed</span>
-					</div>
-				)}
-			</Link>
+	const handlePointerDown = useCallback(
+		(event: PointerEvent<HTMLAnchorElement>) => {
+			if (event.button !== 0) {
+				return;
+			}
+
+			event.currentTarget.setPointerCapture(event.pointerId);
+			dragRef.current = {
+				pointerId: event.pointerId,
+				startX: event.clientX,
+				startY: event.clientY,
+				origin: position,
+				moved: false,
+			};
+		},
+		[position],
+	);
+
+	const handlePointerMove = useCallback(
+		(event: PointerEvent<HTMLAnchorElement>) => {
+			const drag = dragRef.current;
+			if (!drag || drag.pointerId !== event.pointerId) {
+				return;
+			}
+
+			const deltaX = (event.clientX - drag.startX) / canvasScale;
+			const deltaY = (event.clientY - drag.startY) / canvasScale;
+			const nextPosition = clampPosition({
+				canvasX: drag.origin.canvasX + deltaX,
+				canvasY: drag.origin.canvasY + deltaY,
+			});
+
+			if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) {
+				drag.moved = true;
+				event.preventDefault();
+			}
+
+			setDragPosition(nextPosition);
+		},
+		[canvasScale],
+	);
+
+	const handlePointerUp = useCallback(
+		(event: PointerEvent<HTMLAnchorElement>) => {
+			const drag = dragRef.current;
+			if (!drag || drag.pointerId !== event.pointerId) {
+				return;
+			}
+
+			if (event.currentTarget.hasPointerCapture(event.pointerId)) {
+				event.currentTarget.releasePointerCapture(event.pointerId);
+			}
+			dragRef.current = null;
+			setDragPosition(null);
+
+			if (drag.moved) {
+				suppressClickRef.current = true;
+				onPositionChange(service.id, snapPosition(position));
+			}
+		},
+		[onPositionChange, position, service.id],
+	);
+
+	const handlePointerCancel = useCallback(
+		(event: PointerEvent<HTMLAnchorElement>) => {
+			const drag = dragRef.current;
+			if (!drag || drag.pointerId !== event.pointerId) {
+				return;
+			}
+
+			if (event.currentTarget.hasPointerCapture(event.pointerId)) {
+				event.currentTarget.releasePointerCapture(event.pointerId);
+			}
+
+			dragRef.current = null;
+			setDragPosition(null);
+			suppressClickRef.current = false;
+		},
+		[],
+	);
+
+	const handleClickCapture = useCallback(
+		(event: MouseEvent<HTMLAnchorElement>) => {
+			if (!suppressClickRef.current) {
+				return;
+			}
+
+			suppressClickRef.current = false;
+			event.preventDefault();
+			event.stopPropagation();
+		},
+		[],
+	);
+
+	return (
+		<div
+			className="absolute"
+			style={{
+				width: SERVICE_CARD_WIDTH,
+				transform: `translate(${position.canvasX}px, ${position.canvasY}px)`,
+			}}
+		>
+			<ServiceCard
+				service={service}
+				projectSlug={projectSlug}
+				envName={envName}
+				proxyDomain={proxyDomain}
+				dragHandleProps={{
+					className:
+						"touch-none cursor-grab select-none active:cursor-grabbing",
+					onPointerDown: handlePointerDown,
+					onPointerMove: handlePointerMove,
+					onPointerUp: handlePointerUp,
+					onPointerCancel: handlePointerCancel,
+					onClickCapture: handleClickCapture,
+					onDragStart: (event) => event.preventDefault(),
+				}}
+			/>
 		</div>
 	);
 }
@@ -346,6 +584,7 @@ export function ServiceCanvas({
 
 	const [dockerDialogOpen, setDockerDialogOpen] = useState(false);
 	const [githubDialogOpen, setGithubDialogOpen] = useState(false);
+	const [canvasScale, setCanvasScale] = useState(1);
 
 	const {
 		data: services,
@@ -360,6 +599,15 @@ export function ServiceCanvas({
 		},
 	);
 
+	useEffect(() => {
+		const updateCanvasScale = () => setCanvasScale(getCanvasScale());
+
+		updateCanvasScale();
+		window.addEventListener("resize", updateCanvasScale);
+
+		return () => window.removeEventListener("resize", updateCanvasScale);
+	}, []);
+
 	const composeHref = `/dashboard/projects/${projectSlug}/${envName}/import-compose`;
 
 	const menuCallbacks = useMemo(
@@ -390,6 +638,57 @@ export function ServiceCanvas({
 		[projectId, envId, projectSlug, envName, mutate],
 	);
 
+	const handlePositionChange = useCallback(
+		(serviceId: string, position: CanvasPosition) => {
+			const nextPosition = clampPosition(position);
+
+			void mutate(
+				(current) =>
+					current?.map((service) =>
+						service.id === serviceId
+							? {
+									...service,
+									...nextPosition,
+								}
+							: service,
+					),
+				false,
+			);
+
+			void fetch(`/api/projects/${projectId}/services/${serviceId}/position`, {
+				method: "PATCH",
+				headers: { "Content-Type": "application/json" },
+				body: JSON.stringify(nextPosition),
+			})
+				.then(async (response) => {
+					if (!response.ok) {
+						void mutate();
+						return;
+					}
+
+					const savedPosition = (await response.json()) as CanvasPosition;
+
+					void mutate(
+						(current) =>
+							current?.map((service) =>
+								service.id === serviceId
+									? {
+											...service,
+											canvasX: savedPosition.canvasX,
+											canvasY: savedPosition.canvasY,
+										}
+									: service,
+							),
+						false,
+					);
+				})
+				.catch(() => {
+					void mutate();
+				});
+		},
+		[mutate, projectId],
+	);
+
 	if (!environments || isLoading) {
 		return (
 			<>
@@ -535,11 +834,11 @@ export function ServiceCanvas({
 			<ContextMenu>
 				<ContextMenuTrigger
 					className="
-						hidden md:flex
-						relative -mt-6 -mb-6 p-10
+						hidden md:block
+						relative -mt-6 -mb-6
 						left-1/2 right-1/2 -ml-[50vw] -mr-[50vw] w-screen
 						bg-slate-50/50 dark:bg-slate-900/30
-						items-center justify-center overflow-auto
+						overflow-auto
 					"
 					style={{
 						height: "calc(100vh - 3.5rem)",
@@ -556,16 +855,35 @@ export function ServiceCanvas({
 					<div className="absolute top-4 right-4">
 						<AddServiceMenu {...menuCallbacks} />
 					</div>
-					<div className="flex flex-wrap gap-10 justify-center items-stretch">
-						{services.map((service) => (
-							<ServiceCard
-								key={service.id}
-								service={service}
-								projectSlug={projectSlug}
-								envName={envName}
-								proxyDomain={proxyDomain}
-							/>
-						))}
+					<div
+						className="relative mx-auto"
+						style={{
+							width: CANVAS_WIDTH * canvasScale,
+							height: CANVAS_HEIGHT * canvasScale,
+						}}
+					>
+						<div
+							className="relative"
+							style={{
+								width: CANVAS_WIDTH,
+								height: CANVAS_HEIGHT,
+								transform: `scale(${canvasScale})`,
+								transformOrigin: "top left",
+							}}
+						>
+							{services.map((service, index) => (
+								<DraggableServiceCard
+									key={service.id}
+									service={service}
+									index={index}
+									projectSlug={projectSlug}
+									envName={envName}
+									proxyDomain={proxyDomain}
+									canvasScale={canvasScale}
+									onPositionChange={handlePositionChange}
+								/>
+							))}
+						</div>
 					</div>
 				</ContextMenuTrigger>
 				<CanvasContextMenuContent
diff --git a/web/db/schema.ts b/web/db/schema.ts
index deed8b9..bd4126e 100644
--- a/web/db/schema.ts
+++ b/web/db/schema.ts
@@ -310,6 +310,8 @@ export const services = pgTable("services", {
 		enum: ["backing_up", "deleting", "restoring", "failed"],
 	}),
 	deletionError: text("deletion_error"),
+	canvasX: integer("canvas_x"),
+	canvasY: integer("canvas_y"),
 	migrationStatus: text("migration_status", {
 		enum: [
 			"stopping",
diff --git a/web/lib/agent-status.ts b/web/lib/agent-status.ts
index 4a8098d..c21ca33 100644
--- a/web/lib/agent-status.ts
+++ b/web/lib/agent-status.ts
@@ -34,6 +34,16 @@ function isMigrationTargetStarting(status: string | null | undefined) {
 	return status === "deploying_target" || status === "starting";
 }
 
+async function getServerLogName(serverId: string) {
+	const server = await db
+		.select({ name: servers.name })
+		.from(servers)
+		.where(eq(servers.id, serverId))
+		.then((r) => r[0]);
+
+	return server?.name || serverId;
+}
+
 async function completeTargetMigration(serviceId: string) {
 	await db
 		.update(services)
@@ -110,6 +120,12 @@ export async function applyStatusReport(
 
 	await db.update(servers).set(updateData).where(eq(servers.id, serverId));
 
+	let serverLogName: string | undefined;
+	const getCurrentServerLogName = async () => {
+		serverLogName ??= await getServerLogName(serverId);
+		return serverLogName;
+	};
+
 	const reportedDeploymentIds = report.containers
 		.map((c) => c.deploymentId)
 		.filter((id) => id !== "");
@@ -275,11 +291,12 @@ export async function applyStatusReport(
 			);
 
 			if (deployment.rolloutId) {
+				const currentServerName = await getCurrentServerLogName();
 				await ingestRolloutLog(
 					deployment.rolloutId,
 					deployment.serviceId,
 					"deploying",
-					`Deployment ${deployment.id} starting on server ${serverId}`,
+					`Starting container on server ${currentServerName}`,
 				);
 			}
 
@@ -392,11 +409,12 @@ export async function applyStatusReport(
 		}
 
 		if (autohealFailed && deployment.rolloutId) {
+			const currentServerName = await getCurrentServerLogName();
 			await ingestRolloutLog(
 				deployment.rolloutId,
 				deployment.serviceId,
 				"autoheal",
-				`Deployment ${deployment.id} exceeded autoheal restart limit`,
+				`Container exceeded autoheal restart limit on server ${currentServerName}`,
 			);
 			await inngest.send(
 				inngestEvents.resourceStatusChanged.create({
@@ -427,11 +445,12 @@ export async function applyStatusReport(
 				.where(eq(deployments.id, deployment.id));
 
 			if (deployment.rolloutId) {
+				const currentServerName = await getCurrentServerLogName();
 				await ingestRolloutLog(
 					deployment.rolloutId,
 					deployment.serviceId,
 					"health_check",
-					`Deployment ${deployment.id} is healthy`,
+					`Container is healthy on server ${currentServerName}`,
 				);
 				await inngest.send(
 					inngestEvents.resourceStatusChanged.create({
@@ -480,11 +499,12 @@ export async function applyStatusReport(
 				.where(eq(deployments.id, deployment.id));
 
 			if (isRolloutDeployment && deployment.rolloutId) {
+				const currentServerName = await getCurrentServerLogName();
 				await ingestRolloutLog(
 					deployment.rolloutId,
 					deployment.serviceId,
 					"health_check",
-					`Deployment ${deployment.id} failed health check`,
+					`Container failed health check on server ${currentServerName}`,
 				);
 				await inngest.send(
 					inngestEvents.resourceStatusChanged.create({
@@ -523,11 +543,12 @@ export async function applyStatusReport(
 			);
 
 		for (const rollout of rolloutsInDnsSync) {
+			const currentServerName = await getCurrentServerLogName();
 			await ingestRolloutLog(
 				rollout.id,
 				"",
 				"dns_sync",
-				`DNS synced on server ${serverId}`,
+				`DNS synced on server ${currentServerName}`,
 			);
 			await inngest.send(
 				inngestEvents.serverDnsSynced.create({
diff --git a/web/lib/build-assignment.ts b/web/lib/build-assignment.ts
index d31d7e1..afbfe02 100644
--- a/web/lib/build-assignment.ts
+++ b/web/lib/build-assignment.ts
@@ -1,9 +1,51 @@
-import { and, eq, inArray } from "drizzle-orm";
+import { and, eq, gt, inArray } from "drizzle-orm";
 import { db } from "@/db";
 import { getSetting } from "@/db/queries";
 import { servers, serviceReplicas, services } from "@/db/schema";
 import { SETTING_KEYS } from "@/lib/settings-keys";
 
+type BuildTargetServer = {
+	id: string;
+	status: string;
+	meta: { arch: string };
+};
+
+async function getStatefulBuildTargetServer(
+	serviceId: string,
+): Promise<BuildTargetServer> {
+	const targetServers = await db
+		.select({ id: servers.id, status: servers.status, meta: servers.meta })
+		.from(serviceReplicas)
+		.innerJoin(servers, eq(serviceReplicas.serverId, servers.id))
+		.where(
+			and(
+				eq(serviceReplicas.serviceId, serviceId),
+				gt(serviceReplicas.count, 0),
+			),
+		);
+
+	if (targetServers.length !== 1) {
+		throw new Error(
+			"Stateful services must have exactly one active replica server",
+		);
+	}
+
+	const [targetServer] = targetServers;
+
+	if (targetServer.status !== "online") {
+		throw new Error("Stateful service target server is offline");
+	}
+
+	if (!targetServer.meta?.arch) {
+		throw new Error("Stateful service target server architecture is unknown");
+	}
+
+	return {
+		...targetServer,
+		meta: { arch: targetServer.meta.arch },
+	};
+}
+
 export async function selectBuildServerForPlatform(
 	serviceId: string,
 	platform: string,
@@ -18,27 +60,19 @@ export async function selectBuildServerForPlatform(
 		throw new Error("Service not found");
 	}
 
-	if (service.stateful && service.lockedServerId) {
-		const server = await db
-			.select()
-			.from(servers)
-			.where(
-				and(
-					eq(servers.id, service.lockedServerId),
-					eq(servers.status, "online"),
-				),
-			)
-			.then((r) => r[0]);
+	const arch = platform.split("/")[1];
 
-		if (!server) {
-			throw new Error("Locked server is offline");
+	if (service.stateful) {
+		const targetServer = await getStatefulBuildTargetServer(serviceId);
+		if (targetServer.meta.arch !== arch) {
+			throw new Error(
+				`Stateful service target server architecture ${targetServer.meta.arch} does not match platform ${platform}`,
+			);
 		}
 
-		return server.id;
+		return targetServer.id;
 	}
 
-	const arch = platform.split("/")[1];
-
 	const allowedBuildServerIds = await getSetting<string[]>(
 		SETTING_KEYS.SERVERS_ALLOWED_FOR_BUILDS,
 	);
@@ -84,13 +118,23 @@ export async function getTargetPlatformsForService(
 		throw new Error("Service not found");
 	}
 
+	if (service.stateful) {
+		const targetServer = await getStatefulBuildTargetServer(serviceId);
+		return [`linux/${targetServer.meta.arch}`];
+	}
+
 	let targetPlatforms: string[] = [];
 
 	const replicas = await db
 		.select({ meta: servers.meta })
 		.from(serviceReplicas)
 		.innerJoin(servers, eq(serviceReplicas.serverId, servers.id))
-		.where(eq(serviceReplicas.serviceId, service.id));
+		.where(
+			and(
+				eq(serviceReplicas.serviceId, service.id),
+				gt(serviceReplicas.count, 0),
+			),
+		);
 
 	targetPlatforms = [
 		...new Set(
diff --git a/web/lib/inngest/functions/rollout-workflow.ts b/web/lib/inngest/functions/rollout-workflow.ts
index 3747e1b..39c24e8 100644
--- a/web/lib/inngest/functions/rollout-workflow.ts
+++ b/web/lib/inngest/functions/rollout-workflow.ts
@@ -1,7 +1,7 @@
 import { and, eq, inArray, isNull, ne, or } from "drizzle-orm";
 import { db } from "@/db";
 import { getService } from "@/db/queries";
-import { deployments, rollouts } from "@/db/schema";
+import { deployments, rollouts, servers } from "@/db/schema";
 import { ingestRolloutLog } from "@/lib/victoria-logs";
 import { inngest } from "../client";
 import { inngestEvents } from "../events";
@@ -335,8 +335,13 @@ export const rolloutWorkflow = inngest.createFunction(
 				}
 
 				const deploymentStates = await db
-					.select({ id: deployments.id, status: deployments.status })
+					.select({
+						id: deployments.id,
+						status: deployments.status,
+						serverName: servers.name,
+					})
 					.from(deployments)
+					.innerJoin(servers, eq(deployments.serverId, servers.id))
 					.where(inArray(deployments.id, pendingHealthDeploymentIds));
 
 				return deploymentStates.filter(
@@ -347,7 +352,7 @@ export const rolloutWorkflow = inngest.createFunction(
 		);
 
 		if (unhealthyDeployments.length > 0) {
-			const failedDeploymentId = unhealthyDeployments[0].id;
+			const failedDeployment = unhealthyDeployments[0];
 			const failedReason = healthResults.includes(null)
 				? "health_check_timeout"
 				: "health_check_failed";
@@ -357,8 +362,8 @@ export const rolloutWorkflow = inngest.createFunction(
 					serviceId,
 					"health_check",
 					failedReason === "health_check_timeout"
-						? `Health check timed out for deployment ${failedDeploymentId}`
-						: `Health check failed for deployment ${failedDeploymentId}`,
+						? `Health check timed out on server ${failedDeployment.serverName}`
+						: `Health check failed on server ${failedDeployment.serverName}`,
 				);
 			});
 			await step.run("handle-health-timeout", async () => {
@@ -372,7 +377,7 @@ export const rolloutWorkflow = inngest.createFunction(
 			return {
 				status: "failed",
 				reason: failedReason,
-				deploymentId: failedDeploymentId,
+				deploymentId: failedDeployment.id,
 			};
 		}
 
@@ -425,18 +430,32 @@ export const rolloutWorkflow = inngest.createFunction(
 		);
 
 		const dnsTimedOut = dnsResults.some((r) => r === null);
+		const dnsServerNames = await step.run("load-dns-server-names", async () => {
+			if (serverIds.length === 0) {
+				return [];
+			}
+
+			return db
+				.select({ id: servers.id, name: servers.name })
+				.from(servers)
+				.where(inArray(servers.id, serverIds));
+		});
+		const dnsServerNameById = new Map(
+			dnsServerNames.map((server) => [server.id, server.name]),
+		);
 
 		for (let i = 0; i < dnsResults.length; i++) {
 			if (dnsResults[i] === null) {
+				const serverName = dnsServerNameById.get(serverIds[i]) || serverIds[i];
 				console.warn(
-					`[rollout:${rolloutId}] DNS sync timeout for server ${serverIds[i]}`,
+					`[rollout:${rolloutId}] DNS sync timeout for server ${serverName}`,
 				);
 				await step.run(`log-dns-timeout-${serverIds[i]}`, async () => {
 					await ingestRolloutLog(
 						rolloutId,
 						serviceId,
 						"dns_sync",
-						`DNS sync timed out for server ${serverIds[i]}`,
+						`DNS sync timed out for server ${serverName}`,
 					);
 				});
 			}
diff --git a/web/tests/build-assignment.test.ts b/web/tests/build-assignment.test.ts
new file mode 100644
index 0000000..da69c7c
--- /dev/null
+++ b/web/tests/build-assignment.test.ts
@@ -0,0 +1,233 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => {
+	// Results are consumed in the same order as db.select() calls in each test.
+	const queryResults: unknown[][] = [];
+	const queries: Array<{
+		from: ReturnType<typeof vi.fn>;
+		innerJoin: ReturnType<typeof vi.fn>;
+		where: ReturnType<typeof vi.fn>;
+	}> = [];
+
+	function createQuery(result: unknown[]) {
+		const query = {
+			from: vi.fn(() => query),
+			innerJoin: vi.fn(() => query),
+			where: vi.fn(() => query),
+			then: (
+				resolve: (value: unknown[]) => unknown,
+				reject?: (reason: unknown) => unknown,
+			) =>
+				Promise.resolve(result).then(resolve, reject),
+		};
+
+		queries.push(query);
+		return query;
+	}
+
+	return {
+		queryResults,
+		queries,
+		db: {
+			select: vi.fn(() => createQuery(queryResults.shift() ?? [])),
+		},
+		getSetting: vi.fn(),
+	};
+});
+
+vi.mock("@/db", () => ({ db: mocks.db }));
+vi.mock("@/db/queries", () => ({ getSetting: mocks.getSetting }));
+
+import {
+	getTargetPlatformsForService,
+	selectBuildServerForPlatform,
+} from "@/lib/build-assignment";
+
+describe("build assignment", () => {
+	beforeEach(() => {
+		mocks.queryResults.length = 0;
+		mocks.queries.length = 0;
+		mocks.db.select.mockClear();
+		mocks.getSetting.mockReset();
+	});
+
+	function sqlTokens(value: unknown): unknown[] {
+		if (!value || typeof value !== "object") {
+			return [value];
+		}
+
+		const record = value as {
+			name?: string;
+			queryChunks?: unknown[];
+			value?: unknown;
+		};
+
+		if (Array.isArray(record.queryChunks)) {
+			return record.queryChunks.flatMap(sqlTokens);
+		}
+
+		if (Array.isArray(record.value)) {
+			return record.value.flatMap(sqlTokens);
+		}
+
+		if ("value" in record) {
+			return [record.value];
+		}
+
+		if (record.name) {
+			return [record.name];
+		}
+
+		return [];
+	}
+
+	function expectActiveReplicaPredicate(queryIndex: number) {
+		const condition = mocks.queries[queryIndex]?.where.mock.calls[0]?.[0];
+		const tokens = sqlTokens(condition);
+
+		expect(tokens).toEqual(expect.arrayContaining(["service_id", "count", 0]));
+		expect(tokens).toContain(" > ");
+	}
+
+	it("assigns stateful builds to the active replica server", async () => {
+		mocks.queryResults.push(
+			[{ id: "service_1", stateful: true }],
+			[
+				{
+					id: "server_target",
+					status: "online",
+					meta: { arch: "arm64" },
+				},
+			],
+		);
+
+		await expect(
+			selectBuildServerForPlatform("service_1", "linux/arm64"),
+		).resolves.toBe("server_target");
+		expectActiveReplicaPredicate(1);
+	});
+
+	it("rejects stateful builds without exactly one active replica server", async () => {
+		mocks.queryResults.push([{ id: "service_1", stateful: true }], []);
+
+		await expect(
+			selectBuildServerForPlatform("service_1", "linux/arm64"),
+		).rejects.toThrow(
+			"Stateful services must have exactly one active replica server",
+		);
+	});
+
+	it("rejects stateful builds with multiple active replica rows", async () => {
+		mocks.queryResults.push(
+			[{ id: "service_1", stateful: true }],
+			[
+				{ id: "server_1", status: "online", meta: { arch: "arm64" } },
+				{ id: "server_2", status: "online", meta: { arch: "arm64" } },
+			],
+		);
+
+		await expect(
+			selectBuildServerForPlatform("service_1", "linux/arm64"),
+		).rejects.toThrow(
+			"Stateful services must have exactly one active replica server",
+		);
+	});
+
+	it("rejects stateful builds when the active replica server is offline", async () => {
+		mocks.queryResults.push(
+			[{ id: "service_1", stateful: true }],
+			[{ id: "server_target", status: "offline", meta: { arch: "arm64" } }],
+		);
+
+		await expect(
+			selectBuildServerForPlatform("service_1", "linux/arm64"),
+		).rejects.toThrow("Stateful service target server is offline");
+	});
+
+	it("rejects stateful builds when the active replica arch does not match the requested platform", async () => {
+		mocks.queryResults.push(
+			[{ id: "service_1", stateful: true }],
+			[
+				{
+					id: "server_target",
+					status: "online",
+					meta: { arch: "arm64" },
+				},
+			],
+		);
+
+		await expect(
+			selectBuildServerForPlatform("service_1", "linux/amd64"),
+		).rejects.toThrow(
+			"Stateful service target server architecture arm64 does not match platform linux/amd64",
+		);
+		expectActiveReplicaPredicate(1);
+	});
+
+	it("builds a stateful target platform from the active replica server architecture", async () => {
+		mocks.queryResults.push(
+			[{ id: "service_1", stateful: true }],
+			[{ id: "server_target", status: "online", meta: { arch: "arm64" } }],
+		);
+
+		await expect(getTargetPlatformsForService("service_1")).resolves.toEqual([
+			"linux/arm64",
+		]);
+		expectActiveReplicaPredicate(1);
+	});
+
+	it("rejects stateful target platform resolution when the active replica arch is unknown", async () => {
+		mocks.queryResults.push(
+			[{ id: "service_1", stateful: true }],
+			[{ id: "server_target", status: "online", meta: null }],
+		);
+
+		await expect(getTargetPlatformsForService("service_1")).rejects.toThrow(
+			"Stateful service target server architecture is unknown",
+		);
+	});
+
+	it("keeps stateless builds assigned by matching architecture", async () => {
+		mocks.getSetting.mockResolvedValue(null);
+		mocks.queryResults.push(
+			[{ id: "service_1", stateful: false }],
+			[
+				{ id: "server_amd", meta: { arch: "amd64" } },
+				{ id: "server_arm", meta: { arch: "arm64" } },
+			],
+		);
+
+		await expect(
+			selectBuildServerForPlatform("service_1", "linux/arm64"),
+		).resolves.toBe("server_arm");
+	});
+
+	it("limits stateless build assignment to allowed build servers", async () => {
+		mocks.getSetting.mockResolvedValue(["server_arm"]);
+		mocks.queryResults.push(
+			[{ id: "service_1", stateful: false }],
+			[{ id: "server_arm", meta: { arch: "arm64" } }],
+		);
+
+		await expect(
+			selectBuildServerForPlatform("service_1", "linux/arm64"),
+		).resolves.toBe("server_arm");
+
+		const condition = mocks.queries[1]?.where.mock.calls[0]?.[0];
+		expect(sqlTokens(condition)).toEqual(
+			expect.arrayContaining(["status", "online", "id", " in "]),
+		);
+	});
+
+	it("builds target platforms from active replica server architectures", async () => {
+		mocks.queryResults.push(
+			[{ id: "service_1" }],
+			[{ meta: { arch: "arm64" } }, { meta: { arch: "arm64" } }],
+		);
+
+		await expect(getTargetPlatformsForService("service_1")).resolves.toEqual([
+			"linux/arm64",
+		]);
+		expectActiveReplicaPredicate(1);
+	});
+});
