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.
Docker Engine with the Compose plugin (docker compose, not the standalone docker-compose).
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.
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.
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.
Save alongside config.yaml below, in a directory of its own.
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: 1service_account_file points at a Docker volume shared with the IdP container, not a file on your machine.
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
docker compose up -d
Once the Hub reports healthy, its GraphQL API and control-plane UI are both serving on :8085.
curl -f http://localhost:8085/health
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.
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:
curl -fsSL https://khorde.io/quickstart/hub-compose.yaml -o hub-compose.yaml docker compose -f hub-compose.yaml down -v
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.