Skip to content

Rotate the runtime's tokens and keys

View as Markdown

This guide replaces each token and key a self-hosted runtime uses, so an old one stops working.

  • A runtime running as Self-host Kindgi sets it up: the kindgi-server and kindgi-pack containers, on the kindgi network, with kindgi.env and pack.env.
  • Docker and OpenSSL, on the machine that runs the containers.
  • KINDGI_API_TOKEN exported in your shell as the runtime's API token, where the kindgi commands read it.
  • 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.

Each rotation ends with a restart, as Restart the runtime shows.

A new API token replaces the one your CLI, apps and console sign-ins use.

Make a new token:

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

It prints a token, elided here:

kgi_bt_67db…570b
Excerpt

Replace KINDGI_API_TOKEN in kindgi.env with it, and restart.

The Token line in the log now shows the new token's last four characters, and the old token is refused:

{"error":{"code":"auth-missing","message":"Bearer token is not recognized","requestId":"req-…"}}
Excerpt

KINDGI_API_TOKEN is one token, so switch your CLI and apps to the new one when you restart.

Console sessions signed in with the old token end at the restart, and people sign in again. The startup log counts them:

Signed out: 1 console session an earlier token opened

A request with such a session afterwards answers 401.

The runtime and your pack's service share this token. A new one replaces it on both sides.

Make a new one:

Terminal window
openssl rand -base64 32

It prints 32 random bytes in base64, elided here:

YT0L…qlM=
Excerpt

Put it in both files, as KINDGI_PACK_SERVICE_TOKEN in pack.env and in kindgi.env. Then restart the pack's service with the same image:

Terminal window
docker stop -t 30 kindgi-pack
docker rm kindgi-pack
docker run -d --name kindgi-pack --network kindgi --env-file pack.env \
registry.localhost:5050/acme-pack@sha256:<your pack's digest>

<your pack's digest> is the digest of the pack's image you run now.

Docker prints the container's name after stop and rm, and the new container's id after run:

kindgi-pack
kindgi-pack
9b39070edcb7a7b20a373c0842207f6fd51db51bc1783f5f0c37eff06e38cdb5

And restart the runtime. While the two tokens differ, the pack's service refuses the runtime's calls, and your tools fail. The runtime's log says so:

⚠ Pack service at http://kindgi-pack:8080 isn't answering (pack-service-unauthorized: The pack service rejected the pack token). The server is up; pack tools and checks fail until it answers.

Once both sides have the same token, the runtime's calls go through again, with no further restart.

A new license key replaces one that expires, or one of another kind.

Replace KINDGI_LICENSE_KEY in kindgi.env with the new key, and restart. The License line in the log shows the new key:

License: Docs example · non-production · until 2026-11-02

A deployment can also renew its key itself, as Renew the license key shows.

A new signing key replaces the one your pack's releases are signed with, and the old one is revoked.

Rotate under a new key id. A key id stays bound to its public key, and a revoked id can't be trusted again.

  1. Create a key:

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

    acme-selfhost-2 is the new key's id. --env selfhost also prints the lines to add to the selfhost block of kindgi.config.ts. It prints the key as JSON, then where it put it and the lines to add, with the home folder's path elided:

    {
    "keyId": "acme-selfhost-2",
    "privateKeyPath": "…/.kindgi/keys/acme-selfhost-2.pem",
    "publicKeyPath": "…/.kindgi/keys/acme-selfhost-2.pub.pem",
    "fingerprint": "sha256:37c5be5aed8a2688470bdacb",
    "home": "…",
    "env": "selfhost"
    }
    ✓ Ed25519 keypair created: acme-selfhost-2
    private key: …/.kindgi/keys/acme-selfhost-2.pem (mode 0600)
    public key: …/.kindgi/keys/acme-selfhost-2.pub.pem (mode 0644)
    fingerprint: sha256:37c5be5aed8a2688470bdacb
    Next step: add to environments.selfhost in kindgi.config.ts:
    selfhost: {
    // …existing fields…
    signingKey: '~/.kindgi/keys/acme-selfhost-2.pem',
    signerKeyId: 'acme-selfhost-2',
    },
    Then `kindgi build --env=selfhost` will pick up the key.
    Excerpt

    Then trust it on the runtime:

    Terminal window
    npx --yes @kindgi/cli@0.1 key trust acme-selfhost-2 --url http://localhost:4000

    --url is the runtime's URL. It prints the trusted key as JSON, then ✓ Trusted acme-selfhost-2 with the fingerprint key create printed:

    {
    "keyId": "acme-selfhost-2",
    "tenantId": "7e23b761-8fb2-47a0-925d-15fdf2e90fd7",
    "algorithm": "ed25519",
    "publicKey": "LzdQT9vYe71NqL4/bCozeEkTYqPIAhdZYiCC40xQnmA=",
    "createdAt": "2026-10-12T16:48:46.981Z"
    }
    ✓ Trusted acme-selfhost-2 (sha256:37c5be5aed8a2688470bdacb)
    The runtime now accepts deploys this key signs.
  2. Sign your next release with it. Add the two lines key create printed to the selfhost block of kindgi.config.ts:

    signingKey: '~/.kindgi/keys/acme-selfhost-2.pem',
    signerKeyId: 'acme-selfhost-2',
    Excerpt

    Then build and deploy as usual, and run the new image as your pack's service, as step 4 of Self-host Kindgi does. The first line keeps the old key's envelope, to check the revocation in step 3:

    Terminal window
    cp .kindgi/build/deploy-envelope.json old-envelope.json
    pnpm exec kindgi build --local --push --env selfhost
    pnpm exec kindgi deploy --env selfhost

    The deploy prints the deployment as JSON, then each step. Its registering step:

    …
    Registering deployment
    ✓ POST /v1/deployments → 201 Created
    deploymentId: 3c4f4d76-f4a4-4d5f-8339-f05b0497b462
    artifactVersion: 20261008.193855
    …
    Excerpt

    The artifact version is the build time by default, YYYYMMDD.HHMMSS in UTC, so a second build the same day gets its own tag.

  3. Revoke the old key:

    Terminal window
    npx --yes @kindgi/cli@0.1 key revoke acme-selfhost --reason "rotated to acme-selfhost-2" \
    --url http://localhost:4000

    acme-selfhost is the old key's id, and --reason is kept with the revocation. It prints the result as JSON, then two lines:

    {
    "keyId": "acme-selfhost",
    "revoked": true
    }
    ✓ Revoked acme-selfhost
    The runtime refuses new deploys it signs. Deployments it signed keep running, and the key stays listed for audit.

    A revoked id can't be trusted again: kindgi key trust says so, and how to trust another key.

