SSD Nodes Learn 8GB RAM — $66/yr
Guides Matt ConnorBy Matt Connor

Paperless-ngx on a VPS: self-host documents

Run paperless-ngx on a VPS with Docker Compose: the official Postgres stack, PAPERLESS_URL, the consume folder, OCR languages, HTTPS, and backups.

What you are building

Paperless-ngx on a VPS turns a folder of scanned paper into a searchable archive. You drop a PDF into a watched directory, the server runs OCR (optical character recognition) on it, extracts the text, guesses a date and a correspondent, and files it. The install is one Docker Compose file with four services. Everything after that is configuration, and this guide spends most of its length there, because that is where installs break.

Paperless-ngx is the maintained community fork of the original Paperless project. It is free, self-hosted, and stores your documents as plain files on disk, so you are never locked out of your own archive. Running it on a VPS instead of a home box means your scans are reachable from anywhere without opening a port on your home router, and it pairs well with a private Nextcloud instance for the files that are not paper.

What the stack actually runs

The official compose file starts four containers, and knowing what each one does makes the logs readable.

  • webserver: the paperless-ngx image itself. It runs the web interface, the API, the consumer that watches your input folder, and the Celery task workers that do OCR.
  • db: PostgreSQL. It holds metadata, tags, correspondents, and the full-text search index tables. It does not hold your PDFs.
  • broker: Valkey, a Redis-compatible key-value store. It is the task queue between the web process and the workers.
  • gotenberg and tika: optional, only in the -tika compose variants. They convert Office documents (.docx, .xlsx, .odt) to PDF so paperless can index them.

As of July 2026 the postgres compose file pins docker.io/library/postgres:18 and docker.io/valkey/valkey:9-alpine, and pulls the app from ghcr.io/paperless-ngx/paperless-ngx:latest.

Prerequisites

  • An Ubuntu 24.04 KVM VPS with sudo access, and Docker with the Compose plugin already installed. If that part is new, start with the Docker Compose fundamentals for a VPS and come back.
  • A domain name with an A record pointing at the VPS. Paperless refuses to serve on a hostname it has not been told about, so this matters earlier than you expect.
  • Memory is the real constraint. PostgreSQL, Valkey, gunicorn and a Tesseract OCR worker all resident at once fit in 2 GB for light use. Give it 4 GB if you plan to import a backlog of hundreds of scans, because OCR on a large multi-page PDF is the memory spike that gets a worker killed by the kernel out-of-memory killer.
  • Disk: your archive is stored twice, the original file and an OCR'd archive PDF, so budget roughly double the size of your scans.

Get the official compose files

There is an interactive installer:

bash -c "$(curl --location --silent --show-error https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/install-paperless-ngx.sh)"

It asks questions and writes the files for you. Doing it by hand is four commands and leaves you knowing where everything is, which is what you want on a server you will maintain.

mkdir -p ~/paperless && cd ~/paperless
curl -fsSL -o docker-compose.yml https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docker/compose/docker-compose.postgres.yml
curl -fsSL -o docker-compose.env https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docker/compose/docker-compose.env
curl -fsSL -o .env https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docker/compose/.env

The variants live in the same directory: docker-compose.sqlite.yml, docker-compose.mariadb.yml, and a -tika version of each. Pick postgres for a new install. SQLite is fine for a few hundred documents but the full-text search index gets slow well before PostgreSQL does.

The .env file holds one line, COMPOSE_PROJECT_NAME=paperless. That name becomes the prefix on every container and volume, so do not delete it and then wonder why docker compose down -v cannot find your data.

Configure docker-compose.env before the first start

Two settings are not optional. Generate the secret key with the command the project documents:

python3 -c "import secrets; print(secrets.token_urlsafe(64))"

Then edit docker-compose.env:

PAPERLESS_SECRET_KEY=<the long string you just generated>
PAPERLESS_URL=https://paperless.example.com
PAPERLESS_TIME_ZONE=Europe/Berlin
PAPERLESS_OCR_LANGUAGE=deu+eng
USERMAP_UID=1000
USERMAP_GID=1000

PAPERLESS_SECRET_KEY ships as the literal value change-me. It signs session cookies, so leaving it means anyone who knows the default can forge a session. Set it before the first start, because changing it later logs every user out.

PAPERLESS_URL is the one that saves you an hour. Paperless is a Django application, and Django validates the Host header of every request. Set PAPERLESS_URL and it fills in ALLOWED_HOSTS, CORS_ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS for you. Leave it empty, point a domain at the box, and every page returns Bad Request (400) with DisallowedHost in the container log. Write it with no trailing slash and no path.

USERMAP_UID and USERMAP_GID set the user the container runs as. Match them to your own account, checked with id -u and id -g. If they do not match, files you copy into the consume folder are unreadable by the consumer, and the log shows a permission error instead of an import.

Start the stack and create the first user

docker compose pull
docker compose up -d
docker compose run --rm webserver createsuperuser
docker compose logs -f webserver

createsuperuser prompts for a username, an email and a password. There is no default login, so skipping this step leaves you at a sign-in page that will never accept anything. Wait for the log line reporting that the server is listening on port 8000 before you try the browser. The very first start also runs database migrations, which takes a minute or two.

Check it locally before involving a domain:

curl -I http://127.0.0.1:8000

A 302 redirect to /accounts/login/ means the stack is healthy.

Put HTTPS in front of it

