Skip to content

Using a Reverse Proxy with Paperless ngx

Justin Hanley edited this page May 30, 2025 · 15 revisions

Nginx | Apache | Caddy | Traefik

If you want to expose paperless to the internet, you should hide it behind a reverse proxy with SSL enabled. The officially supported method is Nginx.

If you get a CSRF verification failed error upon login, your compose or .env file is missing a valid PAPERLESS_URL value.

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 {

        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;
            add_header Referrer-Policy "strict-origin-when-cross-origin";
        }
    }
}

The PAPERLESS_URL configuration variable is also required when using a reverse proxy; consider setting USE_X_FORWARD_HOST=true, 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 adjust proxy_pass as well:

        ...
        location /paperless {

            # Adjust host and port as required.
            proxy_pass http://localhost:8000/paperless;
        ...

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.

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"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    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 {
    reverse_proxy http://localhost:8000 {
        header_down Referrer-Policy "strict-origin-when-cross-origin"
    }
}

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}
        }
}

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"
Clone this wiki locally
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy