komizo
The komizo command: set up a server, add apps to it, and watch what they are
doing.
The komizo service is decommissioned. Board decision: komizo-be is gone,
and this CLI is the whole product. You manage your servers purely from here,
over SSH, with nothing to sign in to — which is how the CLI always worked
between service calls. The three commands that talked to the service now
refuse, plainly and without touching the network: komizo login, and
komizo enrol without --token (komizo init simply no longer files the box
anywhere). komizo enrol --token and komizo enrol --remove stay — the
exchange happens on the box, so they work against a service you run yourself;
there is no default --api any more, because a default that points at a dead
domain is a silent network call to nothing.
The supported deployment path is the app-scoped deploy-APP Compose operation.
The journaled rollout/gateway experiment published in v0.0.30 through v0.0.39
was abandoned and is superseded by v0.0.40 and later; those experimental
versions remain available only as historical artifacts.
One command, nothing to install first:
go run github.com/nicodes/komizo@latest init --host root@your-server
komizo carries the server agent inside itself, so that setting up a box needs
nothing from the box but sshd. Those agents are build artifacts and are not in
the module the Go proxy serves — so a go run build compiles one on demand,
from the same module at the same version it is itself. Nothing new is fetched
that was not already fetched to get here, and a warm module cache reaches the
network not at all.
Or take a release binary. It carries the agents already, so it needs no Go
toolchain. It connects to your server as root, so verify it before you run it:
gh release download v0.0.17 --repo nicodes/komizo \
-p 'komizo_Linux_x86_64.tar.gz' -p checksums.txt
sha256sum -c checksums.txt --ignore-missing
gh attestation verify komizo_Linux_x86_64.tar.gz --repo nicodes/komizo
tar xzf komizo_Linux_x86_64.tar.gz && sudo install komizo /usr/local/bin/
komizo init --host root@your-server
From a checkout, make build compiles the agents first.
Every operation is a command that takes the server as a flag; komizo on its
own prints the list. Watching a box — its apps, its charts, its logs — is
komizo list, komizo report and komizo logs.
What it is
komizo deploys to your own server from GitHub Actions. This repository is both
halves of the tool: the CLI that runs on your machine, and komizo-box, the
small agent it installs on the server.
- komizo-be — decommissioned; the
docs, and how the whole thing fitted together (historical)
- komizo-actions — the GitHub
Actions a deploying repository uses
What it does to a server
Three commands, each safe to re-run:
komizo init --host root@box # Docker, the shared network, the agent
komizo update --host root@box # re-run all of it, every app included
komizo proxy --host root@box # one Caddy, terminating TLS for every app
komizo add --host root@box ... # a deploy account and its two privileged commands
komizo add --scoped-env fields-postgres-v2 is a separate opt-in, and only for
--app fieldsofrevik. fields-postgres-v1 is withdrawn: a recorded v1 profile
or a ten-key generation is not reported ready and is not started. The profile
installs a root-only provision-scoped-env-fieldsofrevik (mode 0700, not in
doas) and a status-only scoped-env-status-fieldsofrevik. The deploy account
may run status. It may not run provision, and set-secret is not granted for
this profile. Provision takes --compose-file, a root-owned regular file that
is the postgres compose candidate. It does not read or replace the live
compose.yml. The candidate must map each service to its own env file, mount
pg_data on postgres, and name a volume that is absent or empty. A
placeholder or PocketBase compose is refused, and the PocketBase volume is left
in place. Provision reads four Clerk values from a root-owned mode-0600 file
(--clerk-file) or the terminal. The file must contain each fixed Clerk key
once. Terminal entry turns echo off for all four reads, including
CLERK_SECRET_KEY, and restores the previous terminal settings on success,
refusal, and signal. Neither path accepts the secret as an argument or prints
a value. The file is removed after a successful provision; a refused run leaves
it in place, and the operator deletes it without printing it. Provision
generates the four database passwords and WS_SECRET on the host, derives the
two postgres URLs, and writes CLERK_SECRET_KEY only to api.env. That key
must be a non-empty sk_live_ value within the short env-file charset and
length; sk_test_ and a value already set in the environment are refused. It
writes four mode-0600 files and switches secrets/current once. A second run
refuses. It does not restart containers or delete a PocketBase volume. Deploy
of this profile takes a fourth argument, the expected generation id, and checks
the generation under the app lock before changing config. That check, status,
and start read a root-only mode-0400 provenance marker written only by this
provision (profile=fields-postgres-v2, schema=11, and the generation id).
They do not open the env files. A recorded v1 profile, or a generation without
that marker, is not ready. Other apps still take one or three arguments and
reject a fourth.
komizo remove deletes the commands. KEEP_DATA=1 leaves the app directory,
including secrets/, and does not read those files. Clerk values that contain
space, ", #, $, ', backslash, or backtick are refused: Fields compose
still uses the short env_file form and does not set format: raw.
Provisioning is shell, piped down the connection that is already open, run
once and thrown away. It is the half that CHANGES a machine, and it runs as root
exactly as long as it takes.
The local web app
komizo ui, run ON the box as root, serves the local web app: a SolidJS +
Tailwind app (the RN-port screens from the archived reference remain the
design reference), embedded in the binary as a static export
(ui/ is the source, built with make ui; internal/ui/dist is the
committed export CI byte-verifies). It shows this server — status, problems, system facts and
usage, apps and their services, routes, proxy and network, and the events the
box was told (the daemon's command results). The only actions are start,
stop and restart for an app, enforced server-side in the CLI: the
allowlist is the boundary, not the page.
There is no sign-in. The listener is the boundary: it binds loopback by
default, and --bind widens it to the tailnet interface address, where the
network is the identity. It never listens on every interface by accident —
0.0.0.0 only arrives when typed, and it says so when it does.
The data is the box's own files, read with the same box package the daemon
reads them with. The daemon's unix socket is NOT used: every route on it
requires a read token signed by the decommissioned registry and an envelope
signed by a planted device key, and extending the daemon is out of bounds —
the files are the store, so the UI reads them directly. v1 defers two things,
for the same reason: backups visibility and run-backup. No box-local backup
state exists and there is no clean box-local trigger, so the screen shows the
absence honestly rather than inventing a shape nothing writes.
Reading is not. The inventory, the request counts and the cgroup reads come
from komizo-box — a 2.6MB Go binary that init installs and runs on a timer
as root, writing /run/komizo/report.json and nothing else.
That split is the whole design. Root writes a file; something with no privileges
at all reads it. Everything komizo grows next — a dashboard, a phone, alerts —
reads that same file, and none of it needs a way in. See
design/architecture.md.
The trade is real and worth stating: the old shell arrived fresh on every poll,
so a newer komizo read new things off an untouched box. An agent has to be
updated to learn anything new, and komizo report says when one is behind.
Rebuilding a box
A fresh machine is brought back in a fixed order, and every step is safe to
re-run:
komizo init --host root@box # Docker, network, agent
komizo proxy --host root@box # the shared Caddy
komizo add --host root@box --app NAME --config REF # once per app
komizo reconcile --host root@box --inventory expected-apps.json
The order matters: add needs the network and agent that init installs, and
a deploy needs the proxy route. reconcile is last and is the proof the
rebuild worked — after the reachability preflight every komizo command runs,
it fetches the box's report exactly once and compares every registered app and
route against the inventory, exiting nonzero on any missing, unexpected,
duplicate or mismatched entry. The box is only ever read: reconcile provisions
nothing, deploys nothing and rotates no key, so run it as often as you like,
as the operator (root) login. Locally, connecting can create or tighten
~/.ssh to 0700 (for the SSH control socket), and --accept-host-key against
a box never seen before appends its host key to ~/.ssh/known_hosts
(trust-on-first-use) — those are the only local files it can ever touch (SSH
is run with UpdateHostKeys=no, so the box cannot quietly add keys to
known_hosts either). The inventory must be a regular file — on unix a
symlink as the final component is refused, and a FIFO or device is rejected
on the opened descriptor; on Windows a link is followed. That refusal covers
the link itself, not the directory around it: someone who can write the
inventory's parent directory can rename a different file over the path
outright, and no open flag prevents that. Keep the inventory and its parent
directories owned and writable only by the operator, like every other file a
check's answer depends on. It holds
only app names, pinned config-image references and public hostnames (wildcards
like *.api.example.com included, matched exactly). The loader enforces the
boundary mechanically where it can: unknown fields are rejected (a member
named for a secret, token or key cannot ride along), repeated object members
are rejected, values must fit the app/config/route syntax, and literal PEM
(-----BEGIN) material is rejected. It cannot judge meaning — a token-shaped
string that fits the syntax is accepted — so keeping the values non-sensitive
stays the operator's job.
The deploy-key and known-hosts handoff
Two values per app have to reach its repository before CI can deploy, and a
rebuild changes exactly one of them:
KOMIZO_DEPLOY_KEY (secret) — the app's deploy key. komizo add
generates a fresh pair; on a rebuild where the old key is still in the repo
and still intended, komizo add --keep-key regenerates everything else and
leaves the account's authorized key alone. The private half is printed once,
held in memory and written nowhere unless --key PATH says so.
KOMIZO_KNOWN_HOSTS (variable) — the box's host keys against the names
this app's CI dials. A rebuilt box has NEW host keys, so this value always
changes on a rebuild. komizo report --host root@box --known-hosts prints
each app's value without touching the box — reading it costs no rotation.
Both go to the app's repository under Settings → Secrets and variables →
Actions. Values are never written down here, in the inventory, or in any log
komizo keeps.
Four things live on a server: komizo-box and its OpenRC service, the two
per-app scripts, and the shared proxy. komizo update renews all of them --
including the per-app scripts, which are regenerated from the record komizo
already holds for each app, so no deploy key is rotated, no setting is changed
and an app somebody deliberately stopped stays stopped.
Layout
main.go the CLI
cmd/komizo-box/ the agent, which runs on the server
internal/app/ the subcommands, and everything they do to a server
box/ the probes and the report -- shared by both binaries AND the service
internal/agent/ the compiled agents, embedded into the CLI
scripts/ the provisioning shell, embedded with go:embed
box/ is imported by three programs that are not upgraded together:
the agent on a server writes a report, a CLI on a laptop reads one, and the
komizo service receives them from every box it knows about — possibly months
apart, each on a different version. It is public rather than internal/ for
that third reader, which is a different module. The schema rule is in
report.go: add fields, never repurpose one.
scripts/ is the half that runs as root on somebody else's machine, so it
is tested by being executed against a fake box rather than by being read. See
internal/app/deploy_script_test.go.
Building
make # the agents, then the CLI
make check # what CI runs
make, not a bare go build: the agents are embedded into the CLI, and
//go:embed reads the filesystem at build time rather than invoking a compiler,
so they have to exist first. A CLI built without them works for everything
except installing one, and says so.
MIT licensed.