The stock compose file publishes 8000:8000, which binds to every interface. On a public VPS that serves your entire document archive over plain HTTP to anyone who finds the address. Change the port line to bind to loopback only:

    ports:
      - "127.0.0.1:8000:8000"

Then terminate TLS (transport layer security) in a reverse proxy and forward to 127.0.0.1:8000. If this is the only app on the box, any proxy with an ACME (automatic certificate management environment) client will do. If you are running several containers behind one certificate setup, follow the Traefik reverse proxy pattern for multiple Docker Compose apps and attach the webserver service to the proxy network with no published port at all.

Whatever proxy you use, it must send X-Forwarded-Proto: https. Without it Django believes the request arrived over HTTP, the origin check on the login form fails, and you get CSRF verification failed. Request aborted. on a page that looks correct. The other half of that fix is PAPERLESS_URL being set to the exact https:// address you type in the browser.

Also raise the proxy's upload size limit. A 40 MB scan through a proxy that caps bodies at 1 MB is rejected before paperless ever sees it, and the browser reports a generic upload failure.

How the consume directory works

The compose file bind-mounts ./consume from the compose directory into the container. Anything you put there is imported and then deleted from the folder, because the file now lives in the media volume under paperless management.

cp ~/scan-2026-07-14.pdf ~/paperless/consume/
docker compose logs -f webserver

You should see the consumer pick up the filename, run OCR, and finish with a line reporting the document was added. The whole cycle is seconds for a one-page scan and can be a minute or more for a long document.

Two settings change how files are found. PAPERLESS_CONSUMER_RECURSIVE=true makes paperless look in subfolders, and PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS=true turns each subfolder name into a tag, so dropping a file into consume/invoices/2026/ tags it invoices and 2026. That is the cheapest filing system you will ever build.

Detection is the other half. By default PAPERLESS_CONSUMER_POLLING_INTERVAL is 0, meaning paperless uses kernel filesystem notifications, which fire immediately. Those notifications do not cross a network filesystem. If your consume folder is an NFS or SMB share so a network scanner can write to it, nothing is ever detected, and the fix is to set the interval to a positive number of seconds so paperless scans the folder instead.

OCR languages, and what they cost

PAPERLESS_OCR_LANGUAGE takes a three-letter Tesseract code, eng by default. Combine languages with a plus sign, as in deu+eng. Tesseract then tries each one and keeps the best result, so every extra language multiplies the CPU time spent on every page. On a shared-vCPU VPS that is the difference between a scan finishing in ten seconds and finishing in a minute. List only the languages your documents are actually written in.

The image ships English, German, Italian, Spanish and French. For anything else, add the language to PAPERLESS_OCR_LANGUAGES as a space-separated list, for example PAPERLESS_OCR_LANGUAGES=tur ces, and restart. The container downloads the Tesseract data packs at startup, so the first boot after that change is slower.

Back up the database and the media

Copying the Docker volumes while PostgreSQL is running gives you a backup that may not restore. Paperless ships its own exporter, which writes documents plus a JSON manifest of all metadata into the ./export bind mount:

docker compose exec webserver document_exporter ../export --delete --no-progress-bar

--delete removes exported files that no longer match a current document, so the folder stays a mirror rather than growing forever. --no-progress-bar keeps the output clean when this runs from cron.

Restoring is document_importer against that same folder on a fresh stack, which means the export directory is the only thing you have to keep safe. Send it offsite on a schedule with encrypted, deduplicated restic backups from your VPS, and run the export first so restic never captures a half-written archive.

Verify a backup by checking that export/manifest.json exists and that the file count matches your document count in the interface. A backup you have never listed is not a backup.

FAQ

Why does every page return "Bad Request (400)" after I point my domain at it?

Django rejected the Host header because your domain is not in ALLOWED_HOSTS. Set PAPERLESS_URL=https://paperless.example.com in docker-compose.env, with no trailing slash, then run docker compose up -d to recreate the container. Editing the env file alone does nothing, because the running container keeps the environment it started with.

I dropped a PDF in the consume folder and nothing happened. What is wrong?

Check docker compose logs webserver first. A permission error means USERMAP_UID and USERMAP_GID do not match the account that owns the file, so fix those and recreate the container. No log line at all means the file event never arrived, which happens on network shares because kernel notifications do not cross them. Set PAPERLESS_CONSUMER_POLLING_INTERVAL to something like 30 and paperless will scan the folder every 30 seconds instead.

Can I run paperless-ngx with SQLite instead of PostgreSQL?

Yes, docker-compose.sqlite.yml is supported and uses less memory, which suits a small VPS. The tradeoff shows up as your archive grows: full-text search and bulk tag edits slow down noticeably in the thousands of documents. Migrating later means an export and an import, so pick PostgreSQL now if you expect the archive to keep growing.

How much disk does an archive of scans actually need?

Roughly twice the size of your source files. Paperless keeps the original untouched and stores a second OCR'd PDF with a searchable text layer, plus small thumbnails. A 200 KB text-only scan stays small. A 30 MB colour scan of a long contract stores about 60 MB. Add the export directory if you keep it on the same disk, and the same archive is on disk three times.

Do I need the Tika and Gotenberg containers?

Only if you want Word, Excel or OpenDocument files indexed alongside your PDFs. They convert those formats to PDF so paperless can OCR and search them. They also add two more running containers and a few hundred megabytes of memory, so skip them on a small box if everything you file is already a PDF or an image.

#paperless-ngx#documents#self-hosting#docker#ocr