Architektura konfiguracji¶
Modularny Docker Compose (dyrektywa include)¶
Wymaga Compose v2.20+. Główna orkiestracja jest rozbita na pliki tematyczne:
docker-compose.yml # Główna orkiestracja
├── docker-compose.monitoring.yml # Netdata, Loki, Grafana, Alloy, Dozzle
├── docker-compose.database.yml # PostgreSQL + wolumen postgresql_data
├── docker-compose.infrastructure.yml # Nginx, Redis
├── docker-compose.application.yml # appserver, authserver, ofelia, autoheal + wolumeny staticfiles/media
├── docker-compose.workers.yml # Celery (general, denorm, beat, flower, denorm-queue)
└── docker-compose.backup.yml # backup-runner (orkiestrator) + rclone
Wolumeny są definiowane w pliku, który jest ich właścicielem, ale referowane między
plikami (np. staticfiles/media zdefiniowane w application.yml, używane przez workery).
Każdy wpis include: ma env_file: ${BPP_CONFIGS_DIR}/.env, żeby interpolacja ${VAR}
działała w dołączanym YAML-u. BPP_CONFIGS_DIR jest odczytywany z repo-lokalnego .env
automatycznie przez Compose — docker compose up działa bezpośrednio, bez make.
Katalog konfiguracyjny (BPP_CONFIGS_DIR)¶
Konfiguracja żyje poza repozytorium (np. ~/publikacje-uczelnia/). Tworzony przy
pierwszym make przez init-configs. Zawartość: .env, ssl/, rclone/, alloy/,
loki/, netdata/{go.d,health.d}/, grafana/provisioning/{datasources,dashboards}/.
Bind-mountowany bezpośrednio do kontenerów.
Katalog defaults/ repozytorium trzyma szablonowe configi kopiowane przez init-configs
bez nadpisywania istniejących (copy_if_missing) — więc dostrojone przez użytkownika
configi (loki/, netdata/health.d/, netdata/go.d/) przeżywają aktualizacje.
Pliki force-syncowane (nadpisywane przy każdym deploy)¶
Wyjątek od copy_if_missing
Cztery artefakty są nadpisywane z defaults/ przy każdym ensure-config-files
(czyli każdym make up / refresh / run) przez copy_always (tylko gdy treść
się różni):
grafana/provisioning/dashboards/*grafana/provisioning/datasources/datasources.yaml.tplnetdata/netdata.confalloy/config.alloyloki/local-config.yaml
To dlatego, że są to wersjonowane, „read-only-w-UI" artefakty: zaktualizowany dashboard
albo datasource w repo ma trafić na żywe wdrożenie automatycznie z git pull && make up,
bez ręcznego cp.
netdata.conf — renderowany host-side¶
netdata.conf jest renderowany z defaults/netdata/netdata.conf.tpl (tak jak
go.d/postgres.conf.tpl) — bo netdata.conf nie umie interpolować ${VAR}, a hostname
wdrożenia musi trafić do [registry] registry to announce = https://<host>/netdata.
Ten URL steruje przyciskiem „View node" w powiadomieniach ntfy — bez nadpisywania
istniejąca instalacja trzymałaby stary config i przycisk wskazywałby registry.my-netdata.io.
Pokrętła dla użytkownika (retencja dbengine) są parametryzowane przez .env
(NETDATA_DBENGINE_TIER0_RETENTION_MB, NETDATA_DBENGINE_PAGE_CACHE_MB), żeby
force-overwrite nie kasował ręcznego strojenia. Nie edytuj netdata.conf ręcznie —
strój przez .env.
config.alloy — dlaczego force-sync¶
Pipeline logów w Alloy (wykrywanie poziomu, rozkładanie trafień WAF-a na pola) to
kod wersjonowany, nie konfiguracja użytkownika — nie ma w nim ani jednego
pokrętła opisanego jako do edycji; wszystko, co operator stroi, siedzi w .env.
Przy copy_if_missing ten plik był zamrożony w stanie z dnia instalacji na
zawsze. Dotknęło to konkretnej zmiany: mapowanie severity OWASP CRS na poziom
logu, dodane w commicie 60ea290 i opisane w dokumentacji jako działające, nie
dotarło na żadne istniejące wdrożenie. Ta sama pułapka co przy
datasources.yaml.tpl, tylko wykryta później.
Nie edytuj config.alloy ręcznie
Zmiany przepadną przy najbliższym make up. Jeśli potrzebujesz innego
zachowania pipeline'u logów — to zmiana w repo, nie w katalogu konfiguracyjnym.
Analogicznie webserver-init (jednorazowy serwis w
docker-compose.infrastructure.yml) naprawia przy każdym make up uprawnienia
wolumenu access logu oraz kluczy prywatnych — nginx w obrazie CRS chodzi jako
uid 101 i bez tego nie wstaje. Szczegóły: SSL.
loki/local-config.yaml — renderowany host-side¶
Ostatni config monitoringu, który został przeniesiony na force-sync (sierpień 2026).
Przedtem był copy_if_missing, czyli — tak jak config.alloy przed 60ea290 —
zamrożony w stanie z dnia instalacji na zawsze: żadna zmiana schematu, limitów
czy compactora nie docierała na działające wdrożenia. Widać to po obejściu, które
z tego wynikło: wyłączenie wbudowanego wykrywania poziomu logu w Loki musiało
pojechać flagą CLI w docker-compose.monitoring.yml, bo kluczem w tym pliku
nie miało jak — patrz Logowanie.
Blokadą była jedna rzecz: retencja per-stream, którą operator ma prawo dopasować do
swojego dysku. Siedzi teraz w .env (LOKI_RETENTION_DEFAULT, _APPSERVER,
_DBSERVER, _WEBSERVER), a plik jest renderowany z
defaults/loki/local-config.yaml.tpl — dokładnie tym samym mechanizmem co
netdata.conf. Nie edytuj local-config.yaml ręcznie — strój przez .env
(tabela wartości).
Istniejące instalacje nie wymagają żadnego ręcznego kroku: przy pierwszym make up
wartości zostają odczytane ze starego pliku i przepisane do .env, więc ręczne
strojenie przeżywa aktualizację.
Dlaczego migracja czyta stary plik, zamiast wpisać stałe z repo
Wpisanie wartości domyślnych zresetowałoby po cichu retencję dostrojoną przez
operatora — przy zwykłym git pull && make up, czyli dokładnie to, czego
zabrania kontrakt kompatybilności wstecznej.
Stąd odczyt awk-iem z istniejącego local-config.yaml.
Render ma dwie osłony, obie dlatego, że Loki z niepoprawnym duration
w ogóle nie wstaje — a operator odczytałby to jako awarię monitoringu, nie
jako literówkę w .env: przepuszczana jest wyłącznie postać
<liczba><jednostka>, a podmiana pliku jest odrzucana, jeśli przetrwał
w nim którykolwiek placeholder __RETENTION_*.
Świeże instalacje: init-configs woła ensure-config-files zanim powstanie
.env, więc pierwszy render używa wartości domyślnych z repo, a pierwszy
make up dopisuje zmienne i renderuje ponownie bajt w bajt (cmp nie widzi
zmiany). Ta sama sekwencja co przy ALTCHA_HMAC_KEY — dlatego init-configs
nie dostaje drugiej kopii tej logiki.
datasources.yaml.tpl — dlaczego force-sync¶
Z copy_if_missing zaktualizowana instalacja trzymałaby stary .tpl, więc zmiana typu
„Grafana łączy się przez read-only rolę bpp_monitor zamiast superusera aplikacji"
nigdy nie dotarłaby do istniejących wdrożeń. Renderowany datasources.yaml (ze skryptu
scripts/generate-grafana-datasources.sh, który czyta .env z dysku — nie
parse-time export make'a, więc świeżo wygenerowane DJANGO_BPP_PG_MONITOR_PASSWORD nie
jest renderowane jako puste przy pierwszym make up) jest plikiem żywym; .tpl to jego
źródło.
Dashboardy usunięte z defaults/ są zostawiane na miejscu (nie kasowane); dashboardy
tworzone w UI Grafany żyją w jej bazie i nie są ruszane.
Staticfiles — kontrakt z obrazem appservera¶
Wolumen staticfiles jest wypełniany przez appserver (mount /staticroot) i serwowany
przez webserver/nginx (mount /var/www/html/staticroot). Źródłem jest
/app/staticroot.baked/ wbudowane w obraz appservera na etapie build (gdy dostępne jest
node_modules — runtime już go nie ma).
- Entrypoint appservera w Fazie 2 robi
cp -rf /app/staticroot.baked/. "$STATIC_ROOT/". cp -rfzasiewa pusty wolumen i przy upgrade obrazu zawsze nadpisuje. Wariant-ubył tu pułapką: mtime w.bakedpochodzi z czasu builda obrazu, więc restart późniejszy niż build (typowe przy szybkich deployach) powodował, że-upomijał kopiowanie i wolumen zostawał ze starymi plikami.- Pliki, których nie ma w
.baked(np. custom branding wgrany po wdrożeniu), przeżywają —cpnie kasuje treści spoza źródła. - Runtime nie odpala
collectstatic— katalog.bakedto ten sam output. Fallback odpalacollectstatictylko dla obrazów sprzed.baked.
Pliki tekstowe w .baked są prekompresowane gzipem już na etapie builda obrazu
(a wyjście django-compressora w CACHE/ — przy starcie kontenera), dzięki czemu
gzip_static on po stronie nginksa ma co serwować. Powód, dla którego nie wolno
tego robić później, na wolumenie: Pułapki — kompresja odpowiedzi.
STATIC_ROOT=/staticroot/ w .env nadpisuje domyślne /app/staticroot z obrazu.
Po make refresh lub make prune-orphan-volumes wolumen jest ponownie wypełniany z .baked.
Media (pliki uploadowane) — DJANGO_BPP_MEDIA_ROOT¶
Pliki wgrywane przez użytkowników (załączniki, PDF-y, eksporty) trafiają do wolumenu
media, montowanego pod /mediaroot we wszystkich kontenerach Django (appserver,
authserver, workery Celery; backup-runner montuje go read-only).
DJANGO_BPP_MEDIA_ROOT=/mediaroot w .env jest wymagane. Bez niego Django bierze
swój wbudowany domyślny MEDIA_ROOT (~/bpp-media, czyli /root/bpp-media w
kontenerze), który nie leży na wolumenie — pliki użytkowników:
- znikają przy każdym
docker compose up/recreate(są w warstwie kontenera, nie w wolumenie), - nie trafiają do backupu (
backup-cycle.shtaruje/mediaroot, nie/root).
Zmienna jest ustawiana automatycznie:
- nowe instalacje — wpisywana do
.envprzezmake init-configs(obokSTATIC_ROOT), - istniejące instalacje — dopisywana (append-only, nie nadpisuje wartości ustawionej
ręcznie) przez
scripts/ensure-config-files.shprzy każdymmake up/refresh, więcgit pull && make upna starym.envnaprawia ją bez ręcznych kroków.
Możesz nadpisać wartość ręcznie w .env (np. inny punkt montowania) — self-heal jej nie
ruszy. Bez cudzysłowów — validate-env-quotes odrzuca wartości w cudzysłowach.
Captcha zgłoszeń publikacji — ZGLOS_CAPTCHA_ENABLED i ALTCHA_HMAC_KEY¶
Publiczny formularz zgłaszania publikacji jest dostępny bez logowania, więc widzą go też boty. Chroni go ALTCHA — captcha typu proof-of-work: przeglądarka liczy zadanie obliczeniowe w tle, bez klikania w zdjęcia. Jest self-hosted (żadnych usług zewnętrznych, żadnych danych osobowych wysyłanych na zewnątrz).
Captcha dotyczy wyłącznie niezalogowanych. Zalogowany użytkownik nie zobaczy jej nigdy, a obok widgetu jest podpowiedź, że zalogowanie pomija weryfikację.
Dwie zmienne w .env:
| Zmienna | Wartość | Znaczenie |
|---|---|---|
ALTCHA_HMAC_KEY |
64 znaki hex (losowe) | Klucz podpisujący wyzwania ALTCHA |
ZGLOS_CAPTCHA_ENABLED |
1 / 0 |
Włącza captchę (0 = wyłączona) |
Obie ustawiają się automatycznie, bez ręcznego kroku — dopisuje je
scripts/ensure-config-files.sh przy każdym make up/refresh (a make init-configs
woła ten skrypt pod spodem, więc nowe instalacje dostają je tak samo). Na starym .env
wystarczy git pull && make up.
Klucz jest generowany raz i potem nietykany — kolejne make up go nie rotują
(rotacja unieważniłaby wyzwania trzymane przez otwarte w przeglądarkach formularze).
Aby wyłączyć captchę, ustaw w .env:
Wartość przeżyje kolejne git pull && make up — self-heal nie nadpisuje istniejących
wartości. Samo usunięcie linii nie wystarczy: zostanie dopisana z powrotem.
Nie włączaj captchy bez losowego klucza
ZGLOS_CAPTCHA_ENABLED=1 przy braku (albo placeholderze) ALTCHA_HMAC_KEY daje
captchę możliwą do podrobienia — klucz podpisujący jest wtedy znany publicznie.
Automatyka pilnuje kolejności (klucz zawsze przed flagą). Django sygnalizuje zły stan
ostrzeżeniem zglos_publikacje.W001 przy starcie. Jeśli dopisujesz zmienne ręcznie —
dopisz obie:
Captcha wymaga obrazu BPP z ALTCHA (wydania od 202607.1398 wzwyż). Na starszym obrazie
zmienne są nieszkodliwe — Django ich po prostu nie czyta.
Logowanie przez Keycloak (OIDC) — zaufane domeny i wiązanie istniejących kont¶
BPP potrafi logować przez Keycloaka obok zwykłego hasła (metody działają równolegle —
OIDC niczego nie przejmuje). Tożsamość wiąże się z kontem po parze (issuer, sub),
nie po adresie e-mail: sub nadaje serwer tożsamości i jest niezmienny, a e-mail da się
w realmie po prostu wpisać. Gdyby BPP dopasowywało po adresie, ktoś z prawem edycji
własnego adresu mógłby przejąć cudze konto.
Skutek uboczny tej zasady: konto założone przed wdrożeniem SSO nie ma jeszcze wpisu
(issuer, sub). Przy pierwszym logowaniu przez Keycloaka BPP widzi, że konto z tym
adresem już istnieje, i odmawia — nie zakłada drugiego i nie „przejmuje" istniejącego:
failed to get or create user: OIDC: konto z tym adresem już istnieje —
połącz je z SSO przez profil (re-auth hasłem), nie tworzę konta.
Domyślna ścieżka to Profil użytkownika → „Połącz konto z SSO", z potwierdzeniem hasłem. Wymaga to jednak hasła lokalnego — w instalacji, gdzie logowanie idzie wyłącznie przez Keycloaka, konta go nie mają i ta droga jest zamknięta.
Dla takich instalacji są trzy zmienne w .env:
| Zmienna | Wartość | Znaczenie |
|---|---|---|
DJANGO_BPP_OIDC_GRACE_BIND |
1 / 0 |
Włącza jednorazowe dowiązanie istniejącego konta przy logowaniu |
DJANGO_BPP_OIDC_TRUSTED_EMAIL_DOMAINS |
domeny po przecinku | Adresy w tych domenach uznajemy za instytucjonalne |
DJANGO_BPP_OIDC_GRACE_BIND_PRIVILEGED |
1 / 0 |
Pozwala dowiązać także konto z uprawnieniami |
Przykład:
DJANGO_BPP_OIDC_GRACE_BIND=1
DJANGO_BPP_OIDC_TRUSTED_EMAIL_DOMAINS=uczelnia.edu.pl,student.uczelnia.edu.pl
DJANGO_BPP_OIDC_GRACE_BIND_PRIVILEGED=1
Każda z nich ma wariant z prefiksem skrótu uczelni (DJANGO_BPP_OIDC_<SKROT>_…), który
ma pierwszeństwo przed wariantem bez prefiksu — przydatne w instalacji multi-host.
Wszystkie trzy domyślnie są wyłączone; instalacja, która ich nie ustawi, zachowuje
się dokładnie jak dotąd. Nie trzeba nic zmieniać w Compose — zmienne docierają do Django
hurtowym env_file.
Dlaczego lista domen, a nie email_verified¶
Realmy oparte o LDAP często wystawiają dwa adresy: instytucjonalny w claimie mail
i prywatny w email. BPP domyślnie bierze mail. Flaga email_verified opisuje
natomiast claim email, czyli akurat ten prywatny — dla adresu pochodzącego z katalogu
instytucji jest po prostu nieadekwatna. Dlatego zaufanie bierze się z domeny: adres
z właściwego claimu, w wypisanej domenie, jest wiarygodny niezależnie od email_verified.
Dopasowanie domen jest dokładne — bez subdomen i bez wieloznaczników. Domenę studencką trzeba wypisać osobno obok pracowniczej.
Kolejność claimów da się przestawić przez DJANGO_BPP_OIDC_EMAIL_CLAIMS (lista po
przecinku), gdy realm trzyma adres instytucjonalny gdzie indziej.
Lista domen jest jedynym zabezpieczeniem trybu uprzywilejowanego
DJANGO_BPP_OIDC_GRACE_BIND_PRIVILEGED=1 pozwala dowiązać konto administratora —
z is_staff, uprawnieniami i tokenem PBN. Rolę bramki przejmuje wtedy w całości
lista domen plus założenie, że użytkownik nie może samodzielnie zmienić sobie
adresu w katalogu instytucji. Zanim to włączysz, potwierdź to z działem IT.
Blokada wzajemna chroni przed przypadkiem: bez TRUSTED_EMAIL_DOMAINS ta flaga
nie robi nic. Zostają też trzy bezpieczniki — dokładnie jedno konto z danym
adresem, konto aktywne i brak tożsamości w tym samym realmie (konta związanego już
z innym sub nie da się przejąć).
Nie „naprawiaj" tego czyszczeniem adresu e-mail
Skasowanie adresu na istniejącym koncie faktycznie usuwa kolizję — i tworzy drugie, puste konto, a to prawdziwe, z uprawnieniami i powiązanym autorem, zostaje osierocone. Objaw znika, problem się mnoży.
Wiązanie jest jednorazowe: po pierwszym udanym logowaniu konto ma wpis
(issuer, sub) i dalej rozpoznaje się już po nim, niezależnie od tych ustawień.
Funkcja wymaga obrazu BPP z tą zmianą (feat(oidc): zaufanie po domenie
instytucjonalnej, PR #753). Na starszym obrazie zmienne są nieszkodliwe — Django ich
po prostu nie czyta.
Fallback HTML→DOCX — opcjonalny sidecar html2docx¶
Eksport do DOCX robi pandoc z obrazu appservera i to wystarcza w większości
instalacji. Na nielicznych hostach (np. wirtualizacja VMWare ESX) pandoc potrafi
się wywalić core dumpem — dla takich przypadków jest opcjonalny sidecar HTTP
iplweb/html2docx, do którego Django odsyła konwersję.
Sidecar jest domyślnie wyłączony. Włączenie to dwa kroki opt-in — w
dwóch różnych plikach .env:
| Krok | Plik | Wpis |
|---|---|---|
| 1. Uruchom kontener | .env w katalogu repo bpp-deploy |
COMPOSE_PROFILES=html2docx |
| 2. Wskaż go Django | .env w $BPP_CONFIGS_DIR |
DJANGO_BPP_HTML2DOCX_URL=http://html2docx:3030/convert |
Sam krok 1 podnosi kontener, ale nikt do niego nie zagląda; sam krok 2 kieruje
Django pod adres, którego nie ma. Brak DJANGO_BPP_HTML2DOCX_URL to miękka
degradacja — fallback jest po prostu wyłączony, nic się nie wywraca.
Sidecar nie ma publikowanego portu (żyje tylko w sieci projektu, jako
html2docx:3030) ani docker.sock — poprzednia implementacja uruchamiała
konwersję przez docker run z gniazda Dockera podmontowanego do appservera i to
właśnie zdjęcie tego gniazda było celem zmiany. Obraz wersjonuje się niezależnie
od DOCKER_VERSION — pin przez HTML2DOCX_VERSION.
Stara flaga DJANGO_BPP_ENABLE_HTML2DOCX_IMAGE jest martwa
Do lipca 2026 fallback włączała flaga DJANGO_BPP_ENABLE_HTML2DOCX_IMAGE=true,
która powodowała docker pull obrazu przy make up. Ten mechanizm został
usunięty. Flaga w istniejących .env jest nieszkodliwa (nikt jej już nie
czyta) — nie trzeba jej kasować, ale nic już nie robi.
Pierwsze uruchomienie — dwa przebiegi make¶
make # Pierwszy raz: pyta o katalog konfiguracyjny, hostname, admina,
# webhook, katalog backupów, wersję PostgreSQL. Generuje losowe hasła.
make # Drugi raz: startuje usługi normalnie.
Patrz Pierwsze uruchomienie.