Files
SnapOtter/apps/docs/guide/configuration.md
T
SnapOtterandGitHub 60d01ab2dd fix: release-acceptance QA follow-ups (upload crash, scipy ABI conflict, rate limit, OCR fallback) (#458)
* fix(api): prevent a crash when an over-limit upload stream has no consumer yet

busboy's "limit" handler destroyed the file stream with an error but never
attached its own error listener, relying entirely on whatever consumes
part.file downstream to do so. On a fast enough connection (or a fully
buffered body, e.g. Fastify inject()), busboy can process enough bytes to
hit the size limit before the route handler's receiveUpload() call has
attached its own stream listener, leaving the resulting "error" event with
zero listeners -- which crashes the whole process by default in Node.

Surfaced by tonight's FULL_MATRIX+FUZZ integration run (880 uncaught
exceptions, all the same root cause). Reproduces deterministically in
isolation; unrelated to this release's actual code delta (file untouched
since PR #413, well before the baseline QA pass).

Fix: attach a baseline no-op error listener the moment the stream is
created, guaranteeing at least one listener always exists. EventEmitter
delivers "error" to every registered listener, so the real consumer's own
error handling is unaffected.

* fix(ai-bundles): rebuild upscale-enhance and photo-restoration to reconcile scipy ABI

upscale-enhance and photo-restoration both depend on codeformer-pip, whose
transitive closure (basicsr -> realesrgan -> gfpgan) pulls in an unpinned
scipy. Both bundles were last built ~June 18-19, before PR #437 added the
manifest's `constraints` array (numpy==1.26.4, scipy==1.12.0, etc.) to pin
exactly this kind of dependency during bundle builds. Only the ocr bundle
was rebuilt after that fix landed.

