diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 8ff2b65af4..204ec88363 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -79,6 +79,38 @@ jobs:
         run: npm run test:cov
         if: ${{ !cancelled() }}
 
+  cli-unit-tests-win:
+    name: CLI (Windows)
+    runs-on: windows-latest
+    defaults:
+      run:
+        working-directory: ./cli
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Setup Node
+        uses: actions/setup-node@v4
+        with:
+          node-version: 20
+
+      - name: Setup typescript-sdk
+        run: npm ci && npm run build
+        working-directory: ./open-api/typescript-sdk
+
+      - name: Install deps
+        run: npm ci
+
+      # Skip linter & formatter in Windows test.
+      - name: Run tsc
+        run: npm run check
+        if: ${{ !cancelled() }}
+
+      - name: Run unit tests & coverage
+        run: npm run test:cov
+        if: ${{ !cancelled() }}
+
   web-unit-tests:
     name: Web
     runs-on: ubuntu-latest
diff --git a/cli/src/utils.spec.ts b/cli/src/utils.spec.ts
index 409aa8664e..0094b329b8 100644
--- a/cli/src/utils.spec.ts
+++ b/cli/src/utils.spec.ts
@@ -1,4 +1,5 @@
 import mockfs from 'mock-fs';
+import { readFileSync } from 'node:fs';
 import { CrawlOptions, crawl } from 'src/utils';
 
 interface Test {
@@ -9,6 +10,10 @@ interface Test {
 
 const cwd = process.cwd();
 
+const readContent = (path: string) => {
+  return readFileSync(path).toString();
+};
+
 const extensions = [
   '.jpg',
   '.jpeg',
@@ -256,7 +261,8 @@ const tests: Test[] = [
   {
     test: 'should support ignoring absolute paths',
     options: {
-      pathsToCrawl: ['/'],
+      // Currently, fast-glob has some caveat when dealing with `/`.
+      pathsToCrawl: ['/*s'],
       recursive: true,
       exclusionPattern: '/images/**',
     },
@@ -276,14 +282,16 @@ describe('crawl', () => {
   describe('crawl', () => {
     for (const { test, options, files } of tests) {
       it(test, async () => {
-        mockfs(Object.fromEntries(Object.keys(files).map((file) => [file, ''])));
+        // The file contents is the same as the path.
+        mockfs(Object.fromEntries(Object.keys(files).map((file) => [file, file])));
 
         const actual = await crawl({ ...options, extensions });
         const expected = Object.entries(files)
           .filter((entry) => entry[1])
           .map(([file]) => file);
 
-        expect(actual.sort()).toEqual(expected.sort());
+        // Compare file's content instead of path since a file can be represent in multiple ways.
+        expect(actual.map((path) => readContent(path)).sort()).toEqual(expected.sort());
       });
     }
   });
diff --git a/cli/src/utils.ts b/cli/src/utils.ts
index 4919a2b3ca..67948e0bd2 100644
--- a/cli/src/utils.ts
+++ b/cli/src/utils.ts
@@ -1,8 +1,9 @@
 import { getMyUser, init, isHttpError } from '@immich/sdk';
-import { glob } from 'fast-glob';
+import { convertPathToPattern, glob } from 'fast-glob';
 import { createHash } from 'node:crypto';
 import { createReadStream } from 'node:fs';
 import { readFile, stat, writeFile } from 'node:fs/promises';
+import { platform } from 'node:os';
 import { join, resolve } from 'node:path';
 import yaml from 'yaml';
 
@@ -106,6 +107,11 @@ export interface CrawlOptions {
   exclusionPattern?: string;
   extensions: string[];
 }
+
+const convertPathToPatternOnWin = (path: string) => {
+  return platform() === 'win32' ? convertPathToPattern(path) : path;
+};
+
 export const crawl = async (options: CrawlOptions): Promise<string[]> => {
   const { extensions: extensionsWithPeriod, recursive, pathsToCrawl, exclusionPattern, includeHidden } = options;
   const extensions = extensionsWithPeriod.map((extension) => extension.replace('.', ''));
@@ -124,11 +130,11 @@ export const crawl = async (options: CrawlOptions): Promise<string[]> => {
       if (stats.isFile() || stats.isSymbolicLink()) {
         crawledFiles.push(absolutePath);
       } else {
-        patterns.push(absolutePath);
+        patterns.push(convertPathToPatternOnWin(absolutePath));
       }
     } catch (error: any) {
       if (error.code === 'ENOENT') {
-        patterns.push(currentPath);
+        patterns.push(convertPathToPatternOnWin(currentPath));
       } else {
         throw error;
       }
diff --git a/cli/vite.config.ts b/cli/vite.config.ts
index f5dd7c8e15..f538a9a357 100644
--- a/cli/vite.config.ts
+++ b/cli/vite.config.ts
@@ -2,6 +2,7 @@ import { defineConfig } from 'vite';
 import tsconfigPaths from 'vite-tsconfig-paths';
 
 export default defineConfig({
+  resolve: { alias: { src: '/src' } },
   build: {
     rollupOptions: {
       input: 'src/index.ts',