Skip to content

Install the hub

This page installs the hub in your management cluster with Helm. It ends with a hub that is running and reachable. Sign-in, clusters and access come on the next pages.

Full reference: docs/install.md in the repository.

Prerequisites

  • Kubernetes 1.27 or later in every cluster, with Flux installed in the workload clusters.
  • Helm 3.12 or later (Helm 4 works), and kubectl access to the management cluster.
  • PostgreSQL 14 or later for the hub (next section). It needs no extensions.
  • An internal ingress (nginx or an AWS ALB) and a TLS certificate for the UI, and a second internal hostname for agents. Both are covered below.
  • A DNS name for each, for example eddy.internal.example.com and eddy-agents.internal.example.com.

1. Namespace

The hub, its database Secret and its credentials all live in one namespace:

$ kubectl create namespace eddy

2. PostgreSQL

The hub keeps its own data in PostgreSQL: sessions, personal access tokens, threads, audit events and preferences, plus the state replicas share. Cluster state is never stored there. The hub applies its migrations when it starts, so an empty database is enough. Pick one option.

Option A: CloudNativePG

Install the CloudNativePG operator if the cluster does not have it yet:

$ kubectl apply --server-side -f \https://github.com/cloudnative-pg/cloudnative-pg/releases/download/v1.30.1/cnpg-1.30.1.yaml$ kubectl -n cnpg-system rollout status deploy/cnpg-controller-manager

Then create a Cluster in the hub's namespace. One instance is enough to start, and two give you a standby.

apiVersion: postgresql.cnpg.io/v1kind: Clustermetadata:name: eddy-dbnamespace: eddyspec:instances: 2imageName: ghcr.io/cloudnative-pg/postgresql:17storage:size: 5Gibootstrap:initdb:database: eddyowner: eddy

CloudNativePG creates a Secret named eddy-db-app. Its uri key is a complete connection string, so the hub values in step 4 point at it and you do not create a DSN Secret yourself.

Option B: RDS, Aurora, Cloud SQL or any PostgreSQL

  1. Create a database and a role that owns it:
    CREATE ROLE eddy LOGIN PASSWORD 'change-me';CREATE DATABASE eddy OWNER eddy;
  2. Put the connection string in a Secret in the hub's namespace. Use sslmode=verify-full outside the cluster:
    $ kubectl -n eddy create secret generic eddy-db \--from-literal=dsn='postgres://eddy:change-me@eddy.abc123.eu-central-1.rds.amazonaws.com:5432/eddy?sslmode=verify-full'
  3. In the values file you will write below, set the Secret name and key:
    store:postgres:dsnSecret: {name: eddy-db, key: dsn}

Each hub replica opens up to store.postgres.maxOpenConns connections (default 10) plus one to listen for events. Allow for that on the database side.

store.driver: memory needs no database, but it runs one replica and loses every session, token and thread on restart. It is for a quick evaluation only.

3. Secrets

One credentials Secret holds everything secret that the hub reads from its environment. The chart loads it with credentialsSecret, so no secret ever goes into Helm values.

  1. Create the credentials Secret, empty for now:
    $ kubectl -n eddy create secret generic eddy-credentials
    The later pages add keys to it as you need them. The key names are fixed by the config: GITHUB_CLIENT_SECRET for GitHub sign-in, OIDC_<ID>_CLIENT_SECRET for each OIDC provider, EDDY_PROXY_SECRET for the trusted proxy and ANTHROPIC_API_KEY for Ask AI.
  2. Add a key to eddy-credentials. Every page uses this command. It adds or replaces one key and keeps the others, and read -rs keeps the value out of your shell history. Change the key name:
    $ read -rs VALUE$ kubectl -n eddy patch secret eddy-credentials --type merge \-p "{\"stringData\":{\"GITHUB_CLIENT_SECRET\":\"$VALUE\"}}"$ unset VALUE
    The hub reads the Secret when it starts. If it is already running, restart it afterwards: kubectl -n eddy rollout restart deploy/eddy-hub.
  3. Session key (optional). The chart generates the signing key on first install and keeps it across upgrades. With helm template or Argo CD it cannot, so create the Secret yourself and name it in sessionKeySecret. The key is called key and must hold at least 32 random bytes:
    $ kubectl -n eddy create secret generic eddy-session-key \--from-literal=key="$(openssl rand -base64 48)"
  4. Local users (optional). Hash a password for each user as shown in Sign-in, local users, and paste the hash into users.list below.