install_feature.py has no pip install step -- it's a raw tarfile extraction
with no cross-bundle conflict resolution, so installing OCR alongside either
stale bundle left three incompatible scipy versions' files mixed in the same
site-packages directory (a compiled _rotation.*.so from one release next to
Python files expecting a different release's API), breaking the `upscale`
tool and OCR's higher-quality tiers with an ImportError.

Rebuilt both bundles for amd64-gpu and arm64-cpu from the current manifest,
verified scipy/scikit-learn/scikit-image/pandas all resolve to the pinned
versions in the tarballs themselves, then verified end-to-end on real
hardware (Mac arm64 CPU and ubuntu_gpu .248 RTX 4070): installing all
affected bundles together now yields exactly one version of each constrained
package, `upscale` produces correct output, and OCR's balanced/best tiers
correctly use PaddleOCR-GPU instead of erroring out.

Published the rebuilt tarballs to the public deepsafe/feature-bundles
HuggingFace repo and updated this manifest's sha256/sizes to match.

Also adds verify-bundle-compatibility.sh: verify-bundle.sh checks each
bundle in isolation (a fresh venv per bundle), which is exactly why this
shipped twice -- nothing ever checked that bundles built at different times
agree once layered into the one shared venv real installs use. The new
script installs every bundle for an arch into one venv and asserts each
constrained package has exactly one, correct version.

Known follow-up (not fixed here, needs separate discussion): uninstalling a
bundle only removes its downloaded model weights, never the site-packages
it added, so existing installations that already hit this bug have no clean
self-service fix via uninstall+reinstall -- they need a full AI-venv wipe.

* fix(docker): bake a real rate limit default for the all-in-one one-liner

The documented single-container `docker run` install had RATE_LIMIT_PER_MIN=0
(effectively unlimited, ~50k/min) baked in, since only docker-compose.yml
carried a hardened override. A self-hoster following the one-liner path got
no meaningful throttling anywhere, including auth-adjacent routes with no
dedicated per-route limit. Bakes a generous-but-real 1000/min default into
the Dockerfile, raises both compose files' fallback to match so the two
documented install paths converge on the same posture, and updates the Zod
schema default plus docs that quoted the old value.

* fix(api): boot log undercounted tool routes by the conversion-preset total

The "Tool routes: N active" line logged before registerConversionPresets(app)
ran, so it only ever reported the base 158 tools, 83 short of the real
241-tool total. Presets have to register after the base loop (they delegate
to each base tool's own processV2), so the fix moves the log line to after
that call and has registerConversionPresets return its count instead of
reordering the dependency.

* fix(ai): forward {info}/{warning} stderr JSON instead of dropping it

The dispatcher stderr parser only recognized {ready} and {progress,stage}
shaped JSON lines; anything else that parsed as valid JSON (like ocr.py's
GPU-to-tesseract downgrade notice, an {"info": ...} line) matched neither
branch and fell through silently, never reaching docker logs. Adds explicit
{info}/{warning} handling that forwards to console.log/console.warn, same as
the existing [prefix]-tagged non-JSON path.

* fix(api): fall back to a lower OCR tier when PaddleOCR itself is unusable

ocr.ts already retries lower quality tiers on a crashed dispatcher, but the
condition only matched crash-style messages (segfault, exited unexpectedly).
ocr.py's own ImportError/exception handlers already produce messages telling
the caller to use a lower tier (e.g. on the scipy ABI conflict class of bug),
but nothing ever acted on them, so a broken PaddleOCR hard-failed with 422
instead of degrading to Tesseract like ocr-pdf effectively does. Broadens the
retry condition to also catch PaddleOCR-engine-unusable messages.

Note: ocr-pdf's tesseract-only behavior turned out to be an unrelated,
pre-existing, deliberate design choice (PaddleOCR segfaults on rasterized PDF
pages on arm64), not a graceful-fallback mechanism to copy -- the two tools
weren't actually solving the same problem, so this fixes ocr.ts's own gap
rather than trying to mirror ocr-pdf.
2026-07-07 12:18:28 +08:00

7.8 KiB

description
description
All SnapOtter environment variables with defaults. Configure auth, storage, AI models, analytics, and more.

Configuration

All configuration is done through environment variables. Every variable has a sensible default, so SnapOtter works out of the box without setting any of them.

Environment variables

Server

Variable Default Description
PORT 1349 Port the server listens on.
RATE_LIMIT_PER_MIN 1000 Maximum requests per minute per IP. Set to 0 to disable rate limiting.
CORS_ORIGIN (empty) Comma-separated allowed origins for CORS, or empty for same-origin only.
LOG_LEVEL info Log verbosity. One of: fatal, error, warn, info, debug, trace.
TRUST_PROXY true Trust X-Forwarded-For headers from a reverse proxy. Set to false if not behind a proxy.

Authentication

Variable Default Description
AUTH_ENABLED false Set to true to require login. The Docker image defaults to true.
DEFAULT_USERNAME admin Username for the initial admin account. Only used on first run.
DEFAULT_PASSWORD admin Password for the initial admin account. Change this after first login.
MAX_USERS 0 (unlimited) Maximum number of registered user accounts. Set to 0 for unlimited.
SESSION_DURATION_HOURS 168 Login session lifetime in hours (default is 7 days).
SKIP_MUST_CHANGE_PASSWORD - Set to any non-empty value to bypass the forced password-change prompt on first login

Storage

Variable Default Description
STORAGE_MODE local local or s3. S3/MinIO requires a license with the s3_storage feature.
DATABASE_URL postgres://snapotter:snapotter@postgres:5432/snapotter PostgreSQL connection string.
REDIS_URL redis://redis:6379 Redis connection string (used for BullMQ job queues).
WORKSPACE_PATH ./tmp/workspace Directory for temporary files during processing. Cleaned up automatically.
FILES_STORAGE_PATH ./data/files Directory for persistent user files (uploaded images, saved results).

Embedded mode

Run the image with no DATABASE_URL and no REDIS_URL and it starts its own PostgreSQL 17 and Redis inside the container, bound to loopback, with all data on the /data volume. This restores the single-command docker run experience for quick start, homelab, and upgrades from 1.x. It is a convenience path, not a production deployment: for production, run the 3-container Compose stack with separate PostgreSQL and Redis. Embedded mode requires running the container as root and is incompatible with arbitrary-UID runtimes (OpenShift, Kubernetes runAsNonRoot); use Compose there.

Variable Default Description
EMBEDDED auto Auto-enabled when both DATABASE_URL and REDIS_URL are unset. Set to 0 to disable it (the app then fails fast if no external DATABASE_URL/REDIS_URL is set, rather than silently starting an in-container database).
REDIS_MAXMEMORY 512mb Memory cap for the embedded Redis (embedded mode only). Lower it on memory-constrained hosts such as a Raspberry Pi.

Upgrading from 1.x: put your old snapotter.db at /data/snapotter.db in the volume and embedded mode imports it into the embedded PostgreSQL on first boot. The import runs once; later boots skip it.

Telemetry note: embedded mode inherits the image's analytics default like any other configuration. The published image ships with analytics on; build with --build-arg SNAPOTTER_ANALYTICS=off, or use the in-app admin opt-out, to disable it.

Processing limits

Variable Default Description
MAX_UPLOAD_SIZE_MB 100 Maximum file size per upload in megabytes. Set to 0 for unlimited.
MAX_BATCH_SIZE 100 Maximum number of files in a single batch request. Set to 0 for unlimited.
CONCURRENT_JOBS 0 (auto) Number of batch jobs that run in parallel. Set to 0 to auto-detect based on available CPU cores.
MAX_MEGAPIXELS 0 (unlimited) Maximum image resolution allowed in megapixels. Set to 0 for unlimited.
MAX_WORKER_THREADS 0 (auto) Maximum worker threads for image processing. Set to 0 to auto-detect based on available CPU cores.
PROCESSING_TIMEOUT_S 0 (no limit) Maximum processing time per request in seconds. Set to 0 for no timeout.
MAX_PIPELINE_STEPS 20 Maximum number of steps in a pipeline. Set to 0 for no limit.
MAX_CANVAS_PIXELS 0 (no limit) Maximum canvas size in pixels for output images. Set to 0 for no limit.
MAX_SVG_SIZE_MB 0 (unlimited) Maximum SVG file size in megabytes. Set to 0 for unlimited.
MAX_SPLIT_GRID 100 Maximum grid dimension for the image split tool.
MAX_PDF_PAGES 0 (unlimited) Maximum number of PDF pages for PDF-to-image conversion. Set to 0 for unlimited.

Cleanup

Variable Default Description
FILE_MAX_AGE_HOURS 72 How long unsaved processing results (raw uploads and tool outputs) are kept before automatic deletion. Files you explicitly save to the Files library are not affected and persist until you delete them.
CLEANUP_INTERVAL_MINUTES 60 How often the cleanup job runs.

Appearance

Variable Default Description
DEFAULT_THEME light Default theme for new sessions. light or dark.
DEFAULT_LOCALE en Default interface language.
DEFAULT_TOOL_VIEW sidebar Default tool layout. sidebar or fullscreen.

Docker permissions

Variable Default Description
PUID 999 Run the container process as this UID. Set to match your host user for bind mounts (id -u).
PGID 999 Run the container process as this GID. Set to match your host group for bind mounts (id -g).

Docker example

services:
  SnapOtter:
    image: snapotter/snapotter:latest
    ports:
      - "1349:1349"
    volumes:
      - SnapOtter-data:/data
      - SnapOtter-workspace:/tmp/workspace
    environment:
      - AUTH_ENABLED=true
      - DEFAULT_USERNAME=admin
      - DEFAULT_PASSWORD=changeme
      - DATABASE_URL=postgres://snapotter:snapotter@postgres:5432/snapotter
      - REDIS_URL=redis://redis:6379
      - MAX_UPLOAD_SIZE_MB=200
      - CONCURRENT_JOBS=4
      - FILE_MAX_AGE_HOURS=12
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:17-alpine
    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

  redis:
    image: redis:8-alpine
    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

volumes:
  SnapOtter-data:
  SnapOtter-workspace:
  SnapOtter-pgdata:
  SnapOtter-redisdata:

Volumes

The Docker Compose stack uses four volumes:

  • /data (app) - AI models, Python venv, and user files. Mount this to keep uploaded files and installed AI bundles across restarts.
  • /tmp/workspace (app) - Temporary storage for files being processed. This can be ephemeral, but mounting it avoids filling up the container's writable layer.
  • SnapOtter-pgdata (postgres) - PostgreSQL data directory. This holds all relational data (users, settings, pipelines, jobs, audit log). Back up via pg_dump or volume snapshot.
  • SnapOtter-redisdata (redis) - Redis append-only file for durable job queues.