Files
SnapOtter/docs/superpowers/plans/2026-04-04-lite-docker-image.md
T

35 KiB

Lite Docker Image Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship a :lite Docker tag (~1-2 GB) that includes all Sharp-based tools but drops the Python AI/ML sidecar.

Architecture: Single Dockerfile with ARG VARIANT=full (default). In lite mode, Python/ML packages and model downloads are skipped. The API returns 501 for AI routes, and the frontend greys out AI tools with an upgrade toast. CI publishes both :latest and :lite tags.

Tech Stack: Docker multi-stage builds, Fastify, React/Zustand, sonner (toast), VitePress, GitHub Actions

Spec: docs/superpowers/specs/2026-04-04-lite-docker-image-design.md


File Map

Action File Responsibility
Modify packages/shared/src/constants.ts Add PYTHON_SIDECAR_TOOLS constant
Modify apps/api/src/routes/settings.ts Add variant + variantUnavailableTools to GET response
Modify apps/api/src/routes/tools/index.ts Register 501 stubs for AI tools in lite mode
Create tests/integration/lite-variant.test.ts Integration tests for lite mode API behavior
Create apps/web/src/stores/settings-store.ts Zustand store for shared settings + variant info
Modify apps/web/src/App.tsx Add sonner <Toaster />
Modify apps/web/src/components/common/tool-card.tsx AI badge, grey-out, toast on click
Modify apps/web/src/components/layout/tool-panel.tsx Use settings store for variant filtering
Modify apps/web/src/pages/home-page.tsx Grey out variant-unavailable tools
Modify apps/web/src/hooks/use-tool-processor.ts Import PYTHON_SIDECAR_TOOLS from shared
Modify docker/Dockerfile Add ARG VARIANT, conditional Python install
Modify .github/workflows/ci.yml Matrix for both variants in Docker smoke test
Modify .github/workflows/release.yml Matrix for publishing both :latest and :lite
Create apps/docs/guide/docker-tags.md Docs page explaining lite vs full
Modify apps/docs/.vitepress/config.mts Add sidebar entry for docker-tags page

Task 1: Add PYTHON_SIDECAR_TOOLS Constant

Files:

  • Modify: packages/shared/src/constants.ts (append after TOOLS array)

  • Step 1: Add the constant

At the end of packages/shared/src/constants.ts, after the TOOLS array and any other exports, add:

/**
 * Tool IDs that require the Python sidecar (AI/ML tools).
 * Used by the API to register 501 stubs in lite mode,
 * and by the frontend for progress/timeout behavior.
 */
export const PYTHON_SIDECAR_TOOLS = [
  "remove-background",
  "upscale",
  "blur-faces",
  "erase-object",
  "ocr",
] as const;
  • Step 2: Verify typecheck passes

Run: pnpm typecheck Expected: PASS (no errors)

  • Step 3: Commit
git add packages/shared/src/constants.ts
git commit -m "feat: add PYTHON_SIDECAR_TOOLS constant to shared package"

Task 2: API - Settings Endpoint Variant Info

Files:

  • Create: tests/integration/lite-variant.test.ts

  • Modify: apps/api/src/routes/settings.ts

  • Step 1: Write the failing test

Create tests/integration/lite-variant.test.ts:

import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";

describe("Lite variant", () => {
  let testApp: TestApp;
  let app: TestApp["app"];
  let adminToken: string;

  beforeAll(async () => {
    process.env.STIRLING_VARIANT = "lite";
    testApp = await buildTestApp();
    app = testApp.app;
    adminToken = await loginAsAdmin(app);
  }, 30_000);

  afterAll(async () => {
    delete process.env.STIRLING_VARIANT;
    await testApp.cleanup();
  }, 10_000);

  describe("GET /api/v1/settings", () => {
    it("includes variant and variantUnavailableTools", async () => {
      const res = await app.inject({
        method: "GET",
        url: "/api/v1/settings",
        headers: { authorization: `Bearer ${adminToken}` },
      });

      expect(res.statusCode).toBe(200);
      const body = JSON.parse(res.body);
      expect(body.variant).toBe("lite");
      expect(body.variantUnavailableTools).toEqual([
        "remove-background",
        "upscale",
        "blur-faces",
        "erase-object",
        "ocr",
      ]);
    });
  });
});
  • Step 2: Run test to verify it fails

