Nix#
The Nix deployment (deploy/nix/) builds a custom Windmill server from source
with Nix and runs the whole stack under systemd --user, with no container
runtime. A Nix-built binary links against /nix/store and runs natively on
the host. What disqualified Nix for a container image, its dependence on the
store, is exactly what suits a host deployment.
Build and deploy run from the repository root as two flake apps:
nix run .#windmill-build and nix run .#windmill-deploy. There is no
install script; the apps run the steps shown below, so you can equally build a
single component or install by hand. Everything else is sane defaults plus the
ordinary systemd override mechanisms.
The deploy is deliberately imperative over static units: the apps copy
hand-editable unit files and drive systemctl --user, so you tune the
running instance with systemctl --user edit and the ordinary drop-in
mechanism rather than a generator. The planned next evolution is a declarative
home-manager systemd.user.services module, where activation becomes
home-manager switch and the units are generated; it would run on any
Nix-equipped host and would not require NixOS. That change waits on the
trade-off being worth it, since it gives up the directly hand-editable units
this deployment is built around.
For the two-command happy path, see Quick start; the sections below are the full reference.
Build#
nix run .#windmill-build builds each component to its own GC-rooted out-link
under the user state directory. An out-link is a stable path that always points
at the current build and survives nix store gc; the units reach the binary
through it with the %S (state directory) specifier, because systemd
expands specifiers in the executable path but not environment variables. It
runs:
$ pkgs=~/.local/state/windmill/pkgs
$ nix build .#windmill --out-link "$pkgs/windmill"
$ nix build .#postgresql --out-link "$pkgs/postgresql"
$ nix build .#db-setup --out-link "$pkgs/db-setup"
$ nix build .#caddy --out-link "$pkgs/caddy"
$ nix build .#windmill-extra --out-link "$pkgs/windmill-extra"
The server build is heavy (around 10 GB and a clean compile of about 18
minutes). .#windmill-oracle is the same server with the
fourteenth language, Oracle, which pulls the unfree Oracle Instant Client.
Deploy#
nix run .#windmill-deploy does the whole sequence at once: build, install,
activate. Install and activate also run on their own, so you can customise the
installed units (systemctl --user edit) between them.
Install#
nix run .#windmill-install places the units in the user unit directory, the
Caddyfile where the proxy reads it, and the vendor tree where the workers find
it through VENDOR_DIR. The vendor copy is what lets the workers resolve the
nixos-flake’s git and build shells and the qemu-system@.service unit
templates without the source checkout, so a worker-only host needs only the
state directory:
$ cp deploy/nix/systemd/*.service ~/.config/systemd/user/
$ cp deploy/nix/Caddyfile ~/.config/windmill/Caddyfile
$ cp --recursive vendor/. ~/.local/state/windmill/vendor/
Activate#
nix run .#windmill-activate reloads the manager onto the installed units,
lingers the user so the services run without an active login session, then
enables and starts them. enable --now enables (creates the [Install]
symlinks so they start at login) and starts in one step:
$ systemctl --user daemon-reload
$ loginctl enable-linger "$USER"
$ systemctl --user enable --now \
windmill-db windmill windmill-extra windmill-native windmill-caddy
$ systemctl --user enable --now windmill-worker@0000 windmill-worker@0001 \
windmill-worker@0002 windmill-worker@0003
The database service runs windmill-db-setup on first boot: it initialises
the cluster under the state directory, rotates the role password off the shared
default to a generated secret, creates the windmill database, and writes the
DATABASE_URL the rest read. The server then listens on 127.0.0.1:8002,
the LSP gateway on 127.0.0.1:3001, and caddy fronts both on
127.0.0.1:8000. Open https://localhost:8000 in a browser on the host. If
the host is remote, forward the port over SSH first:
$ ssh -L 8000:localhost:8000 <user>@<host> # only if the host is remote
The default is HTTPS with caddy’s internal CA, so the browser warns once on the
untrusted certificate. Trust it where the browser runs: on the host for a local
browser, or on the machine that opened the forward for a remote one. nix run
.#windmill-trust prints the root CA path and the steps to trust it; nix run
.#windmill-untrust removes it again. Host-side caddy trust does not apply
here: the Caddyfile disables the admin API it reads from.
Configure#
State and config split by XDG role. The stack’s state (the database cluster,
the build out-links under pkgs, the generated env) lives under
~/.local/state/windmill, which each unit declares as StateDirectory= so
systemd creates and owns it. Operator config (the Caddyfile and the per-unit
.env overrides) lives under ~/.config/windmill. The PostgreSQL socket
lives in the per-service runtime dir $XDG_RUNTIME_DIR/windmill.
Each unit ships sane defaults as Environment= lines and reads an optional
%E/windmill/<unit>.env override file (%E is $XDG_CONFIG_HOME).
Override either by editing that file or with a drop-in. systemctl edit opens
$SYSTEMD_EDITOR, then $EDITOR, then $VISUAL, falling back to a
built-in default, so set one to use your editor:
$ SYSTEMD_EDITOR=hx systemctl --user edit windmill.service
Export SYSTEMD_EDITOR from your shell profile to make it the default.
The two routes refresh differently. An .env file is re-read on every start,
so a change there needs only a restart of the affected units
(systemctl --user restart 'windmill-worker@*' for the worker pool), not a
daemon-reload. A unit or drop-in change instead alters what the manager
itself runs, so it needs a configuration reload: systemctl --user edit
writes the drop-in and runs daemon-reload for you, while a hand-edited unit
file needs an explicit systemctl --user daemon-reload. Either way, restart
the unit afterward for the new value to reach the running process. A
template-level drop-in (systemctl --user edit windmill-worker@) overrides
every worker instance at once, the drop-in counterpart of the shared
windmill-worker.env.
The knobs, grouped by the unit that carries each. Every one has a working
default, so the stack runs untouched; override any by editing that unit’s
.env file or with a drop-in, as above.
windmill.service (the server):
PORT(8002): the HTTP port caddy proxies to.BASE_URL(https://localhost:8000): the public base URL. It must agree with the scheme caddy serves; see TLS and the base URL.DATABASE_URL(generated): the PostgreSQL DSN. The database service writes it for the co-located stack; set it by hand only on a worker-only host (see On a separate host).
windmill-caddy.service (the public proxy):
WINDMILL_CADDY_PORT(8000): the public port the proxy serves; the Caddyfile reads it.
windmill-extra.service (the LSP and multiplayer gateway):
PORT(3001): the gateway’s own port.WINDMILL_BASE_URL(http://127.0.0.1:8002): where it reaches the server.
windmill-db.service (PostgreSQL):
PGPORT(5432): the listen port; a separate host reaches it here.PGDATA(%S/windmill/pgdata): the cluster data directory.PGHOST_SOCKET(%t/windmill): the Unix-socket directory.
windmill-native.service (the native worker):
WORKER_GROUP(native): the group its jobs pull from.SLEEP_QUEUE(200): the idle queue-poll interval in milliseconds.
windmill-worker@ (the build and vm workers):
NUM_WORKERS(1): worker threads per instance. Leave it at one and scale by enabling more instances; see Workers.WORKER_GROUP(default) andWORKER_TAGS(unset, so the group’s own tags apply): which jobs the instance pulls. The vm and vm-run instances get theirs from install-time drop-ins; see Workers.WORKBENCH_DIR,WORKTREES_DIR,SYSTEM_DIR,MIRRORS_DIR,CCACHE_DIR,STORE_INDEX_DIR,WORKERS_DIR,VENDOR_DIR: the build-area paths, each relocatable on its own. See The workbench for what each roots, how they nest, and their defaults.NIX_BIN(/nix/var/nix/profiles/default/bin): the directory holdingnixon the worker’s PATH. The default suits most hosts; point it at a reachablebinon a NixOS host, whose default profile lives under the store. It is unset in the unit and read by the step code, so to use it add it toWHITELIST_ENVS(below) as well.
A worker passes only the variables named in WHITELIST_ENVS into the job’s
environment, so a step sees a build-area path (or NIX_BIN) only because it
is whitelisted. The shipped list already covers the eight build-area paths and
WORKER_INDEX; to expose any further variable to steps, append its name
there. MODE, WORKER_INDEX, DBUS_SESSION_BUS_ADDRESS and
WHITELIST_ENVS itself are wiring the units set for you, not tuning knobs.
Workers#
Workers are windmill-worker@ instances differentiated only by worker group
and tags, the canonical Windmill mechanism. Instance names are zero-padded
(windmill-worker@0000, @0001) so they sort in order under systemctl
--user list-units; the index is only the worker’s sandbox-dir label, not a
number Windmill reads.
The default deploy ships the full mix, so every flow runs out of the box:
@0000 and @0001 in the build pool (group default), @0002 in the
vm group on the vm tag, and @0003 in the vm group on the
vm-run tag. The vm and vm-run instances get their group and tags from
per-instance drop-ins the install step writes.
The kdevops workspace drives QEMU virtual machines through systemd (the
f/qsu steps), and those jobs use the vm group, split across two tags
so a long job never starves a quick one: the vm tag is the quick lifecycle
and control ops (boot, stop, destroy, status), the vm-run tag is only the
long-lived fstests wait poll. The vm-run instance count is the
concurrent-test-run cap. The vm group needs the System workbench
provisioned and the host vhost_vsock module loaded.
Scale a role by enabling more instances: add default instances to widen
build concurrency, or vm-run instances to raise the test-run cap. Drop a
per-instance override in, then enable it:
$ systemctl --user edit windmill-worker@0004 # then in the drop-in:
[Service]
Environment=WORKER_GROUP=vm
Environment=WORKER_TAGS=vm-run
Then systemctl --user enable --now windmill-worker@0004.
On a separate host#
A second machine can run workers for an existing server without the rest of the
stack, in two steps with the database pointed at the server in between.
nix run .#windmill-worker-install builds just the windmill binary, the same
one the worker runs in worker mode, not the database, proxy, and rest that
windmill-build produces, so there is no separate worker build and no need to
run windmill-build first. It installs only the windmill-worker@ unit,
with a drop-in that clears its local-database dependency and makes the
server-written database.env optional. It bakes in no DATABASE_URL,
because a worker-only host has no local database to default to; set it, then
enable as many instances as you want:
$ nix run .#windmill-worker-install
$ systemctl --user edit windmill-worker@
$ nix run .#windmill-worker-activate -- 4
In the editor systemctl edit opens, set the server’s database for every
instance:
[Service]
Environment=DATABASE_URL=postgres://user:pw@server:5432/windmill
windmill-worker-activate is idempotent, so scaling up is just re-running it
with a larger count: nix run .#windmill-worker-activate -- 8. Underneath,
windmill-worker@ is a systemd template, so each instance points at the one
unit file and a single one can also be added with systemctl --user enable
--now windmill-worker@0004 (no rebuild or reinstall either way). The server’s
PostgreSQL must be reachable from this host: it binds 127.0.0.1 by default,
so expose it or tunnel. Build-pool workers also need the System
workbench provisioned here, the same f/workbench init flow as on any
worker host.
TLS and the base URL#
The Secure flag on Windmill’s session cookie follows the server
BASE_URL: the server sets it from a base URL that starts with https://,
not from a forwarded-proto header. So BASE_URL and the scheme caddy serves
must agree. Serving HTTPS with an http:// base URL leaves the cookie
non-Secure; the reverse drops the cookie and breaks login. The defaults pair
(HTTPS plus https://localhost:8000); change both together. To serve plain
HTTP, or an operator certificate instead of the internal CA, edit the Caddyfile
as its header comment describes and set BASE_URL to match.
The workbench#
The worker build-area paths point at a Workbench: a directory
containing the Developer’s Worktree-groups and
the kdevops-ng infrastructure that defaults under it. It is not a Windmill
workspace. The infrastructure is the System workbench (system/,
SYSTEM_DIR), the host-local singleton holding the mirrors, bares, ssh key,
store, compiler cache and Store index, and the
Worker sandboxes
(workers/<id>/,
WORKERS_DIR), where each worker builds in its own worktree, never in a
developer’s worktree.
The units default WORKBENCH_DIR to %S/windmill/workbench, under the
systemd state directory (%S is $XDG_STATE_HOME), the recommended place
for persistent service state. Each piece relocates on its own: WORKBENCH_DIR
moves the whole area, the worktree-groups included, so set it to put the groups
where you want them, a directory in $HOME such as $HOME/src or one
nested in the repository such as kdevops-ng/workbench; WORKTREES_DIR
roots the worktree-groups alone (default WORKBENCH_DIR), to move the groups
apart from the rest of the area; SYSTEM_DIR and WORKERS_DIR default
inside it but move out independently; MIRRORS_DIR roots the bulky git
mirrors alone (default SYSTEM_DIR/mirror), so you can park the expensive
object store on a separate volume while the bares, ssh key and store stay put;
CCACHE_DIR roots the shared compiler cache alone (default
SYSTEM_DIR/ccache), so you can keep a warm cache on a fast disk, or point it
at an existing cache, independently of the rest; and STORE_INDEX_DIR roots
the Store index alone (default SYSTEM_DIR/store-index), which holds the GC
roots that protect published artifacts from collection. Override any of them
with a drop-in or the windmill-worker.env file.
Run the f/workbench init flow from Windmill to provision the System
workbench (the bare mirrors and the ssh key); the workers fill their sandboxes
as jobs run.
The System workbench’s ssh key and host config live under SYSTEM_DIR/ssh.
Add that config to the top of ~/.ssh/config once, so ssh <vm> reaches a
guest over vsock with the managed key; the flow prints the exact (absolute)
line, since ssh resolves a relative Include against ~/.ssh, not the
including file’s directory:
Include ~/.local/state/windmill/workbench/system/ssh/config
To reuse work you already have (from an earlier workbench, or when relocating
WORKBENCH_DIR), move system/mirror (the expensive clones) and
system/ssh (so the guest key is kept, not regenerated) into the new
SYSTEM_DIR before running the flow; on one filesystem the move is instant.
Or, to leave the mirror where it already sits, point MIRRORS_DIR at it
instead of moving it. f/workbench/fetch then cuts fresh bares from the
mirrors, ssh_key rewrites the ssh config for the new path, and
f/workbench/mirror installs the git-mirror@ timers, so the clones are
refreshed in place rather than re-cloned. The bares borrow the mirror through an
alternate that fetch rewrites authoritatively, so a moved or repointed
mirror leaves one valid alternate, not a dangling one. The bares and worker
sandboxes hold absolute paths, so let the flow regenerate those rather than
moving them.
Tear down#
nix run .#windmill-teardown does the reverse of deploy in one shot:
deactivate, uninstall, wipe. The stages also run on their own, so you can stop
the services without removing anything, or wipe the data but keep the units.
Deactivate#
nix run .#windmill-deactivate stops and disables the services and any worker
instances. systemctl stop accepts a glob but disable does not, so it
stops everything by glob, then disables each unit that has an install symlink
(the worker template instances included):
$ systemctl --user stop 'windmill*'
$ for link in ~/.config/systemd/user/default.target.wants/windmill*; do
$ systemctl --user disable "${link##*/}"
$ done
Linger is left enabled. It is user-global, not a Windmill setting, so disabling
it would stop every lingering user service, the workbench mirrors included. Drop
it explicitly, only when nothing else needs it, with the disable-linger app
(loginctl disable-linger "$USER").
Uninstall#
nix run .#windmill-uninstall removes the installed units, any worker
drop-ins, and the Caddyfile, then reloads the manager:
$ rm --force ~/.config/systemd/user/windmill*.service
$ rm --recursive --force ~/.config/systemd/user/windmill-worker@.service.d
$ rm --force ~/.config/windmill/Caddyfile
$ systemctl --user daemon-reload
Wipe#
nix run .#windmill-wipe deletes the instance data under the state directory:
the database cluster, the build out-links, and the generated env. It leaves the
build-area workbench (also under the state directory) alone. Run it after
deactivate so the cluster is stopped:
$ state=~/.local/state/windmill
$ rm --recursive --force "$state/pgdata" "$state/pkgs" "$state/env"
Switching from Podman#
The units reuse the same names as the Podman deployment, and static user units
shadow podman’s quadlet-generated ones, so the two cannot run at once. Retire
podman first by moving its quadlets aside
(~/.config/containers/systemd/windmill*) and reloading, then deploy the Nix
one. The workspace itself lives in git, so push it to the fresh instance
with wmill sync push once the stack is up.