From 59ada8585fa02438c8c51fa196eaed379a722c71 Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Wed, 20 May 2026 20:52:15 +0200 Subject: [PATCH] feat(atlas): deploy self-hosted honcho --- hosts/common/ports.nix | 1 + hosts/m3-atlas/secrets.nix | 8 + .../m3-atlas/services/containers/default.nix | 1 + hosts/m3-atlas/services/containers/honcho.nix | 209 ++++++++++++++++++ hosts/m3-atlas/services/postgres.nix | 3 +- secrets.nix | 3 + secrets/honcho-selfhost-db-password.age | 31 +++ secrets/honcho-selfhost-env.age | 30 +++ secrets/honcho-selfhost-jwt-secret.age | 31 +++ 9 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 hosts/m3-atlas/services/containers/honcho.nix create mode 100644 secrets/honcho-selfhost-db-password.age create mode 100644 secrets/honcho-selfhost-env.age create mode 100644 secrets/honcho-selfhost-jwt-secret.age diff --git a/hosts/common/ports.nix b/hosts/common/ports.nix index f33fb13..090673b 100644 --- a/hosts/common/ports.nix +++ b/hosts/common/ports.nix @@ -39,6 +39,7 @@ outline = 3019; authentik = 3023; tuwunel = 3024; + honcho = 3025; # Home automation homarr = 7575; diff --git a/hosts/m3-atlas/secrets.nix b/hosts/m3-atlas/secrets.nix index 35fc004..6de6e46 100644 --- a/hosts/m3-atlas/secrets.nix +++ b/hosts/m3-atlas/secrets.nix @@ -3,6 +3,14 @@ secrets = { baserow-env = {file = ../../secrets/baserow-env.age;}; ghost-env = {file = ../../secrets/ghost-env.age;}; + honcho-selfhost-db-password = { + file = ../../secrets/honcho-selfhost-db-password.age; + owner = "postgres"; + group = "postgres"; + mode = "400"; + }; + honcho-selfhost-env = {file = ../../secrets/honcho-selfhost-env.age;}; + honcho-selfhost-jwt-secret = {file = ../../secrets/honcho-selfhost-jwt-secret.age;}; kestra-config = { file = ../../secrets/kestra-config.age; mode = "644"; diff --git a/hosts/m3-atlas/services/containers/default.nix b/hosts/m3-atlas/services/containers/default.nix index 2579a67..7572900 100644 --- a/hosts/m3-atlas/services/containers/default.nix +++ b/hosts/m3-atlas/services/containers/default.nix @@ -2,6 +2,7 @@ imports = [ ./baserow.nix ./ghost.nix + ./honcho.nix ./kestra.nix ./littlelink.nix ./matomo.nix diff --git a/hosts/m3-atlas/services/containers/honcho.nix b/hosts/m3-atlas/services/containers/honcho.nix new file mode 100644 index 0000000..5a2d482 --- /dev/null +++ b/hosts/m3-atlas/services/containers/honcho.nix @@ -0,0 +1,209 @@ +{ + config, + lib, + pkgs, + ... +}: let + serviceName = "honcho"; + image = "ghcr.io/plastic-labs/honcho:v3.0.6"; + + apiIp = "10.89.0.24"; + deriverIp = "10.89.0.25"; + redisIp = "10.89.0.26"; + + postgresHost = "10.89.0.1"; + postgresPort = config.m3ta.ports.get "postgres"; + honchoPort = config.m3ta.ports.get "honcho"; + + # m3-atlas Netbird mesh address, discovered from `netbird status -d`. + # Binding the host port here keeps self-hosted Honcho off public interfaces. + netbirdBindAddress = "100.81.142.56"; + netbirdRange = "100.64.0.0/16"; + + dbName = "honcho"; + dbUser = "honcho"; + redisName = "${serviceName}-redis"; + runtimeDirectory = "/run/${serviceName}"; + runtimeEnvFile = "${runtimeDirectory}/env"; + + # Keep auth disabled for the first deployment because Honcho clients need + # generated JWTs. The JWT secret is still provisioned so enabling auth later is + # a one-line change here plus client token generation. + authUseAuth = false; + + sharedEnvironment = { + CACHE_ENABLED = "true"; + CACHE_URL = "redis://${redisName}:6379/0?suppress=true"; + LOG_LEVEL = "INFO"; + TELEMETRY_ENABLED = "false"; + VECTOR_STORE_MIGRATED = "false"; + VECTOR_STORE_TYPE = "pgvector"; + AUTH_USE_AUTH = lib.boolToString authUseAuth; + }; + + sharedEnvironmentFiles = [ + runtimeEnvFile + config.age.secrets."${serviceName}-selfhost-env".path + ]; + + webNetwork = ip: [ + "--add-host=postgres:${postgresHost}" + "--network=web:ip=${ip}" + ]; + + # The shared web network is intentionally internal. API and deriver also join + # this egress-only network so LLM provider calls can leave the host without + # exposing any extra inbound ports. + networksWithEgress = ip: + (webNetwork ip) + ++ [ + "--network=${serviceName}-egress" + ]; + + apiHealthCmd = ''/app/.venv/bin/python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=2).read()"''; +in { + system.activationScripts.createPodmanNetworkHonchoEgress = lib.mkAfter '' + if ! /run/current-system/sw/bin/podman network exists ${serviceName}-egress; then + /run/current-system/sw/bin/podman network create ${serviceName}-egress + fi + ''; + + virtualisation.oci-containers.containers = { + "${serviceName}-redis" = { + image = "docker.io/redis:8.2"; + autoStart = true; + volumes = ["${serviceName}_redis_data:/data"]; + extraOptions = + (webNetwork redisIp) + ++ [ + "--health-cmd=redis-cli ping" + "--health-interval=5s" + "--health-timeout=5s" + "--health-retries=5" + ]; + }; + + "${serviceName}-api" = { + inherit image; + autoStart = true; + entrypoint = "sh"; + cmd = ["docker/entrypoint.sh"]; + environment = sharedEnvironment; + environmentFiles = sharedEnvironmentFiles; + ports = ["${netbirdBindAddress}:${toString honchoPort}:8000"]; + dependsOn = [redisName]; + extraOptions = + (networksWithEgress apiIp) + ++ [ + "--health-cmd=${apiHealthCmd}" + "--health-interval=5s" + "--health-timeout=5s" + "--health-retries=5" + "--health-start-period=10s" + ]; + }; + + "${serviceName}-deriver" = { + inherit image; + autoStart = true; + entrypoint = "/app/.venv/bin/python"; + cmd = ["-m" "src.deriver"]; + environment = sharedEnvironment; + environmentFiles = sharedEnvironmentFiles; + dependsOn = ["${serviceName}-api" redisName]; + extraOptions = networksWithEgress deriverIp; + }; + }; + + systemd.services = { + "${serviceName}-postgres-bootstrap" = { + description = "Bootstrap Honcho PostgreSQL role, database, password, and pgvector"; + after = ["postgresql.service" "agenix.service"]; + requires = ["postgresql.service" "agenix.service"]; + before = ["${serviceName}-env.service" "podman-${serviceName}-api.service" "podman-${serviceName}-deriver.service"]; + requiredBy = ["podman-${serviceName}-api.service" "podman-${serviceName}-deriver.service"]; + path = [ + config.services.postgresql.package + pkgs.coreutils + ]; + serviceConfig = { + Type = "oneshot"; + User = "postgres"; + Group = "postgres"; + }; + script = '' + set -euo pipefail + test -s ${config.age.secrets."${serviceName}-selfhost-db-password".path} + + psql -v ON_ERROR_STOP=1 --dbname=postgres <<'SQL' + DO $$ + BEGIN + CREATE ROLE ${dbUser} LOGIN; + EXCEPTION WHEN duplicate_object THEN + NULL; + END + $$; + + SELECT 'CREATE DATABASE ${dbName} OWNER ${dbUser}' + WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${dbName}')\gexec + + ALTER DATABASE ${dbName} OWNER TO ${dbUser}; + \set honcho_password `cat ${config.age.secrets."${serviceName}-selfhost-db-password".path}` + ALTER ROLE ${dbUser} WITH LOGIN PASSWORD :'honcho_password'; + SQL + + psql -v ON_ERROR_STOP=1 --dbname=${dbName} <<'SQL' + CREATE EXTENSION IF NOT EXISTS vector; + GRANT ALL PRIVILEGES ON DATABASE ${dbName} TO ${dbUser}; + SQL + ''; + }; + + "${serviceName}-env" = { + description = "Generate Honcho runtime environment file with agenix secrets"; + after = ["agenix.service" "${serviceName}-postgres-bootstrap.service"]; + requires = ["agenix.service" "${serviceName}-postgres-bootstrap.service"]; + before = ["podman-${serviceName}-api.service" "podman-${serviceName}-deriver.service"]; + requiredBy = ["podman-${serviceName}-api.service" "podman-${serviceName}-deriver.service"]; + path = [ + pkgs.coreutils + pkgs.python3 + ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + set -euo pipefail + install -d -m 0750 ${runtimeDirectory} + + db_password_encoded=$( + python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.stdin.read().strip(), safe=""))' \ + < ${config.age.secrets."${serviceName}-selfhost-db-password".path} + ) + jwt_secret=$(tr -d '\r\n' < ${config.age.secrets."${serviceName}-selfhost-jwt-secret".path}) + + umask 077 + cat > ${runtimeEnvFile} <