mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
* fix(api): correct format/filename/container handling across tool routes Found during a comprehensive QA sweep exercising every tool against its full accepted-format matrix: - watermark-image, compose: preserve the requested output format and a matching download filename/extension instead of always emitting the source format - compose: crop oversized overlays to the visible base area instead of crashing Sharp's composite, and reject only overlays fully outside the base image instead of any oversized one - compare, vectorize: switch to the shared image input handler so filenames and formats like .svgz/.tga/RAW survive validation instead of being rejected pre-processing - tool-factory, images-to-video: normalize frames through Sharp before handing them to FFmpeg, fixing GIF/AVIF/RAW image-to-video jobs that previously failed or hung - media-tool, replace-audio, embed-subtitles: fix legacy container MIME/codec handling for MPEG sources and subtitle remux cases - files: expand download MIME mapping for text/data/document/video/audio outputs that were falling back to a generic content type - convert-document/presentation/spreadsheet: same-format conversions now return the original validated file instead of erroring or producing corrupt tiny output Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(web): dropzone a11y, stale localStorage getter, dead code - dropzone: stop making the whole drop-zone section clickable/focusable. A section acting as an interactive element around a real upload button is a nested-interactive-element anti-pattern that confuses screen readers; drag-and-drop doesn't need focus semantics, only the button fallback does. Keeps that button semantic and keyboard-reachable. Updates the two e2e call sites that clicked the section directly. - api, use-auth: read through window.localStorage via the existing API storage helper instead of the bare global, which resolves to Node's experimental localStorage getter under Vitest and threw - find-duplicates-settings, info-settings, login-page: remove dead code (unused zip-download handler, a stale mount-only effect dependency that left cached info stuck at reused indices, an unused response variable) Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(i18n): pt-BR, zh-CN, zh-TW were silently falling back to English The locale loader looked up dynamic-import exports by the raw locale code (mod["pt-BR"], mod["zh-CN"], mod["zh-TW"]), but those three modules export camelCased bindings (ptBR, zhCN, zhTW) since identifiers can't contain hyphens. The lookup returned undefined and every consumer silently fell back to English for these three locales. Replaces the generic lookup with explicit per-locale loaders so the mapping can't drift out of sync again. Also updates the dropzone helper copy across all 21 locales to match the drag-only dropzone wording from the previous commit. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(docs): clear build warnings in the VitePress site - config.mts: add an onwarn handler for the @vueuse INVALID_ANNOTATION warnings emitted during the docs build - deployment.md: the caddyfile code fence language isn't a shiki grammar VitePress ships with, so it warned on every build; use txt instead Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * test(qa): update QA harness for the drag-only dropzone and regen metadata - api-sweep, qa-helpers, verify-ai: add JSON-body tools, multi-input secondary fixtures, async polling for slow valid jobs, 501 FEATURE_NOT_INSTALLED skip handling, and safer per-tool settings - input-preview, pipeline-ui specs: update upload flow for the drag-only dropzone surface - add tests/fixtures/data/valid/chart.json, a valid chart fixture the updated helpers route to - regenerate tools-meta.json against current TOOLS[] Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(security): close a login timing side-channel, harden zip-slip tests Found during a black-box security sweep of the real auth-enabled production container: a nonexistent username returned 401 in ~3-10ms, while a wrong password for a real user took ~35-42ms, because scrypt verification only ran when a user row existed. That timing gap lets an attacker enumerate valid usernames without ever guessing a password. Now runs verification against a cached dummy hash on the unknown-user path too, so both cases cost the same regardless of outcome. extract-zip already had a relative-traversal regression test (../evil.txt), but its absolute-path rejection branches (name.startsWith("/") / startsWith("\\")) had none. Added the three missing cases: deep relative traversal, absolute Unix path, and Windows-style absolute path. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * test(qa): add UI-driven AI bundle install scripts QA_PROMPT.md's Phase 2 requires installing AI models the way a user does -- through the UI, on demand from HuggingFace -- and treats the curl-based admin install endpoint as fallback-only. Nothing in the harness actually drove that flow; tests/qa/seed-ai-models.sh installs via docker exec + pip, which is further from a real user than even the API fallback. install-ai-bundles-ui.mts logs in, opens Settings > AI Features, screenshots the pre-install state, clicks Install All, and screenshots progress -- then exits, since installs continue server-side once triggered. verify-ai-install-complete.mts polls bundle status, screenshots the completed state, and runs one real tool per installed bundle to prove the freshly-downloaded model actually executes. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(qa): correct the apiToolPath import in the AI verify script Dynamic import of the package name failed under tsx's module resolution from apps/api's node_modules context; use the same relative-path import api-sweep.mts already uses successfully. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(web): correct AI bundle size estimates shown before install Measured real downloads during GPU-node QA verification: photo-restoration pulls ~4.4GB (was advertised as 800MB-1GB, off by 4-5x) and ocr pulls ~5.5GB (was advertised as 3-4GB). Both estimates only accounted for model weights, not the pip dependencies (torch/paddle) that come down with them. Updated to reflect actual total download size, since that's what a user deciding whether they have the disk/bandwidth actually needs to know. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(web): make desktop Settings reachable when auth is disabled AvatarDropdown (the only desktop entry point to Settings) was gated behind `!isMobile && authEnabled`. With AUTH_ENABLED=false the synthetic anonymous admin user should have full Settings access per how auth.ts documents this mode -- and the mobile bottom nav already worked this way, showing Settings unconditionally. Desktop just had a stray extra gate the component doesn't need: AvatarDropdown already resolves its own username internally (falling back to "admin") and reads authEnabled itself where it actually matters (hiding the Logout button). Removed the outer gate; verified end-to-end against a fresh AUTH_ENABLED=false instance -- avatar now renders, Settings opens, shows the anonymous/Admin identity correctly. Also documents (not changes) a related finding in install_feature.py: detect_arch() always resolves amd64 hosts to the GPU-bundled archive variant regardless of actual GPU presence, since no CPU-only amd64 archive is published to the bundle repo yet. Left as a code comment rather than a behavior change, since requesting an unpublished archive key would hard-fail installs entirely -- worse than the current oversized-but-working download. Full detail in the QA report. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(ai): stop logging expected dispatcher reloads as crashes After each AI bundle install the Python dispatcher reloads because the venv changed, and after every app shutdown it's SIGTERMed. Both took the close handler's `code !== 0` branch (SIGTERM makes the exit code null), so they were counted as crashes -- producing an alarming "crash" line in the logs and a pointless ~1s recovery backoff after each of 7 installs. A `stopping` flag set in shutdown() lets the close handler tell an intentional stop apart from a real crash. The request-timeout kill path deliberately does not set it, so a genuinely hung script still records a crash and the 5-in-60s permanent-disable threshold is untouched. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(api): return a clean message when content-aware resize times out Carving a very high-resolution image down to a tiny target could exceed the caire subprocess timeout, and the raw error forwarded to the user was caire's terminal output -- ANSI color codes and progress-spinner control characters -- instead of anything actionable. Now: the timeout path throws a clear "timed out; try a smaller image or larger target" message (keeping the raw stderr as `cause` for server logs); friendlyError() strips ANSI/control chars centrally so any subprocess dump surfaced through the shared sanitizer is plain text; and the content-aware-resize route (a custom route that bypassed the sanitizer) now routes its error paths through friendlyError like every other tool. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(ai): stop bundle installs from exhausting host disk Installing an AI bundle on a tight-disk host could push the root filesystem to zero bytes free after the preflight check had already passed. Two root causes: - move_tree used copytree+rmtree, so during the move the extracted payload existed in both staging and the venv at once -- a full transient doubling on disk. Rewrote it to rename entries (a cheap metadata op on the same filesystem, no copy), falling back to a copy only across filesystems. - the preflight budget used the manifest's extractedSize verbatim, which is 0 for several archives, collapsing the estimate to just the compressed size. Added a conservative fallback (3x compressed) so a missing value can't under-reserve. Also added a real-on-disk re-check immediately before the first destructive venv write (measuring the actual extracted payload and whether the move needs extra space for a cross-filesystem copy), which also now covers the offline-import path that previously skipped the disk check entirely; wrapped the moves so an out-of-space failure returns a clean actionable error instead of a traceback; and made the disk check resolve the nearest existing ancestor so it never throws on a not-yet-created venv path. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * feat(web): show the real per-arch AI bundle download size The bundle cards and install prompt showed a hardcoded, architecture-blind estimatedSize string. That's misleading: amd64 hosts always pull the CUDA-inclusive archive (there's no CPU-only amd64 variant published), so a bundle labelled "1-2 GB" can actually download several times that, while arm64 pulls a much smaller archive for the same label. The manifest already carries the real per-arch compressedSize (and extractedSize where measured), so surface those: a new optional downloadBytes/installedBytes on FeatureBundleState, populated in getFeatureStates() for this host's arch (resolver mirrors install_feature.py detect_arch), shown by the UI when present with estimatedSize kept as the fallback label. Also nudged upscale-enhance's fallback string (4-5 -> 5-6 GB) to match its real compressed size, consistent with the earlier photo-restoration/ocr fixes. Fields are optional so demo/mock and existing tests stay compiling; the manifest's extractedSize is 0 for a few archives, which now surfaces as null rather than a bogus 0. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(web): move the AI install queue to the server so it survives tab close Installing multiple bundles could silently lose all but the first. The server rejected a concurrent install with 409, so the client worked around it by queueing the rest in browser-local state and only POSTing each once it saw the previous finish. A single POSTed install is durable (the installer child is detached from the request), but a queued one had zero server footprint -- close the tab mid-queue and those installs vanished with no error, while the UI still showed them "Queued". The client "mutex" didn't even serialize: the queued bundles' local waits all resolved at once and raced into concurrent POSTs that 409'd each other. Now the queue lives on the server (a small in-memory FIFO leaf module). The install endpoint enqueues instead of 409-ing and returns 202 {jobId, queued}; a pump starts the next bundle when the current one's child exits (and after an offline import releases the lock), all behind the existing venv + file locks, which are unchanged. The client just POSTs every bundle immediately and reflects the server-reported queued/installing status; Install All fires all POSTs and lets the server serialize them, keeping the one-shot retry-on-failure. Adds "queued" to FeatureStatus (the bundle card already rendered that state) and surfaces it from getFeatureStates. In-memory is deliberate: it matches the existing contract (survives a tab close, not a server restart, which already clears the lock on boot). Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG * fix(qa): don't log env-derived credentials in the AI-install script CodeQL flagged clear-text logging of sensitive information: the login status line interpolated the QA base URL and username (both read from the process environment) into a console.log. Replaced with a static message. QA helper only, but it's a real hygiene issue and cleared the high-severity code-scanning alert on the PR. Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG
556 lines
20 KiB
Markdown
556 lines
20 KiB
Markdown
---
|
|
description: Deploy SnapOtter to production with Docker. Hardware requirements, GPU setup, and reverse proxy configs for Nginx, Traefik, and Cloudflare.
|
|
---
|
|
|
|
# Deployment
|
|
|
|
SnapOtter deploys as a 3-container Docker Compose stack: the SnapOtter app image, PostgreSQL 17, and Redis 8. The app image supports **linux/amd64** (with NVIDIA CUDA for AI acceleration) and **linux/arm64** (CPU), so it runs natively on Intel/AMD servers, Apple Silicon Macs, and ARM devices like the Raspberry Pi 4/5. Intel/AMD iGPU acceleration through VA-API, Quick Sync, or OpenCL is not supported for AI inference today.
|
|
|
|
See [Docker Image](./docker-tags) for GPU setup, Docker Compose examples, and version pinning.
|
|
|
|
## Quick Start (CPU)
|
|
|
|
```yaml
|
|
# docker-compose.yml - Copy this file and run: docker compose up -d
|
|
services:
|
|
SnapOtter:
|
|
image: snapotter/snapotter:latest # or ghcr.io/snapotter-hq/snapotter:latest
|
|
container_name: SnapOtter
|
|
ports:
|
|
- "1349:1349" # Web UI + API
|
|
volumes:
|
|
- SnapOtter-data:/data # AI models, user files (PERSISTENT)
|
|
- SnapOtter-workspace:/tmp/workspace # Temp processing files (can be tmpfs)
|
|
environment:
|
|
# --- Authentication ---
|
|
- AUTH_ENABLED=true # Set to false to disable login entirely
|
|
- DEFAULT_USERNAME=admin # First-run admin username
|
|
- DEFAULT_PASSWORD=admin # First-run admin password (you'll be forced to change it)
|
|
|
|
# --- Database + Queue ---
|
|
- DATABASE_URL=postgres://snapotter:snapotter@postgres:5432/snapotter
|
|
- REDIS_URL=redis://redis:6379
|
|
|
|
# --- Limits (set 0 for unlimited) ---
|
|
# - MAX_UPLOAD_SIZE_MB=100 # Per-file upload limit in MB
|
|
# - MAX_BATCH_SIZE=100 # Max files per batch request
|
|
# - RATE_LIMIT_PER_MIN=0 # API rate limit (0 = disabled, 100 = recommended for public)
|
|
# - MAX_USERS=0 # Max user accounts
|
|
|
|
# --- Networking ---
|
|
# - TRUST_PROXY=true # Trust X-Forwarded-For headers (set false if not behind a proxy)
|
|
|
|
# --- Bind mount permissions ---
|
|
# - PUID=1000 # Match your host user's UID (run: id -u)
|
|
# - PGID=1000 # Match your host user's GID (run: id -g)
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
redis:
|
|
condition: service_healthy
|
|
restart: unless-stopped
|
|
healthcheck:
|
|
test: ["CMD", "curl", "-f", "http://localhost:1349/api/v1/health"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
start_period: 60s
|
|
retries: 3
|
|
shm_size: "2gb" # Needed for Python ML shared memory
|
|
logging:
|
|
driver: json-file
|
|
options:
|
|
max-size: "10m"
|
|
max-file: "3"
|
|
|
|
postgres:
|
|
image: postgres:17-alpine
|
|
container_name: SnapOtter-postgres
|
|
environment:
|
|
POSTGRES_USER: snapotter
|
|
POSTGRES_PASSWORD: snapotter # Change this for non-local deployments
|
|
POSTGRES_DB: snapotter
|
|
volumes:
|
|
- SnapOtter-pgdata:/var/lib/postgresql/data
|
|
restart: unless-stopped
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U snapotter"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 12
|
|
start_period: 15s
|
|
|
|
redis:
|
|
image: redis:8-alpine
|
|
container_name: SnapOtter-redis
|
|
command: ["redis-server", "--maxmemory-policy", "noeviction", "--appendonly", "yes"]
|
|
volumes:
|
|
- SnapOtter-redisdata:/data
|
|
restart: unless-stopped
|
|
healthcheck:
|
|
test: ["CMD", "redis-cli", "ping"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 12
|
|
start_period: 10s
|
|
|
|
volumes:
|
|
SnapOtter-data: # Named volume - Docker manages permissions automatically
|
|
SnapOtter-workspace:
|
|
SnapOtter-pgdata:
|
|
SnapOtter-redisdata:
|
|
```
|
|
|
|
```bash
|
|
docker compose up -d
|
|
```
|
|
|
|
The app is then available at `http://localhost:1349`.
|
|
|
|
> **Docker Hub rate limits?** Replace `snapotter/snapotter:latest` with `ghcr.io/snapotter-hq/snapotter:latest` to pull from GitHub Container Registry instead. Both registries receive the same image on every release.
|
|
|
|
## Quick Start (NVIDIA CUDA)
|
|
|
|
For NVIDIA CUDA acceleration on AI tools (background removal, upscaling, face enhancement, OCR):
|
|
|
|
```yaml
|
|
# docker-compose-gpu.yml - Requires: NVIDIA GPU + nvidia-container-toolkit
|
|
# Install toolkit: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html
|
|
services:
|
|
SnapOtter:
|
|
image: snapotter/snapotter:latest
|
|
container_name: SnapOtter
|
|
ports:
|
|
- "1349:1349"
|
|
volumes:
|
|
- SnapOtter-data:/data
|
|
- SnapOtter-workspace:/tmp/workspace
|
|
environment:
|
|
- AUTH_ENABLED=true
|
|
- DEFAULT_USERNAME=admin
|
|
- DEFAULT_PASSWORD=admin
|
|
- DATABASE_URL=postgres://snapotter:snapotter@postgres:5432/snapotter
|
|
- REDIS_URL=redis://redis:6379
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
redis:
|
|
condition: service_healthy
|
|
restart: unless-stopped
|
|
healthcheck:
|
|
test: ["CMD", "curl", "-f", "http://localhost:1349/api/v1/health"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
start_period: 60s
|
|
retries: 3
|
|
shm_size: "2gb" # Required for PyTorch CUDA shared memory
|
|
deploy:
|
|
resources:
|
|
reservations:
|
|
devices:
|
|
- driver: nvidia
|
|
count: all # Or set to 1 for a specific GPU
|
|
capabilities: [gpu]
|
|
logging:
|
|
driver: json-file
|
|
options:
|
|
max-size: "10m"
|
|
max-file: "3"
|
|
|
|
postgres:
|
|
image: postgres:17-alpine
|
|
container_name: SnapOtter-postgres
|
|
environment:
|
|
POSTGRES_USER: snapotter
|
|
POSTGRES_PASSWORD: snapotter
|
|
POSTGRES_DB: snapotter
|
|
volumes:
|
|
- SnapOtter-pgdata:/var/lib/postgresql/data
|
|
restart: unless-stopped
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U snapotter"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 12
|
|
start_period: 15s
|
|
|
|
redis:
|
|
image: redis:8-alpine
|
|
container_name: SnapOtter-redis
|
|
command: ["redis-server", "--maxmemory-policy", "noeviction", "--appendonly", "yes"]
|
|
volumes:
|
|
- SnapOtter-redisdata:/data
|
|
restart: unless-stopped
|
|
healthcheck:
|
|
test: ["CMD", "redis-cli", "ping"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 12
|
|
start_period: 10s
|
|
|
|
volumes:
|
|
SnapOtter-data:
|
|
SnapOtter-workspace:
|
|
SnapOtter-pgdata:
|
|
SnapOtter-redisdata:
|
|
```
|
|
|
|
```bash
|
|
docker compose -f docker-compose-gpu.yml up -d
|
|
```
|
|
|
|
Check CUDA detection in the logs:
|
|
|
|
```bash
|
|
docker logs SnapOtter 2>&1 | head -20
|
|
# Look for: [gpu] CUDA available via torch
|
|
```
|
|
|
|
## Hardware Requirements
|
|
|
|
These numbers come from benchmarks run across four systems (Apple M2 Max, AMD Ryzen 5 7500F + RTX 4070, Intel i7-7600U, Docker Desktop on Windows).
|
|
|
|
### Quick Reference
|
|
|
|
| Tier | Use Case | CPU | RAM | GPU | Storage |
|
|
|------|----------|-----|-----|-----|---------|
|
|
| Minimum | Core tools, single user | 1 core | 1 GB | None | 5 GB |
|
|
| Recommended | All tools + AI on CPU | 4 cores | 4 GB | None | 20 GB |
|
|
| Full | All tools + AI on NVIDIA CUDA | 4+ cores | 8 GB | NVIDIA 8 GB+ | 30 GB |
|
|
|
|
### Minimum (core tools, no AI)
|
|
|
|
| Resource | Requirement |
|
|
|---|---|
|
|
| CPU | 1 core |
|
|
| RAM | 1 GB |
|
|
| Disk | 3 GB (image) + 1 GB (data volume) |
|
|
| GPU | Not required |
|
|
|
|
All 138 non-AI tools (image resize/crop/convert, video trim/merge, audio normalize/convert, PDF merge/split/compress, data format conversion, and more) run on any hardware. Most operations complete in under 1 second even on a single core. The exception is AVIF encoding, which takes ~27s on 1 core but drops to ~5s on 4 cores.
|
|
|
|
```yaml
|
|
deploy:
|
|
resources:
|
|
limits:
|
|
cpus: '1'
|
|
memory: 1G
|
|
```
|
|
|
|
### Recommended (AI tools on CPU)
|
|
|
|
| Resource | Requirement |
|
|
|---|---|
|
|
| CPU | 4 cores |
|
|
| RAM | 4 GB |
|
|
| Disk | 3 GB (image) + 14 GB (AI models) + workspace |
|
|
| GPU | Not required (CPU fallback) |
|
|
|
|
AI tools work on CPU but are significantly slower. Some tools are practical on CPU, others are not:
|
|
|
|
| AI Tool | CPU Time | Usable? |
|
|
|---|---|---|
|
|
| blur-faces, smart-crop, red-eye-removal | 2-5s | Yes |
|
|
| remove-background | 37-41s | Marginal (long wait) |
|
|
| upscale (small image) | 22s | Marginal |
|
|
| upscale (large image) | 241s | No |
|
|
| enhance-faces, colorize, noise-removal | 30-90s | Marginal to No |
|
|
|
|
AI model download sizes:
|
|
|
|
| Bundle | Disk Size |
|
|
|---|---|
|
|
| Background removal | 3-4 GB |
|
|
| Upscale + Face enhance + Noise removal | 4-5 GB |
|
|
| Face detection | 200-300 MB |
|
|
| Object eraser + Colorize | 1-2 GB |
|
|
| OCR | 3-4 GB |
|
|
| Photo restoration | 800 MB - 1 GB |
|
|
| **All bundles** | **~14 GB** |
|
|
|
|
```yaml
|
|
deploy:
|
|
resources:
|
|
limits:
|
|
cpus: '4'
|
|
memory: 4G
|
|
```
|
|
|
|
### Full (AI tools on NVIDIA CUDA)
|
|
|
|
| Resource | Requirement |
|
|
|---|---|
|
|
| CPU | 4+ cores |
|
|
| RAM | 8 GB |
|
|
| GPU | NVIDIA with 8+ GB VRAM (12 GB recommended) |
|
|
| Disk | 30 GB total |
|
|
|
|
NVIDIA CUDA acceleration gives 3-13,000x speedup depending on the operation. Measured on an RTX 4070 vs Intel i7-7600U:
|
|
|
|
| AI Tool | GPU Time | CPU Time | Speedup |
|
|
|---|---|---|---|
|
|
| noise-removal (quick) | 17ms | 228s | 13,400x |
|
|
| blur-faces | 0.27s | 27s | 100x |
|
|
| upscale 2x | 6.3s | >300s (timeout) | 47x+ |
|
|
| enhance-faces (GFPGAN) | 2.3s | 28s | 12x |
|
|
| remove-background | 5-10s | 21-41s | 3-8x |
|
|
| OCR (best) | 70s | 243s | 3.5x |
|
|
| restore-photo | 31s | 90s | 2.9x |
|
|
| colorize | 10s | 13s | 1.3x |
|
|
|
|
Peak VRAM usage reaches 7.5 GB during upscale with face enhancement. A 6 GB NVIDIA GPU works for most AI tools individually but will fail on upscale. 8-12 GB VRAM handles everything.
|
|
|
|
Intel/AMD iGPU acceleration through VA-API, Quick Sync, or OpenCL is not supported for AI inference today. Mapping `/dev/dri` into the container does not enable AI GPU acceleration; SnapOtter will run AI tools on CPU unless NVIDIA CUDA is available.
|
|
|
|
```yaml
|
|
deploy:
|
|
resources:
|
|
limits:
|
|
cpus: '4'
|
|
memory: 8G
|
|
reservations:
|
|
devices:
|
|
- driver: nvidia
|
|
count: all
|
|
capabilities: [gpu]
|
|
```
|
|
|
|
### Concurrent Users
|
|
|
|
Benchmarked with parallel resize requests on a large image (Mac M2 Max, 10 Docker CPUs):
|
|
|
|
| Concurrent Users | Avg Response Time | Errors |
|
|
|---|---|---|
|
|
| 1 | 0.28s | 0 |
|
|
| 5 | 0.54s | 0 |
|
|
| 10 | 1.08s | 0 |
|
|
| 20 | 2.10s | 0 |
|
|
|
|
The server scales linearly with no errors or crashes up to 20 concurrent requests.
|
|
|
|
### Supported Image Formats
|
|
|
|
SnapOtter supports **55+ input formats** and **14 output formats**, including RAW files from 20+ camera brands, professional formats (PSD, EPS, OpenEXR, HDR), modern codecs (JPEG XL, AVIF, HEIC, QOI), and scientific/gaming formats (FITS, DDS).
|
|
|
|
See the [complete format list](/guide/supported-formats) for details on every supported format, decoder used, and available quality controls.
|
|
|
|
### Known Limitations
|
|
|
|
- **Content-aware resize** crashes on large images (>5 MP) due to a limitation in the caire binary. Works fine with smaller images.
|
|
- **HEIF decode** takes 13-23 seconds. HEIC (Apple's variant) is much faster at 0.3-0.9 seconds.
|
|
- **OCR Japanese** fails on CPU due to a PaddlePaddle MKLDNN bug. Works on GPU.
|
|
- **Upscale** times out on CPU for anything beyond small images. GPU required for practical use.
|
|
- **CodeFormer** face enhancement is significantly slower than GFPGAN (53s vs 2s on GPU). GFPGAN is recommended for most use cases.
|
|
|
|
## Volumes
|
|
|
|
| Mount / Volume | Purpose | Required? |
|
|
|---|---|---|
|
|
| `/data` (app) | AI models, Python venv, user files | **Yes** - file loss without it |
|
|
| `/tmp/workspace` (app) | Temporary processing files (auto-cleaned) | Recommended |
|
|
| `SnapOtter-pgdata` (postgres) | PostgreSQL data directory (users, settings, pipelines, jobs) | **Yes** - data loss without it |
|
|
| `SnapOtter-redisdata` (redis) | Redis append-only file for durable job queues | Recommended |
|
|
|
|
### Bind mounts vs. named volumes
|
|
|
|
**Named volumes** (recommended) — Docker manages permissions automatically:
|
|
```yaml
|
|
volumes:
|
|
- SnapOtter-data:/data
|
|
```
|
|
|
|
**Bind mounts** — You manage permissions. Set `PUID`/`PGID` to match your host user:
|
|
```yaml
|
|
volumes:
|
|
- ./SnapOtter-data:/data
|
|
environment:
|
|
- PUID=1000 # Your host UID (run: id -u)
|
|
- PGID=1000 # Your host GID (run: id -g)
|
|
```
|
|
|
|
### Storage permissions
|
|
|
|
SnapOtter writes to two locations at runtime: `/data` (user files, logs, AI models and the Python venv) and `/tmp/workspace` (temporary processing scratch). Both must be writable by the user the container runs as. If either is not, the container **fails fast at startup** with a message naming the directory, the running UID/GID, and how to fix it — instead of booting "healthy" and then failing on the first upload with a cryptic error.
|
|
|
|
How permissions are handled depends on how the container is launched:
|
|
|
|
**Default (starts as root, drops to `snapotter`)** — the entrypoint starts as root, fixes ownership of the mounted volumes, then drops to the unprivileged `snapotter` user via `gosu`. Named volumes work with no configuration. For bind mounts, set `PUID`/`PGID` to your host user (above) so the files it writes are owned by you.
|
|
|
|
**Kubernetes / OpenShift (non-root via `runAsUser`)** — launched directly as a non-root user, the container cannot chown the volumes itself, so the orchestrator must make them writable. Set `fsGroup`:
|
|
|
|
```yaml
|
|
securityContext:
|
|
runAsUser: 999
|
|
runAsGroup: 999
|
|
fsGroup: 999 # makes mounted volumes writable by the pod
|
|
```
|
|
|
|
The image's writable directories are group-owned by GID 0 and group-writable, so a pod running with an **arbitrary UID** plus the root supplementary group (the OpenShift default) can write with no `chown`.
|
|
|
|
**TrueNAS Scale (and other "foreign UID" setups)** — TrueNAS runs apps as a non-root user (often `568:568`) and mounts host datasets owned by a different user, so neither the entrypoint nor `fsGroup` makes them writable on its own. Choose one:
|
|
|
|
- **Run the app as root** (recommended) — leave the app's user unset or set it to `0`, and let the default entrypoint fix permissions and drop to `snapotter`.
|
|
- **Run as UID `999`** — set the app's user/group to `999:999` (SnapOtter's built-in `snapotter` user) so it matches the image's ownership.
|
|
- **`chown` the host dataset** to the UID the container runs as, from the TrueNAS shell:
|
|
|
|
```bash
|
|
# Use the UID from the startup error (or run `id` inside the container)
|
|
chown -R 568:568 /mnt/<pool>/<dataset>
|
|
```
|
|
|
|
The startup error names the exact UID to use, so the quickest path is to start the app once, read the message, then `chown` (or adjust the user) accordingly.
|
|
|
|
## Environment Variables
|
|
|
|
| Variable | Default | Description |
|
|
|---|---|---|
|
|
| `AUTH_ENABLED` | `true` | Enable/disable login requirement |
|
|
| `DEFAULT_USERNAME` | `admin` | Initial admin username |
|
|
| `DEFAULT_PASSWORD` | `admin` | Initial admin password (forced change on first login) |
|
|
| `MAX_UPLOAD_SIZE_MB` | `100` | Per-file upload limit |
|
|
| `MAX_BATCH_SIZE` | `100` | Max files per batch request |
|
|
| `RATE_LIMIT_PER_MIN` | `0` (disabled) | API requests per minute per IP |
|
|
| `MAX_USERS` | `0` (unlimited) | Maximum user accounts |
|
|
| `TRUST_PROXY` | `true` | Trust X-Forwarded-For headers from reverse proxy |
|
|
| `PUID` | `999` | Run as this UID (for bind mount permissions) |
|
|
| `PGID` | `999` | Run as this GID (for bind mount permissions) |
|
|
| `LOG_LEVEL` | `info` | Log verbosity: fatal, error, warn, info, debug, trace |
|
|
| `CONCURRENT_JOBS` | `0` (auto) | Max parallel AI processing jobs |
|
|
| `SESSION_DURATION_HOURS` | `168` | Login session lifetime (7 days) |
|
|
| `CORS_ORIGIN` | (empty) | Comma-separated allowed origins, or empty for same-origin |
|
|
|
|
## Health Check
|
|
|
|
The container includes a built-in health check:
|
|
|
|
```bash
|
|
# Check container health status
|
|
docker inspect --format='{{.State.Health.Status}}' SnapOtter
|
|
|
|
# Manual health check
|
|
curl http://localhost:1349/api/v1/health
|
|
# {"status":"healthy","version":"x.y.z"}
|
|
```
|
|
|
|
## Reverse Proxy
|
|
|
|
SnapOtter sets `TRUST_PROXY=true` by default so rate limiting and logging use the real client IP from `X-Forwarded-For` headers.
|
|
|
|
### Nginx
|
|
|
|
```nginx
|
|
server {
|
|
listen 80;
|
|
server_name images.example.com;
|
|
|
|
# Match MAX_UPLOAD_SIZE_MB (0 = nginx default 1M, so set high for unlimited)
|
|
client_max_body_size 500M;
|
|
|
|
location / {
|
|
proxy_pass http://localhost:1349;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection "upgrade";
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
|
|
# SSE support (batch progress, feature install progress)
|
|
proxy_buffering off;
|
|
proxy_read_timeout 300s;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Nginx Proxy Manager
|
|
|
|
1. Add a new Proxy Host
|
|
2. Set Domain Name to your domain
|
|
3. Set Scheme to `http`, Forward Hostname to `SnapOtter` (or your container IP), Forward Port to `1349`
|
|
4. Enable WebSocket support
|
|
5. Under Advanced, add: `client_max_body_size 500M;` and `proxy_buffering off;`
|
|
|
|
### Traefik
|
|
|
|
```yaml
|
|
# Add these labels to the SnapOtter service in docker-compose.yml
|
|
labels:
|
|
- "traefik.enable=true"
|
|
- "traefik.http.routers.snapotter.rule=Host(`images.example.com`)"
|
|
- "traefik.http.routers.snapotter.entrypoints=websecure"
|
|
- "traefik.http.routers.snapotter.tls.certresolver=letsencrypt"
|
|
- "traefik.http.services.snapotter.loadbalancer.server.port=1349"
|
|
# Increase upload limit (default 2MB is too low)
|
|
- "traefik.http.middlewares.snapotter-body.buffering.maxRequestBodyBytes=524288000"
|
|
- "traefik.http.routers.snapotter.middlewares=snapotter-body"
|
|
```
|
|
|
|
### Caddy
|
|
|
|
```txt
|
|
images.example.com {
|
|
reverse_proxy localhost:1349 {
|
|
flush_interval -1
|
|
transport http {
|
|
read_timeout 300s
|
|
write_timeout 300s
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
`flush_interval -1` disables response buffering, which is required for SSE progress events (batch processing, AI tools, feature installs). The extended timeouts allow large file uploads to complete without Caddy closing the connection early.
|
|
|
|
### Cloudflare Tunnels
|
|
|
|
```bash
|
|
cloudflared tunnel --url http://localhost:1349
|
|
```
|
|
|
|
Note: Cloudflare has a 100 MB upload limit on free plans. Set `MAX_UPLOAD_SIZE_MB=100` to match.
|
|
|
|
## CI/CD
|
|
|
|
The GitHub repository has three workflows:
|
|
|
|
- **ci.yml** - Runs automatically on every push and PR. Lints, typechecks, tests, builds, and validates the Docker image (without pushing).
|
|
- **release.yml** - Triggered manually via `workflow_dispatch`. Runs semantic-release to create a version tag and GitHub release, then builds a multi-arch Docker image (amd64 + arm64) and pushes to Docker Hub (`snapotter/snapotter`) and GitHub Container Registry (`ghcr.io/snapotter-hq/snapotter`).
|
|
- **deploy-docs.yml** - Builds this documentation site and deploys it to Cloudflare Pages on push to `main`.
|
|
|
|
To create a release, go to **Actions > Release > Run workflow** in the GitHub UI, or run:
|
|
|
|
```bash
|
|
gh workflow run release.yml
|
|
```
|
|
|
|
Semantic-release determines the version from commit history. The `latest` Docker tag always points to the most recent release.
|
|
|
|
## Analytics
|
|
|
|
SnapOtter includes anonymous product analytics (tool usage patterns, error reports) to help catch bugs and improve features. It is on by default. Your files, file names, and personal data are never part of this. SnapOtter works normally with analytics disabled.
|
|
|
|
### Disabling analytics
|
|
|
|
The runtime opt-out is a one-click admin toggle. Open Settings > System > Privacy and turn off Anonymous Product Analytics. It stops immediately for the whole instance, no rebuild required.
|
|
|
|
For an image that can never emit analytics, set the build-time hard-off by cloning the repository and rebuilding:
|
|
|
|
```bash
|
|
git clone https://github.com/snapotter-hq/SnapOtter.git
|
|
cd SnapOtter
|
|
docker compose -f docker/docker-compose.yml build --build-arg SNAPOTTER_ANALYTICS=off
|
|
docker compose -f docker/docker-compose.yml up -d
|
|
```
|
|
|
|
Or add the build arg to your existing `docker-compose.yml`:
|
|
|
|
```yaml
|
|
services:
|
|
snapotter:
|
|
build:
|
|
context: .
|
|
dockerfile: docker/Dockerfile
|
|
args:
|
|
SNAPOTTER_ANALYTICS: "off"
|
|
```
|