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-serverkomizo 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-serverFrom 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.
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
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 commandskomizo 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.
Some apps need one privileged operation that is not a deploy — take a backup, run a drill, reset a volume. The deploy account gets no Docker membership and no shell grant, so it cannot do those itself; what it gets is permission to ask one root-owned program to do exactly one of them.
komizo installs the program; the app writes it.
komizo add --host root@box --app blog --config ghcr.io/you/blog-config \
--task-script ./deploy/tasks.shThe script is read from the operator's machine, kept on the box under
/var/lib/komizo/tasks/, and installed as /usr/local/bin/task-<app>,
root-owned 0755, granted through doas. --task-script= revokes and removes
both copies. Omitted, the stored script is reinstalled — an update must not
quietly drop it.
Not from the config image, even though that image already reaches the box. This program runs as root, and taking it from something CI pushes would turn "can deploy" into "can run anything as root here" — which is the one thing the deploy account is designed not to be able to do. It comes from a person who read it.
This used to be the other way round: 181 lines of one product's operations —
its compose project, its service and volume names, the path of a binary only
it ships — were compiled into alpine.sh, the script that sets up every
app. The app could not change any of it without a komizo release. By the time
anyone looked, every path in it was stale: the executable it named had been
deleted and all three volumes belonged to a database the product had migrated
off. A generic tool had grown a per-app special case, and the special case had
rotted where only the tool could reach it.
komizo add --host root@box --app gdam --config ghcr.io/you/gdam-config --preview--preview installs the two root-owned helpers a preview needs —
komizo-preview, which narrows the deploy account to four komizo-box preview subcommands, and write-preview-stackenv, which writes one
preview's stack.env 0600 root — and grants both through doas.
--preview=false revokes them. Like --task-script, an omitted flag keeps whatever
the box recorded, so komizo update does not switch previews off.
Both helpers scope themselves to the caller's own app: komizo names every
deploy account komizo-<app>, so komizo-gdam may preview gdam and
nothing else. That is why they are one shared pair of paths rather than one
per app, and why removing them waits until no app on the box previews.
They used to be installed by hand, with doas rules added by hand inside
komizo's managed block — so the next komizo update rewrote the block and
deleted them, and gdam's previews failed on doas: Operation not permitted
in a step that had worked minutes earlier. A feature that needs a privilege
is a feature komizo has to install.
set-secret writes a key into the app's secrets.env, and refuses a value
containing a newline — an env file cannot represent one. Everything that does
not fit that shape used to be put on the box by hand over SSH: an OpenAI key
that has to be mounted rather than exported, an age backup identity, a PEM,
a postgres owner password the database container reads before the app exists.
One portfolio had ten such files across five apps — delivered by nothing,
rotated by nothing, invisible to every komizo command.
--file writes them into the app's secrets/ directory, which is where those
files already live and what the compose files already mount, so adopting it
changes the writer and not the layout:
komizo set-secret --host root@box --app blog --name CLERK_SECRET_KEY
komizo set-secret --host root@box --app blog --name recipient.pem --file --from ./recipient.pem
komizo set-secret --host root@box --app blog --name openai_api_key --file --from ./key --uid 65534The value comes from --from or from stdin, never from an argument: arguments
are visible in the host's process list to every other user on the box. It is
never echoed either, here or on the box.
--uid exists because a mounted secret is read by the container's user, and a
mode-0600 root file is unreadable to a container running as nobody.
This is an operator command over the root connection. It does not widen what
CI can do: it invokes the same set-secret-<app> binary the deploy account
already has, so one piece of code decides where a secret lands and with what
mode. File secrets are listed by komizo unset-secret --list as file:<name>
and removed under that name.
set-secret writes a key and never deletes one. That is right for the write
side — it is what lets CI rotate a credential without being able to read the
ones already there — but it meant nothing could take a value off a box. A name
dropped from a workflow stayed in secrets.env indefinitely: delivered by
nothing, rotated by nothing, still read by whichever containers read the file.
One portfolio accumulated twelve that way, including a PocketBase admin
password for a PocketBase removed months earlier.
komizo unset-secret --host root@box --app blog --list
komizo unset-secret --host root@box --app blog --name OLD_API_KEY --yes
komizo unset-secret --host root@box --app blog --keep DATABASE_URL,CLERK_SECRET_KEY --yes
komizo unset-secret --host root@box --app blog --name file:recipient.pem --yes--list prints names and never a value, of both shapes: env keys bare and
file secrets as file:<name>. --keep is the other direction, for
when what should survive is a shorter list than what should not.
This is an operator command over the root connection, like komizo remove. It
installs nothing: there is no unset-secret-<app> beside set-secret-<app>
and no doas rule for one, because a pipeline that can delete a secret can take
an app down by deleting the one it needs to start.
It refuses a name the file does not have, and changes nothing when it does — a typo that reports success is how stale keys survive being tidied up. Before rewriting, the box copies the file beside itself, dated and mode 0600; delete that once a deploy has proved the app still starts. Running containers keep the values they started with, so the next deploy is what recreates them without these.
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.
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.
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.jsonThe 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.
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 addgenerates a fresh pair; on a rebuild where the old key is still in the repo and still intended,komizo add --keep-keyregenerates everything else and leaves the account's authorized key alone. The private half is printed once, held in memory and written nowhere unless--key PATHsays 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-hostsprints 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.
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.
make # the agents, then the CLI
make check # what CI runsmake, 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.