Run: pnpm vitest run tests/integration/lite-variant.test.ts Expected: FAIL - body.variant is undefined

  • Step 3: Implement variant info in settings endpoint

In apps/api/src/routes/settings.ts, add the import at the top:

import { PYTHON_SIDECAR_TOOLS } from "@stirling-image/shared";

Then modify the GET handler (around line 18-30). Replace the return reply.send({ settings }); line with:

    const variant = process.env.STIRLING_VARIANT === "lite" ? "lite" : "full";
    const variantUnavailableTools =
      variant === "lite" ? [...PYTHON_SIDECAR_TOOLS] : [];

    return reply.send({ settings, variant, variantUnavailableTools });
  • Step 4: Run test to verify it passes

Run: pnpm vitest run tests/integration/lite-variant.test.ts Expected: PASS

  • Step 5: Commit
git add tests/integration/lite-variant.test.ts apps/api/src/routes/settings.ts
git commit -m "feat: include variant and variantUnavailableTools in settings response"

Task 3: API - 501 Stubs for AI Routes in Lite Mode

Files:

  • Modify: tests/integration/lite-variant.test.ts

  • Modify: apps/api/src/routes/tools/index.ts

  • Step 1: Write the failing test

Add to tests/integration/lite-variant.test.ts, inside the outer describe("Lite variant") block, after the settings tests:

  describe("AI tool routes return 501", () => {
    const aiTools = [
      "remove-background",
      "upscale",
      "blur-faces",
      "erase-object",
      "ocr",
    ];

    for (const toolId of aiTools) {
      it(`POST /api/v1/tools/${toolId} returns 501`, async () => {
        const res = await app.inject({
          method: "POST",
          url: `/api/v1/tools/${toolId}`,
          headers: { authorization: `Bearer ${adminToken}` },
          payload: {},
        });

        expect(res.statusCode).toBe(501);
        const body = JSON.parse(res.body);
        expect(body.error).toBe("Not Available");
        expect(body.message).toContain("full image");
      });
    }
  });

  describe("Sharp tools still work in lite mode", () => {
    it("POST /api/v1/tools/info returns 200 with valid image", async () => {
      const { readFileSync } = await import("node:fs");
      const { join } = await import("node:path");
      const { fileURLToPath } = await import("node:url");
      const __dirname = join(fileURLToPath(import.meta.url), "..");
      const png = readFileSync(join(__dirname, "..", "fixtures", "test-200x150.png"));

      const boundary = "----TestBoundary";
      const body = Buffer.concat([
        Buffer.from(
          `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="test.png"\r\nContent-Type: image/png\r\n\r\n`,
        ),
        png,
        Buffer.from(`\r\n--${boundary}--\r\n`),
      ]);

      const res = await app.inject({
        method: "POST",
        url: "/api/v1/tools/info",
        headers: {
          authorization: `Bearer ${adminToken}`,
          "content-type": `multipart/form-data; boundary=${boundary}`,
        },
        payload: body,
      });

      expect(res.statusCode).toBe(200);
    });
  });
  • Step 2: Run test to verify it fails

Run: pnpm vitest run tests/integration/lite-variant.test.ts Expected: FAIL - AI tools return something other than 501 (likely 400 or 500)

  • Step 3: Implement 501 stubs in lite mode

In apps/api/src/routes/tools/index.ts, add the import at the top:

import { PYTHON_SIDECAR_TOOLS } from "@stirling-image/shared";

Then, inside the registerToolRoutes function, after the skipTools set is built (after line 67) and before the toolRegistrations array, add:

  // In lite mode, register 501 stubs for AI tools instead of real handlers
  const isLite = process.env.STIRLING_VARIANT === "lite";
  const liteStubTools = new Set<string>(PYTHON_SIDECAR_TOOLS);

