Skip to content

Self-host Kindgi

View as Markdown

This guide runs the Kindgi runtime on your own machine with Docker and Postgres, deploys a pack to it, and runs a flow end to end.

You run four containers on one Docker network:

  • the runtime: Kindgi's server, with its API, agents and flows;
  • Postgres;
  • your pack's service: your tools' code;
  • a registry the runtime reads your pack's image from.

Everything here runs on one machine with Docker Desktop. On a server the pieces are the same; step 2 says what changes.

  • Docker, openssl, uuidgen and curl, and for a TypeScript pack Node 22.12 or later and pnpm.

  • For kindgi commands: Kindgi 0.1's CLI, which runs from any folder as npx --yes @kindgi/cli@0.1 (Install). Inside a pack project, pnpm exec kindgi runs the project's pinned CLI instead. The build and deploy steps need the pack's folder, so they use pnpm exec kindgi.

  • Ollama, with llama3.1 pulled, for the flow in step 8.

  • A pack. This page uses the sample. --template=sample picks it, and acme-pack is its folder and its pack id:

    Terminal window
    npx --yes @kindgi/cli@0.1 init acme-pack --template=sample
    cd acme-pack
    pnpm install

    init lists the files it made, and pnpm install its packages.

  • A license key. Outside development mode, the runtime needs one: a free non-production key covers staging and CI, and a production key comes with a commercial license. To get one, write to contact@kindgi.com. Licensing explains the keys. kindgi dev needs none.

The runtime runs from Kindgi's image. Log in to its registry and pull it:

Terminal window
docker login quay.io
docker pull quay.io/kindgi/runtime:0.1.8

docker login prints Login Succeeded. It asks for the robot name and token only if kindgi auth registry hasn't logged Docker in already. docker pull prints the image's digest when it's done.

The runtime keeps its data in Postgres, and reads your pack's image from a registry. Make a network for the containers, start Postgres on it, and start the registry on your machine's port 5050:

Terminal window
docker network create kindgi
export DB_PASSWORD="$(openssl rand -hex 24)"
docker run -d --name kindgi-db --network kindgi \
-e POSTGRES_USER=kindgi -e POSTGRES_PASSWORD="$DB_PASSWORD" -e POSTGRES_DB=kindgi \
-v kindgi-db:/var/lib/postgresql/data \
pgvector/pgvector:pg16
docker run -d --name kindgi-registry -p 127.0.0.1:5050:5000 registry:2
  • docker network create kindgi makes the network the containers share. It prints the network's id.
  • DB_PASSWORD is a random password for Postgres, kept in your shell for step 5.
  • -d runs a container in the background, --name names it, and --network kindgi puts it on the network. Each docker run -d prints the new container's id.
  • -e sets the database's user, password and name. -v kindgi-db:/var/lib/postgresql/data keeps the data in a volume, kindgi-db.
  • -p 127.0.0.1:5050:5000 publishes the registry on your machine's port 5050, and only there.

The runtime verifies your pack's image by reading it from a registry. On one machine with Docker Desktop, the name registry.localhost reaches the local registry from two places:

  • Docker: it treats *.localhost as loopback, so it pushes there over plain HTTP;
  • the runtime's container: --add-host (step 5) maps the name to your machine.

On Linux or a server, use your own registry instead, and give the runtime its credentials: KINDGI_IMAGE_REGISTRY_HOST, KINDGI_IMAGE_REGISTRY_USERNAME and KINDGI_IMAGE_REGISTRY_PASSWORD.

Whichever registry you use, two things stop the runtime from reading it:

  • A loopback address, such as localhost, 127.0.0.1 or ::1: by default the runtime refuses to connect to its own host. KINDGI_TENANT_HOST_ACCESS decides that, as step 5 says. registry.localhost isn't one here: in the runtime's container, --add-host maps it to your machine's address on Docker's network.
  • A port Node's fetch refuses, such as 5060 or 5061: the Fetch standard's bad ports, which browsers refuse too. Serve the registry on another port.

