Skip to content

Repository files navigation

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.

Privileged tasks an app needs

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.sh

The 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.

PR previews

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.

Secrets an env file cannot carry

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 65534

The 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.

Taking a secret off a box

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.

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages