Quickstart

Run the Hub locally

One docker compose up, and you have a working Hub: control-plane UI, GraphQL API, and an embedded NATS broker for agents — backed by Postgres, QuestDB, and a local identity provider for auth.

Switch to Khorde Cloud to see the same steps against the hosted platform.

Before you start
Docker & Compose

Docker Engine with the Compose plugin (docker compose, not the standalone docker-compose).

Free ports

8085 (Hub), 4222 (agent connections), 9310 (IdP), 5432 and 8812 (databases).

This is the single-tenant, local-IdP setup for getting your hands on the Hub — not the air-gapped production build. It creates its own admin user the first time it starts.

01
Get it running

Pulls the compose file and brings up the Hub, its databases, and a local IdP in dependency order — the Hub's config is embedded in it, so there's nothing else to save.

shellshell
curl -fsSL https://khorde.io/quickstart/hub-compose.yaml | docker compose -f - up -d

Piping a remote script into your shell deserves a look first — view the file before you run it.

Prefer separate files, or want to edit the config by hand?

Same setup, split into a compose file and a plain config.yaml you can edit directly — closer to how you'd run this for real.

docker-compose.yaml

Save alongside config.yaml below, in a directory of its own.

docker-compose.yamlyaml
name: khorde-hub

services:
  hub:
    image: docker.io/khorde/hub:latest
    ports:
      - "8085:8085"
      - "4222:4222"
    volumes:
      - ./config.yaml:/opt/scada-service/config.yaml
      - zitadel-machinekey:/machinekey:ro
    extra_hosts:
      # So the Hub container can reach the IdP the same way your browser does.
      - "auth.localhost:host-gateway"
    depends_on:
      zitadel: { condition: service_healthy }
      postgres: { condition: service_healthy }
      questdb: { condition: service_started }
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8085/health"]
      interval: "1s"
      timeout: "30s"
      retries: 30
      start_period: "5s"

  questdb:
    image: "questdb/questdb:9.4.2"
    ports:
      - "8812:8812"

  zitadel:
    restart: "unless-stopped"
    image: "ghcr.io/zitadel/zitadel:v4.15.1"
    command: >-
      start-from-init
      --masterkey "MasterkeyNeedsToHave32Characters"
      --tlsMode disabled
      --config /config/zitadel.yaml
      --steps /config/zitadel.yaml
    ports:
      - "9310:9310"
    depends_on:
      postgres: { condition: service_healthy }
    volumes:
      - zitadel-machinekey:/machinekey/:rw
    configs:
      - source: zitadel
        target: /config/zitadel.yaml
    healthcheck:
      test: ["CMD", "/app/zitadel", "ready", "--config", "/config/zitadel.yaml"]
      interval: "5s"
      timeout: "5s"
      retries: 24
      start_period: "60s"

  postgres:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=control_plane
      - PGUSER=postgres
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready", "-d", "control_plane", "-U", "postgres"]
      interval: "5s"
      timeout: "30s"
      retries: 5
      start_period: "20s"

volumes:
  # tmpfs is the only local-driver volume that can be uid 1000 so the IdP can
  # write MachineKeyPath without a host bind
  zitadel-machinekey:
    driver: local
    driver_opts:
      type: tmpfs
      device: tmpfs
      o: uid=1000,gid=1000,mode=0700

configs:
  zitadel:
    content: |
      Port: 9310
      # Lets the Docker network and your browser reach the IdP through the same name.
      ExternalDomain: auth.localhost
      ExternalPort: 9310
      ExternalSecure: false
      TLS:
        Enabled: false

      Database:
        postgres:
          Host: postgres
          Port: 5432
          Database: zitadel
          User:
            Username: zitadel
            Password: zitadel
            SSL:
              Mode: disable
          Admin:
            Username: postgres
            Password: postgres
            SSL:
              Mode: disable

      Eventstore:
        MaxRetries: 10

      HTTPClient:
        DenyList: []

      DefaultInstance:
        Features:
          LoginV2:
            Required: false

      FirstInstance:
        MachineKeyPath: /machinekey/service-account.json
        Org:
          Machine:
            Machine:
              Username: zitadel-admin-sa
              Name: Admin
            MachineKey:
              Type: 1
config.yaml

service_account_file points at a Docker volume shared with the IdP container, not a file on your machine.

config.yamlyaml
bind_address: 0.0.0.0
port: 8085
# URL the IdP uses to reach the Hub.
# Required when the IdP cannot reach bind_address (e.g. running in Docker).
external_url: http://hub:8085
# Origin opened in a browser (OIDC redirect URIs). Defaults to bind_address:port.
public_url: http://localhost:8085
tsdb:
  ip: questdb
  port: 8812
  username: admin
  password: quest
db:
  addr: postgres
  port: 5432
  username: postgres
  password: postgres
zitadel:
  url: http://auth.localhost:9310
  service_account_file: /machinekey/service-account.json
Start it
shellshell
docker compose up -d
02
Confirm it's healthy

Once the Hub reports healthy, its GraphQL API and control-plane UI are both serving on :8085.

shellshell
curl -f http://localhost:8085/health
03
Open the control plane

Visit localhost:8085/ui and log in with admin@khorde.io / SecurePassword1! — the Hub's default admin account, created the first time it starts. Set your own via zitadel.bootstrap.admin in a real config before pointing this at anything reachable by anyone else.

Starting over

docker compose down -v tears everything down, including the IdP's state and the database data, for a clean first boot next time — but it needs the compose file present. If you used the one-liner, grab it first:

shellshell
curl -fsSL https://khorde.io/quickstart/hub-compose.yaml -o hub-compose.yaml
docker compose -f hub-compose.yaml down -v
Next

Connect a REST agent

Create an agent in the control plane, hand it a config, and watch its measurements and alarms flow live over GraphQL. Read the guide.

Explore the GraphQL API

Everything an HMI needs — measurements, alarms, commands — is served over GraphQL on the same :8085. Schema reference is on the Docs page.

Taking this past your laptop?

Talk to us