Wstęp#

Kontynuuję serię przeglądu systemów komentarzy pod Hugo. Po Self-hosting Cusdis z Caddy na VPS przyszedł czas na remark42 . W przeciwieństwie do Cusdis (który ma zarządzaną wersję na cusdis.com) i Disqus (który jest wyłącznie SaaS), remark42 nie oferuje wariantu managed. Jedyna droga to self-hosting. Jeśli chcecie w ogóle spróbować remark42, ten wpis dotyczy dokładnie tego setupu.

Setup z poprzedniego wpisu rozrósł się z czasem. Do Caddy’ego dedykowanego dla Cusdisa (konfiguracja w Caddyfile, porty 80/443 na hoście) dołożyłem kolejne serwisy. Każdy musiał dopisywać się do tego samego Caddyfile, co przy większej liczbie usług robiło się uciążliwe i podatne na literówki. Przesiadłem się więc na lucaslorentz/caddy-docker-proxy : jedna instancja Caddy, konfigurację proxy buduje sam z labeli Dockera na kontenerach. Cusdis też został przemigrowany pod ten wzorzec.

remark42 stawiam już w tym nowym układzie. Domyślne repo remark42 zakłada natomiast, że postawicie własny Caddy razem z serwisem, co dołożyłoby drugą instancję reverse-proxy na tym samym hoście. Tego chciałem uniknąć. Poniżej pokazuję jak podpiąć remark42 pod istniejący caddy-docker-proxy przez labele, czyli zero drugiego Caddy, zero konfliktu portów, pełen setup w kilkanaście minut. Efekt: obraz umputun/remark42:latest waży 91 MB, w spoczynku zjada 14 MiB RAM, baza BoltDB przy pierwszym komentarzu ma 64 KB. Liczby, które mogą zaskoczyć każdego kto pamięta Disqusa.

Punkt wyjścia: caddy-docker-proxy i sieć edge#

W moim setupie osobne compose.yaml uruchamia lucaslorentz/caddy-docker-proxy obserwujący /var/run/docker.sock. Przy każdym starcie kontenera skanuje jego labele i buduje z nich konfigurację reverse proxy.

services:
  caddy:
    image: lucaslorentz/caddy-docker-proxy:2.9-alpine
    ports:
      - "40080:40080"
      - "40081:40081"
      - "40082:40082"
      # ... pozostałe zarezerwowane porty z zakresu wysokich
    environment:
      CADDY_INGRESS_NETWORKS: edge
    networks:
      - edge
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - caddy_data:/data
      - caddy_config:/config

networks:
  edge:
    name: edge

Każdy inny serwis dołącza się do sieci edge i deklaruje przez labele na jakim porcie ma go publikować Caddy. Cusdis po migracji na ten wzorzec wygląda tak:

services:
  cusdis:
    image: djyde/cusdis:latest
    networks:
      - edge
    labels:
      caddy: ":40080"
      caddy.reverse_proxy: "{{upstreams 3000}}"

Caddy dokłada wtedy blok „słuchaj na :40080, przekierowuj do kontenera cusdis:3000". Ten sam wzór stosuję dla remark42.

Wdrożenie krok po kroku#

1. Adaptacja compose.yaml#

Oficjalny compose remark42 publikuje port 8080 bezpośrednio na host i zakłada Caddyfile na hoście. Oba te założenia u mnie nie pasują. Przerabiam serwis w tym samym wzorcu co Cusdis po migracji: dołączenie do sieci edge, labele Caddy zamiast ports.

services:
  remark42:
    image: umputun/remark42:latest
    container_name: blog-remark42
    env_file: .env
    volumes:
      - remark42_data:/srv/var
    restart: unless-stopped
    mem_limit: 200m
    networks:
      - edge
    labels:
      caddy: ":40081"
      caddy.reverse_proxy: "{{upstreams 8080}}"
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

volumes:
  remark42_data:

networks:
  edge:
    name: edge
    external: true

Dwa detale warte wskazania:

  • container_name: blog-remark42, bo Docker Compose bez tego wpisu generuje nazwy w formacie <projekt>-<serwis>-<indeks>. Przy standardowej konfiguracji wychodzi z tego coś w stylu blog-remark42-remark42-1, długo i bez logiki. Nadpisanie container_name daje krótką, przewidywalną nazwę. Świadomy koszt: nie można wtedy skalować serwisu, ale remark42 z BoltDB i tak nie skaluje się horyzontalnie.
  • /srv/var jako volume mount, bo oficjalne przykłady remark42 potrafią pokazywać /var/lib/remark42, a binarka zapisuje wyłącznie do /srv/var. Przy złym mount dane leciałyby efemerycznie przy każdym restarcie mimo obecności wolumenu. Warto sprawdzić przy każdej instalacji.