Then modify the registration loop (currently lines 123-131). Replace it with:

  let skipped = 0;
  let stubbed = 0;
  for (const { id, register } of toolRegistrations) {
    if (skipTools.has(id)) {
      app.log.info(`Skipping disabled/experimental tool: ${id}`);
      skipped++;
      continue;
    }

    if (isLite && liteStubTools.has(id)) {
      // Register a 501 stub instead of the real handler
      app.post(`/api/v1/tools/${id}`, async (_request, reply) => {
        return reply.status(501).send({
          statusCode: 501,
          error: "Not Available",
          message: `The "${id}" tool requires the full image. Pull stirlingimage/stirling-image:latest for all features.`,
        });
      });
      stubbed++;
      continue;
    }

    register(app);
  }

Update the log line at the end:

  const registered = toolRegistrations.length - skipped - stubbed;
  app.log.info(
    `Tool routes: ${registered} active, ${stubbed} lite-stubbed, ${skipped} skipped (${toolRegistrations.length} total)`,
  );

Also remove the individual AI tool imports that are no longer needed in lite mode. Wrap them in a conditional dynamic import. Replace the static AI imports (lines 6, 17, 22, 25, 34) with lazy registration. Change the toolRegistrations array entries for AI tools to use register: () => {} as placeholders, and instead do the actual registration conditionally.

Simpler approach: keep the static imports. They are just JavaScript module imports that don't trigger Python. The bridge only spawns Python on actual request. The 501 stub intercepts before the real handler runs, so the imports are harmless. The only cost is a few KB of JS loaded but never executed.

Keep the existing imports as-is. No changes needed to the import block.

  • Step 4: Run test to verify it passes

Run: pnpm vitest run tests/integration/lite-variant.test.ts Expected: PASS

  • Step 5: Run full integration suite to ensure no regressions

Run: pnpm test:integration Expected: All tests PASS

  • Step 6: Commit
git add tests/integration/lite-variant.test.ts apps/api/src/routes/tools/index.ts
git commit -m "feat: register 501 stubs for AI tools in lite mode"

Task 4: Frontend - Install Sonner and Create Settings Store

Files:

  • Modify: apps/web/package.json (via pnpm add)

  • Modify: apps/web/src/App.tsx

  • Create: apps/web/src/stores/settings-store.ts

  • Step 1: Install sonner

Run: pnpm --filter @stirling-image/web add sonner

  • Step 2: Add Toaster to App.tsx

In apps/web/src/App.tsx, add the import at the top:

import { Toaster } from "sonner";

Inside the App component's return, add <Toaster /> after <ErrorBoundary> and before <BrowserRouter>:

