homelab-codex-ws/kb/audits/vps-stacki-2026-07-27.md
oskar 9f77a723e8 feat(kb): 5 audytow/reconow -> kb/audits/ (type: audit, as_of)
czujniki-2026-07-30 (node-agent vs stability-agent)
lustro-shipping-2026-07-16 (event=dead prom=up, 1507 mismatchy)
prometheus-cutover-2026-07-06 (recon starego toru livenesci)
piha-slim-2026-07-02 (audyt odchudzania PIHA)
vps-stacki-2026-07-27 (audyt niezarzadzanych stackow na VPS)

ODSTEPSTWO OD RECONU — swiadome. Recon typowal te 5 plikow jako SPLIT
(audit+decision / audit+incident / audit+phase). Rozstrzygniecie 2 wprowadza
typ `audit` z polem as_of i mapuje kazdy z nich na JEDNA sciezke
kb/audits/<obszar>-<data>.md. Audyt jest spojna migawka stanu z konkretna
data — rozbicie go na "ustalenia" i "rekomendacje" rozerwaloby ten kontekst
i wymagaloby redakcji tresci, czego etap 2 zabrania. Zostaja w calosci.

Efekt: 29 SPLIT-ow z reconu realizowane jako 24 (10 service+runbook,
14 wielotypowych), 5 zamienionych na caloscowe dokumenty type: audit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:58:46 +02:00

