-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsetup.html
More file actions
1159 lines (1074 loc) · 79.7 KB
/
Copy pathsetup.html
File metadata and controls
1159 lines (1074 loc) · 79.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>App setup · Modular Media Server</title>
<meta name="description" content="Click-by-click first-run configuration for every app: storage paths, wiring qBittorrent to Sonarr and Radarr, Jellyfin and Jellyseerr, Uptime Kuma alerts, and Pocket ID single sign-on.">
<link rel="canonical" href="https://parkertools.github.io/Modular-Media-Server/setup.html">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Modular Media Server">
<meta property="og:title" content="App setup · Modular Media Server">
<meta property="og:description" content="Click-by-click first-run configuration for every app: storage paths, wiring the *arr stack, Uptime Kuma alerts, and Pocket ID single sign-on.">
<meta property="og:url" content="https://parkertools.github.io/Modular-Media-Server/setup.html">
<meta name="twitter:card" content="summary">
<meta name="theme-color" content="#006C87">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23006C87'/%3E%3Ccircle cx='16' cy='16' r='6' fill='%23FDFCDC'/%3E%3Ccircle cx='16' cy='16' r='2.5' fill='%23F07167'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Fraunces:opsz,wght@9..144,700&display=swap">
<style>
:root{--on-accent:#FDFCDC;--surface:#fffef7;--tint-teal:#eaf8f7;--tint-peach:#fff6ee;--tint-soft:#f5fbfa;
--warn-bg:#fff0e3;--tip-bg:#e4f5f4;--line-teal:#a9dde0;--line-peach:#f4d3b6;
--code-fg:#0e5a63;--code-bg:#15333b;--code-txt:#fdfcdc;--code-cm:#8fb3ae;
--pill-teal-bg:#d3f0ef;--pill-coral-bg:#ffe0dc;--pill-peach-fg:#744b32;--pill-coral-fg:#96382f;
--ok:#2f7d5e;--bad:#b0463c;--warnfg:#a06a2c;--blue-hover:#005b73;
--shadow:rgba(21,51,59,.045);--blob-a:var(--peach);--blob-b:rgba(0,175,185,.20);
--blue:#006C87;--teal:#00AFB9;--cream:#FDFCDC;--peach:#FED9B7;--coral:#F07167;--ink:#15333b;--muted:#4a626b;--white:#fffef7;--line:#cfe3e4}
*{box-sizing:border-box}html{scroll-behavior:smooth}
body{margin:0;color:var(--ink);font-family:"DM Sans",sans-serif;
background-color:var(--cream);
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='220' height='220' filter='url(%23g)' opacity='0.5'/%3E%3C/svg%3E"),
radial-gradient(ellipse 900px 620px at 8% 2%,rgba(0,175,185,.10),transparent 62%),
radial-gradient(ellipse 760px 540px at 95% 14%,rgba(254,217,183,.26),transparent 64%),
radial-gradient(ellipse 820px 600px at 78% 62%,rgba(240,113,103,.08),transparent 62%),
radial-gradient(ellipse 900px 640px at 15% 88%,rgba(0,108,135,.07),transparent 62%);
background-repeat:repeat,no-repeat,no-repeat,no-repeat,no-repeat;
background-size:220px 220px,auto,auto,auto,auto;
background-attachment:fixed;background-blend-mode:soft-light,normal,normal,normal,normal}
a{color:var(--blue);text-decoration:none}a:hover{color:var(--coral);text-decoration:underline}
.wrap{max-width:1160px;margin:auto;padding:0 24px}
nav{height:82px;display:flex;align-items:center;justify-content:space-between;position:relative}
.brand{font-weight:700;font-size:18px;letter-spacing:-.04em;color:var(--ink)}.brand span{color:var(--coral)}
.navlinks{display:flex;gap:20px;color:var(--muted);font-size:14px}.navlinks a{color:var(--muted)}.navlinks a:hover{color:var(--blue)}
.navbtn{display:none;background:none;border:1px solid var(--line);border-radius:10px;padding:8px 11px;cursor:pointer;font:inherit;color:var(--ink);line-height:1}
.navbtn span{display:block;width:17px;height:2px;background:var(--ink);border-radius:2px;margin:3px 0;transition:.2s}
h1,h2,h3{font-family:Fraunces,serif;letter-spacing:-.045em;line-height:1.05}
.hero{padding:74px 0 50px;position:relative;overflow:hidden;--hero2:rgba(0,175,185,.20)}
.hero:after{content:"";position:absolute;width:250px;height:168px;background:var(--peach);border-radius:54% 46% 60% 40%;right:-66px;top:34px;transform:rotate(-8deg);z-index:-1}
.hero:before{content:"";position:absolute;width:210px;height:210px;border-radius:52% 48% 44% 56%;background:var(--hero2);right:130px;top:-40px;z-index:-2}
.kicker{text-transform:uppercase;letter-spacing:.15em;font-size:12px;font-weight:700;color:var(--blue)}
h1{font-size:clamp(44px,7vw,72px);max-width:840px;margin:18px 0}
.hero p{font-size:19px;line-height:1.7;color:var(--muted);max-width:700px}
p,li{color:var(--muted);line-height:1.75}ul,ol{padding-left:22px}li{margin-bottom:5px}
code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.87em;background:var(--tint-teal);padding:2px 6px;border-radius:6px;color:var(--code-fg);word-break:break-word}
pre{background:#15333b;color:#fdfcdc;padding:17px;border-radius:14px;overflow:auto;line-height:1.7;font-size:12.5px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;margin:14px 0}
pre .cm{color:#8fb3ae}pre .hl{color:var(--peach)}
.layout{display:grid;grid-template-columns:230px minmax(0,1fr);gap:50px;padding:8px 0 90px}
.toc{position:sticky;top:22px;height:max-content;max-height:calc(100vh - 44px);overflow:auto}
.toc strong{display:block;margin-bottom:11px;font-size:14px;color:var(--ink)}
.toc a{display:block;padding:5px 0;color:var(--muted);font-size:13.5px;line-height:1.4}
.toc a:hover{color:var(--blue)}.toc a.on{color:var(--blue);font-weight:700}
.toc hr{border:0;border-top:1px solid var(--line);margin:12px 0}
section{scroll-margin-top:22px;margin-bottom:58px}
h2{font-size:36px;margin:8px 0 14px}h3{font-size:23px;margin:32px 0 10px}
h4{font-family:"DM Sans";font-size:16px;font-weight:700;letter-spacing:-.02em;color:var(--ink);margin:22px 0 7px}
.lead{font-size:16.5px;color:var(--muted);line-height:1.75;max-width:690px;margin-bottom:20px}
.card{background:var(--white);border:1px solid var(--line);border-radius:20px;padding:22px;margin:18px 0;box-shadow:0 1px 2px rgba(21,51,59,.04),0 6px 18px rgba(21,51,59,.045)}
.card.teal{background:var(--tint-teal);border-color:var(--line-teal)}.card.peach{background:var(--tint-peach);border-color:var(--line-peach)}
.card strong{color:var(--ink)}
.warning{border-left:5px solid var(--coral);background:var(--warn-bg);padding:15px 17px;border-radius:0 14px 14px 0;margin:16px 0;font-size:14.5px;line-height:1.7}
.tip{border-left:5px solid var(--teal);background:var(--tip-bg);padding:15px 17px;border-radius:0 14px 14px 0;margin:16px 0;font-size:14.5px;line-height:1.7}
.warning strong,.tip strong{color:var(--ink)}
.tbl{background:var(--white);border:1px solid var(--line);border-radius:16px;overflow:hidden;margin:16px 0;box-shadow:0 1px 2px rgba(21,51,59,.04)}
.tbl table{width:100%;border-collapse:collapse;font-size:13.5px}
.tbl th{text-align:left;padding:12px 15px;font-size:10.5px;text-transform:uppercase;letter-spacing:.1em;color:var(--blue);background:var(--tint-teal);font-weight:700}
.tbl td{padding:11px 15px;border-top:1px solid var(--line);color:var(--muted);vertical-align:top}
.tbl td:first-child{font-weight:700;color:var(--ink);white-space:nowrap}
.yes{color:var(--ok);font-weight:700}.no{color:var(--bad);font-weight:700}.part{color:var(--warnfg);font-weight:700}
details.app{background:var(--white);border:1px solid var(--line);border-radius:18px;margin-bottom:10px;overflow:hidden;box-shadow:0 1px 2px rgba(21,51,59,.04),0 6px 18px rgba(21,51,59,.04)}
details.app[open]{border-color:var(--line-teal)}
details.app summary{cursor:pointer;padding:17px 46px 17px 20px;list-style:none;position:relative;display:flex;align-items:baseline;gap:11px;flex-wrap:wrap}
details.app summary::-webkit-details-marker{display:none}
details.app summary:after{content:"";position:absolute;right:20px;top:23px;width:8px;height:8px;border-right:2px solid var(--muted);border-bottom:2px solid var(--muted);transform:rotate(45deg);transition:.2s}
details.app[open] summary:after{transform:rotate(-135deg);top:26px}
details.app summary b{font-size:17px;letter-spacing:-.02em;color:var(--ink)}
details.app summary .d{font-size:13.5px;color:var(--muted);flex:1;min-width:170px}
.pill{display:inline-block;font-size:10px;font-weight:700;padding:3px 8px;border-radius:999px;text-transform:uppercase;letter-spacing:.06em}
.pill.first{background:var(--pill-teal-bg);color:var(--code-fg)}.pill.after{background:var(--peach);color:var(--pill-peach-fg)}
.abody{padding:0 20px 20px;border-top:1px solid var(--line);padding-top:16px}
.abody p,.abody li{font-size:14.5px}
.steps{counter-reset:st;list-style:none;padding:0;margin:14px 0}
.steps>li{counter-increment:st;position:relative;padding:0 0 14px 36px;font-size:14.5px;line-height:1.7}
.steps>li:before{content:counter(st);position:absolute;left:0;top:1px;width:24px;height:24px;border-radius:50%;background:var(--tint-teal);border:1px solid var(--line-teal);color:var(--blue);font-size:12px;font-weight:700;display:grid;place-items:center}
.steps ul{margin:7px 0 0}
.fields{background:var(--tint-soft);border:1px solid var(--line);border-radius:13px;padding:5px 16px;margin:12px 0}
.fields dl{margin:0;display:grid;grid-template-columns:minmax(130px,auto) 1fr;gap:0 14px}
.fields dt{font-weight:700;color:var(--ink);font-size:13.5px;padding:8px 0;border-bottom:1px solid #e2eeed}
.fields dd{margin:0;font-size:13.5px;color:var(--muted);padding:8px 0;border-bottom:1px solid #e2eeed}
.fields dl>dt:last-of-type,.fields dl>dd:last-of-type{border-bottom:0}
.btn{display:inline-block;border-radius:999px;padding:12px 20px;font-weight:700;font-size:14.5px;background:var(--blue);color:#fff}
.btn:hover{background:var(--blue-hover);color:#fff;text-decoration:none}
.btn.ghost{background:transparent;border:1px solid var(--line);color:var(--ink)}
.btn.ghost:hover{border-color:var(--teal);color:var(--blue);background:transparent}
footer{border-top:1px solid var(--line);padding:25px 0;color:var(--muted);font-size:13px}
@media(max-width:900px){
.navbtn{display:block}
.navlinks{display:none;position:absolute;top:66px;right:0;left:0;background:var(--white);border:1px solid var(--line);border-radius:16px;flex-direction:column;gap:0;padding:8px;z-index:60;box-shadow:0 10px 30px rgba(21,51,59,.09)}
.navopen .navlinks{display:flex}
.navlinks a{padding:11px 13px;border-radius:10px;font-size:15px}
.layout{grid-template-columns:1fr;gap:0}.toc{position:static;max-height:none;margin-bottom:32px}
h2{font-size:29px}.hero:before{display:none}
.fields dl{grid-template-columns:1fr}
.fields dt{border-bottom:0;padding-bottom:0}
}
@media(max-width:520px){.tbl{overflow-x:auto}}
@media print{body{background-image:none}}
/* ── dark theme ─────────────────────────────────────────────────────
Same hues, inverted. Every pairing here clears WCAG AA. */
html[data-theme="dark"]{
--cream:#0d1a1f;--ink:#eef7f5;--muted:#c3d6d9;--white:#132a31;--surface:#132a31;
--line:#23434c;--blue:#6ec9d6;--blue-hover:#8fdae5;--teal:#00AFB9;
--tint-teal:#12313a;--tint-peach:#2e2118;--tint-soft:#102a31;
--warn-bg:#33201c;--tip-bg:#12313a;--line-teal:#2c5560;--line-peach:#4a3526;
--code-fg:#8fdae5;--code-bg:#08151a;--code-txt:#dfeef0;--code-cm:#7fa8a4;
--pill-teal-bg:#164048;--pill-coral-bg:#43231f;--pill-peach-fg:#e8c09a;--pill-coral-fg:#f6a89f;
--ok:#6cc79b;--bad:#f2857a;--warnfg:#e0b06a;
--shadow:rgba(0,0,0,.30);--blob-a:rgba(254,217,183,.13);--blob-b:rgba(0,175,185,.10);
color-scheme:dark;
}
html[data-theme="dark"] body{
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='220' height='220' filter='url(%23g)' opacity='0.32'/%3E%3C/svg%3E"),
radial-gradient(ellipse 900px 620px at 8% 2%,rgba(0,175,185,.09),transparent 62%),
radial-gradient(ellipse 760px 540px at 95% 14%,rgba(254,217,183,.07),transparent 64%),
radial-gradient(ellipse 820px 600px at 78% 62%,rgba(240,113,103,.06),transparent 62%),
radial-gradient(ellipse 900px 640px at 15% 88%,rgba(110,201,214,.06),transparent 62%);
background-blend-mode:overlay,normal,normal,normal,normal;
}
html[data-theme="dark"] .hero:after{background:var(--blob-a)}
html[data-theme="dark"] .hero:before{background:var(--blob-b)}
html[data-theme="dark"] .generator{background:#0f2830}
html[data-theme="dark"] .btn{background:var(--blue);color:#08151a}
html[data-theme="dark"] .btn:hover{background:var(--blue-hover);color:#08151a}
html[data-theme="dark"] .generator .btn{background:var(--blue);color:#08151a}
html[data-theme="dark"] pre{border:1px solid var(--line)}
html[data-theme="dark"] img{opacity:.92}
/* ── shared chrome ──────────────────────────────────────────────────── */
.navlinks a[aria-current="page"]{color:var(--blue);font-weight:700;position:relative}
.navlinks a[aria-current="page"]:after{content:"";position:absolute;left:0;right:0;bottom:-6px;height:2px;background:var(--blue);border-radius:2px}
.navtools{display:flex;align-items:center;gap:10px}
.themebtn{background:none;border:1px solid var(--line);border-radius:999px;width:34px;height:34px;
cursor:pointer;color:var(--muted);display:grid;place-items:center;padding:0;flex-shrink:0;transition:.15s}
.themebtn:hover{border-color:var(--teal);color:var(--blue)}
.themebtn svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round}
html[data-theme="dark"] .themebtn .i-sun{display:block}
html[data-theme="dark"] .themebtn .i-moon{display:none}
.themebtn .i-sun{display:none}
.themebtn .i-moon{display:block}
/* next / previous pager */
.pager{display:grid;gap:12px;margin:56px 0 10px;padding-top:30px;border-top:1px solid var(--line)}
@media(min-width:760px){.pager{grid-template-columns:1fr 1fr}}
.pager a{display:block;background:var(--surface);border:1px solid var(--line);border-radius:18px;
padding:17px 20px;text-decoration:none;transition:.15s}
.pager a:hover{border-color:var(--teal);transform:translateY(-1px);text-decoration:none}
.pager .dir{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);display:block;margin-bottom:5px}
.pager .ttl{font-family:Fraunces,serif;font-size:19px;letter-spacing:-.03em;color:var(--blue);display:block;margin-bottom:3px}
.pager .sub{font-size:13.5px;color:var(--muted);line-height:1.5}
.pager .prev{text-align:left}
@media(min-width:760px){.pager .next{text-align:right}}
/* site footer */
.sitefoot{border-top:1px solid var(--line);margin-top:20px;padding:38px 0 30px}
.footgrid{display:grid;gap:26px;margin-bottom:26px}
@media(min-width:720px){.footgrid{grid-template-columns:1.4fr 1fr 1fr 1fr}}
.footbrand .fb{font-family:Fraunces,serif;font-size:20px;letter-spacing:-.03em;color:var(--ink)}
.footbrand p{font-size:13.5px;margin:8px 0 0;max-width:270px;line-height:1.6}
.footcol b{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);margin-bottom:10px}
.footcol a{display:block;font-size:14px;padding:4px 0;color:var(--muted)}
.footcol a:hover{color:var(--blue)}
.footbase{border-top:1px solid var(--line);padding-top:20px;font-size:12.5px;color:var(--muted);line-height:1.7}
/* The theme toggle now sits beside the menu button, so the dropdown must
clear both and the tools row must stay visible on small screens. */
@media(max-width:900px){
.navtools{gap:8px}
.navlinks{top:70px}
.navlinks a[aria-current="page"]:after{display:none}
.navlinks a[aria-current="page"]{background:var(--tint-teal)}
}
/* surfaces that were still hardcoded light */
html[data-theme="dark"] details.qa[open]{background:#16333b}
html[data-theme="dark"] .tbl tr:nth-child(even) td{background:rgba(255,255,255,.022)}
html[data-theme="dark"] pre{background:var(--code-bg)}
html[data-theme="dark"] .sbox:before{border-color:var(--muted)}
html[data-theme="dark"] .sbox:after{background:var(--muted)}
html[data-theme="dark"] .genout{background:#08151a}
html[data-theme="dark"] .outwrap,html[data-theme="dark"] .outbar{background:#08151a}
html[data-theme="dark"] .outbar{border-bottom:1px solid var(--line)}
html[data-theme="dark"] .qs-step pre,html[data-theme="dark"] .dcard pre,
html[data-theme="dark"] .stp pre{background:var(--code-bg)}
html[data-theme="dark"] .chip.on,html[data-theme="dark"] .stepbtn.on,
html[data-theme="dark"] .pbtn.on{color:#08151a}
html[data-theme="dark"] .qs-step:before,html[data-theme="dark"] .stp:before{color:#08151a}
html[data-theme="dark"] .steps>li:before{background:var(--tint-teal);color:var(--blue);border-color:var(--line-teal)}
html[data-theme="dark"] .dbadge{color:#08151a}
html[data-theme="dark"] .secretval{background:var(--tint-teal);color:var(--ink)}
html[data-theme="dark"] .util,html[data-theme="dark"] .mod,html[data-theme="dark"] .opt,
html[data-theme="dark"] .pick,html[data-theme="dark"] .price,html[data-theme="dark"] .prov,
html[data-theme="dark"] .aidcard,html[data-theme="dark"] .mini,html[data-theme="dark"] .dcard,
html[data-theme="dark"] details.app,html[data-theme="dark"] details.m,
html[data-theme="dark"] .side,html[data-theme="dark"] .qs-done,
html[data-theme="dark"] .qs-need,html[data-theme="dark"] .spec div,
html[data-theme="dark"] .fields,html[data-theme="dark"] .flow span,
html[data-theme="dark"] .stacklist li{background:var(--surface);border-color:var(--line)}
html[data-theme="dark"] .mod.on,html[data-theme="dark"] .tg.on,
html[data-theme="dark"] .qs-next a,html[data-theme="dark"] .aidcard.teal,
html[data-theme="dark"] .dcard-lead{background:var(--tint-teal);border-color:var(--line-teal)}
html[data-theme="dark"] .mod.auto,html[data-theme="dark"] .dcard:nth-child(3){background:var(--tint-peach);border-color:var(--line-peach)}
html[data-theme="dark"] .cyc>.card:nth-of-type(3n+2){background:var(--tint-teal);border-color:var(--line-teal)}
html[data-theme="dark"] .cyc>.card:nth-of-type(3n){background:var(--tint-peach);border-color:var(--line-peach)}
html[data-theme="dark"] .genfacts div{background:rgba(255,255,255,.07);border-color:rgba(255,255,255,.13)}
html[data-theme="dark"] .head{background:var(--tint-teal);border-color:var(--line-teal)}
/* Pills keep a fixed light background, so their dark-mode surface must be
set explicitly or the label becomes invisible against it. */
html[data-theme="dark"] .tag,
html[data-theme="dark"] .pill,
html[data-theme="dark"] .pill.mod,
html[data-theme="dark"] .pill.after,
html[data-theme="dark"] .v-ok,
html[data-theme="dark"] .vi.warn,
html[data-theme="dark"] .mod .flag.auto,
html[data-theme="dark"] .hw-badge{
background:#4a3526;color:#f0cfa8;border-color:#5d452f}
html[data-theme="dark"] .pill.easy,
html[data-theme="dark"] .pill.first,
html[data-theme="dark"] .pill.teal,
html[data-theme="dark"] .v-best,
html[data-theme="dark"] .t-cyan{background:#164048;color:#9fe0e6}
html[data-theme="dark"] .pill.adv,
html[data-theme="dark"] .pill.w,
html[data-theme="dark"] .v-meh,
html[data-theme="dark"] .mod .flag,
html[data-theme="dark"] .tag.w{background:#43231f;color:#f8b4ab}
html[data-theme="dark"] .stepbtn .n{background:#164048;color:#9fe0e6}
html[data-theme="dark"] .stepbtn.on .n{background:rgba(0,0,0,.28);color:#08151a}
html[data-theme="dark"] .dsteps li:before{background:var(--surface);color:var(--blue);border-color:var(--line-teal)}
/* ── keyboard and motion ────────────────────────────────────────────
Visible focus everywhere: the generator, the collapsible steps and
the FAQ search are all keyboard-operable and were previously
impossible to navigate by sight. */
:focus-visible{outline:3px solid var(--blue);outline-offset:3px;border-radius:4px}
a:focus-visible,button:focus-visible,summary:focus-visible,
input:focus-visible,select:focus-visible,details:focus-visible{outline:3px solid var(--blue);outline-offset:3px}
html[data-theme="dark"] :focus-visible{outline-color:var(--blue)}
/* Older browsers without :focus-visible still get something */
a:focus,button:focus,summary:focus{outline:2px solid var(--blue);outline-offset:2px}
a:focus:not(:focus-visible),button:focus:not(:focus-visible),summary:focus:not(:focus-visible){outline:none}
.skiplink{position:absolute;left:-9999px;top:0;z-index:999;background:var(--blue);color:#fff;
padding:12px 18px;border-radius:0 0 12px 0;font-weight:700;font-size:15px}
.skiplink:focus{left:0;text-decoration:none;color:#fff}
html[data-theme="dark"] .skiplink{color:#08151a}
@media(prefers-reduced-motion:reduce){
*,*:before,*:after{animation-duration:.01ms!important;animation-iteration-count:1!important;
transition-duration:.01ms!important;scroll-behavior:auto!important}
.pager a:hover,.util:hover,.qs-next a:hover{transform:none}
}
/* ── large displays ─────────────────────────────────────────────────
The 1160px cap uses only 30% of a 4K screen. Widen in steps and lift
the base size a little, so the page fills the space without prose
lines growing past a comfortable reading measure. */
@media(min-width:1500px){
.wrap{max-width:1320px}
html{font-size:17px}
}
@media(min-width:2000px){
.wrap{max-width:1680px}
html{font-size:18px}
.hero{padding-top:96px;padding-bottom:74px}
}
@media(min-width:2800px){
.wrap{max-width:2040px}
html{font-size:20px}
}
/* Prose stays readable even as the container grows — long measures are
harder to read, so text blocks keep their own ceiling. */
@media(min-width:1500px){
.lead,.hero p,.pd,.qs-step p,.abody p,.mbody p{max-width:78ch}
.toc{width:230px}
}
/* Wider containers can afford more columns. */
@media(min-width:1900px){
.mods{grid-template-columns:repeat(3,1fr)}
.util-grid{grid-template-columns:repeat(4,1fr)}
.prices{grid-template-columns:repeat(3,1fr)}
.chelp-grid{grid-template-columns:repeat(4,1fr)}
.qs-next{grid-template-columns:repeat(3,1fr)}
}
</style>
<script>
(function(){
var t;
try{ t=localStorage.getItem('mms-theme'); }catch(e){}
if(!t) t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme',t);
})();
</script>
</head>
<body>
<a class="skiplink" href="#main">Skip to content</a>
<div class="wrap"><nav><a class="brand" href="index.html">modular<span>.</span>media</a><div class="navlinks"><a href="index.html">Home</a><a href="guide.html">Guide</a><a href="platforms.html">Platforms</a><a href="generator.html">Generator</a><a href="setup.html" aria-current="page">App setup</a><a href="access.html">Access</a><a href="hardware.html">Hardware</a><a href="documentation.html">Docs</a><a href="faq.html">Help</a></div><div class="navtools"><button class="themebtn" id="themebtn" aria-label="Switch between light and dark"><svg class="i-moon" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><svg class="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg></button><button class="navbtn" aria-label="Menu" aria-expanded="false"><span></span><span></span><span></span></button></div></nav></div>
<section class="hero"><div class="wrap"><div class="kicker">App setup</div>
<h1>Now make them talk to each other.</h1>
<p>Your stack is running, and every app is sitting there empty. This is the click-by-click part: where each one stores things, how they connect, and the handful of details that cause nearly every problem.</p></div></section>
<div class="wrap"><div class="layout">
<aside class="toc" id="toc">
<strong>On this page</strong>
<a href="#rules">The two rules</a>
<a href="#order">What order to do this in</a>
<hr>
<a href="#qbittorrent">qBittorrent</a>
<a href="#jackett">Jackett</a>
<a href="#sonarr">Sonarr</a>
<a href="#radarr">Radarr</a>
<a href="#lidarr">Lidarr</a>
<a href="#bazarr">Bazarr</a>
<hr>
<a href="#jellyfin">Jellyfin</a>
<a href="#jellyseerr">Jellyseerr</a>
<a href="#immich">Immich</a>
<a href="#kima">Kima</a>
<a href="#archive">Archiving apps</a>
<hr>
<a href="#kuma">Uptime Kuma</a>
<a href="#kuma-discord">Discord alerts</a>
<a href="#kuma-telegram">Telegram alerts</a>
<hr>
<a href="#cloudflared">Cloudflare Tunnel</a>
<a href="#pocketid">Pocket ID</a>
<a href="#pid-clients">Adding a client</a>
<a href="#pid-immich">Immich SSO</a>
<a href="#pid-arcane">Arcane SSO</a>
<a href="#pid-jellyfin">Jellyfin SSO</a>
<a href="#pid-rest">Everything else</a>
<a href="#pid-users">Users and groups</a>
<hr>
<a href="faq.html">Troubleshooting →</a>
</aside>
<main id="main">
<!-- RULES -->
<section id="rules">
<div class="kicker">Before you start</div>
<h2>The two rules</h2>
<p class="lead">Almost every configuration problem in this stack comes back to one of these. Learn them now and most of what follows is obvious.</p>
<div class="card teal">
<strong>1. Apps find each other by container name, never localhost</strong>
<p style="margin:10px 0 0">Inside a container, <code>localhost</code> means <em>that container</em>. Sonarr looking at <code>127.0.0.1:8080</code> is looking inside itself. Use the service name from your Compose file: <code>jellyfin</code>, <code>sonarr</code>, <code>radarr</code>.</p>
</div>
<div class="warning">
<strong>The exception that catches everyone: qBittorrent.</strong> Because it runs with <code>network_mode: service:gluetun</code> it has no network identity of its own — it lives inside Gluetun's network. So other apps must connect to <code>gluetun</code>, not <code>qbittorrent</code>. Same for Jackett when it is behind the VPN.
</div>
<div class="card teal">
<strong>2. Every app must see the same file at the same path</strong>
<p style="margin:10px 0 0">If qBittorrent saves to <code>/data/downloads/x.mkv</code>, Sonarr must see that exact path. When they disagree, downloads finish and then never import. With the shared storage layout every app mounts one <code>/data</code> and this is free.</p>
</div>
<div class="tbl"><table>
<thead><tr><th>Connecting to</th><th>Host</th><th>Port</th></tr></thead>
<tbody>
<tr><td>qBittorrent (behind VPN)</td><td><code>gluetun</code></td><td>8080</td></tr>
<tr><td>qBittorrent (no VPN)</td><td><code>qbittorrent</code></td><td>8080</td></tr>
<tr><td>Jackett (behind VPN)</td><td><code>gluetun</code></td><td>9117</td></tr>
<tr><td>Jackett (no VPN)</td><td><code>jackett</code></td><td>9117</td></tr>
<tr><td>Sonarr</td><td><code>sonarr</code></td><td>8989</td></tr>
<tr><td>Radarr</td><td><code>radarr</code></td><td>7878</td></tr>
<tr><td>Lidarr</td><td><code>lidarr</code></td><td>8686</td></tr>
<tr><td>Jellyfin</td><td><code>jellyfin</code></td><td>8096</td></tr>
</tbody></table></div>
</section>
<!-- ORDER -->
<section id="order">
<h2>What order to do this in</h2>
<p class="lead">Each app needs something from the one before it — usually an API key. Going in this order means never doubling back.</p>
<ol class="steps">
<li><b>qBittorrent</b> — set its password and save paths. Nothing else works until this does.</li>
<li><b>Jackett</b> — add indexers, collect the API key.</li>
<li><b>Sonarr, Radarr, Lidarr</b> — point each at qBittorrent and Jackett, set root folders.</li>
<li><b>Bazarr</b> — needs Sonarr's and Radarr's API keys.</li>
<li><b>Jellyfin</b> — create libraries over the folders the *arr apps now fill.</li>
<li><b>Jellyseerr</b> — needs Jellyfin, then Sonarr and Radarr.</li>
<li><b>Uptime Kuma</b> — monitor everything above, then set up alerts.</li>
<li><b>Pocket ID</b> — last, once the apps it protects are working.</li>
</ol>
<div class="tip"><strong>Every *arr app hides its API key in the same place:</strong> <b>Settings → General → API Key</b>. You will copy these between apps constantly, so keep a scratch note open.</div>
</section>
<!-- QBITTORRENT -->
<section id="qbittorrent">
<div class="kicker">Downloads</div>
<h2>qBittorrent</h2>
<p class="lead">The download client. Configure this first — Sonarr and Radarr cannot be finished without it.</p>
<details class="app" open><summary><b>First run</b><span class="pill first">Do this first</span><span class="d">Find the temporary password and change it</span></summary>
<div class="abody">
<p>Recent versions no longer ship a fixed default password. One is generated on first start and written to the log:</p>
<pre>docker compose logs qbittorrent | grep -i password</pre>
<p>In Arcane, open the qBittorrent container and read its logs — the line is near the top.</p>
<ol class="steps">
<li>Open <code>http://your-server:8080</code> and sign in as <code>admin</code> with that password.</li>
<li>Go to <b>Tools → Options → Web UI</b>.</li>
<li>Set a real username and password under <b>Authentication</b>.</li>
<li>Tick <b>Bypass authentication for clients on localhost</b> only if you understand it. Leave it off otherwise.</li>
</ol>
<div class="warning"><strong>Change the password even on a home network.</strong> qBittorrent's web interface can add torrents and write files anywhere it can reach.</div>
</div></details>
<details class="app"><summary><b>Save paths</b><span class="d">Where downloads land, and why it matters</span></summary>
<div class="abody">
<p>Under <b>Tools → Options → Downloads</b>:</p>
<div class="fields"><dl>
<dt>Default Save Path</dt><dd><code>/data/downloads/complete</code></dd>
<dt>Keep incomplete torrents in</dt><dd>tick, then <code>/data/downloads/incomplete</code></dd>
<dt>Copy .torrent files</dt><dd>off — the *arr apps manage this</dd>
<dt>Pre-allocate disk space</dt><dd>on, if you have room; avoids fragmentation</dd>
</dl></div>
<p>These are paths <em>inside the container</em>. With the shared layout, <code>/data</code> is your <code>DATA_ROOT</code>, so <code>/data/downloads</code> is really <code>/srv/data/downloads</code> on the host.</p>
<div class="warning"><strong>Keeping incomplete downloads in a separate folder is not cosmetic.</strong> Without it, Sonarr can see a half-finished file and try to import it, producing a corrupt episode that then needs deleting and re-downloading.</div>
</div></details>
<details class="app"><summary><b>Categories</b><span class="pill after">Set up by the *arr apps</span><span class="d">Keeps TV, films and music apart</span></summary>
<div class="abody">
<p>Categories put each app's downloads in their own subfolder and let each app see only its own work. You do not need to create them by hand — when you connect Sonarr, it creates <code>tv-sonarr</code> for you, Radarr creates <code>radarr</code>, Lidarr creates <code>lidarr</code>.</p>
<p>To check afterwards, look at the left sidebar in qBittorrent. If a category is missing, the connection from that app is not working.</p>
<h4>Seeding and cleanup</h4>
<p>Under <b>Tools → Options → BitTorrent</b> you can set a share ratio and what happens when it is reached. Pausing rather than deleting is the safe default — hardlinked files are safe to remove, but if you are not using hardlinks, deleting the torrent deletes the file your library points at.</p>
</div></details>
<details class="app"><summary><b>API keys and permissions</b><span class="d">For the generator's deploy script</span></summary>
<div class="abody">
<p>The generator can produce a <code>deploy-to-arcane.sh</code> that creates and starts your project through Arcane's API. It needs a key, and that key should be scoped.</p>
<h5>Creating the key</h5>
<ol class="steps">
<li>In Arcane, go to <b>Settings → API Keys</b> and create one.</li>
<li>Grant only these three permissions:
<div class="fields"><dl>
<dt>environments:read</dt><dd>so the script can find which environment to deploy into</dd>
<dt>projects:create</dt><dd>to create the project</dd>
<dt>projects:deploy</dt><dd>to start it</dd>
</dl></div></li>
<li>Copy the key. It is shown once.</li>
</ol>
<div class="warning"><strong>Do not use an unrestricted key.</strong> Arcane holds the Docker socket, so a key with full access is equivalent to root on that machine. These three permissions are all the script uses, and a key cannot exceed the permissions of the user who created it.</div>
<h5>If the names differ on your instance</h5>
<p>Permissions follow a <code>resource:action</code> pattern, and the exact set depends on your Arcane version. Ask your own instance rather than guessing:</p>
<pre>bash deploy-to-arcane.sh --permissions</pre>
<p>That lists every permission your Arcane offers. If a call is refused, the script names the specific permission that was missing rather than failing with a bare error.</p>
<h5>Running it</h5>
<pre>bash deploy-to-arcane.sh</pre>
<p>It prompts for the URL and the key. The key is typed with the input hidden and held only in that shell process — never exported, never written to disk, and never passed as an argument, since arguments are visible to anyone running <code>ps</code>.</p>
<div class="tip"><strong>Environment variables were the wrong tool here.</strong> An exported key lands in your shell history, in <code>/proc</code>, and in the environment of every child process. Typing it at a prompt avoids all three.</div>
</div></details>
</section>
<!-- JACKETT -->
<section id="jackett">
<h2>Jackett</h2>
<p class="lead">Turns many different tracker APIs into one format the *arr apps understand. It has no accounts and no library — it is plumbing.</p>
<details class="app"><summary><b>Adding indexers</b><span class="d">And collecting what Sonarr needs</span></summary>
<div class="abody">
<ol class="steps">
<li>Open <code>http://your-server:9117</code>.</li>
<li>Press <b>Add Indexer</b>, search, and add the ones you use. Public indexers need nothing; private ones need your account details.</li>
<li>Press <b>Test</b> on each. A failure here will also fail in Sonarr, so fix it now.</li>
<li>For each working indexer, press <b>Copy Torznab Feed</b> and keep the URL.</li>
<li>Copy the <b>API Key</b> from the top right of the page. It is the same for every indexer.</li>
</ol>
<div class="tip"><strong>The Torznab URL Sonarr wants</strong> looks like <code>http://gluetun:9117/api/v2.0/indexers/<indexer-id>/results/torznab/</code> when Jackett is behind the VPN. Jackett's copy button gives you a <code>localhost</code> version — swap the host for <code>gluetun</code> (or <code>jackett</code> if you are not using a VPN).</div>
<div class="warning"><strong>You are responsible for which indexers you use</strong> and whether accessing them is lawful where you live.</div>
</div></details>
</section>
<!-- SONARR -->
<section id="sonarr">
<div class="kicker">Automation</div>
<h2>Sonarr</h2>
<p class="lead">TV automation, and the template for Radarr and Lidarr — they are the same app with different libraries. Do this one carefully and the other two take two minutes each.</p>
<details class="app" open><summary><b>1 · Root folder</b><span class="pill first">Start here</span><span class="d">Where finished episodes go</span></summary>
<div class="abody">
<p><b>Settings → Media Management</b>, scroll to <b>Root Folders</b>, press <b>Add Root Folder</b>:</p>
<div class="fields"><dl>
<dt>Path</dt><dd><code>/data/media/tv</code> (shared layout) or <code>/tv</code> (split layout)</dd>
</dl></div>
<p>On the same page, turn on <b>Rename Episodes</b>. Sonarr will then name files the way Jellyfin expects, which fixes most artwork problems before they happen.</p>
<div class="tip"><strong>If the folder does not appear in the browser</strong>, the volume is not mounted. Check what Sonarr can actually see — in Arcane open the container console and run <code>ls /data</code>.</div>
</div></details>
<details class="app"><summary><b>2 · Download client</b><span class="d">Connecting to qBittorrent</span></summary>
<div class="abody">
<p><b>Settings → Download Clients → + → qBittorrent</b>:</p>
<div class="fields"><dl>
<dt>Name</dt><dd>qBittorrent</dd>
<dt>Host</dt><dd><code>gluetun</code> — <b>not</b> <code>qbittorrent</code>, and never <code>localhost</code></dd>
<dt>Port</dt><dd><code>8080</code></dd>
<dt>Username / Password</dt><dd>what you set in qBittorrent</dd>
<dt>Category</dt><dd><code>tv-sonarr</code></dd>
<dt>Use SSL</dt><dd>off</dd>
</dl></div>
<p>Press <b>Test</b>. A green tick means the connection works. Then <b>Save</b>.</p>
<div class="warning"><strong>If Test fails</strong>, it is almost always the host. Prove the name resolves before changing anything else — in Arcane's Sonarr console, run <code>ping -c2 gluetun</code>. If that works, the problem is the port or the password instead.</div>
</div></details>
<details class="app"><summary><b>3 · Indexers</b><span class="d">Pointing at Jackett</span></summary>
<div class="abody">
<p><b>Settings → Indexers → + → Torznab → Custom</b>. Do this once per indexer you added in Jackett:</p>
<div class="fields"><dl>
<dt>Name</dt><dd>whatever the indexer is called</dd>
<dt>URL</dt><dd>the Torznab feed, with the host changed to <code>gluetun</code></dd>
<dt>API Key</dt><dd>Jackett's API key</dd>
<dt>Categories</dt><dd>leave default unless you know otherwise</dd>
</dl></div>
<p>Press <b>Test</b>, then <b>Save</b>.</p>
</div></details>
<details class="app"><summary><b>4 · Quality profile</b><span class="d">Deciding what "good enough" means</span></summary>
<div class="abody">
<p><b>Settings → Profiles</b>. The defaults work, but two adjustments save trouble:</p>
<ul>
<li><b>Cap the quality.</b> A 1080p profile is the sensible default for most hardware. 4K files are enormous and will force Jellyfin to transcode on almost any client.</li>
<li><b>Set a size limit.</b> Under the profile, a maximum size per episode stops a single release filling your disk.</li>
</ul>
</div></details>
<details class="app"><summary><b>5 · Your API key</b><span class="d">Needed by Bazarr and Jellyseerr</span></summary>
<div class="abody">
<p><b>Settings → General → Security → API Key</b>. Copy it — Bazarr and Jellyseerr both need it.</p>
<h4>If downloads finish but never import</h4>
<p>Sonarr and qBittorrent disagree about paths. Compare what each one sees:</p>
<pre>docker compose exec qbittorrent ls /data/downloads
docker compose exec sonarr ls /data/downloads</pre>
<p>Different listings mean different mounts. The proper fix is matching volumes; the patch is <b>Settings → Download Clients → Remote Path Mappings</b>, telling Sonarr that the client's path is really its own.</p>
</div></details>
</section>
<!-- RADARR -->
<section id="radarr">
<h2>Radarr</h2>
<p class="lead">Identical to Sonarr, with three values changed.</p>
<div class="fields"><dl>
<dt>Root folder</dt><dd><code>/data/media/movies</code></dd>
<dt>Download category</dt><dd><code>radarr</code></dd>
<dt>Rename setting</dt><dd><b>Rename Movies</b>, on</dd>
</dl></div>
<p>Everything else — download client host <code>gluetun</code> port 8080, Torznab indexers from Jackett, API key under Settings → General — works exactly as described for <a href="#sonarr">Sonarr</a>.</p>
<div class="tip"><strong>Quality matters more here.</strong> A 4K remux film can be 60 GB and will transcode badly on modest hardware. Unless you have a reason, cap at 1080p.</div>
</section>
<!-- LIDARR -->
<section id="lidarr">
<h2>Lidarr</h2>
<p class="lead">Same again for music, with the usual caveat that music metadata is messier than film and TV.</p>
<div class="fields"><dl>
<dt>Root folder</dt><dd><code>/data/media/music</code></dd>
<dt>Download category</dt><dd><code>lidarr</code></dd>
<dt>Metadata profile</dt><dd>Standard, unless you want every single and remix</dd>
</dl></div>
<p>Download client and indexers are configured exactly as for Sonarr. Expect more manual matching — Lidarr relies on MusicBrainz, so if an artist is wrong there it will be wrong here.</p>
<div class="tip"><strong>If you are running Kima</strong>, it can drive Lidarr from its own interface. Kima needs Lidarr's URL (<code>http://lidarr:8686</code>) and API key — see <a href="#kima">Kima</a> below.</div>
</section>
<!-- BAZARR -->
<section id="bazarr">
<h2>Bazarr</h2>
<p class="lead">Fetches subtitles for the libraries Sonarr and Radarr already manage. It needs their API keys, so do this after both are working.</p>
<details class="app"><summary><b>Connecting to Sonarr and Radarr</b><span class="d">Two forms, same shape</span></summary>
<div class="abody">
<p><b>Settings → Sonarr</b>, toggle it on:</p>
<div class="fields"><dl>
<dt>Address</dt><dd><code>sonarr</code></dd>
<dt>Port</dt><dd><code>8989</code></dd>
<dt>API Key</dt><dd>from Sonarr's Settings → General</dd>
<dt>SSL</dt><dd>off</dd>
</dl></div>
<p>Then <b>Settings → Radarr</b> with <code>radarr</code> and port <code>7878</code>. Press <b>Test</b> on each, then save and restart Bazarr when it asks.</p>
<div class="warning"><strong>Bazarr must see the same paths as Sonarr and Radarr.</strong> If it uses different mounts it will look for your files in the wrong place and quietly find nothing. Its volumes should point at the same media folders.</div>
</div></details>
<details class="app"><summary><b>Languages and providers</b><span class="d">What to fetch, and from where</span></summary>
<div class="abody">
<ol class="steps">
<li><b>Settings → Languages</b> — create a profile, add the languages you want, and set it as the default for series and movies.</li>
<li><b>Settings → Providers</b> — add subtitle sources. Most need a free account; create one and enter the details.</li>
<li><b>Settings → Subtitles</b> — turn on <b>Use embedded subtitles</b> so Bazarr does not fetch what your files already contain.</li>
</ol>
<div class="tip"><strong>Out of sync subtitles</strong> usually mean the subtitle was matched to a different release of the same episode. Bazarr can shift timing, but fetching one matching your file's release group is the real fix.</div>
</div></details>
</section>
<!-- JELLYFIN -->
<section id="jellyfin">
<div class="kicker">Watching</div>
<h2>Jellyfin</h2>
<p class="lead">Where everything above ends up. Libraries are the main thing to get right, and the mistake is always the same one.</p>
<details class="app" open><summary><b>Libraries</b><span class="pill first">The important bit</span><span class="d">Use container paths, and split by type</span></summary>
<div class="abody">
<p><b>Dashboard → Libraries → Add Media Library</b>:</p>
<div class="fields"><dl>
<dt>Content type</dt><dd><b>Movies</b> — make a separate library for Shows</dd>
<dt>Display name</dt><dd>Films</dd>
<dt>Folder</dt><dd><code>/media/movies</code> — the path <em>inside</em> the container</dd>
</dl></div>
<p>Repeat with content type <b>Shows</b> and folder <code>/media/tv</code>, and <b>Music</b> for <code>/media/music</code>.</p>
<div class="warning"><strong>One library per content type.</strong> Pointing a single library at a folder containing both films and TV produces wrong metadata for everything in it, and it is tedious to unpick afterwards.</div>
<h4>If a library comes up empty</h4>
<p>The mount is wrong, not Jellyfin. Check what it can actually see:</p>
<pre>docker compose exec jellyfin ls /media</pre>
<p>Nothing listed means the volume path in your Compose file does not match your real folder.</p>
</div></details>
<details class="app"><summary><b>Users</b><span class="d">Giving the household accounts</span></summary>
<div class="abody">
<p><b>Dashboard → Users → +</b>. For each person:</p>
<ul>
<li>Set which libraries they can see under <b>Library Access</b>.</li>
<li>Turn off <b>Allow media playback that requires transcoding</b> for casual users if your server is CPU-limited — the difference between one device quietly overloading the machine and it politely refusing.</li>
<li>Leave administrative permissions off unless they need them.</li>
</ul>
<div class="tip"><strong>Keep one local admin account with a password</strong> even if you later add single sign-on. If an SSO plugin breaks after an update, that account is how you get back in.</div>
</div></details>
<details class="app"><summary><b>Transcoding</b><span class="d">Only if you need it</span></summary>
<div class="abody">
<p><b>Dashboard → Playback → Transcoding</b>. On native Linux with an Intel chip, set hardware acceleration to <b>Intel QuickSync</b> and enable the codecs your hardware supports. This requires the device mapping in your Compose file:</p>
<pre>devices:
- /dev/dri:/dev/dri</pre>
<div class="warning"><strong>Not available on Docker Desktop.</strong> On Windows and macOS the virtual machine cannot reach the GPU, so transcoding is CPU-only — realistically one stream. Storing files your devices can play directly is the practical answer there.</div>
</div></details>
</section>
<!-- JELLYSEERR -->
<section id="jellyseerr">
<h2>Jellyseerr</h2>
<p class="lead">The request front end. It signs people in against Jellyfin and hands approved requests to Sonarr and Radarr, so it needs all three configured first.</p>
<details class="app" open><summary><b>Setup wizard</b><span class="pill first">Runs once</span><span class="d">Jellyfin, then the *arr apps</span></summary>
<div class="abody">
<ol class="steps">
<li>Open <code>http://your-server:5055</code>. Choose <b>Sign in with Jellyfin</b>.</li>
<li>Jellyfin URL: <code>http://jellyfin:8096</code>. Sign in with your Jellyfin <em>admin</em> account — this becomes the Jellyseerr owner.</li>
<li>It offers to sync your Jellyfin libraries. Tick the ones people should be able to request from, and run the scan.</li>
<li>Next it asks for Radarr and Sonarr. Add both.</li>
</ol>
<h4>Adding Radarr</h4>
<div class="fields"><dl>
<dt>Hostname</dt><dd><code>radarr</code></dd>
<dt>Port</dt><dd><code>7878</code></dd>
<dt>API Key</dt><dd>from Radarr's Settings → General</dd>
<dt>Quality Profile</dt><dd>the profile you set up in Radarr</dd>
<dt>Root Folder</dt><dd><code>/data/media/movies</code></dd>
<dt>Default server</dt><dd>on</dd>
</dl></div>
<p>Sonarr is the same with <code>sonarr</code>, port <code>8989</code>, and root folder <code>/data/media/tv</code>. Sonarr also asks for a <b>Language Profile</b> — pick your default.</p>
<div class="tip"><strong>Test each connection before saving.</strong> If Jellyseerr cannot reach Radarr, requests will be accepted and then silently do nothing, which is a confusing thing to debug later.</div>
</div></details>
<details class="app"><summary><b>Users and quotas</b><span class="d">Letting the household in without losing your disk</span></summary>
<div class="abody">
<ol class="steps">
<li><b>Settings → Users</b> → <b>Import Users from Jellyfin</b>. Everyone with a Jellyfin account appears.</li>
<li>Set <b>Global Movie Request Limit</b> and <b>Global Series Request Limit</b> — something like 5 per week is sane.</li>
<li>Decide on auto-approval. Leaving it off means requests wait for you; turning it on for trusted users saves you being a bottleneck.</li>
</ol>
<div class="warning"><strong>Quotas are worth setting before you share the link.</strong> An enthusiastic new user can request an entire franchise in an afternoon and fill a disk overnight.</div>
</div></details>
</section>
<!-- IMMICH -->
<section id="immich">
<h2>Immich</h2>
<p class="lead">Independent of the media chain. The main setup is the mobile app, since phone backup is the point.</p>
<details class="app"><summary><b>First run and mobile backup</b><span class="d">Admin account, then your phone</span></summary>
<div class="abody">
<ol class="steps">
<li>Open <code>http://your-server:2283</code> and create the admin account. The first account is the administrator.</li>
<li><b>Administration → Settings → Storage Template</b> — turn it on if you want files organised by date on disk rather than by upload id. Decide this before uploading much; changing it later means moving everything.</li>
<li>Install the Immich app on your phone, and enter the server URL. On the same network that is <code>http://your-server:2283</code>; from outside you will need <a href="access.html">remote access</a> first.</li>
<li>In the app, <b>Backup</b> → choose which albums to back up → enable background backup.</li>
</ol>
<div class="warning"><strong>Back up the database, not just the photos.</strong> The files alone are not your library — albums, faces and metadata live in Postgres. Losing it means keeping every image but losing all the organisation.</div>
<div class="tip"><strong>First import is slow, and that is normal.</strong> Thumbnails and face recognition for a large library take hours. Watch <b>Administration → Jobs</b> to see progress rather than assuming it has hung.</div>
</div></details>
</section>
<!-- KIMA -->
<section id="kima">
<h2>Kima</h2>
<p class="lead">Music streaming over the library Lidarr manages, with its own apps via the Subsonic API.</p>
<details class="app"><summary><b>Setup and Lidarr link</b><span class="d">Library, then automation</span></summary>
<div class="abody">
<ol class="steps">
<li>Open <code>http://your-server:3030</code> and create your account.</li>
<li>Point it at your music. Inside the container that is <code>/music</code>, which maps to your media folder's <code>music</code> directory.</li>
<li>Let it scan. Audio analysis is CPU-heavy and takes a while on a large library.</li>
<li>To connect Lidarr, give Kima the URL <code>http://lidarr:8686</code> and Lidarr's API key from Settings → General.</li>
</ol>
<h4>Native apps</h4>
<p>Kima implements the OpenSubsonic API, so apps like Symfonium, Amperfy and DSub can connect to it directly. Point them at your Kima URL with your Kima username and password.</p>
<div class="warning"><strong>Kima's built-in Soulseek downloader is not behind the VPN.</strong> Unlike qBittorrent it runs on the normal network, because it also has to serve your library. If you enter Soulseek credentials, that peer-to-peer traffic uses your ordinary connection. Leaving the integration unconfigured avoids the question entirely.</div>
</div></details>
</section>
<!-- ARCHIVE -->
<section id="archive">
<h2>Archiving apps</h2>
<details class="app"><summary><b>ArchiveBox</b><span class="d">Saving web pages</span></summary>
<div class="abody">
<p>Open <code>http://your-server:8000</code> and sign in with <code>admin</code> and the generated password from your <code>.env</code>.</p>
<p>Add URLs through <b>Add</b>, or import bookmarks and RSS feeds. Each snapshot keeps several formats at once — HTML, PDF, screenshot and more — so storage grows faster than people expect.</p>
<div class="warning"><strong>Public access is off by default</strong> in the generated config (<code>PUBLIC_INDEX</code>, <code>PUBLIC_SNAPSHOTS</code>, <code>PUBLIC_ADD_VIEW</code>). Turn those on only deliberately.</div>
</div></details>
<details class="app"><summary><b>Tube Archivist</b><span class="d">Archiving YouTube channels</span></summary>
<div class="abody">
<p>Open <code>http://your-server:8001</code> and sign in with <code>admin</code> and the <code>TA_PASSWORD</code> from your <code>.env</code>.</p>
<ol class="steps">
<li><b>Settings → Application</b> — set your download quality and format.</li>
<li><b>Subscriptions</b> — add channel URLs.</li>
<li><b>Downloads</b> — run a scan, then start the download queue.</li>
</ol>
<div class="tip"><strong>If it will not start</strong>, it is usually Elasticsearch — either memory limits or <code>vm.max_map_count</code> being too low on the host. On ARM machines its Elasticsearch image has limited support, which is the one module likely to disappoint on a Raspberry Pi.</div>
</div></details>
</section>
<!-- UPTIME KUMA -->
<section id="kuma">
<div class="kicker">Monitoring</div>
<h2>Uptime Kuma</h2>
<p class="lead">Watches everything else and tells you when it stops. Worth setting up early — it is the app that tells you when the others need attention.</p>
<details class="app" open><summary><b>HTTP monitors</b><span class="pill first">Start here</span><span class="d">The most useful check, and it needs no permissions</span></summary>
<div class="abody">
<p>Open <code>http://your-server:3001</code>, create your account, then <b>Add New Monitor</b>:</p>
<div class="fields"><dl>
<dt>Monitor Type</dt><dd><b>HTTP(s)</b></dd>
<dt>Friendly Name</dt><dd>Jellyfin</dd>
<dt>URL</dt><dd><code>http://jellyfin:8096</code> — the container name, not localhost</dd>
<dt>Heartbeat Interval</dt><dd>60 seconds is plenty</dd>
<dt>Retries</dt><dd>2 or 3, so a brief blip does not page you</dd>
</dl></div>
<p>Repeat for each service you care about:</p>
<div class="tbl"><table>
<thead><tr><th>Service</th><th>URL</th></tr></thead>
<tbody>
<tr><td>Jellyfin</td><td><code>http://jellyfin:8096</code></td></tr>
<tr><td>Jellyseerr</td><td><code>http://jellyseerr:5055</code></td></tr>
<tr><td>Sonarr</td><td><code>http://sonarr:8989</code></td></tr>
<tr><td>Radarr</td><td><code>http://radarr:7878</code></td></tr>
<tr><td>Immich</td><td><code>http://immich-server:2283</code></td></tr>
<tr><td>qBittorrent</td><td><code>http://gluetun:8080</code></td></tr>
</tbody></table></div>
<div class="tip"><strong>Why the container name?</strong> Inside Docker, <code>localhost</code> means Uptime Kuma itself. Using container names also means you are testing the internal path rather than your router, so a monitor failing tells you something real.</div>
</div></details>
<details class="app"><summary><b>Docker container monitors</b><span class="d">Needs the socket mounted</span></summary>
<div class="abody">
<p>An HTTP monitor tells you the app is answering. A container monitor tells you the container is running. They fail in different ways, so both are useful.</p>
<p>First check your Compose file has the socket mounted — in the generated stack this line is commented out by default:</p>
<pre>volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro</pre>
<p>Then in Uptime Kuma, <b>Profile → Settings → Docker Hosts → Add Docker Host</b>:</p>
<div class="fields"><dl>
<dt>Friendly Name</dt><dd>Local</dd>
<dt>Connection Type</dt><dd><b>Socket</b></dd>
<dt>Docker Daemon</dt><dd><code>/var/run/docker.sock</code></dd>
</dl></div>
<p>Save, then <b>Add New Monitor</b> with type <b>Docker Container</b>, container name <code>jellyfin</code>, and the Docker host you just made.</p>
<div class="warning"><strong>The socket is a powerful thing to hand out</strong>, even read-only. If you would rather not, HTTP monitors answer the more useful question anyway — whether the app actually works.</div>
</div></details>
<details class="app"><summary><b>Status page</b><span class="d">Optional, and safe to share</span></summary>
<div class="abody">
<p><b>Status Pages → New Status Page</b>. Add the monitors you want visible, and Kuma gives you a clean public page.</p>
<p>This is the one part of Uptime Kuma that is reasonable to expose publicly — it shows only up or down, not your admin interface. If your household keeps asking whether the server is down, this answers it for them.</p>
</div></details>
</section>
<!-- DISCORD -->
<section id="kuma-discord">
<h2>Discord alerts</h2>
<p class="lead">The quickest notification to set up, and the one most people want.</p>
<ol class="steps">
<li>In Discord, open the channel you want alerts in. You need <b>Manage Webhooks</b> permission, so your own server is easiest.</li>
<li><b>Edit Channel → Integrations → Webhooks → New Webhook</b>.</li>
<li>Give it a name — <b>Server alerts</b> — and optionally an avatar. Press <b>Copy Webhook URL</b>.</li>
<li>In Uptime Kuma, edit any monitor and press <b>Setup Notification</b>.</li>
</ol>
<div class="fields"><dl>
<dt>Notification Type</dt><dd><b>Discord</b></dd>
<dt>Friendly Name</dt><dd>Discord</dd>
<dt>Discord Webhook URL</dt><dd>paste it</dd>
<dt>Bot Display Name</dt><dd>optional</dd>
<dt>Default enabled</dt><dd>tick, so new monitors use it automatically</dd>
<dt>Apply on all existing monitors</dt><dd>tick, to cover what you already made</dd>
</dl></div>
<p>Press <b>Test</b>. A message should land in the channel within a second or two. If it does not, the webhook URL is wrong — nothing else in the chain produces that result.</p>
<div class="warning"><strong>A webhook URL is a password.</strong> Anyone who has it can post to your channel. Keep it out of screenshots, and out of git.</div>
</section>
<!-- TELEGRAM -->
<section id="kuma-telegram">
<h2>Telegram alerts</h2>
<p class="lead">A little more setup than Discord, but it reaches your phone properly and does not need a Discord account.</p>
<ol class="steps">
<li>In Telegram, search for <b>@BotFather</b> and start a chat.</li>
<li>Send <code>/newbot</code>. It asks for a display name, then a username ending in <code>bot</code>.</li>
<li>BotFather replies with a <b>token</b> — a long string like <code>123456789:AAG...</code>. That token is a password; treat it like one.</li>
<li>Now start a chat with <em>your new bot</em> and send it any message. A bot cannot message you first, so without this step nothing will arrive.</li>
<li>In Uptime Kuma, <b>Setup Notification → Telegram</b>, paste the bot token, then press <b>Auto Get</b> beside the Chat ID field. It reads the message you just sent and fills the ID in.</li>
</ol>
<div class="fields"><dl>
<dt>Notification Type</dt><dd><b>Telegram</b></dd>
<dt>Bot Token</dt><dd>from BotFather</dd>
<dt>Chat ID</dt><dd>press <b>Auto Get</b> after messaging your bot</dd>
<dt>Send silently</dt><dd>optional — notifies without a sound</dd>
<dt>Default enabled</dt><dd>tick</dd>
</dl></div>
<p>Press <b>Test</b>. If Auto Get finds nothing, you have not messaged the bot yet — that is the step everyone misses.</p>
<div class="tip"><strong>For a group instead of a direct message</strong>, add the bot to the group, send a message there, then press Auto Get. Group chat IDs are negative numbers, which is normal.</div>
</section>
<section id="cloudflared">
<div class="kicker">Connection</div>
<h2>Cloudflare Tunnel</h2>
<p class="lead">Reaches your services from anywhere without opening a single port. An outbound connection from your server to Cloudflare does the work, which also means it works behind CGNAT where port forwarding is impossible.</p>
<div class="card peach">
<strong>Read this before you put Jellyfin on it</strong>
<p style="margin:10px 0 0">The advice you will find everywhere is that streaming media through a tunnel violates <b>Section 2.8</b> of Cloudflare's terms. That section was <b>removed in 2023</b> — so that specific claim is out of date.</p>
<p style="margin:10px 0 0">What replaced it is narrower but still relevant: the content restriction moved into Cloudflare's CDN-specific terms, and their own announcement says customers may serve video and large files through the CDN <em>so long as that content is hosted by a Cloudflare service</em> such as Stream, Images or R2. Video hosted on your own server is not.</p>
<p style="margin:10px 0 0">So the honest position is that it is <b>neither clearly prohibited nor clearly allowed</b>. Plenty of people run Jellyfin through a tunnel without incident; others report throttling or a blocked subdomain. If your access matters, read Cloudflare's current terms yourself rather than trusting any guide, including this one.</p>
</div>
<div class="warning"><strong>Two things that are not about the terms at all.</strong>
<ul style="margin:10px 0 0">
<li><b>Cloudflare terminates TLS.</b> Traffic is encrypted to Cloudflare, decrypted there, then re-encrypted to your server. They can see it. That is how the protection works, but it is not end-to-end.</li>
<li><b>The free plan caps requests at 100 MB.</b> This quietly breaks larger Immich photo and video uploads through the tunnel — the app will simply fail on big files.</li>
</ul></div>
<div class="card teal">
<strong>What it is genuinely good at</strong>
<p style="margin:10px 0 0">Lightweight, text-based interfaces: Jellyseerr for requests, a status page, dashboards. Small payloads, no bandwidth concerns, no terms ambiguity. For Jellyfin and Immich, <a href="access.html">Tailscale</a> avoids every one of the issues above — nothing is public at all.</p>
</div>
<details class="app" open><summary><b>Creating the tunnel</b><span class="pill first">Start here</span><span class="d">All of this happens on Cloudflare's side</span></summary>
<div class="abody">
<p>You need a domain whose DNS is managed by Cloudflare. The free plan is enough.</p>
<ol class="steps">
<li>Go to the <b>Cloudflare Zero Trust</b> dashboard.</li>
<li><b>Networks → Tunnels → Create a tunnel</b>, and choose <b>Cloudflared</b> as the type.</li>
<li>Name it — <code>home-server</code> is fine — and save.</li>
<li>Cloudflare shows you install commands for various platforms. <b>You do not need them.</b> You only want the <b>token</b> — the long string beginning <code>eyJ</code> inside the command it displays.</li>
<li>Paste that token into the generator's Connection step, or into <code>CLOUDFLARE_TUNNEL_TOKEN</code> in your <code>.env</code>.</li>
</ol>
<div class="warning"><strong>That token is a credential.</strong> Anyone holding it can run a tunnel into your Cloudflare account. Keep it out of screenshots and out of git — the generated <code>.gitignore</code> already excludes <code>.env</code>.</div>
</div></details>
<details class="app"><summary><b>Starting it</b><span class="d">No ports, no certificates, no router changes</span></summary>
<div class="abody">
<pre>docker compose up -d cloudflared
docker compose logs -f cloudflared</pre>
<p>You are looking for <code>Registered tunnel connection</code>, usually four of them to different Cloudflare locations. The tunnel then shows as <b>Healthy</b> in the dashboard.</p>
<p>Nothing was opened on your router, and no certificate was issued on your side — Cloudflare handles HTTPS at their edge.</p>
<div class="tip"><strong>If it will not connect</strong>, the token is almost always the cause — truncated on copy, or from a deleted tunnel. The log says so plainly.</div>
</div></details>
<details class="app"><summary><b>Routing services</b><span class="d">Done in the dashboard, not in your files</span></summary>
<div class="abody">
<p>This is the part that surprises people: <b>your Compose file contains no routing at all.</b> Which hostname reaches which service is configured entirely in Cloudflare.</p>
<p>In the tunnel's <b>Public Hostname</b> tab, add an entry per service:</p>
<div class="fields"><dl>
<dt>Subdomain</dt><dd><code>requests</code></dd>
<dt>Domain</dt><dd>your domain</dd>
<dt>Service type</dt><dd><b>HTTP</b></dd>
<dt>URL</dt><dd><code>jellyseerr:5055</code></dd>
</dl></div>
<p>Use the <b>container name</b>, not <code>localhost</code>. cloudflared runs in a container on the same Docker network, so <code>localhost</code> would mean cloudflared itself — the same rule as everywhere else in this stack.</p>
<div class="tbl"><table>
<thead><tr><th>Service</th><th>Tunnel URL</th><th>Sensible?</th></tr></thead>
<tbody>
<tr><td>Jellyseerr</td><td><code>jellyseerr:5055</code></td><td><span class="yes">Good fit</span></td></tr>
<tr><td>Uptime Kuma status</td><td><code>uptime-kuma:3001</code></td><td><span class="yes">Good fit</span></td></tr>
<tr><td>Jellyfin</td><td><code>jellyfin:8096</code></td><td><span class="part">See the terms note above</span></td></tr>
<tr><td>Immich</td><td><code>immich-server:2283</code></td><td><span class="part">100 MB upload cap</span></td></tr>
<tr><td>Arcane, qBittorrent, *arr apps</td><td>—</td><td><span class="no">Never</span></td></tr>
</tbody></table></div>
<p>DNS records are created for you automatically — there is nothing to add by hand.</p>
</div></details>
<details class="app"><summary><b>Adding a login in front</b><span class="d">Cloudflare Access, optional</span></summary>
<div class="abody">
<p>A tunnel publishes a service to the whole internet. Anything without solid authentication of its own should have a login in front of it.</p>
<p>Under <b>Access → Applications</b>, add a self-hosted application for the hostname, then a policy — allow specific email addresses, or a whole domain. Cloudflare then requires a one-time code before the request ever reaches your server.</p>
<div class="tip"><strong>This is the piece that makes a tunnel reasonable</strong> for anything more sensitive than a status page. It also means a service with weak built-in auth is not directly exposed.</div>
<div class="warning"><strong>It still does not make the administrative tools safe to publish.</strong> Arcane holds the Docker socket; qBittorrent can write files anywhere. Keep those on Tailscale or your LAN regardless.</div>
</div></details>
</section>
<!-- POCKET ID -->
<section id="pocketid">
<div class="kicker">Single sign-on</div>
<h2>Pocket ID</h2>
<p class="lead">One passkey login across the apps that support it. This section is long because the honest answer is more complicated than "set up SSO and you are done" — the coverage is real but partial, and it is better to know that before you start.</p>
<div class="card peach">
<strong>What this actually gets you</strong>
<div class="tbl" style="margin:12px 0 0"><table>
<thead><tr><th>App</th><th>Support</th><th>Reality</th></tr></thead>
<tbody>
<tr><td>Immich</td><td><span class="yes">Native</span></td><td>Works fully, mobile app included</td></tr>
<tr><td>Arcane</td><td><span class="yes">Native</span></td><td>Supported directly</td></tr>
<tr><td>Jellyfin</td><td><span class="part">Plugin</span></td><td>Web browser or Quick Connect only</td></tr>
<tr><td>Sonarr, Radarr, Lidarr</td><td><span class="no">None</span></td><td>Their own single login</td></tr>
<tr><td>Bazarr, qBittorrent</td><td><span class="no">None</span></td><td>Their own login</td></tr>
<tr><td>Uptime Kuma</td><td><span class="no">None</span></td><td>Its own accounts</td></tr>
<tr><td>ArchiveBox, Tube Archivist</td><td><span class="no">None</span></td><td>Their own accounts</td></tr>
</tbody></table></div>
<p style="margin:12px 0 0">Realistically that is one login for <b>Immich, Arcane and Jellyfin in a browser</b>. Genuinely useful if those are the apps other people touch. Not stack-wide SSO.</p>
</div>
<div class="warning"><strong>Before anything else: Pocket ID needs HTTPS on a real domain.</strong> Passkeys are only allowed on a secure origin — this is a browser rule, not a setting, and no configuration works around it. Finish <a href="access.html">remote access</a> first so <code>https://id.example.com</code> resolves with a valid certificate.</div>
<details class="app" open><summary><b>First run</b><span class="pill first">Start here</span><span class="d">Admin account and your first passkey</span></summary>
<div class="abody">
<ol class="steps">
<li>Visit <code>https://id.example.com</code>. On first run it presents a setup wizard.</li>
<li>Create the admin account and register a passkey when prompted — your phone, laptop, or a hardware key.</li>
<li><b>Register a second passkey on a different device immediately.</b></li>
</ol>
<div class="warning"><strong>This is the single most important step on this page.</strong> One passkey on one device means losing that device locks you out of every connected app at once. A second passkey — a laptop, or a hardware key in a drawer — costs a minute now.</div>
<h4>If you are locked out anyway</h4>
<p>Pocket ID's CLI can issue a one-time login link:</p>
<pre>docker compose exec pocket-id \
/app/pocket-id one-time-access-token admin</pre>
<p>That prints a URL valid once, for a short window. Open it and register a new passkey.</p>
</div></details>
<details class="app" id="pid-clients"><summary><b>Adding an OIDC client</b><span class="d">The same five steps for every app</span></summary>
<div class="abody">
<p>Every app you connect needs a client entry in Pocket ID. The process never changes:</p>
<ol class="steps">
<li><b>Administration → OIDC Clients → Add OIDC Client</b>.</li>
<li>Name it after the app.</li>
<li>Enter that app's <b>callback URL</b> — each app documents its own, and the ones you need are below.</li>
<li>Save. Pocket ID shows a <b>Client ID</b> and a <b>Client Secret</b>. <b>The secret is shown once</b> — copy it now.</li>
<li>Paste both into the app, along with the issuer URL.</li>
</ol>
<p>Most apps only need the issuer and will discover everything else themselves:</p>
<pre>https://id.example.com/.well-known/openid-configuration</pre>
<div class="tip"><strong>Keep the client secret somewhere safe</strong> — your password manager, not a note file in the stack folder. If you lose it you can regenerate it in Pocket ID, but you then have to update the app too.</div>
</div></details>
<details class="app" id="pid-immich"><summary><b>Immich</b><span class="pill first">Best supported</span><span class="d">Full SSO including the mobile app</span></summary>
<div class="abody">
<p>The one where SSO is most worth having, since Immich is the app family members use daily.</p>
<h4>In Pocket ID</h4>
<p>Create a client named Immich with these callback URLs — all three:</p>
<pre>https://photos.example.com/auth/login
https://photos.example.com/user-settings
app.immich:///oauth-callback <span class="cm"># the mobile app</span></pre>
<h4>In Immich</h4>
<p><b>Administration → Settings → OAuth</b>:</p>
<div class="fields"><dl>
<dt>Issuer URL</dt><dd><code>https://id.example.com/.well-known/openid-configuration</code></dd>
<dt>Client ID</dt><dd>from Pocket ID</dd>
<dt>Client Secret</dt><dd>from Pocket ID</dd>
<dt>Scope</dt><dd><code>openid email profile</code></dd>
<dt>Button Text</dt><dd>Sign in with Pocket ID</dd>