export function App() {
  return (
    <ErrorBoundary>
      <Toaster position="bottom-right" />
      <BrowserRouter>
  • Step 3: Create settings store

Create apps/web/src/stores/settings-store.ts:

import { create } from "zustand";
import { apiGet } from "@/lib/api";

interface SettingsState {
  variant: "full" | "lite";
  variantUnavailableTools: string[];
  disabledTools: string[];
  experimentalEnabled: boolean;
  loaded: boolean;
  fetch: () => Promise<void>;
}

export const useSettingsStore = create<SettingsState>((set, get) => ({
  variant: "full",
  variantUnavailableTools: [],
  disabledTools: [],
  experimentalEnabled: false,
  loaded: false,

  fetch: async () => {
    if (get().loaded) return;
    try {
      const data = await apiGet<{
        settings: Record<string, string>;
        variant: "full" | "lite";
        variantUnavailableTools: string[];
      }>("/v1/settings");

      set({
        variant: data.variant ?? "full",
        variantUnavailableTools: data.variantUnavailableTools ?? [],
        disabledTools: data.settings.disabledTools
          ? JSON.parse(data.settings.disabledTools)
          : [],
        experimentalEnabled: data.settings.enableExperimentalTools === "true",
        loaded: true,
      });
    } catch {
      // Settings fetch failed - default to full with no disabled tools
      set({ loaded: true });
    }
  },
}));
  • Step 4: Verify typecheck passes

Run: pnpm typecheck Expected: PASS

  • Step 5: Commit
git add apps/web/package.json apps/web/src/App.tsx apps/web/src/stores/settings-store.ts pnpm-lock.yaml
git commit -m "feat: add sonner toast and settings store for variant support"

Task 5: Frontend - Update ToolCard for Variant-Unavailable Tools

Files:

  • Modify: apps/web/src/components/common/tool-card.tsx

  • Step 1: Update ToolCard to accept variantUnavailable prop

Replace the entire content of apps/web/src/components/common/tool-card.tsx:

import type { Tool } from "@stirling-image/shared";
import * as icons from "lucide-react";
import { FileImage, Sparkles, Star } from "lucide-react";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import { cn } from "@/lib/utils";

interface ToolCardProps {
  tool: Tool;
  variantUnavailable?: boolean;
}

export function ToolCard({ tool, variantUnavailable }: ToolCardProps) {
  const iconsMap = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>;
  const IconComponent = iconsMap[tool.icon] || FileImage;

  if (variantUnavailable) {
    return (
      <div className="group flex items-center gap-3 relative">
        <button
          type="button"
          className="opacity-0 group-hover:opacity-100 transition-opacity absolute -left-5"
          title="Add to favourites"
        >
          <Star className="h-3 w-3 text-muted-foreground hover:text-yellow-500" />
        </button>
        <button
          type="button"
          onClick={() =>
            toast("This tool requires the full image.", {
              description:
                "Pull stirlingimage/stirling-image:latest for all features including AI tools.",
              action: {
                label: "Learn more",
                onClick: () =>
                  window.open(
                    "https://stirling-image.github.io/stirling-image/guide/docker-tags",
                    "_blank",
                  ),
              },
            })
          }
          className="flex items-center gap-3 py-2 px-3 rounded-lg w-full transition-colors hover:bg-muted/50 opacity-50 cursor-pointer"
        >
          <IconComponent className="h-5 w-5 text-muted-foreground" />
          <span className="text-sm font-medium text-foreground">{tool.name}</span>
          <span className="flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-medium">
            <Sparkles className="h-2.5 w-2.5" />
            AI
          </span>
        </button>
      </div>
    );
  }

  return (
    <div className="group flex items-center gap-3 relative">
      <button
        type="button"
        className="opacity-0 group-hover:opacity-100 transition-opacity absolute -left-5"
        title="Add to favourites"
      >
        <Star className="h-3 w-3 text-muted-foreground hover:text-yellow-500" />
      </button>
      <Link
        to={tool.route}
        className={cn(
          "flex items-center gap-3 py-2 px-3 rounded-lg w-full transition-colors",
          "hover:bg-muted",
          tool.disabled && "opacity-50 pointer-events-none",
        )}
      >
        <IconComponent className="h-5 w-5 text-muted-foreground" />
        <span className="text-sm font-medium text-foreground">{tool.name}</span>
        {tool.experimental && (
          <span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-600 font-medium">
            Experimental
          </span>
        )}
      </Link>
    </div>
  );
}
  • Step 2: Verify typecheck passes

Run: pnpm typecheck Expected: PASS

  • Step 3: Commit
git add apps/web/src/components/common/tool-card.tsx
git commit -m "feat: ToolCard shows AI badge and upgrade toast for variant-unavailable tools"

Task 6: Frontend - Update ToolPanel to Use Settings Store

Files:

  • Modify: apps/web/src/components/layout/tool-panel.tsx

  • Step 1: Replace local state with settings store

Replace the entire content of apps/web/src/components/layout/tool-panel.tsx:

import { CATEGORIES, TOOLS } from "@stirling-image/shared";
import { useEffect, useMemo, useState } from "react";
import { SearchBar } from "../common/search-bar";
import { ToolCard } from "../common/tool-card";
import { useSettingsStore } from "@/stores/settings-store";

export function ToolPanel() {
  const [search, setSearch] = useState("");
  const { disabledTools, experimentalEnabled, variantUnavailableTools, loaded, fetch } =
    useSettingsStore();

  useEffect(() => {
    fetch();
  }, [fetch]);

  const unavailableSet = useMemo(
    () => new Set(variantUnavailableTools),
    [variantUnavailableTools],
  );

  const visibleTools = useMemo(() => {
    if (!loaded) return [];
    return TOOLS.filter((t) => {
      if (disabledTools.includes(t.id)) return false;
      if (t.experimental && !experimentalEnabled) return false;
      return true;
    });
  }, [disabledTools, experimentalEnabled, loaded]);

  const filteredTools = useMemo(() => {
    if (!search) return visibleTools;
    const q = search.toLowerCase();
    return visibleTools.filter(
      (t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q),
    );
  }, [search, visibleTools]);

  const groupedTools = useMemo(() => {
    const groups = new Map<string, typeof TOOLS>();
    for (const tool of filteredTools) {
      const list = groups.get(tool.category) || [];
      list.push(tool);
      groups.set(tool.category, list);
    }
    return groups;
  }, [filteredTools]);

  return (
    <div className="w-72 border-r border-border bg-background overflow-y-auto flex flex-col shrink-0">
      <div className="p-3 sticky top-0 bg-background z-10">
        <SearchBar value={search} onChange={setSearch} />
      </div>
      <div className="px-3 pb-4 flex-1">
        {CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => (
          <div key={category.id} className="mb-4">
            <h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
              {category.name}
            </h3>
            <div className="space-y-0.5">
              {groupedTools.get(category.id)?.map((tool) => (
                <ToolCard
                  key={tool.id}
                  tool={tool}
                  variantUnavailable={unavailableSet.has(tool.id)}
                />
              ))}
            </div>
          </div>
        ))}
        {filteredTools.length === 0 && (
          <p className="text-sm text-muted-foreground text-center py-8">No tools found</p>
        )}
      </div>
    </div>
  );
}
  • Step 2: Verify typecheck passes

Run: pnpm typecheck Expected: PASS

  • Step 3: Commit
git add apps/web/src/components/layout/tool-panel.tsx
git commit -m "feat: ToolPanel uses settings store for variant-aware tool filtering"

Task 7: Frontend - Update HomePage for Variant-Unavailable Tools

Files:

  • Modify: apps/web/src/pages/home-page.tsx

  • Step 1: Add variant awareness to HomePage

In apps/web/src/pages/home-page.tsx, add the import near the top:

import { toast } from "sonner";
import { useSettingsStore } from "@/stores/settings-store";

Inside the HomePage component, after the existing hooks (useFileStore, useNavigate), add:

  const { variantUnavailableTools, fetch: fetchSettings } = useSettingsStore();

  useEffect(() => {
    fetchSettings();
  }, [fetchSettings]);

  const unavailableSet = useMemo(
    () => new Set(variantUnavailableTools),
    [variantUnavailableTools],
  );

Add useEffect and useMemo to the existing import from react:

import { useCallback, useEffect, useMemo } from "react";

Modify handleToolClick to check for variant-unavailable tools:

  const handleToolClick = (route: string, toolId: string) => {
    if (unavailableSet.has(toolId)) {
      toast("This tool requires the full image.", {
        description:
          "Pull stirlingimage/stirling-image:latest for all features including AI tools.",
        action: {
          label: "Learn more",
          onClick: () =>
            window.open(
              "https://stirling-image.github.io/stirling-image/guide/docker-tags",
              "_blank",
            ),
        },
      });
      return;
    }
    navigate(route);
  };

Update the quick actions button onClick (around line 83):

onClick={() => handleToolClick(tool.route, tool.id)}

Add opacity styling to quick action buttons for unavailable tools (around line 84):

className={cn(
  "flex items-center gap-2 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left",
  unavailableSet.has(id) && "opacity-50",
)}

Update the "All Tools" section button onClick (around line 125):

onClick={() => handleToolClick(tool.route, tool.id)}

Add opacity styling to the all-tools buttons for unavailable tools (around line 126-129):

className={cn(
  "flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-left transition-colors",
  unavailableSet.has(tool.id)
    ? "opacity-50 hover:bg-muted/50"
    : "hover:bg-muted text-foreground",
)}
  • Step 2: Verify typecheck passes

Run: pnpm typecheck Expected: PASS

  • Step 3: Commit
git add apps/web/src/pages/home-page.tsx
git commit -m "feat: HomePage greys out variant-unavailable tools with upgrade toast"

Task 8: Frontend - Update use-tool-processor to Use Shared Constant

Files:

  • Modify: apps/web/src/hooks/use-tool-processor.ts

  • Step 1: Replace hardcoded set with shared constant

In apps/web/src/hooks/use-tool-processor.ts, add the import at the top:

import { PYTHON_SIDECAR_TOOLS } from "@stirling-image/shared";

Replace lines 30-38 (the AI_PYTHON_TOOLS definition):

// AI tools that go through Python/bridge.ts and can emit SSE progress.
// smart-crop is category "ai" but uses Sharp (no Python), so it's excluded.
const AI_PYTHON_TOOLS = new Set([
  "remove-background",
  "upscale",
  "blur-faces",
  "erase-object",
  "ocr",
]);

With:

// AI tools that go through Python/bridge.ts and can emit SSE progress.
// smart-crop is category "ai" but uses Sharp (no Python), so it's excluded.
const AI_PYTHON_TOOLS = new Set<string>(PYTHON_SIDECAR_TOOLS);
  • Step 2: Verify typecheck passes

Run: pnpm typecheck Expected: PASS

  • Step 3: Run linter

Run: pnpm lint Expected: PASS (no unused imports, formatting OK)

  • Step 4: Commit
git add apps/web/src/hooks/use-tool-processor.ts
git commit -m "refactor: use shared PYTHON_SIDECAR_TOOLS constant in use-tool-processor"

Task 9: Dockerfile - Add VARIANT Build Arg

Files:

  • Modify: docker/Dockerfile

  • Step 1: Add build arg and conditional Python install

At the very top of docker/Dockerfile, after the comment header (line 5) and before Stage 1, add:

ARG VARIANT=full

In the production stage (after line 41 FROM node:22-bookworm AS production), re-declare the arg:

ARG VARIANT

Replace the system dependencies block (lines 46-57) with:

# System dependencies shared by all variants
RUN apt-get update && apt-get install -y --no-install-recommends \
    imagemagick \
    libraw-dev \
    potrace \
    curl \
    gosu \
    libheif-examples \
    && rm -rf /var/lib/apt/lists/*

# Python/ML system dependencies (full variant only)
RUN if [ "$VARIANT" = "full" ]; then \
    apt-get update && apt-get install -y --no-install-recommends \
        python3 python3-pip python3-venv python3-dev \
        tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra tesseract-ocr-spa \
        build-essential \
        libgl1 libglib2.0-0 \
    && rm -rf /var/lib/apt/lists/* \
; fi

Replace the Python venv and ML install block (lines 59-88) with:

# Python venv + ML packages + model weights (full variant only)
COPY packages/ai/python/requirements.txt /tmp/requirements.txt
RUN if [ "$VARIANT" = "full" ]; then \
    python3 -m venv /opt/venv && \
    /opt/venv/bin/pip install --upgrade pip && \
    /opt/venv/bin/pip install \
        Pillow numpy opencv-python-headless onnxruntime && \
    (/opt/venv/bin/pip install rembg[cpu] || echo "WARNING: rembg not installed") && \
    (/opt/venv/bin/pip install realesrgan || echo "WARNING: realesrgan not installed") && \
    (/opt/venv/bin/pip install paddlepaddle paddleocr || echo "WARNING: PaddleOCR not installed") && \
    (/opt/venv/bin/pip install mediapipe || echo "WARNING: mediapipe not installed") && \
    (/opt/venv/bin/pip install lama-cleaner || echo "WARNING: lama-cleaner not installed") \
; fi && rm -f /tmp/requirements.txt

COPY docker/download_models.py /tmp/download_models.py
RUN if [ "$VARIANT" = "full" ]; then \
    /opt/venv/bin/python3 /tmp/download_models.py && \
    /opt/venv/bin/python3 -c "\
try: \
    from paddleocr import PaddleOCR; \
    print('Downloading PaddleOCR models...'); \
    ocr = PaddleOCR(use_angle_cls=True, lang='en', show_log=False); \
    print('PaddleOCR models ready'); \
except: print('PaddleOCR model pre-download skipped') \
" 2>/dev/null || echo "WARNING: Could not pre-download PaddleOCR models" \
; fi && rm -f /tmp/download_models.py

Replace the build-essential cleanup block (lines 108-109) with:

RUN if [ "$VARIANT" = "full" ]; then \
    apt-get purge -y --auto-remove build-essential python3-dev && \
    rm -rf /var/lib/apt/lists/* \
; fi

Add the variant env var to the ENV block (after RATE_LIMIT_PER_MIN=100 on line 146):

    STIRLING_VARIANT=${VARIANT}

Update the chown line to handle the case where /opt/venv doesn't exist in lite mode. Replace line 150:

RUN chown -R stirling:stirling /app /data /tmp/workspace && \
    ([ -d /opt/venv ] && chown -R stirling:stirling /opt/venv || true)
  • Step 2: Test lite build locally

Run: docker build --build-arg VARIANT=lite -f docker/Dockerfile -t stirling-image:lite-test . Expected: Build succeeds. No Python installation steps in the output.

  • Step 3: Test full build still works

Run: docker build -f docker/Dockerfile -t stirling-image:full-test . Expected: Build succeeds with Python/ML installation as before.

  • Step 4: Verify lite image is smaller

Run: docker images | grep stirling-image Expected: lite-test is ~1-2 GB, full-test is ~11 GB.

  • Step 5: Commit
git add docker/Dockerfile
git commit -m "feat: add VARIANT build arg to Dockerfile for lite image support"

Task 10: CI - Add Lite Variant Smoke Test

Files:

  • Modify: .github/workflows/ci.yml

  • Step 1: Add matrix to docker job

Replace the docker job in .github/workflows/ci.yml (lines 82-98) with:

  docker:
    name: Docker Build Test (${{ matrix.variant }})
    runs-on: ubuntu-latest
    strategy:
      matrix:
        variant: [full, lite]
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/build-push-action@v6
        with:
          context: .
          file: docker/Dockerfile
          push: false
          build-args: VARIANT=${{ matrix.variant }}
          tags: stirling-image:ci-${{ matrix.variant }}
          cache-from: type=gha,scope=${{ matrix.variant }}
          cache-to: type=gha,mode=max,scope=${{ matrix.variant }}
  • Step 2: Commit
git add .github/workflows/ci.yml
git commit -m "ci: add matrix to build both full and lite Docker variants"

Task 11: Release - Matrix for Publishing Both Variants

Files:

  • Modify: .github/workflows/release.yml

  • Step 1: Replace single docker job with matrix

Replace the entire docker job in .github/workflows/release.yml (lines 51-105) with:

  docker:
    name: Docker (${{ matrix.variant }})
    needs: release
    if: needs.release.outputs.new_version != ''
    runs-on: ubuntu-latest
    strategy:
      matrix:
        variant: [full, lite]
        include:
          - variant: full
            suffix: ""
          - variant: lite
            suffix: "-lite"
    steps:
      - name: Checkout release tag
        uses: actions/checkout@v4
        with:
          ref: v${{ needs.release.outputs.new_version }}

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: |
            stirlingimage/stirling-image
            ghcr.io/${{ github.repository }}
          tags: |
            type=semver,pattern={{version}}${{ matrix.suffix }},value=v${{ needs.release.outputs.new_version }}
            type=semver,pattern={{major}}.{{minor}}${{ matrix.suffix }},value=v${{ needs.release.outputs.new_version }}
            type=semver,pattern={{major}}${{ matrix.suffix }},value=v${{ needs.release.outputs.new_version }}
            type=raw,value=${{ matrix.variant == 'full' && 'latest' || 'lite' }}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          file: docker/Dockerfile
          push: true
          build-args: VARIANT=${{ matrix.variant }}
          platforms: linux/amd64,linux/arm64
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha,scope=${{ matrix.variant }}
          cache-to: type=gha,mode=max,scope=${{ matrix.variant }}

This produces for a v1.6.0 release:

Variant Tags
full 1.6.0, 1.6, 1, latest
lite 1.6.0-lite, 1.6-lite, 1-lite, lite
  • Step 2: Commit
git add .github/workflows/release.yml
git commit -m "ci: publish both full and lite Docker images on release"

Task 12: Documentation - Docker Tags Page

Files:

  • Create: apps/docs/guide/docker-tags.md

  • Modify: apps/docs/.vitepress/config.mts

  • Step 1: Create the docs page

Create apps/docs/guide/docker-tags.md:

# Docker Image Tags

Stirling Image ships two Docker image variants to fit different use cases.

## Full (default)

```bash
docker pull stirlingimage/stirling-image:latest

Includes all tools: image processing, AI-powered background removal, upscaling, face blurring, object erasing, and OCR. Size is ~11 GB due to bundled ML models.

Lite

docker pull stirlingimage/stirling-image:lite

Includes all image processing tools (resize, crop, rotate, convert, compress, watermark, collage, and 20+ more) but excludes AI/ML tools. Size is ~1-2 GB.

Use this if you:

  • Only need standard image processing (no AI features)
  • Are running on constrained hardware (Raspberry Pi, small VPS)
  • Want faster pulls and smaller disk footprint

Tools excluded from lite

Tool What it does
Remove Background AI-powered background removal
Upscale AI super-resolution upscaling
Blur Faces AI face detection and blurring
Erase Object AI inpainting to remove objects
OCR Optical character recognition

All other tools (27+) work identically in both variants.

Docker Compose

Full

services:
  stirling-image:
    image: stirlingimage/stirling-image:latest
    ports:
      - "1349:1349"
    volumes:
      - stirling-data:/data
      - stirling-workspace:/tmp/workspace

volumes:
  stirling-data:
  stirling-workspace:

Lite

services:
  stirling-image:
    image: stirlingimage/stirling-image:lite
    ports:
      - "1349:1349"
    volumes:
      - stirling-data:/data
      - stirling-workspace:/tmp/workspace

volumes:
  stirling-data:
  stirling-workspace:

Switching from lite to full

To upgrade from lite to full and unlock AI tools:

  1. Stop your container
  2. Pull the full image: docker pull stirlingimage/stirling-image:latest
  3. Update your compose file or run command to use :latest instead of :lite
  4. Start the container

Your data and settings are preserved in the volumes.

Version pinning

Both variants support semver tags for pinning:

Tag Description
latest Latest full release
lite Latest lite release
1.6.0 Exact full version
1.6.0-lite Exact lite version
1.6 Latest patch in 1.6.x (full)
1.6-lite Latest patch in 1.6.x (lite)

- [ ] **Step 2: Add sidebar entry**

In `apps/docs/.vitepress/config.mts`, add an entry to the Guide sidebar items array (after the "Deployment" entry, around line 34):

```typescript
          { text: "Docker tags", link: "/guide/docker-tags" },
  • Step 3: Commit
git add apps/docs/guide/docker-tags.md apps/docs/.vitepress/config.mts
git commit -m "docs: add Docker tags guide for full vs lite image"

Task 13: Final Verification

  • Step 1: Run full test suite

Run: pnpm test Expected: All unit and integration tests PASS

  • Step 2: Run typecheck

Run: pnpm typecheck Expected: PASS

  • Step 3: Run linter

Run: pnpm lint Expected: PASS (run pnpm lint:fix if formatting issues)

  • Step 4: Verify lite Docker build

Run: docker build --build-arg VARIANT=lite -f docker/Dockerfile -t stirling-image:lite-verify . Expected: Build succeeds, no Python in image

  • Step 5: Smoke test lite container

Run: docker run --rm -d -p 1349:1349 --name si-lite stirling-image:lite-verify

Verify:

  • Health check passes: curl http://localhost:1349/api/v1/health
  • Settings show lite variant: curl -H "Authorization: Bearer <token>" http://localhost:1349/api/v1/settings | jq .variant
  • AI route returns 501: curl -X POST http://localhost:1349/api/v1/tools/remove-background

Run: docker stop si-lite

  • Step 6: Check image size

Run: docker images stirling-image:lite-verify --format '{{.Size}}' Expected: ~1-2 GB