Nginx | Nginx Proxy Manager | Apache | Caddy | Traefik
If you want to expose paperless to the internet, ensure you place it behind a reverse proxy with SSL enabled. This also requires setting the PAPERLESS_URL value correctly.
Nginx
In addition to the usual configuration for SSL, the following configuration is required for paperless to operate:
http {
# Adjust as required. This is the maximum size for file uploads.
# The default value 1M might be a little too small.
client_max_body_size 10M;
server {
# Referrer-Policy: no-referrer everywhere except the login page, which needs
# same-origin so Django's CSRF check gets a real Origin header instead of "null"
# on the login POST (see Troubleshooting below). proxy_hide_header clears any
# Referrer-Policy already set upstream/elsewhere before add_header sets your own.
location = /accounts/login/ {
proxy_pass http://localhost:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off;
proxy_set_header Host $host:$server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $server_name;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header Referrer-Policy;
add_header Referrer-Policy "same-origin" always;
}
location / {
# Adjust host and port as required.
# For docker you need to use the docker network 172.17.0.1:8000 instead of localhost
proxy_pass http://localhost:8000;
# These configuration options are required for WebSockets to work.
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off;
proxy_set_header Host $host:$server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $server_name;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header Referrer-Policy;
add_header Referrer-Policy "no-referrer" always;
}
}
}
The PAPERLESS_URL configuration variable is also required when using a
reverse proxy; consider setting PAPERLESS_USE_X_FORWARD_HOST=true, PAPERLESS_USE_X_FORWARD_PORT=true and PAPERLESS_PROXY_SSL_HEADER='["HTTP_X_FORWARDED_PROTO", "https"]'. Please refer to the hosting and security docs.
When using a domain subpath (e.g. /paperless), you need to set PAPERLESS_FORCE_SCRIPT_NAME=/paperless and ensure the proxy_pass setup strips that prefix:
...
location /paperless/ {
# Adjust host and port as required.
proxy_pass http://localhost:8000;
...
Also read this, towards the end of the section.
Some have found adding the P3P header (add_header P3P 'CP=""'; see #817) works; only IE and Edge support it.
Also make sure to enable Websocket Support in nginx, see https://github.com/paperless-ngx/paperless-ngx/discussions/10762#discussioncomment-14299568.
Nginx Proxy Manager (NPM)
In NPM create a new proxy host and enable websockets support. You may also want to choose to "Force SSL".
Nginx Proxy Manager uses a timeout of 90s by default. This is fine for loading the webpage but will cut the websocket connection if there are no updates for 90s. To prevent that, the following custom Nginx configuration options can be used:
proxy_send_timeout 600m;
proxy_read_timeout 600m;
Apache
Below is an example of an apache2 conf file that you may customize to fit your environment and needs.
DEFINE local_url 127.0.0.1
DEFINE local_port 8000
DEFINE url_prefix paperless
DEFINE public_url ${url_prefix}.my.domain
DEFINE email ${url_prefix}@my.domain
ServerTokens Prod
SSLStaplingCache "shmcb:${APACHE_LOG_DIR}/stapling-cache(150000)"
SSLSessionCache "shmcb:${APACHE_LOG_DIR}/ssl_scache(512000)"
SSLSessionCacheTimeout 300
### If you have Google's Mod PageSpeed, disable it ###
# ModPagespeed Off
<VirtualHost *:80>
ServerName ${public_url}
DocumentRoot /var/www/html
ServerAdmin ${email}
ErrorLog ${APACHE_LOG_DIR}/${url_prefix}.error.log
CustomLog ${APACHE_LOG_DIR}/${url_prefix}.access.log combined
RewriteEngine On
RewriteCond %{REQUEST_URI} !^/\.well\-known/acme\-challenge/
RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L]
</VirtualHost>
<VirtualHost *:443>
ServerName ${public_url}
DocumentRoot /var/www/html
ServerAdmin ${email}
ErrorLog ${APACHE_LOG_DIR}/${url_prefix}.error.log
CustomLog ${APACHE_LOG_DIR}/${url_prefix}.access.log combined
SSLEngine On
SSLCertificateFile /etc/letsencrypt/live/my.domain/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/my.domain/privkey.pem
### Forbid the http1.0 protocol ###
Protocols h2 http/1.1
Timeout 360
ProxyRequests Off
ProxyPreserveHost On
ProxyTimeout 600
ProxyReceiveBufferSize 4096
SSLProxyEngine On
RequestHeader set Front-End-Https "On"
ServerSignature Off
SSLCompression Off
SSLUseStapling On
SSLStaplingResponderTimeout 5
SSLStaplingReturnResponderErrors Off
SSLSessionTickets Off
RequestHeader set X-Forwarded-Proto 'https' env=HTTPS
Header always set Strict-Transport-Security "max-age=15552000; preload"
Header always set X-Content-Type-Options nosniff
Header always set X-Robots-Tag none
Header always set X-XSS-Protection "1; mode=block"
Header always set X-Frame-Options "SAMEORIGIN"
### Referrer-Policy: no-referrer except on the login page, where Django's CSRF check needs a
### same-origin Origin header (not "null") on the login POST — see Troubleshooting below.
### Uses `onsuccess unset` + `always set`, not `always set` alone: mod_headers keeps these in
### two separate tables, so a plain override here would not reliably beat a Referrer-Policy
### already set upstream/globally (e.g. a distro default) ###
<If "%{REQUEST_URI} =~ m#^/accounts/login/#">
Header onsuccess unset Referrer-Policy
Header always set Referrer-Policy "same-origin"
</If>
<Else>
Header onsuccess unset Referrer-Policy
Header always set Referrer-Policy "no-referrer"
</Else>
RequestHeader set X-Real-IP %{REMOTE_ADDR}s
### Lax CSP and will not score the best on Mozilla Observatory or other platforms alike, but won't need to be updated with version changes ###
Header always set Content-Security-Policy "default-src 'none'; base-uri 'self'; font-src 'self' data: ${public_url}; media-src 'self' blob: data: https: ${public_url}; script-src 'self' 'unsafe-inline' ${public_url}; style-src 'self' 'unsafe-inline' ${public_url}; img-src 'self' data: blob: https: ${public_url}; worker-src * blob:; frame-src 'self' https://${public_url}; connect-src 'self' wss: https: ${public_url}; form-action 'self'; frame-ancestors 'self' https://${public_url} https://my.domain https://*.my.domain; manifest-src 'self'; object-src 'self' https://${public_url}"
Header always set Permissions-Policy 'geolocation=(self "https://${public_url}"), midi=(self "https://${public_url}"), sync-xhr=(self "https://${public_url}"), microphone=(self "https://${public_url}"), camera=(self "https://${public_url}"), magnetometer=(self "https://${public_url}"), gyroscope=(self "https://${public_url}"), fullscreen=(self "https://${public_url}"), payment=(self "https://${public_url}")'
SSLHonorCipherOrder Off
### Use next two for very secure connections ###
SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
SSLProtocol All -SSLv2 -SSLv3 -TLSv1 -TLSv1.1
### Use next two for secure connections and support more endpoints ###
#SSLCipherSuite EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH:ECDHE-RSA-AES128-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA128:DHE-RSA-AES128-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES128-GCM-SHA128:ECDHE-RSA-AES128-SHA384:ECDHE-RSA-AES128-SHA128:ECDHE-RSA-AES128-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES128-SHA128:DHE-RSA-AES128-SHA128:DHE-RSA-AES128-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA384:AES128-GCM-SHA128:AES128-SHA128:AES128-SHA128:AES128-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4
#SSLProtocol All -SSLv2 -SSLv3 -TLSv1 -TLSv1.1
### Actually proxy the traffic and really the only important part ###
AllowEncodedSlashes On
RewriteEngine On
SetEnvIf Cookie "(^|;\ *)csrftoken=([^;\ ]+)" csrftoken=$2
RequestHeader set X-CSRFToken "%{csrftoken}e"
### Proxy Websockets Section 1 (works for me) ###
RewriteCond %{HTTP:UPGRADE} ^WebSocket$ [NC]
RewriteCond %{HTTP:CONNECTION} Upgrade$ [NC]
RewriteRule ^/?(.*) "ws://${local_url}:${local_port}/$1" [P,L]
### Proxy Websockets Section 2 (untested) ###
#RewriteCond %{HTTP:UPGRADE} =websocket [NC]
#RewriteRule ^/ws/(.*) ws://${local_url}:${local_port}/ws/$1 [P,L]
### Proxy everything else ###
ProxyPass / http://${local_url}:${local_port}/ connectiontimeout=6 timeout=60
ProxyPassReverse / http://${local_url}:${local_port}/
### If Docker and/or Paperless-NGX server is down but webserver is up, show error page ###
ErrorDocument 503 '<!DOCTYPE html>\n<html xml:lang="en" lang="en" dir="ltr" prefix="og: http://ogp.me/ns#">\n<meta http-equiv="refresh" content="15" />\n<head id="head">\n<meta http-equiv="X-UA-Compatible" content="IE=edge"/>\n<title>Offline</title>\n<style>html{width:100%}body{background-color:#a6a6a6;text-align:center;font-family:Helvetica,Tahoma}</style>\n</head>\n<body>\n<h1>${public_url}</h1>\n<p>Appears to be offline... will try again every 15 seconds.<br><br>Nothing happening? Contact the <a href="mailto:${email}" target="_blank">admin</a>.</p>\n</body>\n</html>'
</VirtualHost>
Caddy
Below is a simple example Caddy configuration running on same host
:80 {
# Referrer-Policy: no-referrer everywhere except the login page, which needs
# same-origin so Django's CSRF check gets a real Origin header instead of "null"
# on the login POST (see Troubleshooting below).
@login path /accounts/login/
reverse_proxy @login http://localhost:8000 {
header_down Referrer-Policy "same-origin"
}
reverse_proxy http://localhost:8000 {
header_down Referrer-Policy "no-referrer"
}
}
Below is a more in-depth, although not comprehensive, Caddy configuration running on different host compatible with Caddy+CloudflareDNS
# Global Options Block
{
}
#### Reusable Snippets
(common) {
tls youremailaddress@host.com {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
header {
# Enable HSTS
Strict-Transport-Security "max-age=31536000; includeSubdomains"
X-XSS-Protection 0
# Prevent browsers from incorrectly detecting non-scripts as scripts and MIME type sniffing
X-Content-Type-Options nosniff
-Server
# Enable cross-site filter (XSS) and tell browser to block detected attacks
X-Frame-Options "ALLOW-FROM *.example.domain"
Permissions-Policy "geolocation=(self *.example.domain), microphone=(), interest-cohort=()"
import content-security-general
}
}
#### Content Security Snippets
(content-security-general) { # Default setup
# Disable unsafe inline/eval and plugins, only load scripts and stylesheets from same origin, fonts from google,
# and images from same origin and imgur. Sites should aim for policies like this.
Content-Security-Policy "frame-ancestors *.example.domain"
#"default-src 'self'; style-src 'self'; script-src 'self'; font-src 'self'; img-src 'self'; form-action 'self'; connect-src 'self'; frame-ancestors 'none';"
#default-src 'none'; font-src https://fonts.gstatic.com; img-src 'self' https://i.imgur.com; object-src 'none'; script-src 'self'; style-src 'self'
}
(content-security-basic) { # Most basic setup
# Disable unsafe inline/eval, only load resources from same origin except also allow images from imgur
# Also disables the execution of plugins
Content-Security-Policy default-src 'self'; img-src 'self' https://i.imgur.com; object-src 'none'
}
(content-security-api) { # API
# Disable the loading of any resources and disable framing, recommended for APIs to use
Content-Security-Policy default-src 'none'; frame-ancestors 'none'
}
#### Sites
paperless.ProxiedExample.domain {
import common
reverse_proxy https://paperless.HostExample.domain {
header_up Host {http.reverse_proxy.upstream.hostport}
}
}
Caddy and cloudflared (Cloudflare tunnel)
I had to use this configuration to allow CORS through Caddy, Cloudflare tunnel (by clourdflared), and Authelia for authentication. Any other configuration makes The WebSocket Connection fail on the System Status UI. Gemini fixed some logic mistakes from the source page and advised the Access-Control-Expose-Headers statement for app compatibility. (me)
paperless.mysite.com {
# Matchers
@cors_preflight method OPTIONS
@trusted_origin header Origin https://paperless.mysite.com
# 1. Handle Preflight
handle @cors_preflight {
header {
Access-Control-Allow-Origin "https://paperless.mysite.com"
Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
Access-Control-Allow-Headers "Content-Type, Authorization, X-CSRFToken, X-Requested-With"
Access-Control-Max-Age "86400"
Vary Origin
}
respond "" 204
}
# 2. Handle Actual Requests
handle {
header @trusted_origin {
Access-Control-Allow-Origin "{header.Origin}"
Access-Control-Expose-Headers "Content-Disposition"
Vary Origin
}
reverse_proxy localhost:8000
}
}
Traefik
Below is an example Traefik configuration you would add to the webserver container.
- Make sure you replace
traefik_proxywith the name of your own reverse proxy network. - Change the hostname to your own.
- Adjust the entrypoints if needed. Usually
websecure, some people have it setup ashttps.
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik_proxy"
- "traefik.http.routers.paperless.rule=Host(`paperless.example.com`)"
- "traefik.http.routers.paperless.entrypoints=websecure"
- "traefik.http.routers.paperless.tls=true"
- "traefik.http.routers.paperless.tls.certresolver=letsencrypt"
- "traefik.http.services.paperless.loadbalancer.server.port=8000"
Troubleshooting
CSRF verification failed / "Origin checking failed - null does not match any trusted origins"
If login fails with a CSRF 403 even though PAPERLESS_URL (or
PAPERLESS_CSRF_TRUSTED_ORIGINS/PAPERLESS_ALLOWED_HOSTS) is set correctly, and your
container logs show:
[WARNING] [django.security.csrf] Forbidden (Origin checking failed - null does not match any trusted origins.)
...suspect a Referrer-Policy: no-referrer response header, coming either from your reverse
proxy config or from something upstream of it (a base OS image default, a distro-provided
hardening template — this is a known default on DietPi, see
MichaIng/DietPi#3675).
Per the Fetch/HTML living standard,
browsers apply the same policy that governs the Referer header to the Origin header on
same-origin POST-navigation requests (e.g. submitting the login form) — no-referrer makes
the browser send a literal Origin: null, and Django's CSRF middleware then rejects it, since
null can never be a trusted origin. This reproduces identically in current Firefox and
Chromium (verified via packet capture) — it's specified browser behaviour, not a bug in one
browser. It's easy to miss because it only breaks form-based (non-GET) navigations — the
login page itself, and any JSON/fetch-based API calls, are unaffected, so everything looks
fine until you actually submit.
Apache is the one proxy here where this reliably bites: a plain Header set/Header always set does not reliably override a Referrer-Policy already present from elsewhere in
the merged config, because mod_headers keeps Header set/Header always set in two
independent internal tables — a directive in one does not remove an entry in the other,
regardless of vhost- vs server-context specificity. The Apache example above uses
Header onsuccess unset + Header always set to rewrite reliably across that table split.
Nginx and Caddy were not actually vulnerable to this in their previous (single-value)
form. Nginx's add_header inheritance is all-or-nothing per context — once a location block
sets its own add_header, an inherited global default is dropped entirely, never sent.
Caddy's header_down and an earlier/global header directive both end up in the response,
but in our testing the proxy's own header_down value consistently arrived last, and per the
Referrer Policy spec the last valid value wins (worth confirming on your own setup, since this
depends on directive ordering that isn't necessarily guaranteed). The examples above now use
the same Nextcloud-style split as Apache —
no-referrer everywhere except the login page (same-origin) — for a consistent, more
private default across all three proxies, not because it was required to avoid the bug.
Traefik: the example above sets no security headers at all, so whatever Referrer-Policy
your app or an upstream layer sends passes through unchanged. If you add headers via
customResponseHeaders and hit this, see the related open issue
traefik/traefik#2618 ("Security headers
don't overwrite upstream headers") — the reported workaround is setting the header to an empty
value first, then the real value in a second pass.
To confirm this is your issue: check the actual outgoing response headers
(curl -I against your public URL — look for Referrer-Policy: no-referrer, possibly
appearing twice), or capture the real request your browser sends (e.g. tcpdump on the
loopback interface between your proxy and Paperless, if they're on the same host) and look
for Origin: null on the failing POST to /accounts/login/.
(See also: #5684, #10177 — same symptom, this is the underlying mechanism.)
- Home
- Lists
- Setup Help
- Examples
- Troubleshooting
Feel free to contribute to the wiki pages - enhance and extend the content!
Also browse Discussions & connect in Matrix chat.