Quickstart: bare metal
This guide brings up a KiCI orchestrator + agent as native services (systemd on Linux, launchd on macOS), connected to the public Platform relay for GitHub webhooks. The orchestrator and its on-demand agents run as native processes — no orchestrator or agent containers. Two backing services do run in containers: PostgreSQL (the database) and a small SeaweedFS object store (which powers kici run remote); running PostgreSQL as a container is the least-fiddly option, so this guide treats it as the default and keeps a native PostgreSQL install as the alternative. Allow ~10 minutes end-to-end. For the fully container-based path (orchestrator + agents in containers too) see the Docker / Podman quickstart. For the chooser overview, see 5-minute quickstart.
You’ll end up with:
- The orchestrator running as a native service (systemd on Linux, launchd on macOS), configured to spawn agent processes on demand via the bare-metal scaler.
- A PostgreSQL 18 database and a SeaweedFS object store backing the orchestrator — PostgreSQL as a container (recommended) or installed natively, SeaweedFS as a container.
- A GitHub push that triggers your first workflow run, with logs visible in the dashboard at
https://app.kici.dev.
Prerequisites
Section titled “Prerequisites”-
A Linux host (systemd — Debian 13 / Ubuntu 24.04 / equivalent) or macOS (launchd).
kici-admin orchestrator installauto-detects which; on Linux a user-level service is fine, with root not required for the basic flow. -
Node.js 24+ available on
PATH(node --version). mise ornvmworks; a system package works too. -
docker(orpodman) withdocker compose(orpodman compose) available — version 2.20+. The orchestrator and its agents run natively, but the backing PostgreSQL and the SeaweedFS object store (the latter powerskici run remote) run as containers. Option B in step 3 installs PostgreSQL 18 natively viaapt(Debian/Ubuntu shipped 18.0 in 2025; for older distros use the official PostgreSQL apt repository) but still runs the SeaweedFS container. -
A GitHub repository you can install a GitHub App on.
-
A GitHub App you’ve created for that repository. You need two things from it now:
- its App ID (shown on the App’s settings page), and
- a private key (
.pem) — click Generate a private key and save the download.
Leave the App’s Webhook URL field blank for now — you’ll generate that URL in step 9 (“Register your GitHub App as a webhook source”) and paste it back into GitHub then.
-
~10 minutes.
1. Sign up at app.kici.dev
Section titled “1. Sign up at app.kici.dev”Go to app.kici.dev and create an account. Self-service sign-up is limited during the beta; see the Docker quickstart for the beta-invite path.
After sign-up you’ll have a personal organisation.
2. Mint an orchestrator registration token
Section titled “2. Mint an orchestrator registration token”In the dashboard, open Settings → Orchestrators → New orchestrator, name it (e.g. home-server), and copy the kici_ok_… token. The token is shown only once — save it now. This authorises your orchestrator to connect to wss://api.kici.dev/ws.
3. Set up PostgreSQL and the orchestrator’s database
Section titled “3. Set up PostgreSQL and the orchestrator’s database”The orchestrator stores runs, sources, secrets, and its job queue in PostgreSQL 18. Pick one of the two options below — the orchestrator itself still runs as a native service either way; only how you run the database differs. Both options end with the database reachable at 127.0.0.1:5432 as role kici / database kici, using the loopback-only stub password kici-local that the step 5 env template already points at.
Option A — PostgreSQL in a container (recommended)
Section titled “Option A — PostgreSQL in a container (recommended)”Even on a bare-metal orchestrator host, the least-fiddly way to run the database is a single container: no apt repository juggling, no pg_hba.conf editing, and a clean teardown with one command. This option needs docker (or podman) with docker compose.
mkdir -p ~/.config/kici
# Download the backing-services compose (PostgreSQL + SeaweedFS) and the# static S3 identity SeaweedFS mounts. Both publish on loopback only.curl -o ~/.config/kici/docker-compose.postgres.yaml \ https://raw.githubusercontent.com/kici-dev/kici-public/main/examples/quickstart/bare-metal/docker-compose.postgres.yamlcurl -o ~/.config/kici/seaweedfs-s3.json \ https://raw.githubusercontent.com/kici-dev/kici-public/main/examples/quickstart/bare-metal/seaweedfs-s3.json
# --wait blocks until both healthchecks pass, so the verify below is reliable.# The DB password defaults to the loopback-only stub `kici-local` (export# POSTGRES_PASSWORD=… first if you want a different value); the env file in# step 5 already points at it.docker compose -f ~/.config/kici/docker-compose.postgres.yaml up -d --wait
# Verify connectivity using the container's own psql (no host psql needed).docker compose -f ~/.config/kici/docker-compose.postgres.yaml exec postgres \ psql -U kici -d kici -c 'SELECT 1;'The compose publishes PostgreSQL on 127.0.0.1:5432 and a SeaweedFS object store on 127.0.0.1:8333, so the orchestrator (a native process on the same host) connects to both exactly as it would native installs. SeaweedFS is what makes kici run remote work in step 8. To stop or remove these later: docker compose -f ~/.config/kici/docker-compose.postgres.yaml down (add -v to also wipe the data volumes).
Option B — Native PostgreSQL install
Section titled “Option B — Native PostgreSQL install”If you already manage PostgreSQL natively (or prefer to), install it from your package manager instead. Only the database is native in this option — kici run remote still needs the SeaweedFS object store, so you’ll bring up just that one container at the end.
sudo apt updatesudo apt install -y postgresql-18 postgresql-contrib
# Create the kici DB + role with the loopback-only stub password the step 5# env template already points at. (Override it here and in step 5 if you'd# rather use your own — this Postgres only listens on 127.0.0.1.)sudo -u postgres psql -c "CREATE USER kici WITH PASSWORD 'kici-local';"sudo -u postgres createdb -O kici kici
# Verify connectivity from your shell.PGPASSWORD=kici-local psql -h 127.0.0.1 -U kici -d kici -c 'SELECT 1;'PostgreSQL listens on 127.0.0.1:5432 by default. If you’d rather use a different port or a remote DB, adjust KICI_DATABASE_URL in step 5 accordingly.
Now bring up the SeaweedFS object store (the only container Option B needs). It reuses the same compose file as Option A, starting only the seaweedfs service:
mkdir -p ~/.config/kicicurl -o ~/.config/kici/docker-compose.postgres.yaml \ https://raw.githubusercontent.com/kici-dev/kici-public/main/examples/quickstart/bare-metal/docker-compose.postgres.yamlcurl -o ~/.config/kici/seaweedfs-s3.json \ https://raw.githubusercontent.com/kici-dev/kici-public/main/examples/quickstart/bare-metal/seaweedfs-s3.json
# Start only the seaweedfs service (the postgres service stays# defined-but-unstarted; its stub-password default means no env is needed).docker compose -f ~/.config/kici/docker-compose.postgres.yaml up -d --wait seaweedfsSeaweedFS is now published on 127.0.0.1:8333 — the same endpoint the env file in step 5 already points at.
4. Install the kici-admin CLI
Section titled “4. Install the kici-admin CLI”npm install -g kici-adminkici-admin --versionThis pulls in @kici-dev/orchestrator transitively and exposes the kici-admin binary on your PATH.
5. Prepare the orchestrator env file
Section titled “5. Prepare the orchestrator env file”Download the bare-metal env template and fill in the three placeholder values:
mkdir -p ~/.config/kicicurl -o ~/.config/kici/kici-orchestrator.env \ https://raw.githubusercontent.com/kici-dev/kici-public/main/examples/quickstart/bare-metal/.env.example
# Edit the file — the three values you need to fill in are:# KICI_PLATFORM_TOKEN ← from step 2 (kici_ok_…)# KICI_SECRET_KEY ← openssl rand -hex 32 (64 hex chars)# KICI_BOOTSTRAP_ADMIN_TOKEN ← openssl rand -hex 32# KICI_DATABASE_URL is already filled in with the loopback stub password# from step 3 — leave it unless you chose a custom password.${EDITOR:-vi} ~/.config/kici/kici-orchestrator.envThe template already carries the KICI_STORAGE_* block wired to the SeaweedFS container from step 3 (http://localhost:8333, bucket kici-cache) — nothing to fill in there. That’s what lets kici run remote work in step 8.
6. Install the orchestrator as a managed service
Section titled “6. Install the orchestrator as a managed service”kici-admin orchestrator install --env-file ~/.config/kici/kici-orchestrator.envkici-admin orchestrator start
# Tail the logs and confirm it boots cleanly.kici-admin orchestrator logs --followWithin ~5 seconds you should see:
[orchestrator] connected to platform api.kici.dev (registration <id>)[orchestrator] listening on :4000Ctrl-C to stop tailing; the service keeps running. Check it any time with kici-admin orchestrator status.
7. Configure the bare-metal scaler
Section titled “7. Configure the bare-metal scaler”The bare-metal scaler launches an agent process on the same host whenever a job arrives and tears it down when the job finishes. No long-running agent service to manage — agents come and go per-job.
Create a scaler config alongside the orchestrator env file:
cat > ~/.config/kici/scalers.yaml <<EOFversion: 1globalMaxAgents: 4
scalers: - name: bare-metal-linux type: bare-metal maxAgents: 4 labelSets: - labels: [linux, bare-metal] binaryPath: $(command -v kici-agent)EOF$(command -v kici-agent) is evaluated by your shell when the heredoc is written, so the absolute path to the kici-agent binary installed in step 4 lands in the file. Inspect the result with cat ~/.config/kici/scalers.yaml — binaryPath: should point at something like /usr/local/bin/kici-agent (or whichever prefix your npm install -g uses).
Point the orchestrator at the scaler config and restart it:
echo "KICI_SCALER_CONFIG_PATH=$HOME/.config/kici/scalers.yaml" >> ~/.config/kici/kici-orchestrator.envkici-admin orchestrator restartTail the orchestrator log to confirm the scaler loaded:
kici-admin orchestrator logs --followWithin a couple of seconds you should see:
[orchestrator] scaler bare-metal-linux loaded (type=bare-metal, maxAgents=4, labels=linux,bare-metal)The first agent process will spawn when you run your first workflow (step 8).
Optional — run both scaler types
Section titled “Optional — run both scaler types”If you want some jobs to run in fully-isolated containers and others to run as
native host processes, run both scaler backends from one config. Download the
dual-scaler example and point binaryPath at your installed agent:
curl -o ~/.config/kici/scalers.yaml \ https://raw.githubusercontent.com/kici-dev/kici-public/main/examples/quickstart/bare-metal/scalers.dual.yamlsed -i "s#binaryPath:.*#binaryPath: $(command -v kici-agent)#" ~/.config/kici/scalers.yamlkici-admin orchestrator restartglobalMaxAgents caps the combined concurrent agents across both scalers.
Jobs route by label: a job whose runsOn includes container lands on the
container scaler (which pulls quay.io/kici-dev/kici-agent), while a job whose
runsOn includes bare-metal lands on the host-process scaler. Two notes when
the orchestrator runs as an unprivileged (user-level) service:
- Set
networkIsolation: falseon the container scaler — the per-agent network firewall needs privileges a user-level process doesn’t have. - Spawned agent containers must be able to reach the orchestrator’s port on the
host. The
host.docker.internalalias (with theextraHostsline in the example) resolves on Docker Desktop and Podman 4+; on a plain host you can instead setorchestratorUrlto the host’s LAN address.
8. Run a workflow without pushing
Section titled “8. Run a workflow without pushing”The SeaweedFS container from step 3 lets you run a workflow against your orchestrator straight from your working tree — no GitHub App, no git push. The fastest way to confirm the pipeline works.
# A throwaway git repo to hold the workflow. `kici run remote` reads your# working tree on top of a commit, so the folder must be a git repo with at# least one commit.mkdir -p hello-kici/.kici/workflows hello-kici/.kici/tests && cd hello-kicigit init -q -b mainprintf 'node_modules/\n' > .gitignore
# The SDK lives in .kici/ — both the compiler (on your machine) and the agent# (the spawned host process) resolve `@kici-dev/sdk` from .kici/node_modules.cat > .kici/package.json <<'EOF'{ "name": "hello-kici-workflows", "private": true, "type": "module", "devDependencies": { "@kici-dev/sdk": "^0.1.14" }}EOF
cat > .kici/workflows/hello.ts <<'EOF'import { workflow, job, step, push } from '@kici-dev/sdk';
export default workflow('hello', { on: push({ branches: ['main'] }), jobs: [ job('greet', { runsOn: 'bare-metal', steps: [ step('say hi', async ({ $ }) => { await $`echo "Hello from KiCI 👋"`; }), ], }), ],});EOF
cat > .kici/tests/push.ts <<'EOF'import { fixture, push } from '@kici-dev/sdk';export const pushMain = fixture('push-main', { event: push({ branches: ['main'] }) });EOF
# Install the SDK and make the first commit (run remote needs a HEAD commit).( cd .kici && npm install )git add -A && git commit -q -m "hello kici"
# Log in to the Platform and select your organization.kici loginkici org use <your-org>
npx kici compilekici run remote push-mainYou should see a green push-main … success run in your terminal — the bare-metal scaler spawned a one-shot agent process on this host, which fetched your working tree from SeaweedFS and ran the step.
kici run remote uses two planes. The control plane (run initiation, status, logs, cancellation) flows from your machine through the Platform, which relays it over a WebSocket connection to your orchestrator. The data plane — your working-tree overlay — uploads directly from your machine to SeaweedFS via a presigned URL and never passes through the Platform. That direct upload is what the KICI_STORAGE_* block from step 5 (http://localhost:8333) enables.
With a single connected orchestrator the Platform selects it automatically. If your org later connects more than one, list them with kici orchestrators list and pin a default with kici orchestrators use <name> (or pass --orchestrator <name> per run).
The workflow uses runsOn: 'bare-metal' to match the scaler’s [linux, bare-metal] label set from step 7.
You’ve now run a workflow end-to-end on your own box. The rest of this guide connects real GitHub pushes so your team’s commits trigger runs automatically.
9. Register your GitHub App as a webhook source
Section titled “9. Register your GitHub App as a webhook source”<YOUR_APP_ID> and the .pem are the two values from the prerequisites; the Webhook URL the command prints below is what you paste into the App’s blank Webhook URL field in the next step.
# The bootstrap admin token is in the env file you wrote in step 5.ADMIN_TOKEN=$(grep '^KICI_BOOTSTRAP_ADMIN_TOKEN=' ~/.config/kici/kici-orchestrator.env | cut -d= -f2)
kici-admin --url http://127.0.0.1:4000 --token "$ADMIN_TOKEN" \ source add github \ --name my-org \ --app-id <YOUR_APP_ID> \ --private-key @./github-app-private-key.pem \ --webhook-secret <YOUR_WEBHOOK_SECRET>Same shape as step 6 of the Docker quickstart. The command prints the public webhook URL the Platform now accepts for this source.
10. Wire GitHub to the webhook URL
Section titled “10. Wire GitHub to the webhook URL”In your GitHub App’s settings:
- Webhook URL: the URL printed above.
- Webhook secret: the same
--webhook-secretyou passed in step 9. - Subscribe to events: at minimum
pushandpull_request.
Click Save changes in GitHub.
11. Push a commit
Section titled “11. Push a commit”In a repo that has the GitHub App installed, drop in the same .kici/ folder you built in step 8 (the .kici/package.json + .kici/workflows/hello.ts + .kici/tests/push.ts), then push it:
mkdir -p .kici/workflowscat > .kici/workflows/hello.ts <<'EOF'import { workflow, job, step, push } from '@kici-dev/sdk';
export default workflow('hello', { on: push({ branches: ['main'] }), jobs: [ job('greet', { runsOn: 'bare-metal', steps: [ step('say hi', async ({ $ }) => { await $`echo "Hello from KiCI 👋"`; }), ], }), ],});EOF
# If this repo doesn't already have a .kici/package.json with @kici-dev/sdk,# copy the one from step 8 (or run `npx kici init`) so the agent can resolve it.npx kici compilegit add .kici/ && git commit -m "ci: hello-world workflow" && git pushWatch the run in the dashboard’s Runs page. This time the trigger came from GitHub: the push hit the Platform, which relayed it to your orchestrator, whose bare-metal scaler spawned a one-shot agent process to clone the repo and run the step.
Service lifecycle
Section titled “Service lifecycle”Agents are spawned on demand by the orchestrator’s scaler — there is no agent systemd unit to manage. To pause job dispatch, stop the orchestrator. To inspect a running or completed agent, use the dashboard’s Runs page or kici-admin runs show <runId>.
# Statuskici-admin orchestrator status
# Stop / start / restartkici-admin orchestrator stopkici-admin orchestrator startkici-admin orchestrator restart
# Uninstall (removes the service; does not touch the env file, DB, or scalers.yaml).kici-admin orchestrator uninstallUpgrading
Section titled “Upgrading”When a new KiCI version ships:
npm install -g kici-admin@latestkici-admin orchestrator restartkici-admin orchestrator restart re-launches the orchestrator from the freshly-installed binary. Scaler-spawned agents pick up the new agent code on the next job — they respawn fresh from $(command -v kici-agent) every time, so a global kici-admin upgrade is all that’s needed. DB migrations run automatically on first start of the new version.
Where to next
Section titled “Where to next”- Switch to Docker / Podman — if you’d rather not maintain the systemd / Postgres install yourself, the Docker quickstart achieves the same end state with containers.
- Run more without pushing —
kici run remote(step 8) runs any fixture against this orchestrator from your local working tree, including uncommitted changes — backed by the SeaweedFS store you set up in step 3. See the testing guide for fixtures, secret contexts, and more. - Advanced service configuration — env vars beyond the basics, multi-instance setups, log rotation, run-as-root for Firecracker. See Service installation.
- Tune the scaler — add label sets for additional runtimes, enable warm pools to pre-spawn agents, set per-job CPU / memory limits, gate specialised hardware behind mandatory labels, or run multiple scaler types on the same host. See Auto-scaler overview, Common configuration, and the Bare-metal backend.
Troubleshooting
Section titled “Troubleshooting”Orchestrator service fails to start with KICI_DATABASE_URL not set. The env file at ~/.config/kici/kici-orchestrator.env is missing or unreadable by the systemd user manager. cat ~/.config/kici/kici-orchestrator.env from your shell — if it lists the keys, run systemctl --user daemon-reload && kici-admin orchestrator restart. If it doesn’t, you skipped step 5.
Auth failure to PostgreSQL on first start. By default both the database and KICI_DATABASE_URL (step 5) use the loopback stub kici-local, so this only happens if you chose a custom password. With Option A (container), the password is baked into the data volume at first boot — if you set a custom POSTGRES_PASSWORD after the volume was created, the old password still applies; docker compose -f ~/.config/kici/docker-compose.postgres.yaml down -v wipes the volume so a new one takes effect. With Option B (native), either the role password doesn’t match KICI_DATABASE_URL, or pg_hba.conf is configured to reject 127.0.0.1 connections — on Debian/Ubuntu the default pg_hba.conf accepts loopback with scram-sha-256; if you’ve edited it, restore host all all 127.0.0.1/32 scram-sha-256.
Orchestrator logs show auth.failed against api.kici.dev. The KICI_PLATFORM_TOKEN in the env file doesn’t match what you minted in step 2. Mint a fresh one and update the env file; then kici-admin orchestrator restart.
kici-admin source add returns 401 / 403. The --token you passed doesn’t match KICI_BOOTSTRAP_ADMIN_TOKEN in the orchestrator’s env file. Re-extract it with grep '^KICI_BOOTSTRAP_ADMIN_TOKEN=' ~/.config/kici/kici-orchestrator.env.
Push happens but no agent spawns. Tail kici-admin orchestrator logs --follow immediately after the push and look near the top for one of three failure modes:
scaler config parse error: the YAML in~/.config/kici/scalers.yamldidn’t validate. Re-read the file (cat ~/.config/kici/scalers.yaml) — most oftenbinaryPath:is empty because$(command -v kici-agent)evaluated to nothing at heredoc-write time, which means step 4’snpm install -g kici-admindidn’t putkici-agenton PATH. Re-runcommand -v kici-agentfrom your shell to confirm; if it’s empty, re-install withnpm install -g kici-admin@latestand regenerate the file.KICI_SCALER_CONFIG_PATH not set(or the orchestrator boots without loading any scaler): the env-file line you appended in step 7 didn’t take effect. The value MUST be an absolute path — systemd env files do NOT expand~or$HOME. Re-check withgrep '^KICI_SCALER_CONFIG_PATH=' ~/.config/kici/kici-orchestrator.env; it should look likeKICI_SCALER_CONFIG_PATH=/home/<you>/.config/kici/scalers.yaml. If it has~or$HOME, rewrite the line andkici-admin orchestrator restart.scaler spawn failed: ENOENT: the orchestrator loaded the config but thebinaryPath:doesn’t exist (someone moved or uninstalledkici-agentbetween heredoc and now). Re-resolve and rewrite the file.
Push happens but no run appears in the dashboard. Either GitHub didn’t deliver the webhook (check the App’s “Recent deliveries” tab in GitHub’s settings — look for 4xx responses), or the orchestrator received it but no workflow matched. Run kici test push against your workflow file to confirm a push to your branch would trigger something.