Skip to content

System Architecture

Infrastructure layout, network topology, and service communication patterns.

High-Level Architecture

graph TD
    DEV["Developer Workstation
(Tailscale client, git, km CLI)"] DEV -->|git push| GITEA["Gitea
source control
unraid:3005"] DEV -->|km CLI| KCORE["Komodo Core
deployment orchestrator
unraid:9120"] GITEA <-->|webhook| KCORE KCORE -->|localhost| FDB["FerretDB :27017
(MongoDB-compatible)
unraid"] KCORE -->|deploy commands| KPERU["Komodo Periphery (unraid)
unraid:8120"] KCORE -->|deploy commands| KPERD["Komodo Periphery (development)
development:8120"] KPERU --> DCU["Docker Compose — 49 stacks
Unraid NAS"] KPERD --> DCD["Docker Compose — 20 stacks
Dev VM"]

Network Topology

graph TD
    subgraph TN["Tailscale Tailnet (WireGuard encrypted)"]
        subgraph UNR["unraid — 100.68.251.84"]
            UNRS["Komodo Core :9120 · FerretDB :27017 · Gitea :3005
Grafana :3002 · RustFS :9000 · Homepage :8088
AdGuard Primary :53 · Uptime Kuma :3004
LiteLLM :4001 · Caddy :80 · Pigsty PG :5439
VictoriaMetrics :8428 · ... 30 more"] end subgraph DEV["dev — 100.126.172.96"] DEVS["Komodo Periphery :8120 only
Redpanda :9092 · RisingWave :4566
Trino :8091 · Lakekeeper :8181 · OpenFGA :8082 · OPA :8282
ntopng :3004 · mikrotik-tools · Push API :8093"] end end NOTE["Client devices access via Caddy reverse proxy (*.home)
Internal *.home via Caddy (macvlan 192.168.100.10)
Public: 100.68.251.84 is CGNAT — unreachable"] TN -.-> NOTE

Key Network Decisions

  • All inter-client traffic encrypted by Tailscale WireGuard

Domain Architecture: Public vs Internal

Layer Domain DNS Resolution Access Auth
Public *.minhluc.info Cloudflare DNS → CF Tunnel Internet Cloudflare + Auth0
Internal *.home AdGuard DNS rewrite → Caddy (192.168.100.10) LAN + Tailscale Tailscale device auth / LAN isolation
Edge (new) (Cloudflare) Workers RPC (when deployed) Global Worker APP_TOKEN per-route
graph LR
    subgraph PUB["Public flow"]
        INET[Internet] -->|*.minhluc.info| CF[Cloudflare] -->|Auth0 SSO| CFT["CF Tunnel"] --> SVC1[Service]
    end
    subgraph INT["Internal flow"]
        LAN["LAN / Tailscale"] -->|*.home| AG["AdGuard
192.168.100.59 / 229"] --> CAD["Caddy
192.168.100.10:80"] --> SVC2[Service] end subgraph EDGE["Edge compute (ADR-0002 + ADR-0003)"] WR[Cloudflare Worker] -->|Hyperdrive binding| HDC[Workers VPC connectivity dir] -->|WS tunnel| CDB[supabase-db:5432 on supabase-internal net] WR -.->|Workers AI binding (future)| AI[CF Workers AI @ edge GPU] end AI -.->|embeddings/cache| WR

Edge Compute Layer (added 2026-07)

Cloudflare Workers stack integrated via Infrastructure-as-Code + Komodo:

Component Location Purpose
Cloudflare Tunnel komodo/stacks/networking/cloudflared-unraid/ (container) Existing HTTP ingress for *.minhluc.info, now also attached to supabase-internal Docker network for Hyperdrive TCP forwarding
Workers VPC Connectivity Dir infrastructure/cloudflare/connectivity.tf TCP service homelab-supabase-pg pointing to supabase-db:5432 via tunnel. TLS verify disabled (self-signed OK, tunnel provides auth).
Hyperdrive Configs infrastructure/cloudflare/hyperdrive.tf 2 configs: homelab-pg-cached (60s TTL, 10 conns) + homelab-pg-fresh (cache disabled, 5 conns). Both target same connectivity service.
Workers AI (LiteLLM bridge) stacks/llm/litellm/config/model_config.yaml cf-qwen3-embedding model → openai/@cf/qwen/qwen3-embedding-0.6b via Cloudflare OpenAI-compatible endpoint. 1024 dims, $0.012/M tokens.
TF Backend State R2 (tofu-state-minhluc/cloudflare) Encrypted, GitOps-driven
Token Scopes Komodo Variable CLOUDFLARE_API_TOKEN 3 scopes needed: Account > Workers AI: Run (inference), Zone > Analytics: Read, Account > Account Analytics: Read. Shared with existing terraform + exporter.

Edge → Internal flow (Worker → Hyperdrive → Supabase PG):

  1. Worker A receives HTTP request at CF POP
  2. Reads env.HYPERDRIVE.connectionString (synthetic, points to nearby CF pool)
  3. Hyperdrive pool opens TCP 5432 to supabase-db via Workers VPC service + cloudflared tunnel
  4. PostgreSQL on unraid serves (TLS self-signed, verify disabled)

Why this matters: Workers get edge caching (60s TTL) + connection pooling near homelab origin — eliminates per-request TLS handshake from user → tunnel → PG. Composes cleanly with Litellm gateway (LiteLLM proxies Workers AI similarly for client apps).

  • *.home not exposed to internet — Tailscale is the auth layer, no SSO needed
  • To expose a service publicly: route via *.minhluc.info → CF tunnel → Auth0
  • AdGuard DNS rewrites only affect *.home — public domains resolve via Cloudflare
  • Caddy on macvlan (192.168.100.10) listens only on LAN interface, not WAN-accessible

DNS High Availability Architecture

graph TD
    CLIENT[Client Devices] -->|query| AG1["AdGuard Primary
192.168.100.59:53"] AG1 -->|fallback if unavailable| AG2["AdGuard Replica
192.168.100.229:53"] AG2 -->|fallback if unavailable| CF["Cloudflare DNS
1.1.1.1:53
internet-only, *.home=NXDOMAIN"] SYNC["AdGuard Sync
every 30min, one-way primary→replica
→ DNS filter changes propagate with up to 30min lag"]

DNS Components

Component Server Ports Purpose
AdGuard Primary unraid 53, 3003 Main DNS server + ad blocking
AdGuard Replica development 53, 3004 Backup DNS server, read-only copy
AdGuard Sync development internal only Syncs config primary → replica

DNS Configuration

DHCP Configuration (MikroTik):

  • Both subnets use: 192.168.100.59, 192.168.100.229, 1.1.1.1
  • Primary DNS: AdGuard Primary (unraid)
  • Secondary DNS: AdGuard Replica (development)
  • Tertiary DNS: Cloudflare fallback (internet only)

Tailscale DNS:

  • Global nameservers: 192.168.100.59, 192.168.100.229
  • Overrides DNS for all tailnet devices
  • Tailnet devices benefit from same failover chain

Sync Configuration

  • Tool: bakito/adguardhome-sync
  • Direction: One-way (primary → replica only)
  • Interval: Every 30 minutes
  • Sync items: DNS filters, rewrites, services, query log config
  • Auth: admin:adminadmin (default, replica only accepts local connections)

Failover Behavior

Scenario Result
Primary available All queries hit primary
Primary down Queries fail over to replica
Both AdGuard instances Fallback to 1.1.1.1 (no blocking)
*.home query at fallback Returns NXDOMAIN (no resolution)

Known limitation: DNS changes made on primary take up to 30min to appear on replica due to sync interval.

Server Layout

Unraid (100.68.251.84) — Primary Server

  • Role: Main NAS and service host
  • OS: Unraid OS 7.x
  • Services: 49 stacks (media, databases, monitoring, core apps, networking, LLM, Komodo Core + FerretDB)
  • GPU: Intel iGPU with VAAPI support (hardware transcoding)
  • Storage: NFS mounts at /mnt/user/appdata/{service}/, media at /mnt/user/{music,youtube,tvshows}/
  • API: GraphQL at https://unraid.tail1137c.ts.net/graphql

Development (100.126.172.96) — Secondary Server

  • Role: Development services, Data Platform, LLM/AI workloads, AdGuard HA replica
  • Services: 20 stacks (Data Platform, ntopng, mikrotik-tools, AdGuard replica+sync, etc.)
  • Storage: Local volumes at /opt/appdata/{service}/
  • Key services: Komodo Periphery (:8120), Redpanda (:9092), RisingWave (:4566), Trino (:8091), Lakekeeper (:8181), OpenFGA (:8082), OPA (:8282), ntopng (:3004), mikrotik-tools, Push API (:8093)

Data Platform Architecture

The data platform is a lakehouse pipeline on the development server. Gravitino → Lakekeeper migration is COMPLETE (see ADR-0001). The Gravitino container has been REMOVED.

graph TD
    subgraph Ingest["Ingestion (sole entry: Push API :8093)"]
        TG["Telegram Telethon
(dp-chat-adapter)"] MK["mikrotik-tools
ntopng scraper"] AD["Push API :8093
dp-chat-adapter container
HTTP /api/v1/events, /metrics, /logs, /chat"] end AD --> RP["Redpanda :9092
tmpfs 2GB RAM disk
zero disk writes"] TG --> AD MK --> AD RP --> RW["RisingWave v3.0.0 :4566
single_node persistent mode
8GB RAM (mem_limit)"] RW -->|"materialized views + sinks"| LK["Lakekeeper :8181
Iceberg REST catalog"] LK -->|"REST catalog"| RFS["RustFS :9000
s3://iceberg/"] LK -->|"warehouse=homelab"| TR["Trino :8091
catalog=gravitino_iceberg (legacy name)"] subgraph Authz["Authz stack"] FGA["OpenFGA :8082
fine-grained auth (future)"] OPA["OPA :8282
policy enforcement for Trino"] end OPA -.->|allow/deny| TR LK -.-> FGA subgraph DBT["dbt analytics (analytics/)"] STG["staging/
stg_chat_messages
stg_chat_messages_valuable"] MV["mvs/
5 materialized views"] STG --> MV end RW <-.->|dbt-risingwave| DBT HC["dp-health-check :60s loop
checks Push API, RW, Redpanda,
Lakekeeper :8181, Trino"] -.->|apprise tag:infra| AP["Apprise gateway"]

Components

Component Container Port Notes
Push API dp-chat-adapter :8093 Sole data entry point for ALL data types (chat, events, metrics, logs)
Redpanda redpanda :9092 tmpfs 2GB RAM disk at /var/lib/redpanda/data — zero disk writes
Redpanda Console redpanda-console :8085 Web UI for topic inspection
RisingWave dp-risingwave :4566 v3.0.0, single_node persistent mode, 8GB RAM (not playground)
Lakekeeper dp-lakekeeper :8181 Rust Iceberg REST catalog (replaces Gravitino :8090)
OpenFGA dp-openfga :8082 Fine-grained authorization backend for Lakekeeper
OPA dp-opa :8282 Policy enforcement in front of Trino
Trino dp-trino :8091 Catalog gravitino_iceberg (name kept for legacy compat, points to Lakekeeper)
Health checker dp-health-check loop Checks Push API, RW :5691, Redpanda :8085, Lakekeeper :8181, Trino :8091

Migration status: Gravitino → Lakekeeper (COMPLETE)

  • Gravitino container REMOVED (folder 10-catalog/ retains only legacy gravitino.conf reference, not deployed)
  • Lakekeeper deployed at 15-lakekeeper/ with OpenFGA auth backend
  • OPA deployed at 16-opa/ as Trino access-control proxy
  • Trino catalog still named gravitino_iceberg (legacy filename retained), iceberg.rest-catalog.uri=http://192.168.100.31:8181/catalog, iceberg.rest-catalog.warehouse=homelab
  • Health checker now hits http://192.168.100.31:8181/management/v1/warehouse (was http://...:8090/api/metalakes/dev)

Push API — sole data entry point

dp-chat-adapter container exposes :8093 as the Push API (HTTP). ALL data types enter the platform through this endpoint:

Endpoint Purpose Kafka topic
POST /api/v1/chat Chat messages (Telegram, future Zalo/WeChat/FB) chat.messages
POST /api/v1/events Platform lifecycle events cdc.events (or platform_events)
POST /api/v1/metrics Time-series metrics platform_metrics
POST /api/v1/logs Structured logs (DNS, access, flows) platform_logs

Direct Kafka producers from outside the platform are NOT permitted — push through :8093 for schema validation and idempotency.

dbt staging layer

The dbt project at analytics/streaming/models/staging/ contains two staging models that clean raw Kafka data before downstream MVs consume it:

Model Purpose
stg_chat_messages COALESCE field mapping: Telegram adapter (message_id, date, sender_id, sender_name) ↔ Push API (entity_id, timestamp, actor_id, actor_name). Picks whichever is populated per row.
stg_chat_messages_valuable Filter + classification: keeps DMs (≤2 participants), groups where owner participated, and all non-Zalo providers. Drops spam. Adds thread_category column.

thread_category values:

Value Meaning
dm Direct message (1–2 participants) — always kept
group_owner Group chat (3+ participants) where owner sent at least one message
group Group chat where owner never participated — dropped
spam Group with spam keywords (tuyển dụng, khuyến mãi, etc.) and no owner activity — dropped

Owner identification per provider:

  • Zalo: msg_sender_name = 'Bạn' (Zalo's convention for "self")
  • Telegram: all messages kept (already curated via WHITELIST_THREAD_IDS)
  • Others: all kept

Redpanda tmpfs mode

Redpanda runs on a tmpfs 2GB RAM disk at /var/lib/redpanda/data. Trade-offs:

  • ✅ Zero disk writes — eliminates btrfs CoW write amplification on dev server
  • ✅ Faster topic produce/consume (in-memory)
  • ⚠️ Data is lost on container restart (topics auto-recreate via auto_create_topics_enabled=true)
  • ⚠️ Not suitable for production — dev-only acceptable because upstream Telegram session is replayable

RisingWave persistence

RisingWave runs in single_node persistent mode (image default CMD = single_node):

  • Version: v3.0.0
  • Volume: /mnt/user/appdata/risingwave:/root/.risingwave (persisted)
  • Memory: mem_limit: 8g, mem_reservation: 6g
  • Sources/MVs/sinks survive restarts — no need to re-bootstrap after restart (unlike the old playground mode)

Service Communication Patterns

graph LR
    subgraph SAME["Same Server, Same Network"]
        SA["Service A
redis:6379"] -->|container name| REDIS[(Redis)] end subgraph CROSS["Cross-Server"] CA["Service A
(unraid)"] -->|Tailscale IP:port| CB["Service B
(development)
100.126.172.96"] end
Scenario Addressing Example Why
Same server, same Docker network Container name redis:6379 Fastest, Docker DNS
Same server, different network LAN IP 192.168.100.59:6379 Avoid Tailscale overhead
Cross-server LAN IP 192.168.100.31:9120 Direct, lower latency
Cross-server (Tailscale only) Tailscale IP 100.68.251.84:9120 When LAN not reachable
User-facing URL *.home grafana.home Via Caddy reverse proxy
Never Public domain redis.minhluc.info Unnecessary hop, auth overhead

Rule: Prefer LAN IP (192.168.100.x) over Tailscale IP for internal service-to-service communication. Only use Tailscale IP when LAN is not reachable (e.g., from outside network). Never use public domains internally.

Docker Networks

Network Type Purpose
reverse-proxy External Inter-service communication on same server
observability Bridge Monitoring stack (OTel + exporters)

Media Architecture

graph TD
    subgraph NAS["Unraid NAS — Media Stack"]
        MUSIC["/mnt/user/music/"] -->|scan| NAV["Navidrome :4533
(Subsonic)"] TV["/mnt/user/tvshows/"] --> JEL["Jellyfin :8096"] JEL <-->|/dev/dri VAAPI / iHD| IGPU["Intel iGPU (VAAPI)"] end

Media Stack Details

Service Port(s) Backend Key Feature
Jellyfin 8096, 7359/udp SQLite HW transcoding via VAAPI/iHD
Navidrome 4533 SQLite Subsonic API, scans /mnt/user/music

Monitoring Architecture

graph TD
    APP["Applications (OTLP)"] --> OTEL
    DB["Databases (OTLP)"] --> OTEL
    NET["Networking (Prom)"] --> OTEL
    OTEL["OTel Collector (unraid)"] --> MIMIR[Mimir
metrics] OTEL --> LOKI[Loki
logs] OTEL --> TEMPO[Tempo
traces] MIMIR --> GRAFANA["Grafana :3002"] LOKI --> GRAFANA TEMPO --> GRAFANA

Monitoring Stack Details

Stack Server Compose Files Purpose
otel-collector unraid compose.yaml + compose.exporters.yaml OTLP + Prometheus scrape
otel-collector development compose.yaml only OTLP receiver only
uptime-kuma unraid compose.yaml Uptime monitoring (32 checks across both servers)
  • Multi-file overlay: compose.exporters.yaml adds exporters for Unraid-specific services
  • COMPOSE_PROFILES does NOT work with Komodo — use file overlay instead
  • Uptime Kuma uses SQLite (does NOT support PostgreSQL) — monitors 32 checks across both servers

Notification Architecture

Two notification paths: direct to Apprise (Komodo, Uptime Kuma) and via Apprise gateway (monitoring scripts, health checks).

graph TD
    subgraph Alert Sources
        KOM["Komodo alerter"]
        UK["Uptime Kuma :3004"]
        MK["MikroTik scripts
(check_ports, dhcp_watch, VPN)"] ROS["ROS freshness-check
(metrics stale >120s)"] VMA["vmalert
(VictoriaMetrics rules)"] DPH["DP health-check
(data platform 60s)"] end subgraph "Apprise Gateway :8008" AP["Apprise
POST /notify/{tag}"] end subgraph Delivery APPR["Apprise :8008"] TG["Telegram DM"] end C1["Phone (Telegram app)"] C2["Browser (Telegram web)"] KOM -->|topic: komodo| APPR UK -->|topic: komodo| APPR MK -->|tag: infra| AP ROS -->|tag: ros-alerts| AP VMA -->|apprise-bridge :9093| AP DPH -->|tag: infra| AP AP --> APPR AP --> TG APPR --> C1 APPR --> C2

Apprise Gateway

Central notification router — receives alerts via REST API and routes to Apprise + Telegram.

Property Value
Image caronc/apprise:latest
Server Development (192.168.100.31:8008)
API POST /notify/{tag} with JSON {"title": "...", "body": "..."}
Config files all.cfg, infra.cfg, ros-alerts.cfg (in /config/)
Routes to Apprise (:8008) + Telegram bot

Apprise Tags

Tag Sources Routes to
infra MikroTik scripts, DP health-check Apprise + Telegram
ros-alerts ROS freshness-check, apprise-bridge (vmalert) Apprise + Telegram
all Manual test Apprise + Telegram

Direct Apprise Path (bypasses gateway)

Source Apprise Topic How
Komodo alerter komodo Apprise endpoint type in komodo/stacks/root_syncs/alerter.toml
Uptime Kuma komodo Apprise notification provider (32 monitors)

Apprise Server

Property Value
Image carrotpc/apprise:latest
Server Development (100.126.172.96:9282)
Auth deny-all (default deny, admin user whitelisted)
Topics komodo, ros-alerts, infra

Alert Flow Summary

Source Path Tag/Topic Destination
Komodo alerter Direct komodo Telegram → phone/browser
Uptime Kuma Direct komodo Telegram → phone/browser
MikroTik scripts Apprise infra Apprise + Telegram
ROS freshness-check Apprise ros-alerts Apprise + Telegram
vmalert (via apprise-bridge) Apprise ros-alerts Apprise + Telegram
DP health-check Apprise infra Apprise + Telegram

vmalert → apprise-bridge → Apprise

vmalert evaluates alerting rules in VictoriaMetrics and sends firing/resolved alerts to apprise-bridge (Alertmanager-compatible API on :9093). apprise-bridge enriches alerts with disk/WAN stats and deduplicates (notify once on fire, once on resolve) before forwarding to Apprise.

Homepage Dashboard

Central service dashboard at http://100.68.251.84:8088 (unraid).

  • Image: ghcr.io/gethomepage/homepage:latest
  • Config: stacks/applications/homepage/config/ (YAML configs copied via pre_deploy.command)
  • Verified endpoints across categories for all deployed services
  • Integrates with Docker API for live container status

Multi-Server URL Routing

Homepage uses Tailscale IPs and *.home domains to reach services on both servers:

Server IP Services reachable from Homepage
Unraid 100.68.251.84 All unraid-hosted services
Development 100.126.172.96 Komodo Periphery, Data Platform (Lakekeeper, OpenFGA, Trino, RisingWave, Redpanda, OPA), ntopng, mikrotik-tools

Storage Architecture

Unraid (NFS):                          Development (Local):
/mnt/user/appdata/{service}/           /mnt/user/appdata/{service}/
├── gitea/                             ├── komodo/
├── immich/                            ├── bigquery-emulator/
├── grafana/                            ├── crawl4ai/
├── postgres/                           ├── clickhouse/
├── nextcloud/                          ├── redpanda/
├── homepage/                           ├── risingwave/
├── uptime-kuma/                        ├── apprise/
└── ...                                 └── ...

Tank mounts (NextCloud on Unraid):
/mnt/user/audiobooks/          → /tank/audiobooks
/mnt/user/podcasts/            → /tank/podcasts
/mnt/user/music/               → /tank/music
/mnt/user/isos/                → /tank/isos

Media libraries (Unraid):
/mnt/user/music/              # Navidrome scans
/mnt/user/downloads/          # Downloads
/mnt/user/tvshows/            # TV content for Jellyfin
Server Mount Type Base Path
Unraid NFS /mnt/user/appdata/{service}/
Development Local /mnt/user/appdata/{service}/

File Management Architecture (NextCloud)

NextCloud replaces OpenList as the file management and WebDAV solution.

graph TD
    subgraph Unraid
        NC["NextCloud :80
(nextcloud:31-apache)"] PG["pigsty-postgres :5439
(nextcloud DB)"] REDIS["Redis
(cache)"] TANK["Tank mounts
audiobooks, podcasts,
music, isos"] end TS["Tailscale sidecar
(HTTPS :443)"] CADDY["Caddy
nextcloud.home:80"] NC --> PG NC --> REDIS NC -->|bind mount RW| TANK CADDY -->|reverse_proxy| NC TS -->|serve :443 → :80| NC CLIENTS["Tailscale clients:
https://nextcloud.tail1137c.ts.net
http://nextcloud.home"] TS -.-> CLIENTS CADDY -.-> CLIENTS

NextCloud Details:

Property Value
Image nextcloud:31-apache
Server Unraid (100.68.251.84)
Database pigsty-postgres (nextcloud DB, nextcloud user)
Cache Redis (shared instance)
Tank mounts audiobooks, podcasts, music, isos (bind mount RW)
Access nextcloud.home:80 (Caddy) + nextcloud.tail1137c.ts.net (Tailscale HTTPS)
Upload limit 10GB (PHP_UPLOAD_LIMIT=10240M)
Trusted domains nextcloud.home, nextcloud.tail1137c.ts.net, 100.68.251.84

Tailscale HTTPS:

NextCloud runs a Tailscale sidecar (tailscale-nextcloud) that serves HTTPS on port 443 via Tailscale Funnel/Serve. The serve.json config maps :443 → container :80.

Caddy route:

nextcloud.home:80 {
    request_body { max_size 10G }
    reverse_proxy nextcloud:80 {
        trusted_proxies private_ranges
        header_up X-Forwarded-Proto {scheme}
        header_up X-Real-IP {remote_host}
    }
}

Legacy: rclone Cloud Storage (Unraid)

rclone FUSE mounts may still exist on Unraid for cloud storage access (Google Drive, ProtonDrive). These are NOT exposed through NextCloud — they are standalone mounts on the Unraid host.

Mount Path Remote Cache
/mnt/user/cloudstorage/gdrive/ Google Drive (15G) 64G VFS cache
/mnt/user/cloudstorage/proton/ ProtonDrive (200G) 64G VFS cache
  • Mount script: /boot/config/scripts/rclone/mount-cloud.sh (auto-start on boot)
  • Health check: /boot/config/scripts/rclone/health-check.sh (cron + Apprise alerts)
  • rclone config: /boot/config/plugins/rclone/.rclone.conf

Architecture Decision Records

ADR-001: Docker Swarm — Không áp dụng

Ngày: 2026-05-08 Trạng thái: ❌ REJECTED — Không cân nhắc lại

Lý do không chọn Docker Swarm

# Lý do Chi tiết
1 Unraid không hỗ trợ Unraid OS không hỗ trợ Swarm mode. Tham gia Swarm cluster = disable Unraid Docker management → mất WebGUI Docker page. Unraid là hard requirement, không replace.
2 2 nodes = anti-pattern Swarm cần odd number of managers (3, 5, 7) cho Raft consensus. 2 nodes = split brain risk. Dù có server 3, Unraid không tham gia cluster → chỉ còn 2 Swarm-capable nodes.
3 Komodo mất vai trò Swarm tự quản lý lifecycle → Komodo deploy_stack không cần nữa. Mất GitOps workflow đã mature (git push → sync → deploy). Phải rewrite toàn bộ TOML configs.
4 Volume không cross-node Swarm KHÔNG quản lý volumes across nodes. Current stack dùng local mounts (/mnt/user/appdata/, /opt/appdata/) → phải migrate sang NFS shared storage cho cross-node services.
5 Double encryption overhead Swarm overlay networks (IPsec) + Tailscale (WireGuard) = double encryption, troubleshooting phức tạp, latency tăng.
6 Tailscale đã giải quyết networking Cross-server communication đang work tốt qua Tailscale direct IP. Swarm service discovery (DNS-based VIP) không thêm giá trị khi mỗi service đã có fixed IP.
7 Complexity không tương xứng Swarm giải quyết: dynamic scheduling, rolling updates, HA — không phải pain points hiện tại. Komodo + Compose đã cover: deploy, auto-update, destroy_before_deploy.

Khi nào nên reconsider

Chỉ khi CẢ HAI điều kiện sau đồng thời đúng:

  • Unraid bị thay thế bởi pure Linux servers (3+ nodes)
  • Cần zero-downtime rolling updates hoặc cross-node workload scheduling

Network Topology (Updated 2026-07-04)

Gateway: mik-main (RouterOS 7.23.1 x86)

graph TD
    INET["Internet (FPT PPPoE)"] --> ONT["ONT (bridge mode)"]
    ONT -->|ether2 WAN, MAC clone, PPPoE| MIK
    subgraph MIK["mik-main 192.168.100.1 — RouterOS 7.23.1 x86, 4GB RAM, Intel 4-core"]
        M1["ether2 = WAN PPPoE MTU 1492 MSS clamp
ether1+3+4 = bridge1 LAN 192.168.100.0/24
Services: REST API :80, Winbox :8291, API :8728
DHCP: pool .100-.199, 23 static leases
DNS: forward to AdGuard .59 + 1.1.1.1
QoS: 4-tier + PCQ · Firewall: 13 filter + 18 NAT + 15 mangle
NAT: masquerade + UDP 41641→unraid Tailscale + PS5 Remote Play (16 rules)
NetFlow v9 → 192.168.100.59:9995 · SNMP: public
UPnP: disabled"] end MIK -->|bridge1| UNR["unraid .59
49 Docker stacks
Komodo Core + Periphery
FerretDB · Grafana · RustFS"] MIK -->|bridge1| DEV["dev .31
21 Docker stacks
Komodo Core"] MIK -->|bridge1| PVE["pve .11
Proxmox VE
VMs: CHR .30, win11 .32"]

IP Allocation Table

Range Purpose Examples
.1 Gateway mik-main
.10 Reverse proxy Caddy macvlan (*.home)
.11 Hypervisor pve (Proxmox)
.30-.32 VMs CHR, development, win11-ltsc
.50-.56 IoT (static DHCP) Printer, Cameras, Aqara, ESP, EcoFlow
.59 NAS unraid (kept — 60 compose refs)
.100-.199 DHCP dynamic Phones, laptops, Mac, M4
.200-.254 Reserved Future use

Monitoring Stack

graph TD
    MIK["mik-main RB5009"] -->|NetFlow v9 :12055| NF2NG["netflow2ng"]
    NF2NG -->|ZMQ| NTOPNG["ntopng :3004
DPI + custom Lua endpoint"] NTOPNG -->|5min scrape| SCRAPER["scrape_ntopng.py
(mikrotik-tools)"] SCRAPER -->|push logs| PUSHAPI["Push API :8093
(dp-chat-adapter)"] PUSHAPI -->|Kafka| RP["Redpanda :9092"] RP -->|consume| RW["RisingWave :4566
13 MVs + 6 sources"] RW -->|PostgreSQL wire| GRAFANA["Grafana :3002
3 dashboards, 29 panels"] RW -->|Iceberg sink| LK["Lakekeeper :8181
(Rust, STS enabled)"] MIK -->|API :8728| MKTXP["mktxp-exporter :49090"] MKTXP -->|scraped by otel-collector 45s| VM["VictoriaMetrics :8428"] VM -->|queried by| GRAFANA MIK -->|REST API :80| MTOOLS["mikrotik-tools
18 scheduler jobs"] MTOOLS -->|metrics| PUSHAPI OPA["OPA :8282
policy enforcement"] -->|allow/deny| TRINO["Trino :8091"] LK -->|Iceberg REST| TRINO TRINO -->|query| RW

Tailscale Overlay

dev      100.126.172.96   ← Komodo Periphery, Data Platform containers
unraid   100.68.251.84    ← Komodo Core + Periphery, FerretDB, NAS
pve      100.114.144.55   ← Proxmox VE
zimaos   100.73.218.119   ← Remote site (113.187.62.194)

P2P: static NAT UDP 41641 → unraid (direct, no DERP relay)

Key Services on unraid (192.168.100.59)

Service Port Stack
AdGuard Home 3003 (web), 53 (DNS) networking/adguard-home
OpenSearch 9200 database/opensearch
OpenSearch Dashboards 5601 database/opensearch
VictoriaMetrics 8428 monitoring/mimir-loki-tempo
Grafana 3002 monitoring/grafana
ntopng 3004 monitoring/ntopng
mktxp-exporter 49090 monitoring/otel-collector
otel-collector 4317, 4318 monitoring/otel-collector
Homepage 8088 applications/homepage
Caddy (*.home) .10 (macvlan) networking/caddy
RustFS (S3) 9000 storage/rustfs
Gitea 3005 applications/gitea

Public Inbound Surface (PS5 Remote Play)

PS5 at 192.168.100.100 is excluded from VPN routing (direct WAN) AND has 16 inbound port forwards for Remote Play from internet:

Ports (TCP+UDP) Target Purpose
9295-9302 .100 PSN Remote Play data
3478-3480 .100 PSN STUN/voice

These are the only inbound DSTNAT rules besides Tailscale (UDP 41641 → .59). Source restriction is in-interface=pppoe-out1 (any internet source). Document because they are a public attack surface that should be inventoried.

Domain-based VPN Exclusions

Some destinations bypass ProtonVPN routing via a mangle accept rule placed before vpn-zone-lan / vpn-zone-iot (the rules that mark traffic for VPN). Each exclusion has a corresponding address-list kept in sync by a scheduler job in mikrotik-tools.

Address-list Mangle rule Populated by Schedule Purpose
github-direct vpn-skip-github update_github_ips.py (GitHub meta API + DNS resolve) daily 3:20 AM Route git/API/CDN traffic to GitHub via WAN for lower latency

Pattern: script fetches authoritative CIDR blocks (e.g., https://api.github.com/meta blocks web/api/git/github_actions), resolves extra domains (Fastly CDN, GHCR, Copilot) to /24 subnets, and idempotently syncs to MikroTik address-list via REST API. The mangle rule references the list by name, so list updates take effect immediately — no firewall reload needed.

Mangle rule (live):

/ip firewall mangle add chain=prerouting action=accept \
    dst-address-list=github-direct comment="vpn-skip-github" \
    place-before=[find comment="vpn-zone-lan"]

Current exclusions summary (devices + subnets + domains):

Type Target Mechanism
Device PS5 .100 Not in zone-lan/zone-iot address-lists
Subnet Tailscale 100.64.0.0/10 vpn-route FIB exclusion → pppoe-out1
Subnet ProtonVPN VN 188.214.152.0/24 vpn-route FIB exclusion (prevents recursive routing)
Subnet ProtonVPN SG 149.50.211.0/24 vpn-route FIB exclusion (Gluetun VPN endpoint)
Domain set GitHub (github-direct list) Mangle accept before VPN mark rules

RouterOS REST API Notes

  • Base URL: http://192.168.100.1/rest/
  • Auth: Basic lucndm:minhluc1
  • Singleton PATCH requires /*ID or script workaround
  • Script pattern: create /system/script → run → delete
  • network_mode: host required for flow-collector (preserves NetFlow source IP)