If your app's code needs a generate step in the image (Prisma's client, for example), set that up first: What the pack's image needs.

In a Python pack, run each kindgi command on this page, pnpm exec kindgi and npx --yes @kindgi/cli@0.1 alike, as uv run kindgi. Lock its dependencies first (uv lock, or poetry lock): the image installs them from the lockfile.

Pick a tenant id. The runtime serves this tenant, and the pack's signature names it:

Terminal window
uuidgen | tr 'A-Z' 'a-z'

It prints the id in lower case. Keep it for the config and kindgi.env.

Create a signing key. acme-selfhost is the key's id, and --env selfhost also prints the lines to add to the selfhost environment:

Terminal window
npx --yes @kindgi/cli@0.1 key create acme-selfhost --env selfhost

It prints the key's JSON, then the paths of its private and public key files and its fingerprint. Rotate a signing key shows that output in full.

Add an environment for this deployment to kindgi.config.ts:

environments: {
selfhost: {
endpoint: 'http://localhost:4000',
registry: 'registry.localhost:5050',
tenantId: '<your tenant id>',
signingKey: '~/.kindgi/keys/acme-selfhost.pem',
signerKeyId: 'acme-selfhost',
},
},
Excerpt
  • endpoint is the runtime's URL, and registry the registry the image goes to.
  • tenantId is the id uuidgen printed, in place of <your tenant id>.
  • signingKey and signerKeyId are the key you created.

In a Python pack, the same keys go in pyproject.toml:

[tool.kindgi.environments.selfhost]
endpoint = "http://localhost:4000"
registry = "registry.localhost:5050"
tenantId = "<your tenant id>"
signingKey = "~/.kindgi/keys/acme-selfhost.pem"
signerKeyId = "acme-selfhost"
Excerpt

Build the image with your own Docker, push it, and sign it. --local builds with your Docker, --push pushes to the environment's registry, and --env selfhost picks the environment:

Terminal window
pnpm exec kindgi build --local --push --env selfhost
✓ Pushed registry.localhost:5050/acme-pack@sha256:faca44e8…
✓ /app/index.json in the image matches the local index byte for byte
✓ Ed25519 signature over (imageDigest, artifactVersion, indexHash, tenantId, publishedAt)
Deploy envelope written to …/acme-pack/.kindgi/build/deploy-envelope.json
Excerpt

A Python pack's build also says where its dependencies come from:

✓ 22 pack file(s) in the image (the pack root, minus caches, virtualenvs and secrets); dependencies from uv.lock

The build imports every tool, guardrail and flow module to index it, inside the image, so anything a module does when it's imported runs during the build too, without your app's files or services. Open files, databases and connections lazily, inside the handler. A module that opens a database file at import fails the build with unable to open database file.

The image is for linux/amd64 by default. On Apple silicon it runs under emulation. --platform picks another.

Your tools' code runs in the pack's own container. The runtime calls it with a token both sides share:

Terminal window
echo "KINDGI_PACK_SERVICE_TOKEN=$(openssl rand -base64 32)" > pack.env
chmod 600 pack.env
docker run -d --name kindgi-pack --network kindgi --env-file pack.env \
registry.localhost:5050/acme-pack@sha256:<the digest kindgi build printed>

--env-file pack.env gives the container the token, and <the digest kindgi build printed> is the digest from step 3's ✓ Pushed line. docker run -d prints the container's id. The container's log, docker logs kindgi-pack, says it's listening:

{"time":"2026-10-08T19:38:48.568Z","level":"info","severity":"INFO","subsystem":"pack","message":"Listening on port 8080","port":8080,"packId":"acme-pack","artifactVersion":"20261008.193828","event":"listening","kind":"listening"}

pack.env holds the token and the variables your pack declares. The service drops any other variable before your code loads. Its log names each one, as env-dropped, never its value. Declare the environment your code reads says more.

Make an API token. Your CLI and apps send it as their bearer:

Terminal window
printf 'kgi_bt_%s\n' "$(openssl rand -hex 32)"

It prints the new token.

Put the runtime's settings in kindgi.env:

Terminal window
KINDGI_DATABASE_URL=postgres://kindgi:<DB_PASSWORD>@kindgi-db:5432/kindgi
KINDGI_TENANT_ID=<your tenant id>
KINDGI_API_TOKEN=<the token>
KINDGI_ENV=production
KINDGI_PACK_SERVICE_URL=http://kindgi-pack:8080
KINDGI_PACK_SERVICE_TOKEN=<the same token as in pack.env>
KINDGI_IMAGE_REGISTRY_INSECURE_HOSTS=registry.localhost:5050
KINDGI_LICENSE_KEY=<your license key>
KINDGI_LOG_FORMAT=pretty
# To try the console on your machine: sign in with the API token.
# In a real deployment, set up single sign-on instead and remove this line.
KINDGI_CONSOLE_TOKEN_SIGN_IN=on
  • <DB_PASSWORD> is the password from step 2: echo "$DB_PASSWORD" prints it.
  • <your tenant id> is the id uuidgen printed.
  • <the token> is the API token printf printed.
  • <the same token as in pack.env> is pack.env's KINDGI_PACK_SERVICE_TOKEN.
  • <your license key> is the key you were sent.

It holds the API token and the license key, so keep it to yourself: chmod 600 kindgi.env.

Every setting is in the environment variable reference. These are worth knowing now:

  • KINDGI_DATABASE_URL connects to Postgres directly, or through a pooler in session mode. The runtime holds session locks on its connection while it migrates. A pooler in transaction mode, such as PgBouncer's pool_mode = transaction, doesn't keep them, as PgBouncer's pooling modes describes.
  • KINDGI_ENV names the environment your tools' secrets resolve in.
  • The port is 4000 unless something sets another. The runtime takes the first that's set: KINDGI_API_PORT, then the platform's PORT, then 4000. Cloud Run, Render, Heroku and Fly set PORT. KINDGI_API_PORT has the whole order.
  • KINDGI_CONSOLE_TOKEN_SIGN_IN=on lets you sign in to the console by pasting the API token from this file, so you can try the console straight away. It's off by default outside kindgi dev, and with it off and no single sign-on, nobody can sign in to the console. In a real deployment, turn on sign-in with your organization's identity provider instead, and remove the line. The token works for the API, the CLI and the SDKs either way.
  • KINDGI_LOG_FORMAT=pretty makes docker logs readable by eye. Without it, a container logs JSON, one record per line, for a log platform to index, as Logs describes.
  • KINDGI_PACK_SERVICE_URL is the pack service's address only. A user and password in it stop the runtime at boot (exit code 2): KINDGI_PACK_SERVICE_URL must not carry a user or password ("https://svc:***@pack.example.com"): the server authenticates to the pack service with KINDGI_PACK_SERVICE_TOKEN. Remove the "user:password@" part.
  • KINDGI_TENANT_HOST_ACCESS isn't set here, so it's deployed, the default outside development. It refuses a stdio MCP endpoint, which would run a command on the runtime's host. Run MCP servers over HTTP instead. It also refuses connections to the runtime's own host, at a loopback address, and to the cloud metadata endpoints, wherever your configuration names a host: an image registry, a model provider, an HTTP tool, an MCP server. local allows all of it, so a registry at localhost needs it. Set that only on a machine where everyone with an API token may run commands.

Start the runtime:

Terminal window
docker run -d --name kindgi-server --network kindgi --restart unless-stopped \
--add-host registry.localhost:host-gateway \
-p 127.0.0.1:4000:4000 --env-file kindgi.env \
quay.io/kindgi/runtime:0.1.8
  • --restart unless-stopped brings the runtime back by itself after a crash, or after its database was unreachable for a while.
  • --add-host registry.localhost:host-gateway maps the registry's name to your machine.
  • -p 127.0.0.1:4000:4000 publishes the API on your machine's port 4000, and only there. --env-file kindgi.env gives it its settings.