4. Write hub-values.yaml

This is a complete minimal file: PostgreSQL from CloudNativePG, one local user, and both internal ingresses on nginx. Change the hostnames and the hash. Add real sign-in in Sign-in.

publicURL: https://eddy.internal.example.comagentsPublicURL: https://eddy-agents.internal.example.comcredentialsSecret: eddy-credentialsusers:list:- username: alicepasswordHash: "$argon2id$v=19$m=65536,t=3,p=4$...$..."groups: [platform]        # becomes the Kubernetes group eddy:platformstore:postgres:dsnSecret: {name: eddy-db-app, key: uri}   # use {name: eddy-db, key: dsn} for option B# onboarding:#   admins:#     groups: [eddy:platform]   # who may use the Add cluster wizard (see Add clusters)ingress:ui:enabled: trueclassName: nginx-internalhosts: [eddy.internal.example.com]annotations:nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"nginx.ingress.kubernetes.io/proxy-buffering: "off"tls:- secretName: eddy-tlshosts: [eddy.internal.example.com]agents:enabled: trueclassName: nginx-internalhosts: [eddy-agents.internal.example.com]annotations:nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"tls:- secretName: eddy-agents-tlshosts: [eddy-agents.internal.example.com]
  • publicURL must be exactly the address people type in the browser, including the scheme. It drives the Origin check and the cookie security, and a mismatch breaks sign-in.
  • agentsPublicURL is the address agents dial. The hub shows it in the Add cluster guide and in its install notes.
  • Leave credentialsSecret out if you use none of its keys.

5. Internal ingress

The hub has two listeners: container port 8080 for the UI, API and MCP, and 8443 for the agent endpoint. Two ClusterIP Services front them: eddy-hub on port 80, and eddy-hub-agents on ports 443 and 80, both to 8443. Expose them on separate hostnames. The UI uses server-sent events and agents hold long WebSockets, so raise the idle timeouts. The agent ingress routes only /agent/v1/connect and skips your sign-in proxy on purpose, because agents authenticate with their token.

nginx

The values above already use an internal ingress class. Add a source restriction on the UI if you like:

ingress:ui:annotations:nginx.ingress.kubernetes.io/whitelist-source-range: 10.0.0.0/8

AWS ALB (one internal ALB for both)

Two ingresses share one internal ALB through the same group.name. Both use the same annotations: the certificate has to cover both hostnames (or use a wildcard), and the health check path /healthz is served by the UI listener and by the agent listener, so both target groups pass.

ingress:ui:enabled: trueclassName: albhosts: [eddy.internal.example.com]annotations:alb.ingress.kubernetes.io/group.name: eddyalb.ingress.kubernetes.io/scheme: internalalb.ingress.kubernetes.io/target-type: ipalb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]'alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:eu-central-1:111122223333:certificate/xxxxalb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=3600alb.ingress.kubernetes.io/healthcheck-path: /healthzagents:enabled: trueclassName: albhosts: [eddy-agents.internal.example.com]annotations:alb.ingress.kubernetes.io/group.name: eddyalb.ingress.kubernetes.io/scheme: internalalb.ingress.kubernetes.io/target-type: ipalb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]'alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:eu-central-1:111122223333:certificate/xxxxalb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=3600alb.ingress.kubernetes.io/healthcheck-path: /healthz

To put both on one hostname instead, set ingress.agents.hosts to the UI host and add alb.ingress.kubernetes.io/group.order: "-1" to the agents annotations. The ALB evaluates rules in group.order, then by ingress name, and eddy-hub (the UI, /) sorts before eddy-hub-agents, so without the order the UI rule would catch /agent/v1/connect.

The network path, with the ports that matter:

  1. Browser to the ALB on 443, then to a hub pod on container port 8080 (UI, API, MCP).
  2. Agent to the ALB on 443 at wss://eddy-agents.internal.example.com/agent/v1/connect, then to a hub pod on container port 8443.
  3. Nothing connects into the workload clusters. The agent only dials out.
  • 8080 and 8443 are container ports only. Agents and browsers use 443. ingress.agents routes only /agent/v1/connect.
  • Raise the idle timeout to 3600 seconds. The ALB default of 60 seconds drops WebSockets and SSE streams (agents reconnect on their own, but the UI would flicker).
  • TLS ends at the ALB, so the hub logs one startup warning about serving plain HTTP behind a load balancer. That is expected.
  • The ALB's security group must allow 443 from your workload clusters' NAT or VPC ranges (over peering or Transit Gateway). Limit the UI to your VPN range with the same group, or put the UI on a second ALB if the two need different sources.