501 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
okf: "0.1"
type: audit
visibility: private
status: active
updated: 2026-07-27
as_of: 2026-07-27
links: []
---
# Audyt niezarządzanych stacków na VPS — 2026-07-27
Recon read-only przed konsolidacją do GitOps. Zebrane przez `ssh vps` (user `oskar`,
grupa `docker`, bez passwordless sudo) + `docker inspect` / `docker exec` (tylko odczyt)
+ kopia `database.sqlite` npm (`docker cp`, odczyt lokalny). **Żaden kontener, plik ani
wolumen nie został zmieniony.**
Ograniczenie dostępu: katalogi `/home/dockeruser/*` mają prawa `750` (owner `dockeruser`),
więc pełnych treści `docker-compose.yml` nie dało się przeczytać jako `oskar`.
Konfiguracje poniżej są **odtworzone z `docker inspect`** (obraz, porty, wolumeny, sieci,
env, restart policy, labelki compose z `depends_on` i ścieżką pliku). Dosłowna treść
plików compose — *do weryfikacji ręcznie jako root/dockeruser*.
---
## 1. TL;DR
- **6 stacków poza GitOps**: npm (ingress!), ai-cluster (6 kontenerów), outline (3),
joplin-server (2), umami (2, **osierocony** — patrz niżej), humanai-landing+mailer
(2, uruchomione ręcznie bez compose).
- **6 portów otwartych z internetu** (potwierdzone sondą TCP z zewnątrz 2026-07-27,
brak firewalla po drodze): 22, 80, 443 (oczekiwane) oraz **81 (panel admina NPM),
8000 (openclaw FastAPI — `/` i `/docs` odpowiadają 200 bez auth), 9100
(node_exporter — pełne `/metrics` bez auth)**. Dodatkowo **3000 (outline)** jest
publiczne, ale ten bind jest *load-bearing*: vhost npm `outline.okit.pl` forwarduje
na `135.181.153.108:3000` (publiczny IP, pętla przez hosta), więc nie wolno go
po prostu zamknąć bez zmiany vhosta.
- **Umami jest osierocone**: katalog `/home/oskar/projects/gethumanai-infra` (compose
+ `.env`) **już nie istnieje**. Kontenery działają, ale nie da się ich odtworzyć
z pliku. `APP_SECRET` i `DATABASE_URL` są odzyskiwalne wyłącznie z `docker inspect`
dopóki kontener istnieje — trzeba je zrzucić do `/opt/homelab/config/umami/.env`
ZANIM cokolwiek ruszy ten stack.
- **Żaden z audytowanych stacków nie ma `mem_limit`** (`Memory=0` wszędzie) — łamie
konwencję VPS z CLAUDE.md (4 GiB RAM, bez swapa).
- `ai-cluster-service-ops-worker` montuje **`/var/run/docker.sock` RW** — kontener
root-equivalent na hoście.
- Repo `services/npm/docker-compose.yml` to **martwy szablon**: wskazuje bind mounty
`/opt/homelab/data/npm/*`, a żywy npm używa `/home/dockeruser/docker/npm/*`.
Deploy z repo bez poprawki = npm startuje z pustą bazą → **utrata wszystkich proxy
hostów i certów w działającej instancji** (dane zostałyby na dysku, ale ingress leży).
- Sekcja CLAUDE.md „Repo-managed services on VPS" opisuje **stan docelowy, nie
faktyczny**: w repo nie ma `services/outline`, `services/joplin`, `services/ai-cluster`
ani żadnego `hosts/vps/runtime/{npm,outline,joplin,ai-cluster}/`.
---
## 2. Mapa portów (stan z `ss -tlnp` + `docker inspect` + sonda zewnętrzna)
| Port | Bind | Kontener / proces | Stack | Droga | Otwarty z internetu? | Ryzyko |
|---|---|---|---|---|---|---|
| 22 | 0.0.0.0 | sshd | host | goły | **TAK** | oczekiwane |
| 80 | 0.0.0.0 | npm | npm | ingress | **TAK** | oczekiwane |
| 443 | 0.0.0.0 | npm | npm | ingress | **TAK** | oczekiwane |
| **81** | 0.0.0.0 | npm (panel admina) | npm | goły | **TAK** | **WYSOKIE** — UI admina ingressu na świecie; do zamknięcia na tailscale/localhost |
| **8000** | 0.0.0.0 | ai-cluster-openclaw-1 (uvicorn) | ai-cluster | goły | **TAK**`/` i `/docs` 200 bez auth | **WYSOKIE** — publiczne API + Swagger; brak vhosta w npm |
| **3000** | 0.0.0.0 | outline-outline-1 | outline | goły, ale konsumowany przez npm vhost przez publiczny IP | **TAK** | **ŚREDNIE/WYSOKIE** — powinno być tylko za npm; bind jest load-bearing (patrz §5) |
| **9100** | `*` (host netns) | node_exporter (`network_mode: host`) | fleet-prometheus (repo-managed) | goły | **TAK**`/metrics` 200 | **ŚREDNIE** — wyciek telemetrii hosta |
| 1883 | 100.95.58.48 (tailscale) | mosquitto | ai-cluster | goły (tailscale-only) | nie | OK |
| 9090 | 100.95.58.48 | fleet-prometheus | repo-managed | tailscale-only | nie | OK |
| 18180 | 100.95.58.48 + 127.0.0.1 | control-plane-ui | repo-managed | tailscale-only | nie | OK |
| 22300 | 127.0.0.1 | joplin-server | joplin-server | za npm (vhost by-name) | nie | OK |
| 53 | 127.0.0.53 | systemd-resolved | host | — | nie | OK |
| 56906 / 34648 | tailscale IP | tailscaled | host | — | nie | OK |
Sonda z zewnątrz potwierdziła też, że 1883/9090/18180/22300/8080 są z internetu
**zamknięte** (filtered/refused). Wniosek: nie ma firewalla tnącego 81/8000/9100 —
*do weryfikacji: czy w Hetzner Cloud jest w ogóle skonfigurowany firewall*.
Zaszłość: aktywny vhost `gethumanai.okit.pl` forwarduje na `135.181.153.108:8080`,
gdzie **nic nie słucha** (martwy backend, zostawia 502).
---
## 3. Stacki — szczegóły
### 3.1 npm (ŻYWY INGRESS — nie ruszać)
- Compose: `/home/dockeruser/docker/npm/docker-compose.yml` (project `npm`,
compose v5.1.2). Treść pliku nieczytelna jako oskar — poniżej rekonstrukcja.
- Obraz: `jc21/nginx-proxy-manager:latest` (build **2.14.0**, 2026-02-17).
- Restart: `unless-stopped`. `mem_limit`: **brak**.
- Porty: `80:80`, `81:81`, `443:443` — wszystkie **0.0.0.0**.
- Sieć: `npm_default` (172.19.0.0/16) — współdzielona, patrz §4.
- Env (jedyny nie-domyślny): `TZ=Europe/Warsaw`.
Wolumeny (bind, **krytyczne — całe państwo npm**):
| Host | Kontener | Rozmiar |
|---|---|---|
| `/home/dockeruser/docker/npm/data` | `/data` | 276 MB (w tym `database.sqlite` 155 KB, wygenerowane confy nginx) |
| `/home/dockeruser/docker/npm/letsencrypt` | `/etc/letsencrypt` | 560 KB (12 żywych certów LE: npm-13…npm-37) |
Rekonstrukcja compose:
```yaml
services:
npm:
image: jc21/nginx-proxy-manager:latest
container_name: npm
restart: unless-stopped
environment:
TZ: Europe/Warsaw
ports:
- "80:80"
- "81:81"
- "443:443"
volumes:
- /home/dockeruser/docker/npm/data:/data
- /home/dockeruser/docker/npm/letsencrypt:/etc/letsencrypt
```
Proxy hosty (z `database.sqlite`, kopia read-only; DB ostatnio zmieniona 2026-07-01;
brak WAL, więc kopia aktualna). Tylko aktywne (`is_deleted=0`):
| ID | Domena | Forward | Cert | Uwagi |
|---|---|---|---|---|
| 1 | joplin.okit.pl | http://joplin-server:22300 | LE npm-13 | by-name przez `npm_default` |
| 2 | ha.okit.pl | http://100.108.208.3:8123 | brak (HTTP) | → PIHA (tailscale) |
| 3 | forgejo.okit.pl | http://100.108.208.3:3000 | LE npm-15 | → PIHA; **nie** VPS |
| 4 | outline.okit.pl | http://135.181.153.108:3000 | LE npm-14 | **przez publiczny IP** — pułapka §5 |
| 5 | agents.okit.pl | http://100.108.208.3:18180 | LE npm-18 | → operator-ui na PIHA |
| 7 | chz2m.kapalla.org | http://192.168.1.201:8080 | brak | LAN chelsty (spacja wiodąca w forward_host — prawdopodobnie martwy, *do weryfikacji*) |
| 8 | chha.kapala.org | http://192.168.1.241:8123 | brak | LAN chelsty |
| 10 | gethumanai.okit.pl | http://135.181.153.108:8080 | LE npm-26 | **MARTWY backend** (nic na 8080) |
| 12 | gethumanai.pl | http://humanai-landing:80; custom location `/api` → http://humanai-mailer:3000 | LE npm-36 | by-name przez `npm_default` |
| 13 | stats.gethumanai.pl | http://umami:3000 | LE npm-37 | by-name przez `npm_default` |
Redirect: `kapala.org`, `www.kapala.org` → 302 `https://gethumanai.pl/?utm_source=…`
(cert LE npm-33). Streamów i dead hostów brak. Jeden user: `oskar@kapalla.org`.
Uwaga: vhost `kb.kapala.org` (ostatnie commity) **nie jest** w tym npm — siedzi na
innej instancji (PIHA); tu tylko odnotowane, żeby nikt nie szukał go na VPS.
### 3.2 ai-cluster (6 kontenerów)
- Compose: `/home/dockeruser/docker/ai-cluster/docker-compose.yml` (project
`ai-cluster`, compose v5.1.3). Plik `.env` istnieje w katalogu stacku; nazwy
zmiennych (odczytane przez `cut -d= -f1` w kontenerze, wartości nietknięte):
`TELEGRAM_BOT_TOKEN`, `MQTT_PASSWORD`, `ALLOWED_CHAT_IDS`.
- Sieć: `ai-cluster_ai-cluster` (172.22.0.0/16); openclaw dodatkowo w `npm_default`.
- Wszystkie: restart `unless-stopped`, **bez mem_limit**.
- Obrazy `ai-cluster-*` budowane lokalnie (build cache z ~kwietnia).
| Kontener | Obraz | Cmd | Porty | Sieci | depends_on |
|---|---|---|---|---|---|
| openclaw | ai-cluster-openclaw (local) | `uvicorn main:app --host 0.0.0.0 --port 8000` | **0.0.0.0:8000→8000** | ai-cluster, npm_default | redis, mosquitto |
| codex-worker | ai-cluster-codex-worker | `python worker.py` | — | ai-cluster | redis, mosquitto |
| planner-worker | ai-cluster-planner-worker | `python planner_worker.py` | — | ai-cluster | mosquitto |
| service-ops-worker | ai-cluster-service-ops-worker | `python service_ops_worker.py` | — | ai-cluster | mosquitto |
| redis | redis:7-alpine | `redis-server` (bez AOF) | — (expose 6379) | ai-cluster | — |
| mosquitto | eclipse-mosquitto:2 | mosquitto -c …/mosquitto.conf | **100.95.58.48:1883→1883** (tailscale-only) | ai-cluster | — |
Env (nazwy; sekrety zamaskowane):
- openclaw: `MQTT_HOST=mosquitto`, `MQTT_PORT=1883`, `MQTT_USERNAME=codex`,
`MQTT_PASSWORD=<SECRET:MQTT_PASSWORD>`, `REDIS_URL=<SECRET:REDIS_URL>`
- codex-worker: jw. + `AGENT_ID=vps-dev-1`, `ROLE=dev`,
`GATEWAY_BASE_URL=http://piha:8080` (zależność cross-node do PIHA!),
`REQUEST_TIMEOUT_SECONDS=30`
- planner-worker: MQTT jw. + `AGENT_ID=vps-planner-1`, `ROLE=planner`
- service-ops-worker: MQTT jw. + `AGENT_ID=vps-service-ops-1`, `ROLE=service-ops`,
`COMPOSE_PROJECT_NAME=ai-cluster`
Wolumeny / mounty:
- service-ops-worker: `/home/dockeruser/docker/ai-cluster/.env → /app/.env` (ro),
`…/docker-compose.yml → /app/docker-compose.yml` (ro),
**`/var/run/docker.sock → /var/run/docker.sock` (RW)** — root-equivalent.
- mosquitto: bind ro `/home/dockeruser/docker/ai-cluster/mosquitto → /mosquitto/config`
(zawiera `mosquitto.conf`, `passwd` 0600, `acl` 0600 — **krytyczny zestaw do
zachowania**); dane i logi na **anonimowych** wolumenach (`ab200e89…` i `9551f9e4…`,
oba 0 B — brak retained/persystencji do stracenia).
- redis: anonimowy wolumen `4f60cb72…` (3.5 kB).
Konfig mosquitto: `listener 1883`, `allow_anonymous false`, password_file + acl_file.
Ryzyka: publiczny 8000 bez auth (+ otwarty Swagger `/docs`); docker.sock;
anonimowe wolumeny znikną przy `compose down -v` / zmianie projektu.
Per CLAUDE.md compute-workery docelowo na SOLARIA.
### 3.3 outline (3 kontenery)
- Compose: `/home/dockeruser/docker/outline/docker-compose.yml` (project `outline`,
compose v5.1.3). Czy env z `.env` czy inline — *do weryfikacji* (plik nieczytelny).
- Sieć: **tylko** `outline_outline_internal` (172.21.0.0/16) — outline NIE jest w
`npm_default`, dlatego vhost idzie przez publiczny IP.
| Kontener | Obraz | Porty | Wolumen | Rozmiar |
|---|---|---|---|---|
| outline | outlinewiki/outline:**1.6.1** | **0.0.0.0:3000→3000** | `outline_outline_storage → /var/lib/outline/data` | 3.3 MB |
| postgres | postgres:16-alpine | — | `outline_postgres_data` | 69 MB |
| redis | redis:7-alpine (`--appendonly yes`) | — | `outline_redis_data` | 25 MB |
depends_on: outline → postgres (healthy), redis (healthy).
Env outline (nazwy; wartości sekretów zamaskowane): `URL=https://outline.okit.pl`,
`FORCE_HTTPS=true`, `PORT=3000`, `FILE_STORAGE=local`,
`FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data`, `PGSSLMODE=disable`,
`ALLOWED_DOMAINS=gmail.com`, `NODE_ENV=production`,
`GOOGLE_CLIENT_ID=84321112439-….apps.googleusercontent.com`,
`GOOGLE_CLIENT_SECRET=<SECRET:GOOGLE_CLIENT_SECRET>`,
`SECRET_KEY=<SECRET:SECRET_KEY>`, `UTILS_SECRET=<SECRET:UTILS_SECRET>`,
`DATABASE_URL=<SECRET:DATABASE_URL>`, `REDIS_URL=<SECRET:REDIS_URL>`,
`SMTP_HOST/PORT/USERNAME/PASSWORD/FROM_EMAIL/REPLY_EMAIL/SECURE=<SECRET:SMTP_*>`,
`SLACK_KEY/SLACK_SECRET=<SECRET:SLACK_*>`, komplet pustych/ustawionych `OIDC_*`
(`OIDC_CLIENT_SECRET`, `OIDC_AUTH_URI`, `OIDC_TOKEN_URI` maskowane).
Vhost: `outline.okit.pl``http://135.181.153.108:3000` (§3.1, poz. 4).
### 3.4 joplin-server (2 kontenery)
- Compose: `/home/dockeruser/docker/joplin-server/docker-compose.yml`
(project `joplin-server`, compose v5.1.2/5.1.3).
- Sieci: `joplin-net` (172.20.0.0/16) + app dodatkowo w `npm_default`.
| Kontener | Obraz | Porty | Wolumen | Rozmiar |
|---|---|---|---|---|
| joplin-server (service `app`) | joplin/server:**latest** | **127.0.0.1:22300→22300** | — | — |
| joplin-db (service `db`) | postgres:**18** | — | `joplin_postgres_data → /var/lib/postgresql` | 423 MB |
depends_on: app → db (healthy).
Env app: `APP_BASE_URL=https://joplin.okit.pl`, `APP_PORT=22300`, `TRUST_PROXY=1`,
`DB_CLIENT=pg`, `POSTGRES_HOST=db`, `POSTGRES_PORT=5432`,
`POSTGRES_USER/DATABASE/DB=joplin`, `POSTGRES_PASSWORD=<SECRET:POSTGRES_PASSWORD>`.
Quirk: kontener app ma nadpisane `command` — inline skrypt node łatający
`http.Server.listen`, żeby serwer słuchał na `0.0.0.0` w kontenerze (obejście
znanego problemu joplin-server). Przy przenoszeniu compose musi zachować to
`command:` — inaczej app wstanie na localhost w kontenerze i npm go nie dosięgnie.
Vhost: `joplin.okit.pl``http://joplin-server:22300` (by-name, wymaga `npm_default`).
Uwaga na `postgres:18` + mount na `/var/lib/postgresql` (nie `…/data`) — nowy layout
wolumenu Postgresa 18; nie zmieniać ścieżki przy migracji.
### 3.5 umami (2 kontenery) — OSIEROCONY
- Compose był w `/home/oskar/projects/gethumanai-infra/services/umami/` z `.env`
(labelki `environment_file` to potwierdzają; compose v2.27.0 — stary binarny
docker-compose). **Katalog `/home/oskar/projects/` już nie istnieje** — stack nie
jest odtwarzalny z dysku. Kontenery żyją tylko dzięki `restart: unless-stopped`.
- Sieci: umami w `npm_default` + `umami_internal` (172.26.0.0/16); db tylko internal.
| Kontener | Obraz | Porty | Wolumen | Rozmiar |
|---|---|---|---|---|
| umami | ghcr.io/umami-software/umami:postgresql-latest | — (expose 3000) | — | — |
| umami-db | postgres:16-alpine | — | `umami_umami-db-data` | 67 MB |
Env umami: `DATABASE_TYPE=postgresql`, `DATABASE_URL=<SECRET:DATABASE_URL>`,
`APP_SECRET=<SECRET:APP_SECRET>`, `PORT=3000`, `HOSTNAME=0.0.0.0`.
Env umami-db: `POSTGRES_DB/USER=umami`, `POSTGRES_PASSWORD=<SECRET:POSTGRES_PASSWORD>`.
Rekonstrukcja compose (do wciągnięcia do repo):
```yaml
services:
umami:
image: ghcr.io/umami-software/umami:postgresql-latest
container_name: umami
restart: unless-stopped
environment:
DATABASE_TYPE: postgresql
DATABASE_URL: <SECRET:DATABASE_URL> # postgres://umami:…@umami-db:5432/umami
APP_SECRET: <SECRET:APP_SECRET>
networks: [npm_default, internal]
depends_on:
umami-db: {condition: service_healthy}
umami-db:
image: postgres:16-alpine
container_name: umami-db
restart: unless-stopped
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: <SECRET:POSTGRES_PASSWORD>
volumes:
- umami-db-data:/var/lib/postgresql/data
networks: [internal]
volumes:
umami-db-data: # UWAGA: żywy wolumen nazywa się umami_umami-db-data
networks:
npm_default: {external: true}
internal: {}
```
Vhost: `stats.gethumanai.pl``http://umami:3000` (by-name).
**Pilne przed czymkolwiek innym przy tym stacku**: zrzucić sekrety z
`docker inspect umami umami-db` do `/opt/homelab/config/umami/.env`. Jeśli kontener
zostanie usunięty przed tym zrzutem, `APP_SECRET` (podpisy sesji) przepada.
### 3.6 humanai-landing + humanai-mailer — patrz §6 (kontenery ręczne)
---
## 4. Współdzielone zasoby
1. **Sieć `npm_default` = wspólna szyna ingressu.** Członkowie: npm, joplin-server,
umami, humanai-landing, humanai-mailer, **openclaw**. Trzy vhosty (joplin,
gethumanai.pl + /api, stats) resolwują backendy **po nazwie kontenera** w tej
sieci. Konsekwencje:
- sieci nie da się usunąć, dopóki wisi na niej 5 cudzych kontenerów (dobra
zapora przed przypadkowym `compose down` niszczącym ingress);
- każdy przenoszony stack musi po migracji nadal być w sieci osiągalnej przez
npm **pod tą samą nazwą kontenera**;
- w GitOps-owym compose npm sieć powinna być zadeklarowana tak, żeby zachować
**nazwę** `npm_default` (project `npm` + default network, albo
`networks: {default: {name: npm_default}}`).
- openclaw siedzi w `npm_default` mimo braku vhosta — zaszłość, *do weryfikacji*
czy potrzebne.
2. **`ai-cluster_ai-cluster`**: workers + redis + mosquitto + openclaw.
3. **mosquitto (VPS) na tailscale 1883** — broker dostępny dla całego mesha; workers
ai-cluster używają go po nazwie, ale klienci spoza hosta mogą wchodzić po
`100.95.58.48:1883` (auth wymagane). *Do weryfikacji: kto poza ai-cluster używa.*
To jest INNY broker niż `services/mosquitto` (chelsty) w repo.
4. **Bazy danych NIE są współdzielone między stackami** — każdy stack ma własny
postgres (outline pg16, joplin pg18, umami pg16) i własny redis (outline, ai-cluster).
To upraszcza rozdzielne przenoszenie.
5. **Zależność cross-node**: codex-worker → `http://piha:8080` (llm-gateway na PIHA).
6. **docker.sock** w service-ops-worker — współdzielony dostęp do demona Dockera.
7. Wolumeny anonimowe: 3 w użyciu (mosquitto data/log — puste; ai-cluster redis —
3.5 kB) + **5 osieroconych** (0 links: `2bebc404…`, `a1849a75…`, `ab6fd040…`,
`b7c791d3…`, `c76234b3…`) — kandydaci do sprzątnięcia, *do weryfikacji osobno*.
---
## 5. Rekomendowana kolejność wciągania do repo
Właściciel chce **npm pierwszy**. Ocena: **wykonalne bezpiecznie**, pod warunkami
z 5.1. npm nie ma zależności od innych stacków (to inni zależą od niego), stan jest
w dwóch bind mountach, a cutover to sekundy przerwy. Największe ryzyko to błąd
ścieżek wolumenów — obecny plik w repo dokładnie ten błąd zawiera.
### 5.1 npm (pierwszy — fundament, zamyka port 81)
Co zachować: bind mounty `/home/dockeruser/docker/npm/{data,letsencrypt}` **w miejscu**
(zgodnie z regułą CLAUDE.md „data paths stay in place at cutover"). Certy odnawia
wbudowany certbot — katalog letsencrypt musi przeżyć 1:1.
Pułapki:
1. **Repo `services/npm/docker-compose.yml` wskazuje `/opt/homelab/data/npm/*`**
trzeba to zmienić (w samym compose albo w `hosts/vps/runtime/npm/…override.yml`,
który dziś NIE istnieje) na `/home/dockeruser/docker/npm/*`. Bez tego npm wstaje
pusty: zero vhostów, zero certów → cały ingress leży mimo że dane są na dysku.
2. **Nazwa sieci**: żywa sieć to `npm_default` z 5 cudzymi kontenerami. GitOps-owy
deploy musi użyć **tej samej** sieci (project name `npm` albo jawne
`name: npm_default`). Compose nie usunie sieci z podpiętymi kontenerami, ale
przy złej konfiguracji utworzy nową i npm straci by-name backendy (joplin,
umami, gethumanai.pl, /api).
3. **Cutover wymaga zatrzymania starego kontenera** (ten sam `container_name` i
porty). Sekwencja: `docker stop npm` (stary) → `docker compose up -d` z repo →
healthcheck + test 23 vhostów. Rollback: `docker start npm` w starym katalogu.
To jest jedyna dopuszczalna przerwa (sekundy). Wymaga zgody operatora — poza
zakresem tego audytu.
4. **Zamknięcie 81**: w override zbindować `100.95.58.48:81:81` (+ ew.
`127.0.0.1:81:81`). NIE zostawiać `81:81`.
5. Dodać `mem_limit` (npm dziś bez limitu; propozycja 256512 MB) i rozważyć
`oom_score_adj: -900` — npm to de facto najbardziej krytyczny kontener na hoście.
6. Stary katalog compose zostaje jako rollback do czasu stabilizacji; potem
oznaczyć jako wycofany (nie usuwać danych!).
### 5.2 umami (drugi — bo konfiguracja już nie istnieje)
Nie dlatego, że ważny, tylko dlatego, że **jest jeden incydent od utraty sekretów**
(restart hosta przeżyje, ale `docker rm` już nie). Kroki: zrzut env z inspect →
`/opt/homelab/config/umami/.env` → compose w repo wg rekonstrukcji §3.5 →
cutover. Pułapki: nazwa wolumenu musi zmapować się na istniejący
`umami_umami-db-data` (project name `umami`!); kontener `umami` musi zostać
w `npm_default` pod tą samą nazwą (vhost stats.gethumanai.pl).
### 5.3 joplin-server (trzeci — najprostszy)
Zachować: wolumen `joplin_postgres_data` (project name `joplin-server`!, mount na
`/var/lib/postgresql` — layout pg18), sieć `npm_default` dla app, bind
`127.0.0.1:22300`, **nadpisane `command`** (patcha listen z §3.4). Ryzyko niskie.
Dodatkowo: przypiąć tag obrazu (dziś `joplin/server:latest` i `postgres:18`
`latest` na produkcyjnej bazie to ryzyko samo w sobie).
### 5.4 outline (czwarty — zamyka publiczny 3000)
Tu jest sprzężenie z npm — trzy zmiany muszą pójść **razem**:
1. dodać kontener outline do sieci osiągalnej z npm (np. `npm_default`),
2. przepiąć vhost `outline.okit.pl` z `135.181.153.108:3000` na `http://outline:3000`
(jedyna w tym planie zmiana w konfiguracji npm — przez UI/API, nie przez sqlite),
3. zdjąć publiczny bind (usunąć `ports:` albo `127.0.0.1:3000:3000`).
Kolejność: (1) → (2) → test → (3). Wtedy 3000 znika ze świata bez przerwy w działaniu.
Zachować: wszystkie 3 named volumes `outline_*` (project name `outline`!). Zmiana
project name = compose tworzy NOWE puste wolumeny — najprostszy sposób na „zniknięcie"
wiki. Wersja przypięta 1.6.1 — zostawić, upgrade osobnym tematem.
### 5.5 humanai-landing + humanai-mailer (piąty)
Złożyć w jeden compose w repo (dziś: landing ma compose na dysku, ale kontener
uruchomiony ręcznie i rozjechany z plikiem; mailer nie ma nic). Wymaga odzyskania
źródła mailera (*do weryfikacji: repo na Forgejo*) — obrazy są budowane lokalnie.
Przy okazji: usunąć/naprawić martwy vhost `gethumanai.okit.pl` (→ nieistniejący
:8080). Dane: brak wolumenów — nic do zachowania poza env SMTP (zrzut z inspect,
jak przy umami).
### 5.6 ai-cluster (ostatni — największy i architektonicznie do przebudowy)
Powody na koniec: 6 kontenerów, docker.sock, lokalne buildy, a per CLAUDE.md
compute-workery mają docelowo iść na SOLARIA — nie warto cementować obecnego
kształtu w repo dwa razy. Co zachować: bind `/home/dockeruser/docker/ai-cluster/`
(`.env` + `mosquitto/{mosquitto.conf,passwd,acl}` — bez passwd/acl broker odetnie
wszystkich klientów). Przy migracji: anonimowe wolumeny → nazwane (dane do stracenia
~0 B, więc bez migracji danych). **Niezależnie od kolejności: publiczny 8000 do
zamknięcia szybciej** (bind na `100.95.58.48:8000` albo vhost z auth w npm — decyzja
właściciela; dziś każdy z internetu widzi Swaggera openclaw).
### Ryzyka wspólne dla wszystkich cutoverów
- Każdy stack dostaje `mem_limit` w `hosts/vps/runtime/<svc>/…override.yml`
(dziś suma limitów = 0; budżet 3.1 GiB per CLAUDE.md; RAM: 3.8 GiB, ~1.7 GiB used).
- Project name przy `docker compose up` MUSI odpowiadać staremu (wolumeny
`<project>_<volume>`, sieci `<project>_default`).
- Nigdy `docker compose down -v` na starych stackach.
- Zaszłość compose v2.27 (umami) vs v5.x (reszta) — bez znaczenia po przejściu na
repo, byle project name się zgadzał.
---
## 6. Kontenery ręczne (bez compose) — rekonstrukcja z `docker inspect`
### 6.1 humanai-landing
- Obraz: `humanai-landing:latest` — build lokalny 2026-06-25, multi-stage
(Astro build → nginx:alpine). Źródło: `/home/oskar/gethumanai-landing`
(git, origin: Forgejo na PIHA `ssh://git@100.108.208.3:222/oskar/gethumanai-landing.git`;
deploy skryptem `deploy.sh` z SATURN).
- Kontener NIE odpowiada plikowi `docker-compose.yml` leżącemu w tym katalogu
(plik przewiduje `ports: 8080:80`; żywy kontener nie publikuje nic i nie ma
labelek compose → uruchomiony ręcznie). Stąd martwy vhost `gethumanai.okit.pl → :8080`.
- Rekonstrukcja:
```bash
docker run -d --name humanai-landing \
--restart unless-stopped \
--network npm_default \
humanai-landing:latest
# nginx słucha na :80 w kontenerze; ruch wyłącznie przez npm (vhost gethumanai.pl)
```
- Brak wolumenów i env — cała treść w obrazie. Nginx w obrazie: statyczny serwing
`/usr/share/nginx/html` + security headers + gzip (konfig w repo źródłowym).
### 6.2 humanai-mailer
- Obraz: `humanai-mailer:latest` — build lokalny 2026-06-24 (node:22). Proces:
`node mailer.mjs`, słucha na `:3000` w kontenerze (potwierdzone przez
`/proc/net/tcp6`). **Źródła NIE znaleziono na dysku VPS***do weryfikacji*
(prawdopodobnie repo na Forgejo).
- Konsumowany wyłącznie przez npm: vhost `gethumanai.pl`, custom location
`/api``http://humanai-mailer:3000`.
- Rekonstrukcja:
```bash
docker run -d --name humanai-mailer \
--restart unless-stopped \
--network npm_default \
-e SMTP_HOST=<SECRET:SMTP_HOST> \
-e SMTP_PORT=<SECRET:SMTP_PORT> \
-e SMTP_USER=<SECRET:SMTP_USER> \
-e SMTP_PASS=<SECRET:SMTP_PASS> \
humanai-mailer:latest
```
- Brak wolumenów. Wartości SMTP odzyskiwalne z `docker inspect humanai-mailer`
(zrzut do `/opt/homelab/config/` przed jakimkolwiek ruszaniem kontenera —
jak przy umami).
---
## 7. Czego nie dało się ustalić (do weryfikacji ręcznie)
1. Dosłowna treść czterech compose pod `/home/dockeruser/docker/` (prawa 750;
potrzebny root/dockeruser). Rekonstrukcje z inspect są funkcjonalnie kompletne,
ale komentarze/`env_file`/healthchecki zdefiniowane w plikach — nieznane.
2. Czy `/home/dockeruser/docker/` zawiera COŚ WIĘCEJ niż npm/ai-cluster/outline/
joplin-server (listing niedostępny).
3. Czy istnieją `.env` przy npm/outline/joplin i co zawierają (nazwy zmiennych).
4. Firewall Hetzner Cloud — sonda wskazuje brak filtrowania (81/8000/9100 otwarte);
sprawdzić w panelu Hetznera.
5. Źródło humanai-mailer (Forgejo?) i czy landing w kontenerze == HEAD repo.
6. Kto/co konsumuje `openclaw:8000` z internetu — czy można zbindować na tailscale.
7. Vhost `chz2m.kapalla.org` (spacja wiodąca w forward_host `' 192.168.1.201'`) —
czy w ogóle działa.
8. 5 osieroconych anonimowych wolumenów — czyje, czy do kasacji.
9. `gokapi` zadeklarowane w `hosts/vps/services.yaml` (exposure: public, :53842),
a nie biega na VPS — rozjazd manifestu z rzeczywistością.