Repository navigation
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.
| 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 |
pnpm install
pnpm setup --local
docker compose up -d # http://localhost:3000Services, 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/canvasand the pdf.js renderer ship native binaries), with a separate migrator stage. - The app starts only after
migrateexits successfully (migrations plus the workflow schema). - Durable runs live in Postgres through
@workflow/world-postgres, andinstrumentation.tsstarts 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,rcclientv0.1.35) replaced MinIO in 1.3.0.rustfs-volume-inithands the existing volume to RustFS's user, andrustfs-initcreates 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
localhostinOLLAMA_BASE_URL/AI_BASE_URLtohost.docker.internal. Cloud credential files (AWS profiles, Google ADC) are not mounted automatically; add a read-only mount in a Compose override. -
ANONIFY_PUBLIC_URLis 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).
| 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) |
| 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
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # ENCRYPTION_KEY
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # FINGERPRINT_SECRETCRON_SECRET: any long random string. A malformed ENCRYPTION_KEY fails the deployment at boot rather than the first upload.
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.
- Build command: the default
pnpm build(postinstallrunsprisma generateand 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 (
iad1is the default for both).
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 401Hobby'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.
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_BYTESinlib/config.tsif 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.
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 cleanupThe 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_SECRETbefore 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 pointingANONIFY_URLat 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.
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).
- Set
ANONIFY_PROFILE=demo, or set quotas explicitly.self-hostedhas no daily quotas, which is fine on a laptop and not on a shared URL. This includesANONIFY_QUOTA_ASSISTANT_TOKENSif a model is configured. - Review rate limits:
pnpm rate-limit show. - Set
ANONIFY_AI_DAILY_SPEND_USDwith 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
curlabove), and that it is the endpoint variant if you want the admission backstop. - Set
ANONIFY_PUBLIC_URLso 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.
Anonify: AI proposes, the application applies, and only what a person accepted is removed.
Repository · Report a bug · Request a feature · Security policy · Apache-2.0
This wiki describes v1.10.0 and summarises and links the in-repo documentation. Where the two disagree, the files under docs/, CHANGELOG.md and SECURITY.md are canonical.
Getting going
Using it
- Supported Formats
- Review Workspace
- Search and Rules
- Hush Assistant
- Redaction Presets
- Export and Anonymisation
- Batches and Mailboxes
How it works
- Architecture
- Processing Workflow
- Redaction Engine
- Storage and Encryption
- Streaming IO
- Data Model
- API Reference
- Security Model
When it breaks
Contributing
Repository · Issues · Releases · Changelog