Agents in other VPCs or accounts reach the endpoint over VPC peering, Transit Gateway or PrivateLink. If you prefer an internal NLB with no ingress for agents, set ingress.agents.enabled: false and service.agents.type: LoadBalancer, annotate the eddy-hub-agents Service with service.agents.annotations, and let the hub terminate TLS itself with agentTLS.secretName (a kubernetes.io/tls Secret).

Until mTLS for agents arrives (planned for v1.1), the agent token is the main control on the agent endpoint. Keep that endpoint internal, or restrict it by source CIDR. /mcp on the UI host must not sit behind your sign-in proxy, because MCP clients send a personal access token.

6. Install

$ helm install eddy-hub oci://ghcr.io/idestis/charts/eddy-hub --version 1.0.1 \--namespace eddy -f hub-values.yaml

The chart is published under oci://ghcr.io/idestis/charts and the images are ghcr.io/idestis/eddy-hub and ghcr.io/idestis/eddy-agent. Helm installs the Cluster resource definition on first install but never upgrades it; see Operations.

7. Verify

  1. Wait for the rollout, then read the install notes the chart prints. They list the callback URLs to register, the agent endpoint, and the exact agent command for every cluster in clusters[]:
    $ kubectl -n eddy rollout status deploy/eddy-hub$ helm -n eddy get notes eddy-hub
  2. Check readiness on the metrics port, 9090. A hub pod is ready once the cluster registry has synced. Database or peer-link trouble shows as ok (degraded: …) but does not take the pod out of service:
    $ kubectl -n eddy get pods$ kubectl -n eddy port-forward deploy/eddy-hub 9090$ curl -s localhost:9090/readyz
  3. Read the log. It is JSON, one object per line:
    $ kubectl -n eddy logs deploy/eddy-hub
  4. Open https://eddy.internal.example.com. You should see the sign-in page. Sign in as the local user you defined. There are no clusters yet, and what people can see in each one is decided by the userRBAC values of that cluster's agent.

Sizing and high availability

  • The hub runs two replicas by default (replicaCount), active/active. Each serves the UI, API, MCP and agent endpoint. An agent connects to whichever replica the load balancer picks, and the others relay to it over a peer channel on port 8444 that only hub pods can reach. Do not run fewer than two in production.
  • A PodDisruptionBudget (maxUnavailable: 1) and a rolling update that never removes a replica before its replacement is ready keep the UI up during node drains and upgrades. Replicas also spread across zones and nodes by default.
  • Every replica must mount the same session key. The chart does that, and each replica logs a keyFingerprint at start. A replica that fails to authenticate its peers reports degraded in /readyz.
  • Default resources are 100m CPU and 256Mi memory requested, with a 512Mi memory limit. Raise them with resources if you watch many clusters.
  • Sessions, rate-limit counters and the agent session registry live in UNLOGGED tables. A database crash or a failover to a standby empties them: everyone signs in again and limits reset. Nothing else is lost, and agents re-register within about ten seconds.
  • With networkPolicy.enabled: true, set the from sources for the UI, agents and metrics ports, and allow egress to PostgreSQL. For CloudNativePG:
    networkPolicy:enabled: trueegress:postgresTo:- podSelector:matchLabels: {cnpg.io/cluster: eddy-db}

Troubleshooting

  • A pod stays not ready. The cluster registry has not synced: check the hub's RBAC on clusters.gitops.eddy.dev and its log.
  • /readyz says degraded. It names the missing peer link or the unreachable store. Check that the network policy allows port 8444 between hub pods and that every replica logs the same keyFingerprint.
  • Sign-in answers 503. The hub cannot reach PostgreSQL. Reads of cluster data keep working, but sign-in and writes fail closed until it is back. Check the DSN Secret and the network path to the database.
  • Sign-in loops or fails with an Origin error. publicURL must equal the address in the browser, including the scheme.
  • The hub refuses to start. The config is parsed strictly, so an unknown key stops it. Run kubectl -n eddy logs deploy/eddy-hub and look for invalid configuration.
  • Everyone was signed out. The store is memory, PostgreSQL crashed or failed over, or the session key Secret changed.

Next: set up sign-in, then add your clusters.