{"slug": "from-paperless-gpt-to-paperless-ngx-v3-dropping-a-container-cutting-complexity", "title": "From paperless-gpt to Paperless-NGX v3: dropping a container, cutting complexity", "summary": "Paperless-NGX v3.0.0 upgrade required replacing the paperless-gpt side container with built-in AI configured for Gemini via the OpenAI-compatible endpoint, and the embedding model name must be 'gemini-embedding-001' instead of 'text-embedding-004'. The upgrade of a 1,805-document library took 11 minutes, with four config changes including pinning the image to 3.0.0, setting PAPERLESS_DBENGINE to postgresql, changing OCR_MODE to skip_noarchive, and deleting PAPERLESS_OCR_SKIP_ARCHIVE_FILE. The author built a rollback plan with backups and hardlink snapshots, but never needed it.", "body_md": "# From paperless-gpt to Paperless-NGX v3: dropping a container, cutting complexity\n\nUpgrading to Paperless-NGX v3, replacing the paperless-gpt side container with the new built-in AI configured for Gemini via the OpenAI-compatible endpoint. Includes the one embedding model gotcha that took me ten minutes to find.\n\n## The 404 that took me ten minutes to understand\n\n```\nOpenAIError(\"Error code: 404 - {'error': {'code': 404, 'message':\n'models/text-embedding-004 is not found for API version v1main, or is\nnot supported for embedContent...'}}\")\n```\n\nThat was Paperless-NGX v3, after I'd upgraded my 1,805-document library, ripped out the side container that used to handle AI, and pointed the new built-in AI at Gemini. `text-embedding-004`\n\nis the name Google's own docs use for their embedding model. It's the name every tutorial uses. It is not the name Gemini's OpenAI-compatible endpoint accepts.\n\nThe correct name is `gemini-embedding-001`\n\n. Nothing on the internet told me that. This post exists so you don't spend ten minutes staring at a stack trace to find it.\n\nEverything else about the v3 upgrade - the DB migration, the rollback plan, dropping the paperless-gpt container, the config-as-code work - was routine. That one line was the whole trick.\n\n## Before I upgraded, I built a rollback\n\nThe v3 migration doc has one line worth taking seriously: **\"the document contains no rollback procedures or downgrade guidance.\"** Once v3's Postgres migrations run, you're on the new schema. There is no downgrade command.\n\nSo the plan started with backups - not the daily one that's been running for months, but a fresh one, five minutes before the pull:\n\n```\n# 1. Fresh Paperless export (the app's own backup)\n./paperless.sh backup\n\n# 2. Direct pg_dump into a file under my control\ndocker exec paperless-db pg_dump -U paperless -Fc paperless \\\n  > pre-v3-dump.pgcustom\n\n# 3. Hardlink snapshots of media/ and data/ (zero extra disk on the same FS)\ncp -al media media.pre-v3\ncp -al data  data.pre-v3\n\n# 4. Pin the current 2.20.15 image by digest\ndocker inspect paperless-ngx --format '{{.Image}}' > pre-v3-image-digest.txt\n\n# 5. Snapshot .env and docker-compose.yml\ncp .env .env.pre-v3\ncp docker-compose.yml docker-compose.yml.pre-v3\n```\n\nThe `cp -al`\n\ntrick is the one worth knowing: on the same filesystem it makes a hardlink tree that costs zero bytes until v3 actually rewrites a file. If anything had gone sideways, I could stop the container, `pg_restore`\n\nthe dump, rename my snapshots back into place, revert the compose file, and be running 2.20.15 again in a couple of minutes.\n\nI never had to. But knowing I could is what let me hit \"go\" at all.\n\n## The four config changes v3 forced\n\nReading the migration doc, I made a list of what would break in my existing `docker-compose.yml`\n\n:\n\n| Before (v2.20.15) | After (v3.0.0) | Why |\n|---|---|---|\n`image: paperless-ngx:latest` |\n`image: paperless-ngx:3.0.0` |\nNever let a `docker compose pull` do a major-version upgrade unattended. Pin. |\n(no `PAPERLESS_DBENGINE` ) |\n`PAPERLESS_DBENGINE: postgresql` |\nv3 no longer infers the engine from `DBHOST` . Must be explicit. |\n`PAPERLESS_OCR_MODE: skip` |\n`PAPERLESS_OCR_MODE: skip_noarchive` |\nThe `skip` variant was removed. `skip_noarchive` is the closest match. |\n`PAPERLESS_OCR_SKIP_ARCHIVE_FILE: with_text` |\n(deleted) |\nThe setting is gone; behaviour is folded into `OCR_MODE` . |\n\nThat's it. Everything else - 40-ish other env vars for barcodes, ASN, storage paths, mail rules, workflows - carried through untouched.\n\n## The upgrade itself: 11 minutes\n\nI made the four edits, changed the image tag, ran `docker compose pull paperless && docker compose up -d paperless`\n\n, and tailed the logs.\n\n```\nRunning migrations:\n  Applying documents.0003_remove_document_storage_type... OK\n  Applying documents.0004_workflowtrigger_filter_has_any_correspondents_and_more... OK\n  ... (through 0015) ...\n[paperless.migrations] Recomputing SHA-256 checksums for 1809 document(s)...\n[paperless.migrations] SHA-256 checksum progress: 500/1809 (27%)\n```\n\nThe checksum recompute is a v3 thing - every document gets re-hashed under the new integrity system. It ran for about six minutes. Then the search index switched from Whoosh (v2's default) to Tantivy (v3's default) in the background. `docker compose ps`\n\nsaid `healthy`\n\nafter eleven minutes total.\n\nNothing broke. All documents present, 20 workflows intact, 522 correspondents there, mail rules still firing.\n\n## Ripping out paperless-gpt\n\nMy old setup had a companion container called **paperless-gpt** - a Go service that watched Paperless via API, called Gemini for each new document, and wrote back title/tag/correspondent/type suggestions. It worked well. It also meant:\n\n- Two containers to keep updated\n- A Google Cloud service-account JSON file baked into a bind mount\n- API tokens and prompt templates to remember to back up\n- A layer of indirection every time I wanted to change an AI setting\n\nv3 folds all of that into the main app. What v3's built-in AI actually does:\n\n- Every document details page has a\n**Suggestions** drawer with LLM-generated proposals for title, correspondent, document type, tags, storage path, and dates. One click accepts each. - The LLM index gets consulted internally as RAG context (similar prior documents feed the suggestion prompt, so your existing correspondent and tag conventions carry over to new docs).\n- There's also a chat interface for asking questions across the library (\n`/api/chat/`\n\nstreams answers; the UI exposes it as an \"Ask AI\" panel). - A weekly cron rebuilds the index for consistency; incremental updates happen automatically on ingest.\n- One\n`PAPERLESS_AI_ENABLED`\n\nflag turns the whole thing on or off.\n\nSame behaviour paperless-gpt gave me, minus a container.\n\n### Wiring Gemini into v3 (the gotcha, in context)\n\nv3's dropdown for **LLM Backend** offers two options: `openai-like`\n\nand `ollama`\n\n. That's it. No \"Gemini\" option.\n\nGemini exposes an OpenAI-compatible endpoint, so `openai-like`\n\nis the right choice - you just fill in Gemini's URL. Here's what I have in my `docker-compose.yml`\n\n:\n\n```\nenvironment:\n  PAPERLESS_AI_ENABLED: \"true\"\n  PAPERLESS_AI_LLM_BACKEND: \"openai-like\"\n  PAPERLESS_AI_LLM_ENDPOINT: \"https://generativelanguage.googleapis.com/v1beta/openai/\"\n  PAPERLESS_AI_LLM_MODEL: \"gemini-2.5-flash\"\n  PAPERLESS_AI_LLM_API_KEY: \"<your key from aistudio.google.com/apikey>\"\n  PAPERLESS_AI_LLM_OUTPUT_LANGUAGE: \"de\"\n  PAPERLESS_AI_LLM_REQUEST_TIMEOUT: 60\n  PAPERLESS_AI_LLM_EMBEDDING_BACKEND: \"openai-like\"\n  PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT: \"https://generativelanguage.googleapis.com/v1beta/openai/\"\n  PAPERLESS_AI_LLM_EMBEDDING_MODEL: \"gemini-embedding-001\"\n```\n\nEvery line except the last one you could work out from the upstream docs and a copy of Google's Gemini quickstart. `PAPERLESS_AI_LLM_EMBEDDING_MODEL: \"gemini-embedding-001\"`\n\nyou cannot - the docs say `text-embedding-004`\n\n, and that value returns the 404 from the top of this post. Google's OpenAI-compat gateway routes to a different API surface (`v1main`\n\n) than their native (`v1beta`\n\n), and that surface only accepts the newer model name. Nothing in Google's documentation makes that obvious.\n\nOnce flipped, the LLM index rebuild ran in **691.9 seconds** - 11 min 32 sec - and embedded the whole library into a 125 MB local vector database with zero rate-limit errors.\n\n### Cost sanity check\n\nNumbers before turning it loose on the whole library. Paid tier:\n\n- One-time index build:\n**~$0.54**(3.6M embedding tokens) - Per new document (embedding + auto-tag call):\n**~$0.003** - Weekly consistency task: near-zero, incremental\n- 20 documents a day:\n**~$0.06/day → about $2/month**\n\nGoogle AI Studio free tier absorbed the entire rebuild - 1,805 embedding calls in 11 minutes, no throttling - so this is realistically free for a personal library at normal ingest rates. Batch imports of hundreds of docs at once will spike above the free daily quota and either 429 or start billing, depending on how you have the key configured.\n\n### Config in the compose file, not in the UI\n\nOne thing bothered me after I first got it working. I'd set the AI options through Paperless's Application Configuration screen, which stores them in Postgres. That's fine while running - but a DB restore from any pre-today backup would silently lose the AI config, and my `docker-compose.yml`\n\nwas no longer a complete description of the running system.\n\nThe upstream docs are explicit: every one of those settings has an equivalent env var. So the compose block above isn't just illustrative - that's where the config actually lives on my box. The rest of my Paperless setup is already configured this way; AI shouldn't be different.\n\nClearing the UI overrides so the env vars actually take effect is one API call:\n\n```\ncurl -X PATCH -H \"Authorization: Token $PAPERLESS_TOKEN\" \\\n     -H \"Content-Type: application/json\" \\\n     \"$PAPERLESS_URL/api/config/1/\" \\\n     -d '{\"ai_enabled\": null, \"llm_backend\": null, \"llm_endpoint\": null,\n          \"llm_model\": null, \"llm_api_key\": null, \"llm_output_language\": null,\n          \"llm_request_timeout\": null, \"llm_embedding_backend\": null,\n          \"llm_embedding_endpoint\": null, \"llm_embedding_model\": null}'\n```\n\nNull in the DB means \"inherit from env.\" After that, the AI Settings tab in the UI shows every field blank - matching the pattern Barcode and OCR settings have followed forever.\n\n## The installer already knew v2. Now it knows v3.\n\nThe public installer at [tural-ali/paperless-overconfigured](https://github.com/tural-ali/paperless-overconfigured?ref=turalali.com) needed the same treatment. In case you're running it: `PAPERLESS_VERSION=3.0.0`\n\nis pinned in the generated `.env`\n\n, the four v3 config changes are baked in, and step `[4/9]`\n\nnow offers Gemini (with the correct embedding model name), OpenAI native, or Ollama - no more `paperless-gpt`\n\nservice in the compose. The Google Document AI OCR sub-question is gone, since v3 has no native slot for Document AI and Tesseract has been good enough for me since Paperless improved its multilingual handling.\n\nThe commit is `84c8075`\n\non `main`\n\n. `bash <(curl -fsSL …/install.sh)`\n\nfrom today wires up v3 native AI in one go.\n\n## What I'd tell someone else doing this upgrade\n\nThree things I didn't figure out until I'd already done them:\n\n**Watch** The file starts at zero and reaches a plateau size when the initial embed is done. Divide that size by your document count to get bytes-per-doc; multiply the byte total by your provider's per-token rate to size a year of ingest before you commit. On Gemini I hit ~$0.30/1,000 docs. Cheap enough that I stopped thinking about it after the first estimate.`llmindex.db`\n\ngrow to estimate ongoing cost.**Delay the paperless-gpt teardown by a few days.** Stopping the container is free; deleting it is not, because the compose block is what documents how it worked. I kept the container stopped-but-not-deleted for a bit so I could bring it back if v3's native AI turned out worse at some specific job. It wasn't, but I liked having the option.\n\n**Test the embedding model with one curl before you trust the whole rebuild.** One request against the OpenAI-compat endpoint per candidate model name catches the `text-embedding-004`\n\ntrap in ten seconds instead of after 1,800 celery errors. The command:\n\n```\ncurl -sS -o /dev/null -w \"%{http_code}\\n\" \\\n  -H \"Authorization: Bearer $GEMINI_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  \"https://generativelanguage.googleapis.com/v1beta/openai/embeddings\" \\\n  -d '{\"model\":\"gemini-embedding-001\",\"input\":\"hello\"}'\n```\n\n200 means Paperless will accept it. 404 means don't schedule the rebuild.\n\n## Wrap\n\nUnder thirty minutes of active work. One fewer container, forty fewer lines of compose, every AI feature paperless-gpt gave me still present as a first-class part of the app. The migration doc doesn't hand you a downgrade path, so you build your own; once it's built, the upgrade itself is quiet.\n\n*Related:*", "url": "https://wpnews.pro/news/from-paperless-gpt-to-paperless-ngx-v3-dropping-a-container-cutting-complexity", "canonical_source": "https://turalali.com/from-paperless-gpt-to-paperless-ngx-v3-dropping-a-container-cutting-complexity/", "published_at": "2026-07-22 22:47:50+00:00", "updated_at": "2026-08-09 13:56:45.937817+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-tools", "ai-infrastructure"], "entities": ["Paperless-NGX", "Gemini", "Google", "OpenAI"], "alternates": {"html": "https://wpnews.pro/news/from-paperless-gpt-to-paperless-ngx-v3-dropping-a-container-cutting-complexity", "markdown": "https://wpnews.pro/news/from-paperless-gpt-to-paperless-ngx-v3-dropping-a-container-cutting-complexity.md", "text": "https://wpnews.pro/news/from-paperless-gpt-to-paperless-ngx-v3-dropping-a-container-cutting-complexity.txt", "jsonld": "https://wpnews.pro/news/from-paperless-gpt-to-paperless-ngx-v3-dropping-a-container-cutting-complexity.jsonld"}}