2. Konfiguracja .env#

remark42 czyta konfigurację z zmiennych środowiskowych. Minimalny zestaw dla self-hostingu wygląda tak:

# .env
REMARK_URL=https://remark42.example.com
SITE=blog
SECRET=<32+ znaków losowych, np. z openssl rand -hex 32>
ALLOWED_HOSTS=self,https://example.com

# Anonymous auth, najniższa frykcja dla czytelnika, bez OAuth
AUTH_ANON=true

# Wypełnić po pierwszym zalogowaniu (patrz krok 4)
# ADMIN_SHARED_ID=

REMARK_URL to publiczna domena instancji remark42 (musi mieć TLS). ALLOWED_HOSTS to biała lista domen, z których widget może odwoływać się do API. Dodajcie tam domenę bloga który będzie osadzał komentarze. SECRET służy do podpisywania JWT-ów, wygenerujcie mocny random i trzymajcie go w .env poza gitem.

Auth zostawiam na start jako anonymous. Czytelnik podaje nick, remark42 tworzy deterministyczny identyfikator z hasha nazwy. Bez OAuth, bez emaila, minimalna frykcja. OAuth GitHub/Google można dołożyć potem gdy analityka pokaże że warto.

3. Uruchomienie#

Wystarczy standardowe:

docker compose up -d
docker compose logs --tail=20 remark42

W logach szukajcie potwierdzenia że serwis znalazł swoją bazę i wystartował:

[INFO]  start server on port :8080
[INFO]  bolt store for sites [{FileName:./var/blog.db SiteID:blog}]
[INFO]  anonymous access enabled
[INFO]  activate http rest server on :8080

Weryfikacja z zewnątrz:

curl https://remark42.example.com/ping
# oczekiwane: pong

4. Bootstrap admina#

ADMIN_SHARED_ID to identyfikator użytkownika, który dostanie uprawnienia administracyjne. Format <provider>_<hex> (dla anon: anonymous_<sha1(nick)>). Trzeba się najpierw zalogować, żeby ten identyfikator w ogóle powstał.

remark42 nie ma osobnego panelu logowania z formularzem. Konto powstaje przez widget osadzony na stronie albo bezpośrednio przez endpoint auth. Najprościej użyć drugiego wariantu, w pasku adresu przeglądarki:

https://remark42.example.com/auth/anonymous/login?user=admin&aud=blog

To ustawi JWT cookie i przekieruje do prostego endpointu. Sam identyfikator znajdziecie w payloadzie JWT (middle-part cookie, base64 z JSON-em) w polu user.id. Wklejcie do .env:

ADMIN_SHARED_ID=anonymous_a1b2c3...

I zrestartujcie serwis, żeby zmienna weszła w życie:

docker compose up -d --force-recreate

Kolejne logowanie tego samego nicka wypluje już JWT z attrs.admin: true.

5. TRUSTED_PROXY: produkcyjne bezpieczeństwo#

Po uruchomieniu remark42 loguje ostrzeżenie:

[WARN] --trusted-proxy not set: forwarding headers are trusted from any client
       and can be spoofed to bypass rate limiting / vote dedup

remark42 identyfikuje klienta po nagłówku X-Forwarded-For, żeby móc egzekwować per-IP rate limit i dedup głosów. Bez TRUSTED_PROXY ufa temu nagłówkowi od dowolnego źródła, więc atakujący mógłby wpisać sfałszowany IP w request i obejść liczniki.

Sprawdźcie CIDR sieci Docker w której siedzi Caddy:

docker network inspect edge --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'
# przykładowo: 172.21.0.0/16

Dodajcie do .env:

TRUSTED_PROXY=172.21.0.0/16

Po docker compose up -d --force-recreate WARN znika.

Ważne zastrzeżenie: to sama flaga nie jest pełną obroną. TRUSTED_PROXY w remark42 chroni głównie przed atakami, w których ktoś próbuje uderzyć w kontener z pominięciem Caddy’ego. Pełna ochrona przed spoofingiem X-Forwarded-For wymaga też, żeby reverse proxy przed remark42 kasowało przychodzący nagłówek, a nie tylko doklejało do niego swój wpis. Docsy remark42 opisują to w kontekście autorskiego Reproxy, ale reguły są identyczne dla Caddy’ego: Configure with Reproxy .

Wyniki#

Zmierzyłem podstawowe metryki tuż po starcie i po dodaniu pierwszego komentarza:

