From 30c18173db8bd2d2bf38cb7283e705989eed6e49 Mon Sep 17 00:00:00 2001 From: Bobby DeSimone Date: Mon, 8 Jun 2026 18:47:20 -0700 Subject: [PATCH 1/4] docs(guides): add Paperless-ngx guide behind Pomerium front-door SSO Self-hosted document management behind Pomerium at the front-door authorization tier. Dual Zero/Core tabs, runnable docker-compose + config with byte-identical .yaml.md mirrors, request-flow diagram, sealed E2E fixtures. AI-assisted (Claude Opus); human-reviewed and validated. --- .../img/paperless-ngx/paperless-login.png | Bin 0 -> 21546 bytes .../img/paperless-ngx/request-surface.svg | 94 ++++++++++ content/docs/guides/paperless-ngx.mdx | 161 ++++++++++++++++++ .../examples/guides/paperless-ngx/config.yaml | 20 +++ .../guides/paperless-ngx/config.yaml.md | 22 +++ .../guides/paperless-ngx/docker-compose.yaml | 75 ++++++++ .../paperless-ngx/docker-compose.yaml.md | 77 +++++++++ .../paperless-ngx/validate/assert.spec.ts | 72 ++++++++ .../validate/compose.validate.yaml | 119 +++++++++++++ .../validate/routes.validate.yaml | 12 ++ .../guides/paperless-ngx/validate/url.txt | 1 + 11 files changed, 653 insertions(+) create mode 100644 content/docs/guides/img/paperless-ngx/paperless-login.png create mode 100644 content/docs/guides/img/paperless-ngx/request-surface.svg create mode 100644 content/docs/guides/paperless-ngx.mdx create mode 100644 content/examples/guides/paperless-ngx/config.yaml create mode 100644 content/examples/guides/paperless-ngx/config.yaml.md create mode 100644 content/examples/guides/paperless-ngx/docker-compose.yaml create mode 100644 content/examples/guides/paperless-ngx/docker-compose.yaml.md create mode 100644 content/examples/guides/paperless-ngx/validate/assert.spec.ts create mode 100644 content/examples/guides/paperless-ngx/validate/compose.validate.yaml create mode 100644 content/examples/guides/paperless-ngx/validate/routes.validate.yaml create mode 100644 content/examples/guides/paperless-ngx/validate/url.txt diff --git a/content/docs/guides/img/paperless-ngx/paperless-login.png b/content/docs/guides/img/paperless-ngx/paperless-login.png new file mode 100644 index 0000000000000000000000000000000000000000..b31d666ab4a6a16ffa1fa4aeb840c3bd146ad6d7 GIT binary patch literal 21546 zcmeIaXH=72*DW06R#8+?6hs6>MUbi>y$UEw??_jvQU#=!pn|A~AV`xEI#L7Dqy_~A zX+e4qNUx!V&;n=0=XuYM^W%&&zV991IO7{nei(0)T-V-fuRZ5nbM5PXqoJmF^7w`0 z2n6Ef{d)>p2*hFd>!A*+L+~ccszd>A6s}r|a)^TVi*pFXCB%J&+d7^Jiz6PsZPo{k zs|yK>3TN1w!ox3Fy&BQU*6rphmBZWid@i%q&amo9($&#?JJ?PCkC0u1ZG&1M?XfrE zHCTLDmznpc1Xqii+)aI&Hw;&$KQfD9QhjE{ixqe!uq|af19Feu&~c`Tf%2LpR9pvd8~|KP#@?=jIdG9bJzl@EyGUMSFlC-a#Iy5PNAsh@Kp{k5(DbC?EC?fM_{ z+vMCBNK57W2Z3k`fiH4COTn`6jugw0Qa{_luh%ZaSC8x8z5BDnGOZiUsu*<6O$<22 z6mm#*+;FQg!tNWzeBUEY^|zH5fphqQbCPKjF@BmgTLw!5Ax)QgkN$fMoIHImD5c|N z9u!Y`Ev~0>!@oDZC*Sg{{<6uE;6hi!q(a-z*{=+D^iKneF$0TfthE2L-(#(Z;hEE? z`cMPJJXU)1FCntGHNUj8a!N#f`0#35-J&568DTG0u-UG?a8Ma}PC@|_TGPr#JJ zC0dC22~`e{MwfZ@D~)f*0LO=GlcTM6I2gxT?D9RzM}J-Ng?7c;!-fziSw6BHKu0Hh zskHL>e5;le4lGF^@1BT%_R@=VDv?OCWgx~YR6%@59BNQE6yt1fq{D6hN#Sb z{1~xUpE-bG9WuU@CKi=zV{5N|86={|6ykOCIyV>gD~ZLd!ID6B+HXD}DL%jEsx}Uo2bnH%p3&o{UtwWvL}rx-IC9 zSzemy%{LOUY(ASGv$;6Xm7|;6X9`o_PW_t-PT{$?`$kbKw;!dO3DOA?n@hu(C88tS zfB*hHROYZSRPL0a6h+T(YCQG><}7xad}~pc?a$bE>X%bd^gqW`ZGEWO-m^%3%B{M5 z*VE?jT6s$vNR6B@P-n`B(R?b1ZmT?tXVd3e0 z*BdTH6C`&tc_a(=Gz6{JzPkt4+-&>f?K#peu}{!S8pRGmw-XRacC~D(;S`&~Y_TFT zVIXq7Y8wB*S4J}Z#==gc55aICrpO2*?LnQ7+*@r_isG89^d{{PUT~g-`svnhD4>;SUPz7`nBiQ zN=jN<+RK;n-`?kGWfz!Kx&LW+^YyE!TS!KR<@$7+l%(XE@IwoW$sZvMtdgFz^nYKc zPd~D<(#uqd6R>QC)X9<$KGoaXE5^zzD!SDi#T~{X<-Ilu`&T_RH5KO3{PDWEo!xG) zkyEK{Pjgcfr<27+I;{f-LH2UN}?JT`p;H9=;dXyx9m zGx7$;in&^`EUPLhb-L=>?VO~hHu_@m3pPak_wQef1%!niN2|AC7%)~A8UMTm#t9-0 zDbuoPX#VrvF)FI2QSEFU7SX4d#Kpa*TjP{t_}WIl_4Rpdt&BSimKaof78Ms4C!yG@ zK7Rb@w$QsYTC=+`*LD5+^#O_QTz&jY8tGXa1``?@x}O3#QfxmUkRNkRB^HuiJ4&ha zNmnL>#+r|ZUpuq)cbekJ@UV++u^z083C5rFobqVNvX@?pT+k_I@e}87)OB?U22;Lz z^-3*4bTv$7|DLjPS5IR{N1@mH^hlKlzBfO zjOqOO$wD`@pM-oi7rVQ4^2^iH(=STy6Skfn5*L-|-DJYMeV3i(*2_Enw0z??G007QzB8#S4 zN-ohq7uZDTm-g;sO*kk^8Y^B*J?MR8(}zxy_7i{7nZBs6%N4ah6vir3YuhO#mp30)wJS@#FUnsi|3T58v@mIJ%kl(I%N^a2&`{~P#!%sSzQKnJXiU#=7vyw)#|;S=j6OhNa&7dlkL8-t#b~7rp5w#a3;@y~$7NU!C;Z-BeX;-^pbpMMOk+ z6Xr{in9ZqU9?Qeq8(r#MUF$Bk`i~z!j%E_E?=Q6Zoho^PJ1 zJn*ASL*@7+DW8wc#(Snl`gilSGL%M}HLzW<9HLId%&aWpLLa_0PH=!BHSB_r5>_6w zql-4JF~j)BInkXt^YcA(e0fSBEv%a}1ZK@w0+HMV(jocl+xxG3T-`cS(VKpluJ&n} zDq0*3#;jjgEXLyk&L@xdvbeaRdO4$@)ue-O+s2u*XFtw3mo)dBc*=6(qG$&Whih-= zQ{IJmdn^q`p*}!rN;{2K?=1Fi6LVczFOSl+V@2p$S#i1gXxEwcCyoQmr%yY> z29gXD^Vzg-!%@Q~f3T1fm~cZx1SMdpl*CQ(UqS9^@k~U9@tai+ulLVFtg)T@D^F}v zqAeaf^_~cQ&#i(cznJgoK;suabI3 z#Poq2sz*evT4P&ZzW;LL)~y6srzmdyBpIYX01FvnKIPRyP652!G9p9N0xsk9mAeXn zgFk+ZRJb+*r~}o&D7%MqD*(#zHN>Q8-paCDg12rN*ZO)H8xI3KQ64(vzBDN0y)k>_ z^gRO0@LPO?sr8AK)1g0kNIbxH3 z6#4!zRzo0StIH=(o$4*tjHZ3uk$k({aYWpAs}PMw!`vP|Ob!cEca+(PqVSw-`oPA< z23fX7AV~S|ZDlCOY{=AAJC0QH_~`LID7+-Bw`<`KnM@*)KzTY@TU$SRG`_Utw=(t( zD|29MH`CqS4V-RUla9*Dnkjr#{}VC*E(8?PKQbak^YWj6{;|i^nk=xpy1L59V4P~V ze}ZCYXh?@`2LeFy=~%#{C}C)~bx-DhIu(l$ra* z?u8M@I4A0rIRiEY;1r;A{mN%^t@?2Y@c@xFmI$dKR>eCAw8yh&}fr(wY%T)6cAev6L)~C?pL61kP zy__(lvROt4(RK|yVa5+b_^t{{N!zB@mT$IF1$0O4g_@hSE*DXeb1KDgWo zmqfZh0b3RuCpgoQB2P`vGlsZ|;H3Bq&I1nyXDcd6-K^_J+Rw_pGZA8A6T3ZPlfLUa z=6hlIQQa)dfq}D0fO2yDttT$PrpM3iuS>3jz;Gh~(sz3AMbJLp-`j=|afas@mf4Hh z_ut5m@!#LJma?-v02XcS1GWO*?HnjizHrzkW9o4KZL(*B`oxk$bRijG7dJgp7 z917hoW`eyj)ZcI8XqcI;k2bATyRk_iwD~4Z=_5XY6#28b2-1HBh}nDmdk~ZwkbHV( zF7ECu;?Hb0jcy@>Q?FmUHXW%hoo!soNzKhXW*vH1%=xZ_vb9w0xRBlJPhLO?G zP4k~87zM8GvP)sB)Z&HPIy82XWA8#kA^#p+QlwZIK<2{Tpt8Cs;+W8zd;9k7I5fXW zWx8Fs%)wqU%+Zja{p!`($^}pvkXqd)1PrKziK!?z)JpZ6*RPvYk_f7=!gDdZ`C)at_JCeJwDbUPhVv`y_B7PjzMpZ%JZ02RkS9m9FMAz zl2J~!>;61GH#Y#X9N++GEFs|{NG5_-wpIr4ZHhHnE1W-n-eyFKd#1QIic7ciAkThx zbII-SSVsr4{Bo7wZdt+UW%2^GCtmE0ynf@xjbJH|ED1y3+1rX1C0!5(Bj1o$pB(x{vD$r z{3-OL^QyJ=jPX7v=qYODWS}({F8w0kITvs7Ma-Dp6US};dSSWO=XUk8v7B6WhfbV4 zfxPfV!)~XC^%}s;9|HMCkVQG%q-YM9{_+Uy@FVZC^L_(hJYc%`G6* zeEQMYFe}+A=Jev_OV|FcU)gp0mK~*U^!&^QudH#AZFw1W!Xn2f* zV6alVjNRyuA3hWTL;Vb8TEKanM667MaV2}RcQ`F>|I|^wt$nnm6I(;=`cbImTuMEz ze-7hwcvR!Kn6r6uh`u|ZB6Vitiir=k-Iv0`j=`jL^KjF+M!>fM_4`Un&A$0=6i@xD zJy$OWOHn^n&RX0FEds%1A9p=l+1o}jH z-B&l+*%ai|L0lfGsuVVD-#$^8)Rx=LXZ$sJA!7Q-@6FB4oyK33l$0+Iv1((!Z(aX_TnE_d zFEVe0(zbT%PY5jEwD0GVl9EN`7*L$#G9urooV=}CTX|;Eq&!!~_O{o>@H7a|w7&r2 zG%`9@IlUroq93Upi9TYop7W>Z;03;>@FAOmD>1WN-nFJQ!Vtuw;Et-_)U*-$xmNZw zSJx`%iC4Y3+_vcnVE)d|&H^(G%lY%3Kw1-xVG9FY%1b~rs-JHCB8DA8a2{$UI~1FD z8Y!l)?M$N`C98ESVLlJy)8^mj6!ErVylqFrK zTMtP(iD5q~WfUZqo7MVe+lkl{mY@GH8nM5_1pMe?X}KlQn>*&Cpb7U+*z){VkX>F2!xX~ zIm?{Nddti_wzbrQJF>!>4P(y9#m2>E$H?m!UPosk=`&iDPU6KUw-R0^UMS~(?M>b4 zgRjY2h)~Ly$6*d2l0qoL$z#VDMW2qre-Pfqt+RL70>CrN_*YLKlY0LAIpBMOh*MHh z)h$*FUbEUg34C?hLe}hX#rkNCPpK(s9V>(Lnn?tGLv!I4CzvAG#u^rr%4fgb$mXH^ zs}uLZgz}#Q^Fmr$8cG@4(dvqQQZ7)9C{!shvx`#Rw{|Vi+1c6U<;12BSKLMlw)5Py zv^wGMLnTiHT_&4=Z!Pim=H^}5+8k2x;WLjU_@HQi!QO$;So9mMJ@}xk$d9YaA=vh> zuf#a>2hq9NCQtBmKc`0(MmxS>AD^(VkQ^Wyq>4~icDIL^Qj1I0uy+6*@`QuFx7TR@KE zL4QC3&a3-Q*#NteT~+pPsr=LKN!_G7do7DGT3V4W>+#fV7y7oW+n$zZnrs|>$gfX> zqL=a(?h>QiymmuC;DVr4D@fCgjr+>V63=GClX{vC-|3Tda0fzf@9czNf=R+G<*n1l z3k74BjB^bvAb_OfP#Ld&yx2J&I=V!=LYWtRyHqNPV59 zD${;M%xj$~$-n2wsf(Xyim)OYFsxFmEHnRYcGQppc|(Awv{R1ZvxlVwecX(t0&m-4 zsH|;`I)qum-MA*l&rSXmFoZRq1PUCsXv%1pTqv)+S!j+Rs>jJHRZhqC^B1d)w~I?* zY2OPDAN?w(M<^J0FtRpLkP)YU^5YRgj<+A_8$)~}x~h6(@twc6l<|D^&z$u;DH`^} zUyBeP=i7U(J*d!yVwhby+NQ?B+PWNq>)Vbq1$PYcW1ErfKo4!2Z|(Xe;hu_$akZ!O z^biy~_am=O73TnpGcqwzv%Prn0+j8{-U&+!i(8RT&)jUfKYu`@osvBC1w=$}psN&F{XT|WpbiKM`T-zPQ&R(- zl&~;z8VKM_tK4}nU%o8E?gnh3j*_150}IVsTI58$l#e^qG;4x~5Xm-V4?y7wu5hXS zemvKpxK-KJYaE-7zt{6G99*ms{4;}O$ueH+d7)?8hHE`p;F1B;KYHq7N2QmR`||QK z)Xd!BSg@2!5))0-yrF;@#bVL0L3XNyY&yos)~C;AtHZoM6ufFlk^^N9L&hVz_jq`D ziFh1vk`5D7yzz()#2#uQAPS}n`&GB^zdUl1Y;T<~(Ht94Lg>hfPgjK;$4uH~O zQ?%9Fz@KDWat4J=3ihEuIGq9v|9Hz(SCU5MAWc3E$>P1?Lf9Jbx<22x?!i;LzLiA# zXMv`+kUuhQMUUr}ZTBqf-i1Cixn$|d0`&)+BL;pI_e+~oJ^4m`I2;n{X78O_cD=1o zy~9M?+uOw$i@}`%eOCcBKMcieJ2^WgWxNVs>oZpO9Kfq5&pA3=?Y-WvkGD6UY4sK;V{jB&?;}CAshnnIHS31< z#_=E1GUcIWf?mG(BFaHOuE4LWD^`tfMgnQ0xOK#ZXC7+m>puWJRPIA`gmN7E5@1n? ziHlcOR+2qrkm~gGhQi0X(6ApX+c}%}XlxV|8o=VExhP6|=FHm4xV)mGqK5|&#^eSH zLoVp}_O<~G7nT>o_Y2kp7}xN@10C2||5{Y!LVh`rg2m&<5vWzOuo*1s0Lobq0oC3F z+k5x!85<|$=GuYy0Eha5u>IENkkgE<&2w7*{_QBqiJkqRLt*V+Tz1)U(xzYW>U2^+TpFF1TJ=w;)E9^3CfpJB1 zO^)@@f=(`M`0<0+w7MTuT4Uo(FsuJpsH(sd0bYZtCT7fl9)RR=j$C9(`Ul}D zNbWy{%PS;Q3gs3wpkTudS_hsv$&A$C7)FP(MILaP-9avH5v#kPu*! zRl_gQt49wb&K@U&l*Ojy;tOPzX16Zl@U^Q~Ey&!=3u!%%$g{Gv%uvJF!Y!o0i-s;$ zx08CJID6pMY+BeY#it(R7Y!9vhkXLL^U$0IWy2A;vbOdK5d9J99wMOcHRuq;+fYyr z1qQ`;i+?mV-6KI0Se^>nVwqmLBw;Bj6B0yB*oW2aZF^^`4ws*=uNq34HxOj8;&c}L zSO^A*;1q6-<{bhLN)l(9p>&R|0mRc_xf5#OJTvnW9*@eHf&KUM9izFE6DlEryz`-L z0|3s?8bpa0rLunky-|x4|Mwoy?Tn*N>gwtO4(g=Qh;>)Mu7$Y>g8Y>9asd#~&Q!VHxkdrK`PX4#{PP!}~wh6dGdxC!BTJ!r_kYiD6?ZXOvCvG(T&wYv{sO6WOb01$fM zJ}2M_U<)B8=yrxjL_D^$Q{Fioa3;+9Z^#l75Le4G+ovwvx<~aJ zvLUtL6hdQ`+_pemC7X7@09jdCcEY`R2CkqI=VzW%AlT1C10T^e1yKgwS?N!z{O~~& zq6S@x-Wned_st^+M)LZ=OKU79k^GMM&*9B~$AH~HwvOO^vZm+2Zo|D;&7_L|!G^h< z+>4!gi8iA|alg#ZcCB2H?56T~(osKZi<149wXwo}R?kly24b*&&xWFYfvj>BHN-Bn8X6j&dlrg7oK*%TD*J0@adL8g{&L`F(~-8OCY$b9XPm!a=DSeU zXh&FKiavDwr%%1P3zk6814$n~IL|DPG=|lK2{tuu+6YF+dgnyrqjE=_u%IAu<7s)} zjlSxsK^z5$_JV?fC~mQZ?4{{^Xi5eT3`V=5wTJ5JWT{d#I1S5fKNut_W$Z|ddF$xt zL~#|Q{q(i7Gb+ZF@2v$X9y%j+#*b_WGYdeSp)56zo#=@(oLjJ@fzJnDCJc%l1L*lW*C z9+z*!2BS_w^m2iU3clNfrzu($It7c!IhU5`HV&kSv=4icv$OoG_nv#At!??X564uw z?Q(UwqjfpWOsQ7C1xwAeC;hHA=(5IMK1#!Q0p!?^o~hJ>sY=r%8Ge!`Rgw8j!4h)O zc5rfubktY1O$Cpmi4*a;+d|(~jBHN7-eqiJd^hakz9>(g{0t34d%Pg>_n$A8&BI^4 z7AC^H!!Amf+`!mF3xfyNG{E@%7ISG?nR~YJ{o>Wu0gV6NlYv(_|9q3&Oxiy)W_qE_ zBL4USX6P<_OJy}GwUKt;xaWrD$FP(+uSc&={2ZE6?)EpL(`coX`Hl{F zU2UhBM4X=NsL{FYoRizySX}M;f-A)Yx~A;pGu%Jh3UHy*jCvXPg*`PE{q2K4Zw0IC{m1`lG5qPZ*|KGc9#xtJjG>C-}N=lF6>G8iOf_UpA) z6ZOF*4x`DKgjy;ubq=$bdpSVu*J$22Uo*N$> z%F3!q9I5fSE%*AH{HvU_Y}(6g(%&je+%|K>-E1%(I0{tQPBgc*(MVgqbJ{CusVY0^ zhdhBRPtmTM=yhnV>*?vuGw2$dbjJYO8|@qf3ke7)0dL;Fuc>G$m80jZi_zdbd-v5V z=+IT{!RbrRpRn};BJhz1ev>-~6`I^|-iMF%*#!db=;M=t;L`f$~5yUjgz6cl|id}hwu%{}ZP({`J4#QV4<36Ii+{tUTom(%K~P*#z$p9lC; z4*R&zd82#_QD#AL+sNhSU%Uv!$McXv2!;GlLQtf{SFF2wd+*eejvAHOCnY5K!X-0g z_|~3!=$ipLRqp%%fP)JH2=+j~V@8en$jPlduLXAsiswRhIt+4f7DU9cTVrG$U{8)p zorcK6>MgbMsv^#y?Lv=T?`J+=Z(+Ng_I@X5Aad9_%1W7UCM>hM&nb(5cPb-sd{;T*XwS`(r?h&5@2wCF`W-EMX@@bkjdb_-UyCnRnL8@IjW5m}Dks;{f7YiTi` z4d;(RKe!&~ne}T(zV?FT<`5nZhD99@%8r|(>B`h`ai^EtyWudOo{huw6{MAx!kld3 zN8%py;UxKBU$HGq6&0eXUn_4b-5)w*V-tGb*!T?f(D@!B>xR_R6%Ei(kF`1@ImX@3 zHKWaT?VckkJ@6yNJeCGy^@k8EcQqRt8yVa{+*F_L?CQFbBrPN;R9tjDaZ0P9E6XY~ z6B6Cf@r`GW2G=5&PSaYl6Zi@$D)vFgr+Tnw79u`=+~5)D{QmuNOGU$cX&1^@c%|XD zd~ff8y?az_Y=1GfQCg}J+1<#&Dg#FmBOV_)j|f0Q>r1rXdgPK}zTEbjxYMt)tddU| z@S4h<(NR@ZP2|$+_Wm_IEXm8e)bOhdYJ;1?0&v9aD~sn9uK5aBJpEHpIesxGUreO0 z-J}X>MDzh6dFOgadb)+;(cmy|;{0z?eQE(U^#9)gmej@b0sKj`rF;wu>Yexqy6_bF z^e4Qu#?D_QzyE*uJjjbjnsDf|sj5lzM~K3m$8YbcXs2f|xCzPrvb0F%vb@amPB%m4 z4x_8CCLH-~x^l#B z(>LEH-%EN#@%Eh-o3Vpnvys3#Xi1$wA`b;TzLu~-eYW&O)K{VkBTM}mIBjj^2?2Z? z*>Lvs83Jl!S3vW0)Mu4@n(3MA9?r>y=NUh@hU%70GwNj?wC}u9xSp@t)%Gr=L;xY{ zSoqHc_+da}bN?jr<#Jlg5v8E-UH2b3<#SrzMI1S;m*722-NNScm`ApiYnFd7$n!!658=8`|9#zkKjza} z8}3u+5!ut8ExHN~2fI}qZ8EWtDHnlH-iC^XW+Cac&Tb0-^Dvo8w4aksOUNo9ayya= z@#DzfFMK?}%@|~yVU&C1&}>%q{IbTNN{ZHXodXQhv*S#K(9eOh(gA-IQ)uTFC8*CP zmYhhrw0TJOCiocSJo?{0d&N2KI9kcf#1ON+X<%XDsi5#d56-G<-1=)_I1GN_UxQ(mxEnMy`c}(eDw&NLcRKJO7(rRkwf@ovV#v{nOKG?ai zD|UlTYypY`A5?g&PTW0N?l`0g7O0&ax2xc~1lUr+is>0yM0{U)L~$|gcYOaUz+kJ2 zYMNPfo_3X0m?P_PHh<&F>H!H=wFJp?jD%{uOMmlhTfcpIkP zU+p#oLQgi6!6dM{$JM>joOa=uQ4jm+?GA#d6QB$@;j zBNKjdvIpfHcVT8cV zn{VEF`vWn7QS<8LyvJ@~2<@NYZE6r7on4(*E+=uFQ{v`si7$7|-35)LEp>+{@bjPf z$JY9{smA|ki3xBiQE8^gZ5O58O;67}f}=U+wNhA8N+`DeFyC&Plyv0Ck<84@bKK&U zIt5?{K7ROcY;kd8&kK%Yg}_C9ydjw+{dP7CdbkQ;X?ZKrkXRp|lKYRxvNWjLINWr( zQZ%@NXiIH-JuwdP6C7&$wNFB#qI83aL&A>TD{$a-_1H20#`oD05~BU_@$tE4Ljwbr zr?krK841)Ga&ey`a!fU9_KB(~DPhCIIR>%u9M{;bq`T~_Yb7euZYX3ie)@ z|EgSyMgj?rt9%&A(dC}R!-ETdA1Pr^sEG3dG@Bk`;5UmdE7QaHmk&s^;T%3CM7R(J zaq+@V`Px-wKypFH&w*x>9bx0;ZR=P;9cXbz9q?V~^v!plTkGLa|K1qNoZP#_zkdJC8$b&KJ$?=_9n&)nqq#B$$oLT+t^s;^Q8|&I zjXGn$b3j9yHa0e{e*O)kPDSbTHLf=2)wu(y_8o`54e~=g;o30CfU_!42EPj}ErHO) z##%hNb?a8imoLrrS(9u#IZ<4eAIg~khFsp0O%{0l=NVOq!HvI~MQFm3WFZ1cB#XmafJ%}?^PM?fZ@}6kwgGK>03K zOijUKj*Yn){Qjaz?c*ttG{p-{Obl*M#FEdzwYZ{kJ}*Gpv6G)%Qj+Gj<38%+!xs3m zrpEkEBmSCdjn{f~R#q^rT!vOqkklLR{o&Tu?ty^;g)8S|yf;MWw_UlpbCufFl%_CY ztyiyJ1qUQq>&@HuWn@URzH*sU2xIeZZjoC@{-FATsaqE$G_O@M*`UH1$Xb8F%qmR&VnN*h0 zeurAcL1O>uvu9v`NqU|iO!2GIlg#3Kk$HY<6 zR7;fI@9KdOA7ZaVzlC;XiB`7fMf%>xEPWGxb+!gV9TSIoVzz0`;P@3q+@^m^hD%2N zkcVllOX=HOWec5;Mm;%qNI{@8B3>f)kV_+8zTJf92dmi0*o#LFQ8l0BQWP8FqH0bl zD@~ToUn(=P&G$6w+b=P3$R`*MnT^b&Wp<5rbBF!}aogo9E|^H>q((={D+eL895oJo zxH9=kMPQqB)30!^;m+&t_G@0!2cH;ghhTsiEoM-qwTTR=Oq_*V0Vi zH|FN%QO|WOH3Q$ys4i~Fduu#SWxBw}YHH^gC1Q|<;zY-O&FW8-h=VsrU?H>=&pDwb zrzRtJHkO&?zHUT>e2R}R=8((kQdK12iG8}ca#U{=E=Sqs4I2+YkDcyn@dMkSnnioxjRsHo}rE{H$7w<%b;wzl>& zOv3qB=i7@?4hUJFD*+UPnD5_TL`3{ixa>T6T2WzPKX_tl-A-g`(#5r8;b~H-p)-9gw&*z1o-J^Qc5}gkR zfUa|HVOhl$pDmPCxF~PBK(7RyB=sex+!IN^$;giNbgMy) zRomg2HY&Dv%&vP)r|SI@@O7BfYLPfjV%y*hy}1I1I*v6U864MzwI&GG5#ATGdet+ z@XI2}bAApwH^0-~5Bc{gzfvSGQ+a-%Mgs{RE=JQS9)AOIq98+k&;* zp#ul|@m^>wwncdZ-{;gk7dg}F#TR(Pt24XQ&8>WIu`D&!f~%*W)O4jC<*Y`PlDAk$ z-7Z5cr;%A}&{l`WZ${VL>V%r4|HkJ<=2NV~&qzBb2V_Qvs`RmN8rl+vxG2Y|bPaak z&%9mhgRwy-IV$Kgd|fnRh>4C~uCcAV+35Rd{$mwBQTHPc7bnlwAkJ^w%zD(#1{7=Z zh*EcN@3c21;%MGlO`;-UZnbE*2hJi#{F)pU8|;rQ7}Yf~)-l#|@88|ehNZokxU6Fe z&yyMYeFhPiD3J?|w6`H4L_Z7I`fy&f+PraZZ;ymQidyvAdlH-KTU=`n1yD|(dl=9U z2X5vlozx+y$oDo*u=VVJh$vk>lPJEVFL;(c@{W(M06n6v@w*RMGQ564u5B+JKLoNm zfRpMW@M`L%|JDx%)AyA*qt0)0fsg+u3)Rk?VtJAQU*A{>9MY2qiZ`Q=6*H^Sy5Kqd^u$@Spd8WR4Z}&adGh2J z;UAmwy%)!l{WzNl2!u4t{>xK{LZ{Ii5I)(n|B(MDESj`+@TbCUun7^ghbf@v{Qvxe zf8FHB!Omo>jBfS*W@c26Ta`=A;lZf)8O%qBES}%PZ^o$9+~_kxY<>Q(c`wv_-I@yV z_cw6O6!R&~VDEdG?05g_zI)0I%){w(m;_N5-}gr;R!&nr3=p>8WM!@17Afz|n0Jx- zCso5x$7N!mZ+|3E5CR*Zx5n}?pv<;Cbq2W>Z`93yQNS&GhiQMQk)Jmyet`{95}^-- zkV7H(%Gyw%!j$6o^`;i&Zc@1mWMS{haah}pkjCYE^j9G*AAjUg*^Rv$w4~IRsYY7DDfo?RU{!+a~_oQ^wIrm$v5Go@vU1KXLg;hKE09N znN5#$J&}C!OdY~O{ql!}ULs9`4xuKT2V_+XY7vvP?Jf4m#GaAzi!2 zCYqHc97haO>Ou{AQUcZ8X3heoJ3qKOv5sMS~yk&Q9IaM=qtw$N)v+322G2v z_)KVy?8>-IP|js;d~K2G>K;BT$H~j}tHk2M1#MPOj*K4}GO_+W0ZEEx&xS0k=G~cR zU%_>?bRry;vf?{gw<%UvA4uh1hKPzjRC|6Tz)!4F@qmw69Je04>)z)?^{8#SRHS`p zbU>sp{93^`22|@AIShatHq>tK0+O`?Bz{RMkd*rRzd_=Ce(osH-7;P6Hh& zCH2=)2@mwNHs>Q`@^N3r$7C209c9NJBKl)qJ>v>|I-E>IjqYt#XR)A&-OIT&xD~u| zpI+@^7v%2OogCn=SRReHl6Lc;8lT5tT!ssa^!MkFdhAay|M>Oi z`->NVxp?f}_#y*~WGwa{M2XiZ@7x(qbAGtX3?MOP-O8?CnMZbc)^hJ4$MJvZ$ zobNZ)Fw)$cafXW?e-1<~JA1m^d-aeLu3bdQ>4+x`R)zxg?J}FQPA9~Oy&*>lES`i7 zZX&Yh5iDKRB#OU^^mgC+B#YPhtp2F?5{iFo^>kuo={(yZSsuU^-`DF-HIt!HPHN|G zF4y956@Ny5VD~qEEnsV{8Z_(ob0@OiC0>-oM$ zvhg@#DNZ|?VocsXZ}8cUkhQ;e9f$h4$c1J;~CPG&VivXsL=_7hM8WZG6mC zCG)EkvOLv?Ai`5^7{1!24?`H=i4C?H|M@RN812$x+n?#h*qz0`P3g6YJN$vPd~xo# zH*NdY3bcQ!n0|4eOLAHr(IK=anE}QhU!xf-@63M2#Z+U^7B@{RH~xH>5}|MpL0R`a zTe(L@lISrr>g`N5zHu{>C_Ogy11$;vr0+lV_TVdND!2e8g*P)*5y`+1)?0{@R;;M) zY8epbGR-lRvI%=R=MkGzy&?Vr?z5|O7*|_(ek7@!-^A#&9B0#R04hCF5$QkQ-{s^# zhV1F>yQt^eZ-Q<0-Vc@WZ}@WHiikC@nUX5i-WA&RNE|lAIUgr{fG8s6B z+MfT^W><`iCHf`B5;qt*R1y!6X4wzQtNC@~=SqCxDbrk0fn_b~yqD53;!_0c`n(bMW(SYaWAo{Y~K-A1c9 zE&C{-`j9t?I|ee|xR83fyYGBvcLs1MucbU|yo`>I*lRs(P7~oD#Hm=Im50b4@`%5> z;2NLt;y>NH`u2DPzRFLr?LDoWw(HKrQ~w|~@4yTipRDF!cuYyXLS_e}pT9No8L$2< zGZ|72QF^tp+rZGMYE&_Y4#WD0_fb+@G6mSPiA*>|!WrEEvV~XQo^SXt;qA7ho515} z1E;FoCUTufbKhcEa+G^!yqQ^PvScYs?vSO(nYwTzc;{_y0oq3(ME`eOFB{*TAwOfR z9^mOV*TMc@1a^(EJjmTgcgfVRlF$ybG?a&m_X|sx>zZ z!duC@2eu)*HXlf!e}6yNW6@(u5Y$e*v#Q%X;LGZssQ-0;cwRhR( zYAx{V76$d$%}}fwmi+VU|7MtyQHQWm|Heo`P&>f+gB9QRUMOdo_-@A?gp)Zv330?` zebfs-!nC~m`)w$<2pmt=e56@0BU=@rl;zDl>Ut0LO(fBk^dT{>#US0kRF5weJ%sANQxE+&N~`}|>c2ss{wIk43F7~Y^5*}7^cg>(X!s$I VTfCY;KEH~%e@9KBK + Paperless-ngx request surface, with and without Pomerium + Two panels. Without Pomerium, the public internet reaches the + Paperless-ngx Django app directly, so its own login is the only barrier. With + Pomerium, the internet reaches Pomerium first; Pomerium authenticates against + the identity provider and enforces policy, and only allowed requests reach + Paperless, which keeps its own login behind that gate. + + + + + + + + + Without Pomerium + + + Public internet + anyone, unauthenticated + + + + + exposed + + + + Paperless + Django login + + The app's own login is + the only barrier. Scanners + and brute force hit it directly. + + + With Pomerium + + + Public internet + + + + + + + + Pomerium + front-door gate + + + + + IdP + policy + SSO, groups, audit + + + + + allowed only + + + + Paperless + Django login + + Pomerium authenticates + every request and applies + policy before Paperless is + ever reached. The app keeps + its own login behind the gate. + diff --git a/content/docs/guides/paperless-ngx.mdx b/content/docs/guides/paperless-ngx.mdx new file mode 100644 index 000000000..a39d5e731 --- /dev/null +++ b/content/docs/guides/paperless-ngx.mdx @@ -0,0 +1,161 @@ +--- +title: Secure Paperless-ngx with Pomerium +sidebar_label: Paperless-ngx +lang: en-US +keywords: + [ + pomerium, + paperless-ngx, + paperless, + sso, + document management, + identity aware proxy, + self-hosted, + django, + ] +description: Put self-hosted Paperless-ngx behind Pomerium so every request is authenticated and authorized at the front door before it reaches your documents. +# cSpell:ignore paperless ngx celery +--- + +import TabItem from '@theme/TabItem'; +import Tabs from '@theme/Tabs'; + +import Config from '/content/examples/guides/paperless-ngx/config.yaml.md'; +import Compose from '/content/examples/guides/paperless-ngx/docker-compose.yaml.md'; + +# Secure Paperless-ngx with Pomerium + +## What this guide does + +You'll put a self-hosted [Paperless-ngx](https://docs.paperless-ngx.com/) instance behind Pomerium so that Pomerium becomes the single front door: every request is authenticated against your identity provider (IdP) and checked against your policy before it ever reaches Paperless-ngx. Paperless-ngx keeps running its own login and per-user document permissions on top, so Pomerium acts as an additional gate rather than replacing Paperless-ngx's accounts. + +Paperless-ngx is a document management system that stores scanned and digitized records, often a household's or a company's most sensitive paperwork: tax filings, contracts, medical records, and IDs. That makes it a high-value target to keep off the open internet. + +## When to use this guide + +Use it when you run self-hosted Paperless-ngx and want to make sure only people from your organization can even reach it, without exposing its web interface directly to the internet. Pomerium handles the network-level access decision through centralized single sign-on (SSO), group-based policy, and an audit trail of who reached the route; Paperless-ngx continues to manage documents, tags, and its own user sessions behind that gate. + +The value here is not "add a second login." Paperless-ngx already has a login. The value is moving the access decision to a single, centrally managed front door so you can enforce SSO and group policy, get an audit log of access, and shrink the attack surface that faces the internet. + +```mermaid +sequenceDiagram + actor U as User (browser) + participant P as Pomerium + participant I as Identity provider + participant A as Paperless-ngx (Django) + actor S as Scanner / attacker + + U ->> P: GET https://paperless.yourdomain.com + P ->> I: No session: redirect to IdP for SSO + I ->> P: SSO success (identity + groups) + P ->> P: Evaluate policy (allowed?) + P ->> A: Allowed: forward request (Host preserved) + A ->> P: Paperless-ngx login, then documents + P ->> U: Response (proxied back through Pomerium) + S ->> P: Direct probe of the public host + P ->> S: No session, not allowed: blocked at the gate +``` + +## Prerequisites + +This guide assumes you've completed the [Quickstart](/docs/get-started/quickstart), so you already have Pomerium running and signing users in through the hosted authenticate service. + +You also need: + +- [Docker](https://docs.docker.com/install/) and [Docker Compose](https://docs.docker.com/compose/install/) +- A domain you control for the Paperless-ngx route (this guide uses `paperless.yourdomain.com`) + +This guide was last tested with Paperless-ngx 2.18.4 and Pomerium 0.32.7. + +:::tip Prefer to self-host the identity provider? + +This guide uses the hosted authenticate service so you don't have to run an IdP. To run your own instead, follow [Keycloak + Pomerium](/docs/integrations/user-identity/oidc) and swap the `authenticate_service_url` / `idp_*` settings into the config below. + +::: + +## Configure Pomerium + + + + +In the [Zero Console](https://console.pomerium.app): + +1. Create a **Route**. In **From**, enter `https://paperless.`; in **To**, enter `http://paperless:8000`. +2. On the route's settings, enable **Preserve Host Header**. Paperless-ngx is a Django application that validates the incoming `Host` against its `ALLOWED_HOSTS` (derived from `PAPERLESS_URL`) and uses it for cross-site request forgery (CSRF) checks, so the original host must reach Paperless-ngx unchanged. +3. Set the policy to scope access to who should reach Paperless-ngx (for example, **Any Authenticated User** or a specific group or domain). + + + + +Create a `config.yaml`. It routes `paperless.yourdomain.com` to the Paperless-ngx container and preserves the host header so Django's `ALLOWED_HOSTS` and CSRF checks pass. + + + +Replace `paperless.yourdomain.com` with your domain and `you@example.com` with the email (or switch to a group or domain match) that should be allowed through. + + + + +## Configure Paperless-ngx + +Paperless-ngx runs as a Django application backed by PostgreSQL and Redis. Pomerium terminates TLS at the front door, so Paperless-ngx serves plain HTTP on the internal Docker network. The key settings in the Compose file below: + +- `PAPERLESS_URL: https://paperless.yourdomain.com`: Paperless-ngx derives Django's `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` from this. It **must** equal the public route host, or Django answers `HTTP 400` to every request that arrives behind the proxy. +- `PAPERLESS_REDIS` and the `PAPERLESS_DB*` values: point Paperless-ngx at the Redis broker and PostgreSQL database that ship in the same Compose file. +- `PAPERLESS_SECRET_KEY`: Django's signing key. Generate your own with `openssl rand -base64 48`; never reuse the placeholder. +- `PAPERLESS_ADMIN_USER` / `PAPERLESS_ADMIN_PASSWORD`: bootstrap the first superuser on the very first startup. + +Paperless-ngx keeps its own login. The first time you reach it, sign in with the admin user you bootstrapped above. + +The request surface is the whole point of putting it behind Pomerium: + +![With vs without Pomerium: a directly exposed Paperless-ngx app versus a front-door gated one](./img/paperless-ngx/request-surface.svg) + +## Run the stack + +The Compose file runs Pomerium Core alongside Paperless-ngx, PostgreSQL, and Redis. For Zero, drop the Core `pomerium` service, keep `paperless`, `db`, and `redis` on `paperless-internal`, and attach the Quickstart's `pomerium` service to `paperless-internal` so it can resolve `paperless` by name. + + + +Start it: + +```bash +docker compose up -d +``` + +Paperless-ngx runs database migrations and builds its search index on first boot, so the container can take a couple of minutes before it answers requests. Watch `docker compose logs -f paperless` until it reports that the web server is listening. + +## Verify the setup + +1. **The route requires authentication.** In a fresh browser, open `https://paperless.yourdomain.com`. You should be redirected to sign in through Pomerium, not straight to Paperless-ngx. +2. **An allowed user reaches Paperless-ngx.** Sign in with a user your policy allows. Pomerium redirects you back and Paperless-ngx's own sign-in page loads behind the gate. + +![The Paperless-ngx sign-in page reached through Pomerium](./img/paperless-ngx/paperless-login.png) + +3. **Sign in to Paperless-ngx.** Use the admin account you bootstrapped. Paperless-ngx authenticates you and lands you on its document dashboard, served through Pomerium. +4. **A request that skips the gate is blocked.** In the Compose file above, Paperless-ngx sits on an internal-only Docker network with no published host ports, so a direct probe of the upstream cannot even resolve or connect; the only path in is through Pomerium. + +Pomerium gates the route; Paperless-ngx runs its own login on top. The admin account and first-run setup are Paperless-ngx's concern, not Pomerium's. + +When you're done testing, stop the stack with `docker compose down`. Add `-v` only if you mean to delete the database, media, Redis, and credential volumes. + +## Common failure modes + +- **`HTTP 400 Bad Request` on every page.** `PAPERLESS_URL` doesn't match the public route host, so Django rejects the host. Set `PAPERLESS_URL` to exactly `https://paperless.yourdomain.com` and make sure `preserve_host_header` is enabled on the route. +- **Redirects or links point at the container name or the wrong host.** `preserve_host_header` isn't set, so Paperless-ngx sees `paperless:8000` instead of the public name. Enable it on the route. +- **`502` or `503` right after `docker compose up`.** Paperless-ngx hasn't finished its first-boot migrations and search-index build yet. Wait until `docker compose logs -f paperless` shows the web server listening; first boot routinely takes a couple of minutes. +- **CSRF verification failures when signing in or uploading.** The browser's `Origin` doesn't match Django's `CSRF_TRUSTED_ORIGINS`. This is the same root cause as the `400` above: keep `PAPERLESS_URL` and the route host identical, over HTTPS. + +## Security considerations + +- Paperless-ngx runs its own authentication, so this guide uses Pomerium as a front-door gate on top of that login. (If you'd rather have Pomerium sign users in directly, Paperless-ngx natively supports trusted-header SSO; see [Next steps](#next-steps).) Either way, **don't expose Paperless-ngx directly**: only Pomerium should reach `paperless:8000`. The Compose file keeps Paperless-ngx (and its PostgreSQL and Redis) on an internal-only Docker network with no published host ports, so the only path in is through Pomerium and the policy can't be bypassed. +- Scope the route policy (group or domain) to who should have any access to Paperless-ngx at all. Paperless-ngx's per-user document permissions still apply on top of that. +- Paperless-ngx holds sensitive documents and exposes an API and admin interface under the same host. Because the whole host sits behind Pomerium, those surfaces inherit the same SSO and policy gate; don't add a second public route that bypasses it. That also means Paperless API tokens, mobile clients, scanner automation, and share links are not externally usable through this route unless the request also authenticates to Pomerium, for example with a Zero or Enterprise service account. +- Generate a unique `PAPERLESS_SECRET_KEY` and strong database and admin passwords. The placeholders in this guide are examples, not safe defaults. + +## Next steps + +- **Go further: let Pomerium sign users in.** Paperless-ngx supports trusted-header SSO ([`PAPERLESS_ENABLE_HTTP_REMOTE_USER`](https://docs.paperless-ngx.com/configuration/)). Set `pass_identity_headers: true` on the route so Pomerium forwards the verified identity as an `X-Pomerium-Claim-*` header, then point `PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME` at that header so Paperless-ngx logs the user in directly instead of keeping a separate login. Only do this when Pomerium is the sole path in and strips any client-supplied copy of that header. +- [Build policies](/docs/get-started/fundamentals/zero/zero-build-policies) +- [Custom domains](/docs/capabilities/custom-domains) +- [Self-host the identity provider](/docs/integrations/user-identity/oidc) diff --git a/content/examples/guides/paperless-ngx/config.yaml b/content/examples/guides/paperless-ngx/config.yaml new file mode 100644 index 000000000..b74c6a48d --- /dev/null +++ b/content/examples/guides/paperless-ngx/config.yaml @@ -0,0 +1,20 @@ +# Pomerium Core configuration for Paperless-ngx. Uses the hosted authenticate +# service, so you don't run your own identity provider. To self-host the IdP, see +# the Keycloak guide: https://www.pomerium.com/docs/integrations/user-identity/oidc +authenticate_service_url: https://authenticate.pomerium.app + +# Obtain TLS certificates automatically from Let's Encrypt. +autocert: true + +routes: + - from: https://paperless.yourdomain.com + to: http://paperless:8000 + # Paperless-ngx is a Django app: it validates the Host header against + # ALLOWED_HOSTS (derived from PAPERLESS_URL) and uses it for CSRF checks, so + # forward the original Host unchanged or it answers HTTP 400. + preserve_host_header: true + policy: + - allow: + or: + - email: + is: you@example.com diff --git a/content/examples/guides/paperless-ngx/config.yaml.md b/content/examples/guides/paperless-ngx/config.yaml.md new file mode 100644 index 000000000..ed7c82402 --- /dev/null +++ b/content/examples/guides/paperless-ngx/config.yaml.md @@ -0,0 +1,22 @@ +```yaml title="config.yaml" +# Pomerium Core configuration for Paperless-ngx. Uses the hosted authenticate +# service, so you don't run your own identity provider. To self-host the IdP, see +# the Keycloak guide: https://www.pomerium.com/docs/integrations/user-identity/oidc +authenticate_service_url: https://authenticate.pomerium.app + +# Obtain TLS certificates automatically from Let's Encrypt. +autocert: true + +routes: + - from: https://paperless.yourdomain.com + to: http://paperless:8000 + # Paperless-ngx is a Django app: it validates the Host header against + # ALLOWED_HOSTS (derived from PAPERLESS_URL) and uses it for CSRF checks, so + # forward the original Host unchanged or it answers HTTP 400. + preserve_host_header: true + policy: + - allow: + or: + - email: + is: you@example.com +``` diff --git a/content/examples/guides/paperless-ngx/docker-compose.yaml b/content/examples/guides/paperless-ngx/docker-compose.yaml new file mode 100644 index 000000000..1a1ee3d09 --- /dev/null +++ b/content/examples/guides/paperless-ngx/docker-compose.yaml @@ -0,0 +1,75 @@ +services: + pomerium: + image: pomerium/pomerium:v0.32.7@sha256:e10d1d267af24f581157f485d9b0bc08469e2428675b696a08e42ceb09b2279c + volumes: + - ./config.yaml:/pomerium/config.yaml:ro + - pomerium-cache:/data + ports: + - 443:443 + - 80:80 + # Pomerium is the only service on both networks: the default network for public + # traffic, and the internal-only network to reach Paperless. This bridge is the + # single path in, so the policy can't be bypassed. + networks: + default: {} + paperless-internal: {} + restart: always + + paperless: + image: ghcr.io/paperless-ngx/paperless-ngx:2.18.4@sha256:3421ebe06ed27662d014046cf5089e612de853aae0c676a2bc72f73b38080e57 + depends_on: + - db + - redis + environment: + PAPERLESS_REDIS: redis://redis:6379 + PAPERLESS_DBHOST: db + PAPERLESS_DBUSER: paperless + PAPERLESS_DBPASS: change-this-database-password + PAPERLESS_DBNAME: paperless + # Generate your own: openssl rand -base64 48 + PAPERLESS_SECRET_KEY: change-this-to-a-long-random-string + # Must equal the public route host below, or Django answers HTTP 400 behind + # the proxy (ALLOWED_HOSTS / CSRF_TRUSTED_ORIGINS are derived from this). + PAPERLESS_URL: https://paperless.yourdomain.com + # Bootstraps the first superuser on initial startup only. + PAPERLESS_ADMIN_USER: admin + PAPERLESS_ADMIN_PASSWORD: change-this-admin-password + volumes: + - paperless-data:/usr/src/paperless/data + - paperless-media:/usr/src/paperless/media + networks: + - paperless-internal + restart: always + + db: + image: postgres:16-alpine@sha256:16bc17c64a573ef34162af9298258d1aec548232985b33ed7b1eac33ba35c229 + environment: + POSTGRES_DB: paperless + POSTGRES_USER: paperless + POSTGRES_PASSWORD: change-this-database-password + volumes: + - paperless-db:/var/lib/postgresql/data + networks: + - paperless-internal + restart: always + + redis: + image: redis:7-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99 + volumes: + - paperless-redis:/data + networks: + - paperless-internal + restart: always + +networks: + # Internal-only: no route to the outside, so Paperless, Postgres, and Redis are + # reachable only via Pomerium, which is the lone service bridging it to default. + paperless-internal: + internal: true + +volumes: + pomerium-cache: + paperless-data: + paperless-media: + paperless-db: + paperless-redis: diff --git a/content/examples/guides/paperless-ngx/docker-compose.yaml.md b/content/examples/guides/paperless-ngx/docker-compose.yaml.md new file mode 100644 index 000000000..5b1445964 --- /dev/null +++ b/content/examples/guides/paperless-ngx/docker-compose.yaml.md @@ -0,0 +1,77 @@ +```yaml title="docker-compose.yaml" +services: + pomerium: + image: pomerium/pomerium:v0.32.7@sha256:e10d1d267af24f581157f485d9b0bc08469e2428675b696a08e42ceb09b2279c + volumes: + - ./config.yaml:/pomerium/config.yaml:ro + - pomerium-cache:/data + ports: + - 443:443 + - 80:80 + # Pomerium is the only service on both networks: the default network for public + # traffic, and the internal-only network to reach Paperless. This bridge is the + # single path in, so the policy can't be bypassed. + networks: + default: {} + paperless-internal: {} + restart: always + + paperless: + image: ghcr.io/paperless-ngx/paperless-ngx:2.18.4@sha256:3421ebe06ed27662d014046cf5089e612de853aae0c676a2bc72f73b38080e57 + depends_on: + - db + - redis + environment: + PAPERLESS_REDIS: redis://redis:6379 + PAPERLESS_DBHOST: db + PAPERLESS_DBUSER: paperless + PAPERLESS_DBPASS: change-this-database-password + PAPERLESS_DBNAME: paperless + # Generate your own: openssl rand -base64 48 + PAPERLESS_SECRET_KEY: change-this-to-a-long-random-string + # Must equal the public route host below, or Django answers HTTP 400 behind + # the proxy (ALLOWED_HOSTS / CSRF_TRUSTED_ORIGINS are derived from this). + PAPERLESS_URL: https://paperless.yourdomain.com + # Bootstraps the first superuser on initial startup only. + PAPERLESS_ADMIN_USER: admin + PAPERLESS_ADMIN_PASSWORD: change-this-admin-password + volumes: + - paperless-data:/usr/src/paperless/data + - paperless-media:/usr/src/paperless/media + networks: + - paperless-internal + restart: always + + db: + image: postgres:16-alpine@sha256:16bc17c64a573ef34162af9298258d1aec548232985b33ed7b1eac33ba35c229 + environment: + POSTGRES_DB: paperless + POSTGRES_USER: paperless + POSTGRES_PASSWORD: change-this-database-password + volumes: + - paperless-db:/var/lib/postgresql/data + networks: + - paperless-internal + restart: always + + redis: + image: redis:7-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99 + volumes: + - paperless-redis:/data + networks: + - paperless-internal + restart: always + +networks: + # Internal-only: no route to the outside, so Paperless, Postgres, and Redis are + # reachable only via Pomerium, which is the lone service bridging it to default. + paperless-internal: + internal: true + +volumes: + pomerium-cache: + paperless-data: + paperless-media: + paperless-db: + paperless-redis: +``` diff --git a/content/examples/guides/paperless-ngx/validate/assert.spec.ts b/content/examples/guides/paperless-ngx/validate/assert.spec.ts new file mode 100644 index 000000000..916c7877d --- /dev/null +++ b/content/examples/guides/paperless-ngx/validate/assert.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from "@playwright/test"; +import { login, alice } from "../lib/authn"; +import { shot } from "../lib/shot"; + +// Real end-to-end test for the Paperless-ngx front-door gate. Pomerium makes the +// access decision; Paperless-ngx keeps its own Django login on top. This drives the +// whole chain: an unauthenticated request is bounced to the IdP, an allowed user +// passes the gate, and Paperless-ngx then serves its own sign-in page (HTTP 200 +// with the Paperless-ngx login markers) behind that gate. A final negative control +// proves the upstream is reachable only through Pomerium. +const BASE = process.env.POMERIUM_URL as string; + +test("unauthenticated request is redirected to the identity provider", async ({ page }) => { + await page.goto(BASE, { waitUntil: "domcontentloaded" }); + await expect(page).toHaveURL(/keycloak\.localhost\.pomerium\.io/); +}); + +test("allowed user passes the gate and reaches the Paperless-ngx login", async ({ page }) => { + await login(page, BASE, alice); + + // Past the Pomerium gate, Paperless-ngx serves its own Django sign-in page. + // preserve_host_header keeps Django's ALLOWED_HOSTS / CSRF checks satisfied, so a + // 400 here would flag a host-header misconfiguration rather than a dead upstream. + const res = await page.request.get(`${BASE}/accounts/login/`, { ignoreHTTPSErrors: true }); + expect( + res.status(), + "Paperless-ngx should serve its login page (200) behind the Pomerium gate", + ).toBe(200); + + const body = await res.text(); + // Shape check: the Django sign-in page carries the Paperless-ngx brand and the + // login/password form fields. A Pomerium error page or a Django 400 would not. + expect(body, "response should be the Paperless-ngx sign-in page").toContain("Paperless-ngx"); + expect(body, "the sign-in page should render its login field").toContain('name="login"'); + expect(body, "the sign-in page should render its password field").toContain('name="password"'); + + // Render the gated login page in the browser and capture it for the guide. + await page.goto(`${BASE}/accounts/login/`, { waitUntil: "networkidle" }); + await expect(page.locator('input[name="login"]')).toBeVisible(); + await expect(page.locator('input[name="password"]')).toBeVisible(); + await shot(page, "paperless-login"); +}); + +test("Paperless-ngx is not reachable except through Pomerium", async ({ page }) => { + // Positive control first: Paperless IS serving through Pomerium (after SSO). This + // proves the service is up, so the direct-hit failure below is caused by network + // topology, not a dead/typo'd endpoint. + await login(page, BASE, alice); + const viaPomerium = await page.request.get(`${BASE}/accounts/login/`, { + ignoreHTTPSErrors: true, + }); + expect(viaPomerium.ok(), "the route through Pomerium should work").toBeTruthy(); + + // Paperless sits on an internal-only network shared with Pomerium alone. The + // test-runner is not on that network, so a direct hit at paperless:8000 + // (bypassing Pomerium) must fail at name resolution / connection, NOT with an + // HTTP response. Asserting the specific error keeps a typo or a down service from + // masquerading as isolation. + let directError = ""; + try { + await page.request.get("http://paperless:8000/accounts/login/", { + ignoreHTTPSErrors: true, + timeout: 5000, + }); + } catch (e) { + directError = String(e); + } + expect( + directError, + "Paperless must be unreachable directly; the only path in is through Pomerium", + ).toMatch(/ENOTFOUND|getaddrinfo|EAI_AGAIN|ECONNREFUSED/i); +}); diff --git a/content/examples/guides/paperless-ngx/validate/compose.validate.yaml b/content/examples/guides/paperless-ngx/validate/compose.validate.yaml new file mode 100644 index 000000000..453b70402 --- /dev/null +++ b/content/examples/guides/paperless-ngx/validate/compose.validate.yaml @@ -0,0 +1,119 @@ +# Sealed E2E validation for the Paperless-ngx guide. Reuses the shared harness +# (Keycloak + certs + headless runner) and adds the Paperless-ngx stack behind a +# Pomerium wired to the in-network IdP. +# Run with: scripts/validate-guide-fixtures.sh paperless-ngx +# +# This is a real end-to-end test: it proves Pomerium gates the route, an allowed +# user passes the gate, and Paperless-ngx then serves its own Django sign-in page +# behind that gate (HTTP 200 with the Paperless-ngx login markers). The admin +# credential below is a throwaway bootstrap user for this sealed, ephemeral stack. +# +# Network isolation mirrors the guide's trust boundary: Paperless, Postgres, and +# Redis sit ONLY on the internal-only `paperless-internal` network, shared with +# pomerium alone. The test-runner is on `default`, so it cannot reach Paperless +# directly (the spec proves this); the only path in is through Pomerium, which +# bridges both networks. +# +# PAPERLESS_URL MUST equal the public route host. Paperless-ngx is a Django app and +# derives ALLOWED_HOSTS / CSRF_TRUSTED_ORIGINS from it; a mismatch yields HTTP 400 +# behind the proxy. Paperless runs migrations + builds its search index on first +# boot, so its healthcheck uses a generous start_period before Pomerium waits on it. + +include: + - ../../_harness/compose/compose.harness.yaml + +services: + redis: + image: redis:7-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99 + networks: + paperless-internal: + aliases: + - redis + + db: + image: postgres:16-alpine@sha256:16bc17c64a573ef34162af9298258d1aec548232985b33ed7b1eac33ba35c229 + environment: + POSTGRES_DB: paperless + POSTGRES_USER: paperless + POSTGRES_PASSWORD: paperless-e2e-db-pass + networks: + paperless-internal: + aliases: + - db + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U paperless -d paperless'] + interval: 5s + timeout: 5s + retries: 30 + start_period: 20s + + paperless: + image: ghcr.io/paperless-ngx/paperless-ngx:2.18.4@sha256:3421ebe06ed27662d014046cf5089e612de853aae0c676a2bc72f73b38080e57 + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + environment: + PAPERLESS_REDIS: redis://redis:6379 + PAPERLESS_DBHOST: db + PAPERLESS_DBUSER: paperless + PAPERLESS_DBPASS: paperless-e2e-db-pass + PAPERLESS_DBNAME: paperless + PAPERLESS_SECRET_KEY: paperless-e2e-throwaway-secret-key + # Must equal the public route host or Django answers HTTP 400 behind Pomerium. + PAPERLESS_URL: https://paperless.localhost.pomerium.io + PAPERLESS_ADMIN_USER: admin + PAPERLESS_ADMIN_PASSWORD: paperless-e2e-admin-pass + networks: + paperless-internal: + aliases: + - paperless + healthcheck: + # python3 ships in the image; hit the local Django sign-in page. 200 means + # migrations ran and the app is serving (not just the port being open). + test: + [ + 'CMD-SHELL', + 'python3 -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen(''http://localhost:8000/accounts/login/'', timeout=5).status==200 else 1)"', + ] + interval: 10s + timeout: 10s + retries: 40 + start_period: 180s + + pomerium: + image: pomerium/pomerium:v0.32.7@sha256:e10d1d267af24f581157f485d9b0bc08469e2428675b696a08e42ceb09b2279c + command: ['--config', '/pomerium/config.yaml'] + env_file: + - ../../_harness/pomerium/validation.env + volumes: + - certs:/certs:ro + - ./routes.validate.yaml:/pomerium/config.yaml:ro + depends_on: + certs-init: + condition: service_completed_successfully + keycloak: + condition: service_healthy + paperless: + condition: service_healthy + networks: + # On `default` for the IdP + test-runner, and on the internal-only network to + # reach Paperless. This is the only service bridging the two. + default: + aliases: + - authenticate.localhost.pomerium.io + - paperless.localhost.pomerium.io + paperless-internal: {} + healthcheck: + test: ['CMD', 'pomerium', 'health'] + interval: 5s + timeout: 5s + retries: 30 + start_period: 10s + +networks: + # Internal-only: no route to the outside, and the test-runner is not attached, + # so Paperless is reachable only via Pomerium. + paperless-internal: + internal: true diff --git a/content/examples/guides/paperless-ngx/validate/routes.validate.yaml b/content/examples/guides/paperless-ngx/validate/routes.validate.yaml new file mode 100644 index 000000000..223fad1d4 --- /dev/null +++ b/content/examples/guides/paperless-ngx/validate/routes.validate.yaml @@ -0,0 +1,12 @@ +# Routes-only validation config. Shared settings (IdP, secrets, signing key, certs) +# come from ../../_harness/pomerium/validation.env via env_file in compose.validate.yaml. +# Paperless-ngx runs its own Django login, so the route is a front-door gate. +# preserve_host_header keeps Django's ALLOWED_HOSTS / CSRF checks happy behind the proxy. +routes: + - from: https://paperless.localhost.pomerium.io + to: http://paperless:8000 + preserve_host_header: true + policy: + - allow: + or: + - authenticated_user: true diff --git a/content/examples/guides/paperless-ngx/validate/url.txt b/content/examples/guides/paperless-ngx/validate/url.txt new file mode 100644 index 000000000..69fa710ed --- /dev/null +++ b/content/examples/guides/paperless-ngx/validate/url.txt @@ -0,0 +1 @@ +https://paperless.localhost.pomerium.io From 0177bdb13801d39a871d8f97b9a1901552e7f409 Mon Sep 17 00:00:00 2001 From: Bobby DeSimone Date: Wed, 10 Jun 2026 09:58:15 -0700 Subject: [PATCH 2/4] docs(guides): clean up Paperless-ngx guide prose and diagrams Address review feedback: condense duplicated value framing, use canonical Pomerium terms (front door, route, policy, the From URL), replace the client-gating SVG with a markdown table, simplify the mermaid diagram, rewrite double-hyphen and em-dash punctuation per the guide style rules, reorder config sections, and scope prerequisites to what each tab actually needs. --- .../img/paperless-ngx/request-surface.svg | 94 ------------------- content/docs/guides/paperless-ngx.mdx | 81 +++++++--------- 2 files changed, 34 insertions(+), 141 deletions(-) delete mode 100644 content/docs/guides/img/paperless-ngx/request-surface.svg diff --git a/content/docs/guides/img/paperless-ngx/request-surface.svg b/content/docs/guides/img/paperless-ngx/request-surface.svg deleted file mode 100644 index 6f9d8ca04..000000000 --- a/content/docs/guides/img/paperless-ngx/request-surface.svg +++ /dev/null @@ -1,94 +0,0 @@ - - Paperless-ngx request surface, with and without Pomerium - Two panels. Without Pomerium, the public internet reaches the - Paperless-ngx Django app directly, so its own login is the only barrier. With - Pomerium, the internet reaches Pomerium first; Pomerium authenticates against - the identity provider and enforces policy, and only allowed requests reach - Paperless, which keeps its own login behind that gate. - - - - - - - - - Without Pomerium - - - Public internet - anyone, unauthenticated - - - - - exposed - - - - Paperless - Django login - - The app's own login is - the only barrier. Scanners - and brute force hit it directly. - - - With Pomerium - - - Public internet - - - - - - - - Pomerium - front-door gate - - - - - IdP + policy - SSO, groups, audit - - - - - allowed only - - - - Paperless - Django login - - Pomerium authenticates - every request and applies - policy before Paperless is - ever reached. The app keeps - its own login behind the gate. - diff --git a/content/docs/guides/paperless-ngx.mdx b/content/docs/guides/paperless-ngx.mdx index a39d5e731..db88e8203 100644 --- a/content/docs/guides/paperless-ngx.mdx +++ b/content/docs/guides/paperless-ngx.mdx @@ -14,7 +14,7 @@ keywords: django, ] description: Put self-hosted Paperless-ngx behind Pomerium so every request is authenticated and authorized at the front door before it reaches your documents. -# cSpell:ignore paperless ngx celery +# cSpell:ignore paperless ngx --- import TabItem from '@theme/TabItem'; @@ -27,43 +27,26 @@ import Compose from '/content/examples/guides/paperless-ngx/docker-compose.yaml. ## What this guide does -You'll put a self-hosted [Paperless-ngx](https://docs.paperless-ngx.com/) instance behind Pomerium so that Pomerium becomes the single front door: every request is authenticated against your identity provider (IdP) and checked against your policy before it ever reaches Paperless-ngx. Paperless-ngx keeps running its own login and per-user document permissions on top, so Pomerium acts as an additional gate rather than replacing Paperless-ngx's accounts. +Put a self-hosted [Paperless-ngx](https://docs.paperless-ngx.com/) instance behind Pomerium so every request is authenticated against your identity provider (IdP) and checked against the route policy before it reaches Paperless-ngx; unauthenticated requests are blocked at the front door. You get single sign-on (SSO), group-based policy, and an audit log of who reached the route. Paperless-ngx keeps its own login and per-user document permissions on top. + +```mermaid +flowchart LR + Browser --> Pomerium["Pomerium
SSO + route policy"] + Pomerium -.->|"sign in"| IdP[Identity provider] + Pomerium --> Paperless["Paperless-ngx
own login + permissions"] +``` Paperless-ngx is a document management system that stores scanned and digitized records, often a household's or a company's most sensitive paperwork: tax filings, contracts, medical records, and IDs. That makes it a high-value target to keep off the open internet. ## When to use this guide -Use it when you run self-hosted Paperless-ngx and want to make sure only people from your organization can even reach it, without exposing its web interface directly to the internet. Pomerium handles the network-level access decision through centralized single sign-on (SSO), group-based policy, and an audit trail of who reached the route; Paperless-ngx continues to manage documents, tags, and its own user sessions behind that gate. - -The value here is not "add a second login." Paperless-ngx already has a login. The value is moving the access decision to a single, centrally managed front door so you can enforce SSO and group policy, get an audit log of access, and shrink the attack surface that faces the internet. - -```mermaid -sequenceDiagram - actor U as User (browser) - participant P as Pomerium - participant I as Identity provider - participant A as Paperless-ngx (Django) - actor S as Scanner / attacker - - U ->> P: GET https://paperless.yourdomain.com - P ->> I: No session: redirect to IdP for SSO - I ->> P: SSO success (identity + groups) - P ->> P: Evaluate policy (allowed?) - P ->> A: Allowed: forward request (Host preserved) - A ->> P: Paperless-ngx login, then documents - P ->> U: Response (proxied back through Pomerium) - S ->> P: Direct probe of the public host - P ->> S: No session, not allowed: blocked at the gate -``` +Use it when you run self-hosted Paperless-ngx and want only people from your organization to reach it. This guide layers Pomerium in front of Paperless-ngx's stock login; if you want Pomerium to sign users into Paperless-ngx directly, Paperless-ngx supports trusted-header SSO natively (see [Next steps](#next-steps)). ## Prerequisites -This guide assumes you've completed the [Quickstart](/docs/get-started/quickstart), so you already have Pomerium running and signing users in through the hosted authenticate service. - -You also need: - - [Docker](https://docs.docker.com/install/) and [Docker Compose](https://docs.docker.com/compose/install/) -- A domain you control for the Paperless-ngx route (this guide uses `paperless.yourdomain.com`) +- For the Pomerium Zero path: a [Pomerium Zero](https://console.pomerium.app) account with its Pomerium instance running locally via the [Quickstart](/docs/get-started/quickstart) Compose file; the route uses the starter domain that comes with it +- For the Pomerium Core path: a domain you control for the route (this guide uses `paperless.yourdomain.com`), with DNS pointed at the host running Pomerium and ports 80 and 443 reachable so `autocert` can provision certificates; the Compose file below runs Pomerium itself This guide was last tested with Paperless-ngx 2.18.4 and Pomerium 0.32.7. @@ -100,24 +83,18 @@ Replace `paperless.yourdomain.com` with your domain and `you@example.com` with t Paperless-ngx runs as a Django application backed by PostgreSQL and Redis. Pomerium terminates TLS at the front door, so Paperless-ngx serves plain HTTP on the internal Docker network. The key settings in the Compose file below: -- `PAPERLESS_URL: https://paperless.yourdomain.com`: Paperless-ngx derives Django's `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` from this. It **must** equal the public route host, or Django answers `HTTP 400` to every request that arrives behind the proxy. +- `PAPERLESS_URL: https://paperless.yourdomain.com`: Paperless-ngx derives Django's `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` from this. It must equal the public route host, or Django answers `HTTP 400` to every request that arrives behind the proxy. - `PAPERLESS_REDIS` and the `PAPERLESS_DB*` values: point Paperless-ngx at the Redis broker and PostgreSQL database that ship in the same Compose file. - `PAPERLESS_SECRET_KEY`: Django's signing key. Generate your own with `openssl rand -base64 48`; never reuse the placeholder. -- `PAPERLESS_ADMIN_USER` / `PAPERLESS_ADMIN_PASSWORD`: bootstrap the first superuser on the very first startup. - -Paperless-ngx keeps its own login. The first time you reach it, sign in with the admin user you bootstrapped above. +- `PAPERLESS_ADMIN_USER` / `PAPERLESS_ADMIN_PASSWORD`: bootstrap the first superuser on first startup. -The request surface is the whole point of putting it behind Pomerium: +The Compose file runs Pomerium Core alongside Paperless-ngx, PostgreSQL, and Redis. For Zero, drop the Core `pomerium` service, keep `paperless`, `db`, and `redis` on `paperless-internal`, and attach your Zero `pomerium` service (the [Quickstart](/docs/get-started/quickstart) Compose service with your `POMERIUM_ZERO_TOKEN`) to `paperless-internal` so it can resolve `paperless` by name. On the Zero path, also set `PAPERLESS_URL` to the route's **From** URL, `https://paperless.`, so Django's host and CSRF checks accept the proxied requests. -![With vs without Pomerium: a directly exposed Paperless-ngx app versus a front-door gated one](./img/paperless-ngx/request-surface.svg) + ## Run the stack -The Compose file runs Pomerium Core alongside Paperless-ngx, PostgreSQL, and Redis. For Zero, drop the Core `pomerium` service, keep `paperless`, `db`, and `redis` on `paperless-internal`, and attach the Quickstart's `pomerium` service to `paperless-internal` so it can resolve `paperless` by name. - - - -Start it: +Start the stack: ```bash docker compose up -d @@ -128,17 +105,27 @@ Paperless-ngx runs database migrations and builds its search index on first boot ## Verify the setup 1. **The route requires authentication.** In a fresh browser, open `https://paperless.yourdomain.com`. You should be redirected to sign in through Pomerium, not straight to Paperless-ngx. -2. **An allowed user reaches Paperless-ngx.** Sign in with a user your policy allows. Pomerium redirects you back and Paperless-ngx's own sign-in page loads behind the gate. +2. **An allowed user reaches Paperless-ngx.** Sign in with a user your policy allows. Pomerium redirects you back and Paperless-ngx's own sign-in page loads. ![The Paperless-ngx sign-in page reached through Pomerium](./img/paperless-ngx/paperless-login.png) 3. **Sign in to Paperless-ngx.** Use the admin account you bootstrapped. Paperless-ngx authenticates you and lands you on its document dashboard, served through Pomerium. -4. **A request that skips the gate is blocked.** In the Compose file above, Paperless-ngx sits on an internal-only Docker network with no published host ports, so a direct probe of the upstream cannot even resolve or connect; the only path in is through Pomerium. - -Pomerium gates the route; Paperless-ngx runs its own login on top. The admin account and first-run setup are Paperless-ngx's concern, not Pomerium's. +4. **A request that bypasses Pomerium fails.** In the Compose file above, Paperless-ngx sits on an internal-only Docker network with no published host ports, so a direct probe of the upstream cannot resolve or connect; the only path in is through Pomerium. When you're done testing, stop the stack with `docker compose down`. Add `-v` only if you mean to delete the database, media, Redis, and credential volumes. +## What Pomerium protects, and what it doesn't + +Everything in this guide lives on one host behind one route, so Pomerium's SSO and policy stand in front of every way into Paperless-ngx: + +| Access channel | What gates it | Credential the client presents | +| --- | --- | --- | +| Web interface in a browser | Pomerium route policy, then Paperless-ngx's login | Pomerium SSO session, then a Paperless-ngx login | +| REST API | The same Pomerium route; API clients can't complete browser SSO, so the route blocks them | Paperless-ngx API token, on a path you deliberately provide | +| Mobile scanner apps | The same Pomerium route, with the same constraint as the API | Stored Paperless-ngx credentials or API token | + +API clients and scanner apps authenticate to Paperless-ngx directly and can't complete browser SSO, so they don't work through this route. If you need them, the options are a separate [public access](/docs/reference/routes/public-access) route (not identity-protected, so Paperless-ngx's own auth becomes the only control), [a TCP tunnel](/docs/capabilities/non-http), or access over the private network. API clients that can send custom headers have one more option on Pomerium Zero or Enterprise: authenticate to this protected route with a [Pomerium service account](/docs/capabilities/service-accounts) token, with Paperless-ngx's API token authorizing the call as usual. + ## Common failure modes - **`HTTP 400 Bad Request` on every page.** `PAPERLESS_URL` doesn't match the public route host, so Django rejects the host. Set `PAPERLESS_URL` to exactly `https://paperless.yourdomain.com` and make sure `preserve_host_header` is enabled on the route. @@ -148,14 +135,14 @@ When you're done testing, stop the stack with `docker compose down`. Add `-v` on ## Security considerations -- Paperless-ngx runs its own authentication, so this guide uses Pomerium as a front-door gate on top of that login. (If you'd rather have Pomerium sign users in directly, Paperless-ngx natively supports trusted-header SSO; see [Next steps](#next-steps).) Either way, **don't expose Paperless-ngx directly**: only Pomerium should reach `paperless:8000`. The Compose file keeps Paperless-ngx (and its PostgreSQL and Redis) on an internal-only Docker network with no published host ports, so the only path in is through Pomerium and the policy can't be bypassed. +- **Don't expose Paperless-ngx directly**: only Pomerium should reach `paperless:8000`. The Compose file keeps Paperless-ngx (and its PostgreSQL and Redis) on an internal-only Docker network with no published host ports, so the only path in is through Pomerium and the policy can't be bypassed. - Scope the route policy (group or domain) to who should have any access to Paperless-ngx at all. Paperless-ngx's per-user document permissions still apply on top of that. -- Paperless-ngx holds sensitive documents and exposes an API and admin interface under the same host. Because the whole host sits behind Pomerium, those surfaces inherit the same SSO and policy gate; don't add a second public route that bypasses it. That also means Paperless API tokens, mobile clients, scanner automation, and share links are not externally usable through this route unless the request also authenticates to Pomerium, for example with a Zero or Enterprise service account. +- Paperless-ngx exposes an API and admin interface under the same host as the web interface. Because the whole host sits behind Pomerium, those surfaces inherit the same SSO and policy; don't add a second public route that bypasses them. - Generate a unique `PAPERLESS_SECRET_KEY` and strong database and admin passwords. The placeholders in this guide are examples, not safe defaults. ## Next steps -- **Go further: let Pomerium sign users in.** Paperless-ngx supports trusted-header SSO ([`PAPERLESS_ENABLE_HTTP_REMOTE_USER`](https://docs.paperless-ngx.com/configuration/)). Set `pass_identity_headers: true` on the route so Pomerium forwards the verified identity as an `X-Pomerium-Claim-*` header, then point `PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME` at that header so Paperless-ngx logs the user in directly instead of keeping a separate login. Only do this when Pomerium is the sole path in and strips any client-supplied copy of that header. +- **Let Pomerium sign users in.** Paperless-ngx supports trusted-header SSO ([`PAPERLESS_ENABLE_HTTP_REMOTE_USER`](https://docs.paperless-ngx.com/configuration/)). Set `pass_identity_headers: true` on the route so Pomerium forwards the verified identity as an `X-Pomerium-Claim-*` header, then point `PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME` at that header so Paperless-ngx logs the user in directly instead of keeping a separate login. Only do this when Pomerium is the sole path in and strips any client-supplied copy of that header. - [Build policies](/docs/get-started/fundamentals/zero/zero-build-policies) - [Custom domains](/docs/capabilities/custom-domains) - [Self-host the identity provider](/docs/integrations/user-identity/oidc) From e3ec3823f1af591135b410dab1e1c47cf5fbd647 Mon Sep 17 00:00:00 2001 From: Bobby DeSimone Date: Wed, 10 Jun 2026 14:33:11 -0700 Subject: [PATCH 3/4] docs(guides): restore real em dashes per style policy change The guide-review style rule now welcomes real em dashes and only bans double-hyphen fakes, so restore the reviewer's verbatim suggestion and the em-dash section headings. --- content/docs/guides/paperless-ngx.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/guides/paperless-ngx.mdx b/content/docs/guides/paperless-ngx.mdx index db88e8203..a3dee1714 100644 --- a/content/docs/guides/paperless-ngx.mdx +++ b/content/docs/guides/paperless-ngx.mdx @@ -114,7 +114,7 @@ Paperless-ngx runs database migrations and builds its search index on first boot When you're done testing, stop the stack with `docker compose down`. Add `-v` only if you mean to delete the database, media, Redis, and credential volumes. -## What Pomerium protects, and what it doesn't +## What Pomerium protects — and what it doesn't Everything in this guide lives on one host behind one route, so Pomerium's SSO and policy stand in front of every way into Paperless-ngx: From 057695699f145b7756e5f94010d9248196bf4e5b Mon Sep 17 00:00:00 2001 From: Bobby DeSimone Date: Wed, 10 Jun 2026 14:35:43 -0700 Subject: [PATCH 4/4] docs(guides): use the From URL term for route-host references Finish the terminology sweep from the Jellyfin review: replace the remaining 'public route host' / 'public route' / 'public name' phrasings with the route's From URL where the route hostname is meant. --- content/docs/guides/paperless-ngx.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/guides/paperless-ngx.mdx b/content/docs/guides/paperless-ngx.mdx index a3dee1714..5c48ef01e 100644 --- a/content/docs/guides/paperless-ngx.mdx +++ b/content/docs/guides/paperless-ngx.mdx @@ -83,7 +83,7 @@ Replace `paperless.yourdomain.com` with your domain and `you@example.com` with t Paperless-ngx runs as a Django application backed by PostgreSQL and Redis. Pomerium terminates TLS at the front door, so Paperless-ngx serves plain HTTP on the internal Docker network. The key settings in the Compose file below: -- `PAPERLESS_URL: https://paperless.yourdomain.com`: Paperless-ngx derives Django's `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` from this. It must equal the public route host, or Django answers `HTTP 400` to every request that arrives behind the proxy. +- `PAPERLESS_URL: https://paperless.yourdomain.com`: Paperless-ngx derives Django's `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` from this. It must equal the route's **From** URL, or Django answers `HTTP 400` to every request that arrives behind the proxy. - `PAPERLESS_REDIS` and the `PAPERLESS_DB*` values: point Paperless-ngx at the Redis broker and PostgreSQL database that ship in the same Compose file. - `PAPERLESS_SECRET_KEY`: Django's signing key. Generate your own with `openssl rand -base64 48`; never reuse the placeholder. - `PAPERLESS_ADMIN_USER` / `PAPERLESS_ADMIN_PASSWORD`: bootstrap the first superuser on first startup. @@ -128,7 +128,7 @@ API clients and scanner apps authenticate to Paperless-ngx directly and can't co ## Common failure modes -- **`HTTP 400 Bad Request` on every page.** `PAPERLESS_URL` doesn't match the public route host, so Django rejects the host. Set `PAPERLESS_URL` to exactly `https://paperless.yourdomain.com` and make sure `preserve_host_header` is enabled on the route. +- **`HTTP 400 Bad Request` on every page.** `PAPERLESS_URL` doesn't match the route's **From** URL, so Django rejects the host. Set `PAPERLESS_URL` to exactly `https://paperless.yourdomain.com` and make sure `preserve_host_header` is enabled on the route. - **Redirects or links point at the container name or the wrong host.** `preserve_host_header` isn't set, so Paperless-ngx sees `paperless:8000` instead of the public name. Enable it on the route. - **`502` or `503` right after `docker compose up`.** Paperless-ngx hasn't finished its first-boot migrations and search-index build yet. Wait until `docker compose logs -f paperless` shows the web server listening; first boot routinely takes a couple of minutes. - **CSRF verification failures when signing in or uploading.** The browser's `Origin` doesn't match Django's `CSRF_TRUSTED_ORIGINS`. This is the same root cause as the `400` above: keep `PAPERLESS_URL` and the route host identical, over HTTPS.