Self-host Kindgi
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.
Before you start
Section titled “Before you start”-
Docker,
openssl,uuidgenandcurl, and for a TypeScript pack Node 22.12 or later and pnpm. -
For
kindgicommands: Kindgi 0.1's CLI, which runs from any folder asnpx --yes @kindgi/cli@0.1(Install). Inside a pack project,pnpm exec kindgiruns the project's pinned CLI instead. The build and deploy steps need the pack's folder, so they usepnpm exec kindgi. -
Ollama, with
llama3.1pulled, for the flow in step 8. -
A pack. This page uses the sample.
--template=samplepicks it, andacme-packis its folder and its pack id:Terminal window npx --yes @kindgi/cli@0.1 init acme-pack --template=samplecd acme-packpnpm installinitlists the files it made, andpnpm installits 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 devneeds none.
1. Pull the runtime image
Section titled “1. Pull the runtime image”The runtime runs from Kindgi's image. Log in to its registry and pull it:
docker login quay.iodocker pull quay.io/kindgi/runtime:0.1.9docker 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.
2. Start Postgres and a registry
Section titled “2. Start Postgres and a registry”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:
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:2docker network create kindgimakes the network the containers share. It prints the network's id.DB_PASSWORDis a random password for Postgres, kept in your shell for step 5.-druns a container in the background,--namenames it, and--network kindgiputs it on the network. Eachdocker run -dprints the new container's id.-esets the database's user, password and name.-v kindgi-db:/var/lib/postgresql/datakeeps the data in a volume,kindgi-db.-p 127.0.0.1:5050:5000publishes 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
*.localhostas 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.1or::1: by default the runtime refuses to connect to its own host.KINDGI_TENANT_HOST_ACCESSdecides that, as step 5 says.registry.localhostisn't one here: in the runtime's container,--add-hostmaps 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.
3. Build, sign and push your pack
Section titled “3. Build, sign and push your pack”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:
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:
npx --yes @kindgi/cli@0.1 key create acme-selfhost --env selfhostIt 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', },},endpointis the runtime's URL, andregistrythe registry the image goes to.tenantIdis the iduuidgenprinted, in place of<your tenant id>.signingKeyandsignerKeyIdare 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"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:
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.jsonA 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.lockThe 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.
4. Run your pack's service
Section titled “4. Run your pack's service”Your tools' code runs in the pack's own container. The runtime calls it with a token both sides share:
echo "KINDGI_PACK_SERVICE_TOKEN=$(openssl rand -base64 32)" > pack.envchmod 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.
5. Configure and start the runtime
Section titled “5. Configure and start the runtime”Make an API token. Your CLI and apps send it as their bearer:
printf 'kgi_bt_%s\n' "$(openssl rand -hex 32)"It prints the new token.
Put the runtime's settings in kindgi.env:
KINDGI_DATABASE_URL=postgres://kindgi:<DB_PASSWORD>@kindgi-db:5432/kindgiKINDGI_TENANT_ID=<your tenant id>KINDGI_API_TOKEN=<the token>KINDGI_ENV=productionKINDGI_PACK_SERVICE_URL=http://kindgi-pack:8080KINDGI_PACK_SERVICE_TOKEN=<the same token as in pack.env>KINDGI_IMAGE_REGISTRY_INSECURE_HOSTS=registry.localhost:5050KINDGI_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 iduuidgenprinted.<the token>is the API tokenprintfprinted.<the same token as in pack.env>ispack.env'sKINDGI_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 but the four database ones below is in the environment variable reference. These are worth knowing now:
KINDGI_DATABASE_URLconnects 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'spool_mode = transaction, doesn't keep them, as PgBouncer's pooling modes describes.- The database timeouts (runtime 0.1.9 or later): the runtime sets three Postgres timeouts on its own connections, in milliseconds, and
0turns one off.KINDGI_DB_STATEMENT_TIMEOUT_MS,60000(60 seconds), is how long one statement may run before Postgres ends it (statement_timeout).KINDGI_DB_LOCK_TIMEOUT_MS,30000, is how long a statement may wait for a lock (lock_timeout); with both on, it must be shorter than the statement timeout.KINDGI_DB_IDLE_IN_TRANSACTION_TIMEOUT_MS,30000, is how long a transaction may sit idle between statements before Postgres ends its session (idle_in_transaction_session_timeout). A value that isn't a whole number up to 2147483647 stops the boot, naming the variable. A GET or HEAD a statement or lock timeout ends answers503 database-busywithRetry-After: 5(under/auth/, a GET is sent back to the sign-in page instead and a HEAD answers500), any other request500, and the log has adbwarning naming the variable. The migrations at boot and the runtime's admin commands (kindgi-runtime tenants …,check …anderasures …) run with no statement timeout. A migration waits 5 seconds for a lock, 6 tries in all; then the boot fails, naming the table and the sessions holding it. KINDGI_DB_POOL_WAIT_TIMEOUT_MS(runtime 0.1.9 or later),10000(10 seconds), is how long a query waits for one of the server's pooled connections while every one is taken;0lets it wait as long as it takes. Past it the query fails before it runs: a GET or HEAD answers503 database-busywithRetry-After: 5(under/auth/, a GET is sent back to the sign-in page instead and a HEAD answers500), any other request500, and the log has adbwarning naming the variable.kindgi-runtime tenants …andcheck …wait as long as it takes.- Through PgBouncer: on runtime 0.1.9 or later, the runtime sends the three database timeouts as startup parameters. A PgBouncer on its default settings refuses them, so the runtime can't connect (
unsupported startup parameter: statement_timeout). On PgBouncer 1.26.0 or later, have it pass them on withtrack_extra_parameters = statement_timeout, lock_timeout, idle_in_transaction_session_timeout(PgBouncer's settings). Earlier versions mishandle a tracked parameter that Postgres doesn't report, as it doesn't these three, when a client sends it at startup (fixed in 1.26.0, PgBouncer's changelog).ignore_startup_parameterswith the same names lets the runtime connect, but PgBouncer drops them, and your role's, database's or server's settings hold instead. The runtime's admin commands (kindgi-runtime tenants …andkindgi-runtime check …) send theirs the same way, with no statement timeout, and don't read back what's in effect. So behind a PgBouncer that drops them, a long purge or check runs under those settings, and can be ended by them. - The runtime checks at boot: its startup line shows the timeouts Postgres has in effect, and it logs a warning for each one that differs from what it sent.
- The database timeouts (runtime 0.1.9 or later): the runtime sets three Postgres timeouts on its own connections, in milliseconds, and
KINDGI_ENVnames 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'sPORT, then 4000. Cloud Run, Render, Heroku and Fly setPORT.KINDGI_API_PORThas the whole order. KINDGI_CONSOLE_TOKEN_SIGN_IN=onlets 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 outsidekindgi 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=prettymakesdocker logsreadable by eye. Without it, a container logs JSON, one record per line, for a log platform to index, as Logs describes.KINDGI_PACK_SERVICE_URLis 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_ACCESSisn't set here, so it'sdeployed, the default outside development. It refuses astdioMCP 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.localallows all of it, so a registry atlocalhostneeds it. Set that only on a machine where everyone with an API token may run commands.
Start the runtime:
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.9--restart unless-stoppedbrings the runtime back by itself after a crash, or after its database was unreachable for a while.--add-host registry.localhost:host-gatewaymaps the registry's name to your machine.-p 127.0.0.1:4000:4000publishes the API on your machine's port 4000, and only there.--env-file kindgi.envgives it its settings.
docker run -d prints the container's id.
6. Check it
Section titled “6. Check it”Ask the runtime whether it's ready. -s hides curl's progress:
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:
docker logs kindgi-serverKindgi 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 checkWithout 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 …Behind a load balancer or ingress
Section titled “Behind a load balancer or ingress”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:
1behind one load balancer or ingress,2behind 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.
7. Trust your key and deploy
Section titled “7. Trust your key and deploy”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:
export KINDGI_API_TOKEN=<the token from kindgi.env>npx --yes @kindgi/cli@0.1 key trust acme-selfhost --url http://localhost:4000It prints the trusted key as JSON, then confirms it:
… ✓ Trusted acme-selfhost (sha256:7bfbd97be075d796eb24f80d)…The fingerprint is the one kindgi key create printed. The runtime now
accepts deploys this key signs.
Then deploy. --env selfhost picks the environment:
pnpm exec kindgi deploy --env selfhost "status": 201, "outcome": "created", … "primitives": { "tools": 3, "guardrails": 1, "agents": 1, "flows": 1 },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.
8. Add a model, and run a flow
Section titled “8. Add a model, and run a flow”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:
npx --yes @kindgi/cli@0.1 providers register --spec=@ollama.json --url http://localhost:4000npx --yes @kindgi/cli@0.1 runs start --flow=acme-pack.echo-flow --input='{"name":"Ada"}' --url http://localhost:4000register prints the provider's id, as JSON. The run ends:
"status": "completed", … "output": { "reply": "…", "greeting": "Hello, Ada!" }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.
Clean up
Section titled “Clean up”To remove the containers, the database's volume and the network:
docker rm -f kindgi-server kindgi-pack kindgi-registry kindgi-dbdocker volume rm kindgi-dbdocker network rm kindgiEach command prints the names it removed. -f removes a container even while
it runs.
- Operate a self-hosted runtime: health and logs, backups, upgrades, and rotating its tokens and keys.
- Deploy on Google Cloud Run: the same pieces as Cloud Run services, with Cloud SQL, Artifact Registry and Cloud KMS.
- Turn on sign-in, for the console in a real deployment.