# 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 2–3 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 256–512 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ą.
