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 afterTOOLSarray) -
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:
- Stop your container
- Pull the full image:
docker pull stirlingimage/stirling-image:latest - Update your compose file or run command to use
:latestinstead of:lite - 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