Table of Contents
Nginx | Nginx Proxy Manager | 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 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"
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_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"
Feel free to contribute to the wiki pages - enhance and extend the content!
Also browse Discussions & connect in Matrix chat.