# heather — Arion compose (nix-native docker-compose). # # Все web-apps в docker-контейнерах, описаны декларативно в nix. # Запускается как systemd-сервис через virtualisation.arion.projects.heather. # # Порты проброшены на localhost (127.0.0.1:PORT) — Caddy на хосте терминирует # TLS и роутит по Host (см. caddy.nix). Контейнеры НЕ выставлены наружу # напрямую (кроме forgejo git ssh на :2222). # # Data: named volumes везде, кроме silverbullet (bind mount на syncthing # sync'аемую папку /srv/syncthing/herbarium — исключение, см. комментарии). # # Backup: offen/docker-volume-backup как sidecar, nightly tar.gz всех named # volumes → /var/backups (добавим последним). # # Порядок миграции: forgejo → tuwunel+admin → cinny → teable → # silverbullet. Ниже forgejo как первый proof-of-concept, остальные добавим # по мере миграции (секции заготовлены, закомментированы до теста). { pkgs, ... }: let # Homepage (gethomepage) dashboard configs — baked via pkgs.writeText, # bind-mounted ro в контейнер. Полностью декларативно, версионировано в git. # Документация: https://gethomepage.dev/configs/ homepageServicesYaml = pkgs.writeText "homepage-services.yaml" '' - Infrastructure: - Forgejo: icon: forgejo.png href: https://git.iscg.dev description: Git forge (ssh :2222) - Syncthing: icon: syncthing.png href: https://sync.iscg.dev description: File sync GUI - tuwunel-admin: icon: matrix.png href: https://admin.iscg.dev description: Matrix admin room UI - Communication: - Tuwunel: icon: matrix.png href: https://iscg.dev description: Matrix homeserver (iscg.dev) - Cinny: icon: cinny.png href: https://chat.iscg.dev description: Matrix web client - Jitsi Meet: icon: jitsi-meet.png href: https://jitsi.iscg.dev description: Video conferencing - Productivity: - Silverbullet: icon: silverbullet.png href: https://note.iscg.dev description: Notes wiki (herbarium) - Teable: icon: /images/teable.png href: https://teable.iscg.dev description: No-code database ''; homepageSettingsYaml = pkgs.writeText "homepage-settings.yaml" '' title: iscg.dev theme: dark color: slate headerStyle: boxed layout: Infrastructure: Communication: Productivity: quicklaunch: searchDescriptions: true hideInternetSearch: true hideVersion: true # Static navigation only: no Docker API integration. # Background photo (mounted ro at /app/public/images/ from /var/lib/homepage-images). # cardBlur = frosted-glass / matte semi-transparent service & bookmark cards. # NB: cardBlur incompatible with background blur/saturate/brightness filters, # so only `opacity` is used here to soften the photo. background: image: /images/background.jpg opacity: 70 cardBlur: md ''; homepageBookmarksYaml = pkgs.writeText "homepage-bookmarks.yaml" '' - Forge: - nix-config: - abbr: Nx href: https://git.iscg.dev/mikl/nix-config ''; # Explicitly empty to avoid stale/default Docker integration configuration. homepageDockerYaml = pkgs.writeText "homepage-docker.yaml" "{}\n"; in { # Обязательное имя проекта (COMPOSE_PROJECT_NAME). Объекты префиксуются им: # heather_forgejo-data и т.д. project.name = "heather"; # --- Named volumes (декларируем явно, никаких анонимных хэшей) --- # docker volume create при первом up. Видно в `docker volume ls`. docker-compose.volumes = { forgejo-data = {}; tuwunel-data = {}; tuwunel-media-cache = {}; teable-data = {}; teable-db = {}; teable-redis = {}; teable-assets = {}; # jitsi-стек (minimal, per official docker-compose.yml) jitsi-web-config = {}; jitsi-prosody-config = {}; jitsi-prosody-data = {}; jitsi-jicofo-config = {}; jitsi-jvb-config = {}; jitsi-transcripts = {}; }; # Custom network for jitsi (FQDN-based comms, prosody alias xmpp.meet.jitsi) networks.meet-jitsi.driver = "bridge"; networks.matrix-rtc.driver = "bridge"; services = { # === Forgejo (git.iscg.dev) === # Первый сервис, proof-of-concept для цепочки arion→docker→Caddy. forgejo = { service.image = "codeberg.org/forgejo/forgejo:15.0.9"; service.container_name = "forgejo"; service.restart = "unless-stopped"; service.volumes = [ "forgejo-data:/data" ]; service.ports = [ "127.0.0.1:3030:3000" # web → Caddy git.iscg.dev "2222:22" # git ssh (публично, как на jul11) ]; service.environment = { # Домен для клонов: ssh://git@git.iscg.dev:2222/... FORGEJO__server__DOMAIN = "git.iscg.dev"; FORGEJO__server__ROOT_URL = "https://git.iscg.dev/"; FORGEJO__server__SSH_DOMAIN = "git.iscg.dev"; FORGEJO__server__SSH_PORT = "2222"; }; }; # === Matrix tuwunel (homeserver) + cinny (web client) === # iscg.dev → tuwunel:8008 (matrix client API + federation + well-known). # chat.iscg.dev → cinny:80. admin.iscg.dev → tuwunel-admin (systemd, не контейнер, # см. tuwunel-admin.nix — бинарник статический, публичного docker-образа нет). # Поднято с нуля 2026-07-13: пользователей/медиа со старого jul11 НЕ переносим. # Медиа — на Yandex Object Storage (S3-совместимый), bucket iscg-tuwunel-media. # Конфиг tuwunel.toml (с s3 secret + registration token) вне git: # /var/lib/tuwunel-secrets/tuwunel.toml (root:docker 0640, bind-mount ro) # Cinny config: /var/lib/tuwunel-secrets/cinny-config.json (bind-mount ro). tuwunel = { service.image = "ghcr.io/matrix-construct/tuwunel:v1.9.4"; service.container_name = "tuwunel"; service.restart = "unless-stopped"; service.volumes = [ "tuwunel-data:/var/lib/tuwunel" "tuwunel-media-cache:/var/cache/tuwunel" "/var/lib/tuwunel-secrets/tuwunel.toml:/etc/tuwunel/tuwunel.toml:ro" ]; service.ports = [ "127.0.0.1:8008:8008" ]; service.environment = { TUWUNEL_CONFIG = "/etc/tuwunel/tuwunel.toml"; }; }; # MatrixRTC: SFU and OpenID authorization. Secrets never enter the Nix store. matrix-livekit = { service.image = "livekit/livekit-server:v1.13.11"; service.container_name = "matrix-livekit"; out.service.mem_limit = "1g"; service.restart = "unless-stopped"; service.user = "65532:65532"; service.capabilities.ALL = false; out.service.security_opt = [ "no-new-privileges:true" ]; out.service.read_only = true; out.service.pids_limit = 256; service.networks = [ "matrix-rtc" ]; service.command = "--config /run/secrets/livekit.yaml"; service.volumes = [ "/var/lib/matrix-rtc-secrets/livekit.yaml:/run/secrets/livekit.yaml:ro" "/var/lib/matrix-rtc-secrets/turn-secret:/run/secrets/turn-secret:ro" ]; service.ports = [ "127.0.0.1:7880:7880" "7881:7881/tcp" "7882:7882/udp" ]; }; matrix-rtc-jwt = { service.image = "ghcr.io/element-hq/lk-jwt-service:0.9.0"; service.container_name = "matrix-rtc-jwt"; # Avoid public-IP hairpin NAT for same-host OpenID and SFU calls; TLS stays verified. out.service.extra_hosts = [ "iscg.dev:host-gateway" ]; out.service.mem_limit = "256m"; service.restart = "unless-stopped"; service.user = "65532:65532"; service.capabilities.ALL = false; out.service.security_opt = [ "no-new-privileges:true" ]; out.service.read_only = true; out.service.pids_limit = 256; service.networks = [ "matrix-rtc" ]; service.ports = [ "127.0.0.1:8081:8080" ]; service.volumes = [ "/var/lib/matrix-rtc-secrets/api-key:/run/secrets/api-key:ro" "/var/lib/matrix-rtc-secrets/api-secret:/run/secrets/api-secret:ro" ]; service.environment = { LIVEKIT_URL = "wss://iscg.dev/livekit/sfu"; LIVEKIT_KEY_FROM_FILE = "/run/secrets/api-key"; LIVEKIT_SECRET_FROM_FILE = "/run/secrets/api-secret"; LIVEKIT_FULL_ACCESS_HOMESERVERS = "iscg.dev"; LIVEKIT_JWT_BIND = ":8080"; }; }; cinny = { service.image = "ajbura/cinny:latest"; service.container_name = "cinny"; service.restart = "unless-stopped"; service.volumes = [ "/var/lib/tuwunel-secrets/cinny-config.json:/app/config.json:ro" ]; service.ports = [ "127.0.0.1:8080:80" ]; }; # === Teable (teable.iscg.dev) === # Стек: app + postgres + redis. Данные перенесены с iscg-dev (2026-07-12): # - postgres восстановлен из pg_dump (18M, consistent, без остановки источника) # - .assets (3.2G, 4392 файлов) в named volume heather_teable-assets # - teable-data (local-storage) пустой — наполняется при работе # - redis пустой — кэш, перестраивается # Секреты (POSTGRES_PASSWORD, SECRET_KEY) новые, в pass iscg.dev/teable/. # НЕ пушить env-файл в forgejo — secrets. teable = { # Пин по digest: стабильный latest на 2026-09-14. # Release: release.2026-09-11T07-36-49Z.3059 (Docker Hub amd64 digest). # Старый digest baddd95f был зафиксирован из-за сломанного роутинга в release 2198; # latest теперь снова проверен по /health и контейнерному старту. service.image = "teableio/teable@sha256:04c0c041c79564de7c06fc005d5560f9553161557a776caf1ed36c9c38ea8600"; service.container_name = "teable"; service.restart = "unless-stopped"; # Teable previously had no cgroup limit and helped trigger the host-wide # OOM on 2026-09-24. Keep its startup/runtime spike from taking down # SSH, Caddy, and the rest of the server. # Arion 0.2.2 does not expose mem_limit as a typed option; use its # raw output escape hatch so docker-compose gets the cgroup limit. out.service.mem_limit = "2g"; service.ports = [ "127.0.0.1:2345:3000" ]; service.volumes = [ "teable-data:/app/.local-storage" "teable-assets:/app/.assets" ]; service.depends_on = [ "teable-db" "teable-redis" ]; service.env_file = [ "/var/lib/teable-secrets/teable.env" ]; service.environment = { POSTGRES_HOST = "teable-db"; POSTGRES_PORT = "5432"; POSTGRES_DB = "teable"; POSTGRES_USER = "teable"; REDIS_URL = "redis://teable-redis:6379"; # Newer Teable releases validate the explicit cache configuration. BACKEND_CACHE_PROVIDER = "redis"; BACKEND_CACHE_REDIS_URI = "redis://teable-redis:6379"; PUBLIC_DOMAIN = "teable.iscg.dev"; PUBLIC_ORIGIN = "https://teable.iscg.dev"; }; }; teable-db = { service.image = "postgres:15-alpine"; service.container_name = "teable-db"; service.restart = "unless-stopped"; service.volumes = [ "teable-db:/var/lib/postgresql/data" ]; service.env_file = [ "/var/lib/teable-secrets/teable.env" ]; }; teable-redis = { service.image = "redis:alpine"; service.container_name = "teable-redis"; service.restart = "unless-stopped"; service.command = "redis-server --appendonly yes"; service.volumes = [ "teable-redis:/data" ]; }; # === Jitsi Meet (jitsi.iscg.dev) === # Installed from scratch 2026-07-13 per official docs: # https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker # Repo: https://github.com/jitsi/docker-jitsi-meet (release stable-11031) # Minimal stack: web/prosody/jicofo/jvb. No coturn/jigasi/jibri. # - Images: jitsi/* (docker hub; GHCR ghcr.io/jitsi/* not accessible) # - read_only + tmpfs from official compose NOT supported by arion schema (skipped) # - Named volumes (config + prosody data separate) # - Reverse proxy: DISABLE_HTTPS=1, Caddy terminates TLS # - JVB_ADVERTISE_IPS=51.250.45.111, :10000/udp public # - Auth: internal (ENABLE_AUTH=1, AUTH_TYPE=internal). No GUESTS # (template bug: guest domain lacks websocket module → disconnect). # - Secrets in /var/lib/jitsi-secrets/jitsi.env (root:docker 0640) jitsi-web = { service.image = "jitsi/web:stable-11031"; service.container_name = "jitsi-web"; service.restart = "unless-stopped"; service.ports = [ "127.0.0.1:8000:80" ]; service.volumes = [ "jitsi-web-config:/config" "jitsi-transcripts:/usr/share/jitsi-meet/transcripts" ]; service.depends_on = [ "jitsi-jvb" ]; service.networks = [ "meet-jitsi" ]; service.env_file = [ "/var/lib/jitsi-secrets/jitsi.env" ]; service.environment = { PUBLIC_URL = "https://jitsi.iscg.dev"; TZ = "Europe/Moscow"; HTTP_PORT = "8000"; DISABLE_HTTPS = "1"; ENABLE_HTTP_REDIRECT = "0"; ENABLE_HSTS = "0"; ENABLE_LETSENCRYPT = "0"; JVB_ADVERTISE_IPS = "51.250.45.111"; DOCKER_HOST_ADDRESS = "51.250.45.111"; ENABLE_AUTH = "1"; AUTH_TYPE = "internal"; ENABLE_XMPP_WEBSOCKET = "1"; ENABLE_COLIBRI_WEBSOCKET = "1"; # TURN (coturn) — relay for clients behind symmetric NAT TURN_HOST = "51.250.45.111"; TURN_PORT = "3478"; TURN_TRANSPORT = "udp"; # Guest access: unauthenticated users join via guest domain, wait in # lobby until a moderator (mikl) authenticates and admits them. ENABLE_GUESTS = "1"; }; }; jitsi-prosody = { service.image = "jitsi/prosody:stable-11031"; service.container_name = "jitsi-prosody"; service.restart = "unless-stopped"; service.expose = [ "5222" "5269" "5347" "5280" ]; service.volumes = [ "jitsi-prosody-config:/config" "jitsi-prosody-data:/var/lib/prosody" # Patched prosody template: guest VirtualHost gets "websocket" module # (upstream bug — guest domain lacks it → wss disconnect loop). # Patched file at /var/lib/jitsi-secrets/jitsi-meet.cfg.lua.template "/var/lib/jitsi-secrets/jitsi-meet.cfg.lua.template:/defaults/conf.d/jitsi-meet.cfg.lua:ro" ]; service.networks.meet-jitsi.aliases = [ "xmpp.meet.jitsi" ]; service.env_file = [ "/var/lib/jitsi-secrets/jitsi.env" ]; service.environment = { TZ = "Europe/Moscow"; PUBLIC_URL = "https://jitsi.iscg.dev"; ENABLE_AUTH = "1"; AUTH_TYPE = "internal"; ENABLE_XMPP_WEBSOCKET = "1"; ENABLE_IPV6 = "0"; ENABLE_GUESTS = "1"; }; }; jitsi-jicofo = { service.image = "jitsi/jicofo:stable-11031"; service.container_name = "jitsi-jicofo"; service.restart = "unless-stopped"; service.ports = [ "127.0.0.1:8888:8888" ]; service.volumes = [ "jitsi-jicofo-config:/config" ]; service.depends_on = [ "jitsi-prosody" ]; service.networks = [ "meet-jitsi" ]; service.env_file = [ "/var/lib/jitsi-secrets/jitsi.env" ]; service.environment = { TZ = "Europe/Moscow"; ENABLE_AUTH = "1"; AUTH_TYPE = "internal"; ENABLE_GUESTS = "1"; }; }; jitsi-jvb = { service.image = "jitsi/jvb:stable-11031"; service.container_name = "jitsi-jvb"; service.restart = "unless-stopped"; service.ports = [ "10000:10000/udp" ]; service.volumes = [ "jitsi-jvb-config:/config" ]; service.depends_on = [ "jitsi-prosody" ]; service.networks.meet-jitsi.aliases = [ "jvb" ]; service.env_file = [ "/var/lib/jitsi-secrets/jitsi.env" ]; service.environment = { TZ = "Europe/Moscow"; PUBLIC_URL = "https://jitsi.iscg.dev"; JVB_ADVERTISE_IPS = "51.250.45.111"; JVB_PORT = "10000"; DOCKER_HOST_ADDRESS = "51.250.45.111"; }; }; # === Coturn (TURN relay for jitsi) === # Host network: real client IPs + correct NAT traversal. No port conflicts # on heather (no system coturn). Relays RTP on 49152-65535/udp. # YC SG already has 3478/5349/49152-65535 open. Secret TURN_STATIC_AUTH_SECRET # in jitsi.env (shared with jitsi-web via TURN_CREDENTIALS). # Docs: https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker#turn-server-configuration jitsi-coturn = { service.image = "coturn/coturn:4.7"; service.container_name = "jitsi-coturn"; service.restart = "unless-stopped"; service.network_mode = "host"; service.env_file = [ "/var/lib/jitsi-secrets/jitsi.env" ]; service.command = "-v --log-file=stdout --realm=turn.iscg.dev --external-ip=51.250.45.111 --use-auth-secret --static-auth-secret=$${TURN_STATIC_AUTH_SECRET} --min-port=49152 --max-port=65535 --listening-port=3478 --no-tls --no-dtls --fingerprint --no-cli --simple-log"; }; # === Silverbullet (note.iscg.dev) — ИСКЛЮЧЕНИЕ: bind mount === # Space = /srv/syncthing/herbarium (sync'ается syncthing на устройства). # Единственный сервис без named volume — по твоему решению, чтобы notes # были доступны и через web UI, и через obsidian/vscode на poppy. # Конфликты syncthing (одновременная правка) решаются через .sync-conflict. # Данные перенесены с iscg-dev (2026-07-12), auth-файл .silverbullet.auth.json # в составе данных — silverbullet подхватит. silverbullet = { service.image = "zefhemel/silverbullet:latest"; service.container_name = "silverbullet"; service.restart = "unless-stopped"; service.ports = [ "127.0.0.1:3001:3000" ]; service.volumes = [ "/srv/syncthing/herbarium:/space" # bind mount, НЕ named volume ]; service.environment = { SB_USER = "mikl:milkingfemboys"; SB_INDEX_PAGE = "flowers"; }; }; # === Homepage (home.iscg.dev) — dashboard со всеми сервисами === # gethomepage/homepage: статичный список ссылок на все сервисы heather. # Конфиги (services/settings/bookmarks.yaml) генерятся через pkgs.writeText # в nix store и bind-mount ro. Без docker socket — без живых статусов, # просто навигация. Образ :latest (GHCR для gethomepage доступен, в отличие # от jitsi). BASE_URL = публичный домен. homepage = { service.image = "ghcr.io/gethomepage/homepage:latest"; service.container_name = "homepage"; service.restart = "unless-stopped"; service.ports = [ "127.0.0.1:3002:3000" ]; service.volumes = [ "${homepageServicesYaml}:/app/config/services.yaml:ro" "${homepageSettingsYaml}:/app/config/settings.yaml:ro" "${homepageBookmarksYaml}:/app/config/bookmarks.yaml:ro" "${homepageDockerYaml}:/app/config/docker.yaml:ro" # Background image (photo), outside git (binary). See settings.yaml `background`. "/var/lib/homepage-images:/app/public/images:ro" ]; service.environment = { HOMEPAGE_ALLOWED_HOSTS = "home.iscg.dev"; }; }; # === Backup sidecar (последним) === # offen/docker-volume-backup: nightly tar.gz всех named volumes. # Монтирует volumes read-only, шлёт в /var/backups (или S3). # TODO: добавить когда основные сервисы мигрированы. }; }