Skip to content

Deployment

Nabeel Wasif edited this page Sep 29, 2026 · 2 revisions

Deployment

Three ways to run Anonify. The one thing they all have in common: documents are temporary, and that is only true if something is actually deleting them.

Canonical source for Vercel: docs/deploy-vercel.md. Upgrading an existing install: Upgrading.


Which one

Docker Compose Vercel (Hobby) Node on a host
External accounts none Neon, Vercel Blob, Mistral (+ optional AI key) your choice
Storage RustFS (S3-compatible) Vercel Blob S3 / RustFS / local disk
Upload path through /api/upload/local browser → Blob directly through /api/upload/local
OCR Tesseract, local Mistral either
AI model optional; Ollama / LM Studio / llama.cpp on the host, or any hosted provider any hosted provider any
Durable runs Postgres + a polling worker the platform Postgres + a polling worker
Expiry sweep opt-in scheduler, or pnpm cleanup from cron vercel.json cron (daily on Hobby) + sweep.yml Action your cron / systemd timer
Best for privacy, evaluation, offline a public demo a real self-hosted service

1. Docker Compose

pnpm install
pnpm setup --local
docker compose up -d     # http://localhost:3000

Services, ports and credentials: Getting Started §2. Every port binds to 127.0.0.1.

  • The image is a Next.js standalone build on node:22-slim (Debian, not Alpine, because sharp, @napi-rs/canvas and the pdf.js renderer ship native binaries), with a separate migrator stage.
  • The app starts only after migrate exits successfully (migrations plus the workflow schema).
  • Durable runs live in Postgres through @workflow/world-postgres, and instrumentation.ts starts the worker. Without that worker, uploads are accepted and never processed. The startup log line "message":"workflow worker started" is how to tell.
  • RustFS (pinned 1.0.0-rc.6, rc client v0.1.35) replaced MinIO in 1.3.0. rustfs-volume-init hands the existing volume to RustFS's user, and rustfs-init creates the bucket with --ignore-existing, so both a fresh install and an upgraded volume start cleanly.
  • A local model on the host is reachable from the app container: Compose maps localhost in OLLAMA_BASE_URL / AI_BASE_URL to host.docker.internal. Cloud credential files (AWS profiles, Google ADC) are not mounted automatically; add a read-only mount in a Compose override.
  • ANONIFY_PUBLIC_URL is passed to the build. Rebuild after changing it.

CI boots this exact stack on every pull request, runs pnpm smoke:storage, and redacts every format through it with pnpm smoke. That is how the container's assembly gets checked at all: a missing native library or an unreachable workflow world passes every unit test and fails there. A weekly job does the same with a real Ollama + Qwen3-VL model (pnpm smoke --ai).


2. Vercel, Hobby plan

What Hobby actually constrains