MetrykaWartość
Rozmiar obrazu umputun/remark42:latest91 MiB
RAM (spoczynek)14 MiB / 200 MiB limit
RAM (po pierwszym komentarzu)13 MiB, mieści się w szumie
CPU (spoczynek)0.01–2.4%
BoltDB (pusta)40 KB
BoltDB (po pierwszym komentarzu)64 KB (+24 KB)
Całe /srv/var (z avatarami, backupami)72 KB
Cold-start (docker compose up -d)~1.0 s
Ping po starcie~250 ms (dominuje sieć, nie remark42)

Kilka rzeczy które sam mnie zaskoczyły. Po pierwsze, 14 MiB RAM w spoczynku dla pełnego serwera komentarzy z bazą to bardzo mało. remark42 jest statycznie linkowaną binarką Go z wbudowanym serwerem HTTP i BoltDB w tym samym procesie. Zero runtime overhead, brak Node.js, brak PHP-FPM, brak zewnętrznej bazy. Cały stan aplikacji siedzi w jednym pliku .db.

Po drugie, 24 KB w bazie za jeden komentarz. Blog z tysiącem komentarzy zmieści się w mniej niż 30 MB, a backup to docker run --rm -v remark42_data:/data -v $PWD:/backup alpine tar czf /backup/$(date +%F).tar.gz -C /data . i śpicie spokojnie.

Multi-tenant: jedna instancja na kilka blogów#

remark42 obsługuje wiele stron natywnie, przez listę w SITE i odpowiednio poszerzony ALLOWED_HOSTS:

SITE=blog,docs,portfolio
ALLOWED_HOSTS=self,https://blog.example.com,https://docs.example.com,https://portfolio.example.com

Każdy blog w swoim widgetcie ustawia własne site_id. remark42 trzyma osobne wątki komentarzy per site, wszystko w tej samej instancji BoltDB. To realny plus dla kogoś kto ma kilka projektów: jeden VPS, jeden backup, jeden panel adminowy.

Warto od razu widzieć trade-off: awaria remark42 to awaria komentarzy na wszystkich blogach naraz. ADMIN_SHARED_ID jest globalny, więc jeden zestaw adminów moderuje wszystko. Jeśli to nie problem, wielotenancy w remark42 jest tak prosta jak w tej sekcji.

Kiedy remark42 self-hosted ma sens#

remark42 to inna filozofia niż Cusdis, mimo że oba są open source i „privacy-first". Cusdis nie ma sesji, każdy komentarz to formularz do zatwierdzenia przez admina i cross-domain działa bez zabaw z cookies. remark42 buduje pełną tożsamość komentującego (JWT, reputacja, upvote/downvote, edycja własnych wpisów w oknie czasowym), więc daje bogatszy UX kosztem większego widgetu (kilkanaście razy cięższego niż Cusdis) i pewnej konfiguracyjnej gimnastyki przy cross-domain.

Wybieram remark42 gdy zależy mi na:

  • Wątkowaniu i głosach: dyskusje techniczne zyskują na strukturze.
  • Tożsamości komentujących między wpisami: reputacja i możliwość blokady konkretnego usera.
  • Możliwości edycji własnego komentarza: czytelnicy nie boją się kliknąć „Send" z literówką.

Wybieram Cusdis (albo Giscus) gdy priorytetem jest surowa wydajność widgetu albo unikanie sesji. Szczegółowe porównanie liczbowe będzie w osobnym wpisie zamykającym serię.

Podsumowanie#

Postawienie remark42 obok istniejącego caddy-docker-proxy sprowadza się do trzech decyzji: adaptacji compose.yaml (labele zamiast portów), wypełnienia .env (SECRET, SITE, ALLOWED_HOSTS, AUTH_ANON) i bootstrapu admina przez URL loginu. Cała procedura zajmuje kilkanaście minut i daje serwis, który zjada 14 MiB RAM i trzyma stan w jednym pliku bazy.

Trzy rzeczy które warto zapamiętać:

  • Volume mount pilnujcie: /srv/var, nie /var/lib/remark42. Popularna pomyłka w oficjalnych przykładach.
  • TRUSTED_PROXY w produkcji jest obowiązkowy, jeśli nie chcecie żeby ktoś ominął rate limiting przez X-Forwarded-For.
  • caddy-docker-proxy + labele to komfortowy wzorzec dla wielu serwisów na jednym VPS. Czytelnie widać w compose każdego kontenera na jakim porcie i pod jakim proxy siedzi.

W kolejnym wpisie z serii pokażę integrację widgetu remark42 z Hugo, łącznie z rozwiązaniem cross-domain, bez konieczności hostowania remark42 pod subdomeną bloga.

Źródła i dalsze materiały#