1
0
Fork 0
mirror of https://github.com/paperless-ngx/paperless-ngx.git synced 2026-07-26 02:32:56 +02:00
31 Using a Reverse Proxy with Paperless ngx
pkoevesdi edited this page 2026-07-20 10:52:02 +02:00

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_proxy with 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 as https.
    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.)