From then on, the runtime refuses a deploy signed by the old key. Here, an envelope it signed earlier:

Terminal window
pnpm exec kindgi deploy --env selfhost --from-envelope old-envelope.json \
--idempotency-key redeploy-old

--from-envelope deploys a signed envelope as it is. --idempotency-key gives this deploy its own key. Without it, the CLI's key is a hash of the envelope, so sending an envelope you already deployed returns the first answer again, for 24 hours, and deploys nothing.

…
Registering deployment
✗ POST /v1/deployments → HTTP 403
code: signer-not-trusted
message: Signer key "acme-selfhost" is not on this tenant's trust list
…
Excerpt

The runtime checks signatures when you deploy. A deployment the revoked key signed keeps running, through restarts too. The revoked key stays listed for audit:

Terminal window
curl -s "http://localhost:4000/v1/signing-keys?includeRevoked=true" -H "authorization: Bearer $KINDGI_API_TOKEN"

It answers with the keys as JSON. Each key in the list has its revokedAt and revokedReason, if it was revoked:

{"data":[{"keyId":"acme-selfhost","tenantId":"7e23b761-8fb2-47a0-925d-15fdf2e90fd7","algorithm":"ed25519","publicKey":"Fgx7GGmlWq8CTnoSlW3pcHwuLNrozmX3RtAE6R7JWTo=","createdAt":"2026-10-12T16:48:41.774Z","revokedAt":"2026-10-12T16:48:48.236Z","revokedReason":"rotated to acme-selfhost-2"},{"keyId":"acme-selfhost-2","tenantId":"7e23b761-8fb2-47a0-925d-15fdf2e90fd7","algorithm":"ed25519","publicKey":"LzdQT9vYe71NqL4/bCozeEkTYqPIAhdZYiCC40xQnmA=","createdAt":"2026-10-12T16:48:46.981Z"}],"hasMore":false}

If browsers follow runs, the runtime signs their public run tokens with a key of its own. A new key replaces it.

Make one:

Terminal window
openssl genpkey -algorithm ed25519 -out public-token-signing.pem
chmod 600 public-token-signing.pem

Both print nothing. The key is in public-token-signing.pem, readable only by you.

Give it to the runtime as a file. Add this line to kindgi.env:

Terminal window
KINDGI_PUBLIC_TOKEN_SIGNING_KEY_PATH=/etc/kindgi/public-token-signing.pem

Then restart with the file mounted:

Terminal window
docker run -d --name kindgi-server --network kindgi --restart unless-stopped \
--add-host registry.localhost:host-gateway \
-v "$PWD/public-token-signing.pem:/etc/kindgi/public-token-signing.pem:ro" \
-p 127.0.0.1:4000:4000 --env-file kindgi.env \
quay.io/kindgi/runtime:0.1.8

-v mounts the key file, read-only, at the path kindgi.env names. The other flags are the restart's. It prints the new container's id:

bb9217f9a692c0a22b2a5bae10620194f0843fe2de8c01e5c478e2aec51464bb

The file must have mode 0600, and the runtime's user in the container, uid 10001, must be able to read it. The log says:

Public run tokens: on (browsers follow runs; no CORS origins)

To rotate it, replace the file with a new key, with the same two commands, and restart with the same mount. Tokens signed with the old key are refused from then on:

{"error":{"code":"auth-missing","message":"Bearer token is not recognized","requestId":"req-…"}}
Excerpt

Tokens minted after the restart work as before.