Limit Hobby's value Where it bites
Function duration 300 s, default and maximum the SSE streams wanted 900 s (#53)
Cron frequency once per day; more often fails the deploy the expiry sweep wants every 15 min
Response body 4.5 MB buffered every download (hence streaming)
Function bundle 250 MB uncompressed the workflow step function with OCR is ~228 MB
Memory / CPU 2 GB / 1 vCPU Tesseract on a multi-page scan (hence Mistral OCR)

Services

Service Gives you Variable
Neon Postgres DATABASE_URL (the pooled string)
Vercel Blob storage (Storage → Create → Blob, then connect it) BLOB_READ_WRITE_TOKEN (set by connecting)
Mistral OCR MISTRAL_API_KEY
an AI provider the contextual pass and Hush (optional) e.g. AI_GATEWAY_API_KEY, or any provider from AI Providers

Secrets

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"   # ENCRYPTION_KEY
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"   # FINGERPRINT_SECRET

CRON_SECRET: any long random string. A malformed ENCRYPTION_KEY fails the deployment at boot rather than the first upload.

Environment variables (Production and Preview)

DATABASE_URL           postgresql://…            # Neon, pooled
BLOB_READ_WRITE_TOKEN  vercel_blob_rw_…
ENCRYPTION_KEY         <64 hex chars>
FINGERPRINT_SECRET     <64 hex chars>
CRON_SECRET            <random>
OCR_PROVIDER           mistral                   # must be set at BUILD time
MISTRAL_API_KEY        <key>
ANONIFY_PROFILE        demo                      # strict quotas and rate limits
AI_GATEWAY_API_KEY     <key>                     # optional; or another provider's settings

Leave WORKFLOW_TARGET_WORLD and WORKFLOW_POSTGRES_URL unset. They select the self-hosted world and start a polling worker, which on Vercel would run inside a function that does not live long enough to poll. OCR_PROVIDER must be visible at build time, because it decides what is bundled into the step function. For a provider other than the Gateway, run pnpm ai verify locally against the same model and copy AI_PROVIDER, AI_MODEL and AI_MODEL_CAPABILITIES in.

Project settings

  • Build command: the default pnpm build (postinstall runs prisma generate and copies the PDF worker).
  • Node 22+. Fluid compute enabled (the 300 s maximum depends on it; without it Hobby caps at 60 s).
  • One region, near your Neon database (iad1 is the default for both).

Migrate, then deploy

Vercel does not run migrations. From your machine, with DATABASE_URL pointing at production:

pnpm db:migrate:deploy
vercel --prod
curl -H "authorization: Bearer $CRON_SECRET" https://<your-app>/api/cron/cleanup   # should return counts, not 401

The sweep on Hobby: sweep.yml

Hobby's cron fires once a day, but retention is a promise about how long a document lives, and a document that outlives its TTL by hours has broken it. .github/workflows/sweep.yml fills the gap by calling /api/cron/cleanup roughly every 15 minutes from GitHub Actions. To use it, add two repository secrets:

Secret Value
SWEEP_URL https://<your-deployment>/api/cron/cleanup
CRON_SECRET the same value as the deployment's

Without them the job exits without calling anything, so a fork never fails every fifteen minutes. On Pro, delete it and raise the schedule in vercel.json instead. Running both is harmless, because the work is idempotent.

Known limits of a Hobby deployment

None of these are bugs.

  • Uploads over ~20 MB may not finish processing. The upload itself is fine (it goes straight to Blob), but a step is bounded by 2 GB, one core and 300 s, and a large scanned PDF can exhaust that. The run fails cleanly. Lower MAX_UPLOAD_BYTES in lib/config.ts if you would rather refuse such files up front.
  • Blob storage is 1 GB, holding sources, models and exports. The sweep keeps it bounded.
  • Workflow runs are kept for 1 day (7 on Pro). Documents follow their own TTL.
  • 50,000 workflow events and 1 GB of workflow data a month. A document's run makes about 11 step calls at 3 events each, plus retries: roughly 35–40 events per document, so a bit over a thousand documents a month. Batch exports are extra runs.
  • Cold starts are slow on the ~87 MB step function with native bindings.

3. Expiry: the part you cannot skip when self-hosting

On Vercel, the sweep is the cron entry in vercel.json. Nothing outside Vercel reads that file.

Option How Admits stalled queued documents?
A. Run it directly pnpm cleanup from cron, a systemd timer (OnUnitActiveSec=15min) or Task Scheduler. No server, no secret. Exits non-zero if a document could not be fully purged. No
B. Compose scheduler docker compose --profile scheduler up -d. Calls the endpoint every CLEANUP_INTERVAL_SECONDS (900) at ANONIFY_URL (http://app:3000; use http://host.docker.internal:3000 for an app on the host). Yes
C. Call the endpoint yourself curl -H "authorization: Bearer $CRON_SECRET" https://…/api/cron/cleanup from any scheduler Yes
*/15 * * * *  cd /srv/anonify && pnpm cleanup

The endpoint (options B and C) also admits documents left queued behind a slot that a killed run never freed. If you use option A and a run is ever killed mid-flight (a crash, a deploy mid-batch), documents may wait in queued until the next upload, retry or run end admits them. Prefer B or C on a busy instance.

Set CRON_SECRET before using the scheduler. The image runs as production, where the endpoint refuses any request without it. Without one, the scheduler starts, gets a 401 every cycle, and documents outlive their retention window. It says so, once at startup and on every refusal. (Outside production the endpoint accepts an unauthenticated sweep, so pointing ANONIFY_URL at a dev server is legitimate and only warns.)

Whichever you choose, the sweep deletes the source, the normalized model, every export, report and vault, then the row, and is idempotent. A document removed by someone else during the sweep no longer fails the run (1.8.0), and one document that cannot be purged is retried next time instead of stopping the rest.


4. Building standalone yourself

NEXT_OUTPUT=standalone pnpm build produces .next/standalone. Since 1.8.0 it no longer copies the working tree into the output. Before that, it copied source, tests, docs, local document storage (.anonify-storage), downloaded OCR models, and .env with the key that opens those documents. The container image and Vercel were not affected. If you built standalone on a machine with local storage and copied .next/standalone elsewhere, delete that copy's .anonify-storage. The output is also smaller (about 155 MB down to 138 MB).


5. Before you expose it to other people

  • Set ANONIFY_PROFILE=demo, or set quotas explicitly. self-hosted has no daily quotas, which is fine on a laptop and not on a shared URL. This includes ANONIFY_QUOTA_ASSISTANT_TOKENS if a model is configured.
  • Review rate limits: pnpm rate-limit show.
  • Set ANONIFY_AI_DAILY_SPEND_USD with per-model prices (AI_MODEL_PRICES), or the cap cannot be computed.
  • Use an API key, not a ChatGPT sign-in, for any shared instance.
  • Confirm the expiry sweep runs (the curl above), and that it is the endpoint variant if you want the admission backstop.
  • Set ANONIFY_PUBLIC_URL so link previews point at you.
  • Do not reuse the Compose development credentials anywhere reachable from outside your machine.
  • Remember the demo is anonymous: whoever holds the cookie owns the documents. For real multi-user identity, fork it (ownership flows through one function, requireDocument).

See Configuration for every setting.


See also

Clone this wiki locally