docker run -d prints the container's id.

Ask the runtime whether it's ready. -s hides curl's progress:

Terminal window
curl -s http://localhost:4000/ready
{"ok":true,"database":"ok","erasures":"unreplayable"}

/ready answers once the runtime is up and its database answers. /health checks only the process, as Check health and logs explains. Its erasures says whether an erasure can be replayed after a backup restore. It's unreplayable until you set KINDGI_ERASURE_LEDGER_KEY, as Erasures and backups shows.

Open http://localhost:4000/ in Chrome or Firefox: it leads to the console, at /console/, where you sign in with the API token from kindgi.env. The sign-in is a Secure cookie, which Safari keeps only over https, even on localhost. A runtime started without the console answers there with a short page naming what it serves: /health, /ready, and the API reference at /docs.

Its log names what it's running with:

Terminal window
docker logs kindgi-server
Kindgi API server listening on http://localhost:4000 (local to this host or container: set KINDGI_PUBLIC_URL to the address clients use)
Tenant: …
Token: kgi_bt_…3236 (provided)
User: … (KINDGI_API_TOKEN's user from now on; KINDGI_SEED_USER_ID names one)
…
Console: http://localhost:4000/console/
Console sign-in: an API token (KINDGI_CONSOLE_TOKEN_SIGN_IN)
Deployments: on (signed images, /v1/deployments)
…
License: …
…
Env: production (tool secrets resolve in it)
Tenant host access: deployed (stdio MCP endpoints refused; tenant-chosen hosts can't reach the metadata server or this host; KINDGI_TENANT_HOST_ACCESS)
Pack service: http://…:8080 — acme-pack (artifact …), protocol 2, 3 tools, 1 check
Excerpt

Without KINDGI_LICENSE_KEY, the runtime doesn't start. It exits with code 2 and says:

KINDGI_LICENSE_KEY is not set. Outside development mode the Kindgi runtime needs a license key: a production key comes with a commercial license, and a free non-production key covers staging and CI. To get one: contact@kindgi.com. Local development needs none: `kindgi dev` runs the runtime with KINDGI_DEV=true.

A key within 30 days of expiry adds a warning under the license line:

License: Docs example · non-production · until 2026-11-02
⚠ The license key expires in 29 days (2026-11-02). Renew it: contact@kindgi.com.

Behind a proxy or a load balancer, or on a published port other than the one it binds, set KINDGI_PUBLIC_URL in kindgi.env to the address clients use. The first line then names both. The banner's links use the public address: Docs, and Console when it serves the console:

Kindgi API server listening on http://localhost:4000 (reached at https://kindgi.example.com)
…
Docs: https://kindgi.example.com/docs
…
Excerpt

The runtime limits how often one client can ask for some things, such as a page that starts sign-in. It also records the client's address in its sign-in records. For both, it needs to know which address is the client's.

Unset, it uses the address of whatever connects to it. It ignores X-Forwarded-For, because any client can send that header with any address in it. That's right when clients connect directly. Behind a proxy, every request comes from the proxy, so every client shares one limit, and the runtime warns once:

Requests arrive through a proxy (X-Forwarded-For), but KINDGI_TRUSTED_PROXIES is unset: every client counts as the proxy, so rate limits are shared by everyone. Set it to the number of proxies in front (e.g. 1) or their IP ranges.

Tell it which proxies to trust with KINDGI_TRUSTED_PROXIES in kindgi.env, in one of two forms:

  • How many proxies are in front: 1 behind one load balancer or ingress, 2 behind two, such as Azure Front Door in front of a Container Apps ingress.

    Terminal window
    KINDGI_TRUSTED_PROXIES=1
  • Your proxies' addresses: IPs or CIDR ranges, comma-separated.

    Terminal window
    KINDGI_TRUSTED_PROXIES=10.0.0.0/8, 192.168.0.0/16

Each proxy appends the address it got the request from to the right of X-Forwarded-For. So the runtime reads that header from the right, skips the proxies you trust, and takes the next address as the client's. It never takes the leftmost address on its own: that's the one a client can write.

Count only the proxies that are really in front. One too many, and a client can choose its own address. One too few, and every client counts as your outermost proxy.

The start log says which it uses, with one of these lines:

Client address: the connection's peer (KINDGI_TRUSTED_PROXIES unset; behind a proxy, set it)
Client address: X-Forwarded-For behind 1 trusted proxy hop (KINDGI_TRUSTED_PROXIES)
Client address: X-Forwarded-For behind trusted proxies 10.0.0.0/8, 192.168.0.0/16 (KINDGI_TRUSTED_PROXIES)

Caching: every /v1 answer, data and errors alike, says Cache-Control: no-store, so neither a proxy nor a browser keeps one to show it again, or to someone else. Outside /v1, on the console's files and the docs, the runtime doesn't add no-store.

Point the load balancer's health check at /ready, as Check health and logs explains.

The runtime deploys only images signed by a key its tenant trusts. Trust yours, with the API token exported as KINDGI_API_TOKEN. --url is the runtime:

Terminal window
export KINDGI_API_TOKEN=<the token from kindgi.env>
npx --yes @kindgi/cli@0.1 key trust acme-selfhost --url http://localhost:4000

It prints the trusted key as JSON, then confirms it:

…
✓ Trusted acme-selfhost (sha256:7bfbd97be075d796eb24f80d)
…
Excerpt

The fingerprint is the one kindgi key create printed. The runtime now accepts deploys this key signs.

Then deploy. --env selfhost picks the environment:

Terminal window
pnpm exec kindgi deploy --env selfhost
"status": 201,
"outcome": "created",
…
"primitives": { "tools": 3, "guardrails": 1, "agents": 1, "flows": 1 },
Excerpt

To deploy a client's pack into its own project, add --project <id or slug> (Deploy a client's pack into its project).

The runtime checked the signature, read the pack's index from the image, and registered its tools, agents and flows.

The sample's agent needs a model that can call tools. Any OpenAI-compatible endpoint whose model supports tool calling works. This example uses Ollama on the same machine, which needs no key. Pull its model first, with ollama pull llama3.1. The runtime reaches it at host.docker.internal. Docker Desktop provides that name. On Linux, add --add-host host.docker.internal:host-gateway to the runtime's docker run. Save the provider as ollama.json:

{
"adapter_id": "@kindgi/adapter-model-openai-compat",
"adapter_config": { "baseURL": "http://host.docker.internal:11434/v1" },
"metadata": {
"id": "ollama-local",
"region": "unspecified",
"models": [{ "name": "llama3.1", "contextWindow": 131072, "features": ["tool-use"],
"cost": { "promptUsdPer1kTokens": 0, "completionUsdPer1kTokens": 0 } }]
}
}

Register it, then start the sample's flow. --spec=@ollama.json reads the provider from the file, --flow is the flow, and --input its input:

Terminal window
npx --yes @kindgi/cli@0.1 providers register --spec=@ollama.json --url http://localhost:4000
npx --yes @kindgi/cli@0.1 runs start --flow=acme-pack.echo-flow --input='{"name":"Ada"}' --url http://localhost:4000

register prints the provider's id, as JSON. The run ends:

"status": "completed",
…
"output": {
"reply": "…",
"greeting": "Hello, Ada!"
}
Excerpt

The flow's tool step ran in your pack's container, and its agent step answered with the model. What the reply says depends on the model. A first call can outlast the agent's time budget while the model loads. If it does, run it again.

To remove the containers, the database's volume and the network:

Terminal window
docker rm -f kindgi-server kindgi-pack kindgi-registry kindgi-db
docker volume rm kindgi-db
docker network rm kindgi

Each command prints the names it removed. -f removes a container even while it runs.