Prepare a build machine
A bare Linux host that can build every artefact your product needs — Go services, Node and frontend bundles, Rust and Zig components, and anything linking native image libraries — installed by a flow rather than by hand.
When you finish you will have one flow that takes a fresh host to a working builder, and a final task that fails if any toolchain is missing. Run it against the next machine and you get the same machine, not a similar one.
Why bother
Section titled “Why bother”Installing a toolchain by hand takes twenty minutes and works. Doing it by hand four times produces four machines that are almost the same, and “almost” is where the cost lands.
| Without this | With this |
|---|---|
| A new builder takes a day, and the person who set up the last one is on leave | A new builder takes one command and the time to download |
| Two machines disagree about a compiler minor, so a build passes on one and fails on the other | Every machine pins the same versions from the same file |
| Upgrading Go means finding every host and remembering what you did | Change one var, re-run, done |
| ”It works on the build box” is unfalsifiable | The box is a flow you can read, diff and review |
| A lost builder is an outage | A lost builder is a re-run |
The version list at the top of this flow becomes the definition of your build environment. When a build breaks after an upgrade, the diff that caused it is one line in version control rather than a memory.
This pays for itself the second time you run it, and every time someone joins.
Before you start
Section titled “Before you start”| You need | Why |
|---|---|
| A host you can reach over SSH | Every task runs remotely |
A user with sudo | Toolchains install system-wide |
The kis CLI locally | To run the flow |
Step 1 — declare what “a build machine” means
Section titled “Step 1 — declare what “a build machine” means”Start with the versions, not the commands. A build machine is defined by the versions it pins, and putting them at the top means upgrading one is a one-line diff rather than a search.
name: prepare-build-machinevars: host: builder.example.com user: build keypath: /path/to/key
go_version: "1.23.4" node_version: "22" rust_channel: stable zig_version: "0.13.0"Nothing installs yet. This is the contract.
Step 2 — install the system compiler first
Section titled “Step 2 — install the system compiler first”Everything else depends on it. Native modules, image libraries and Rust’s linker all need a C toolchain present before they are installed, so this task comes first and the rest follow.
tasks: - name: system-compiler ssh: host: "{{host}}:22" username: "{{user}}" privatekeypath: "{{keypath}}" commands: | sudo apt-get update -y sudo apt-get install -y build-essential pkg-config curl gitStep 3 — add the language toolchains
Section titled “Step 3 — add the language toolchains”Each is its own task. That matters: when one fails you see which, and you can re-run the flow without repeating the ones that succeeded.
- name: go ssh: host: "{{host}}:22" username: "{{user}}" privatekeypath: "{{keypath}}" commands: | curl -fsSL https://go.dev/dl/go{{go_version}}.linux-amd64.tar.gz -o /tmp/go.tgz sudo rm -rf /usr/local/go sudo tar -C /usr/local -xzf /tmp/go.tgz echo 'export PATH=$PATH:/usr/local/go/bin' | sudo tee /etc/profile.d/go.sh
- name: node ssh: host: "{{host}}:22" username: "{{user}}" privatekeypath: "{{keypath}}" commands: | curl -fsSL https://deb.nodesource.com/setup_{{node_version}}.x | sudo -E bash - sudo apt-get install -y nodejs
- name: rust ssh: host: "{{host}}:22" username: "{{user}}" privatekeypath: "{{keypath}}" commands: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ | sh -s -- -y --default-toolchain {{rust_channel}}Step 4 — add the native libraries
Section titled “Step 4 — add the native libraries”The ones that only announce themselves when a build fails at link time. Install them explicitly rather than letting a package pull them in, so the machine is complete before any build runs.
- name: native-libs ssh: host: "{{host}}:22" username: "{{user}}" privatekeypath: "{{keypath}}" commands: | sudo apt-get install -y libvips-dev libssl-dev zlib1g-devStep 5 — verify, and fail if anything is missing
Section titled “Step 5 — verify, and fail if anything is missing”This is the task that makes the flow worth having. Without it, “the flow ran” and “the machine works” are different claims and you find out which during a build.
- name: verify ssh: host: "{{host}}:22" username: "{{user}}" privatekeypath: "{{keypath}}" commands: | set -e /usr/local/go/bin/go version node --version ~/.cargo/bin/rustc --version pkg-config --exists vips && echo "vips ok" echo "BUILD MACHINE READY"set -e is what turns this from a report into a gate: the first missing tool exits non-zero and the
flow fails.
The finished thing
Section titled “The finished thing”name: prepare-build-machinelist: truevars: host: builder.example.com user: build keypath: /path/to/key go_version: "1.23.4" node_version: "22" rust_channel: stable
tasks: - name: system-compiler ssh: host: "{{host}}:22" username: "{{user}}" privatekeypath: "{{keypath}}" commands: | sudo apt-get update -y sudo apt-get install -y build-essential pkg-config curl git
- name: go ssh: host: "{{host}}:22" username: "{{user}}" privatekeypath: "{{keypath}}" commands: | curl -fsSL https://go.dev/dl/go{{go_version}}.linux-amd64.tar.gz -o /tmp/go.tgz sudo rm -rf /usr/local/go sudo tar -C /usr/local -xzf /tmp/go.tgz echo 'export PATH=$PATH:/usr/local/go/bin' | sudo tee /etc/profile.d/go.sh
- name: node ssh: host: "{{host}}:22" username: "{{user}}" privatekeypath: "{{keypath}}" commands: | curl -fsSL https://deb.nodesource.com/setup_{{node_version}}.x | sudo -E bash - sudo apt-get install -y nodejs
- name: rust ssh: host: "{{host}}:22" username: "{{user}}" privatekeypath: "{{keypath}}" commands: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ | sh -s -- -y --default-toolchain {{rust_channel}}
- name: native-libs ssh: host: "{{host}}:22" username: "{{user}}" privatekeypath: "{{keypath}}" commands: | sudo apt-get install -y libvips-dev libssl-dev zlib1g-dev
- name: verify ssh: host: "{{host}}:22" username: "{{user}}" privatekeypath: "{{keypath}}" commands: | set -e /usr/local/go/bin/go version node --version ~/.cargo/bin/rustc --version pkg-config --exists vips && echo "vips ok" echo "BUILD MACHINE READY"function main() { const c = ssh.connect({ host: host, user: user, keyPath: keypath }); const run = (label, cmd) => { const r = ssh.execute({ clientId: c.clientId, commands: [cmd] }); if (!r.success) throw new Error(`${label}: ${r.error}`); log.info(`ok: ${label}`); return r.output; };
try { run('system compiler', 'sudo apt-get update && sudo apt-get install -y build-essential git curl'); run('go', `curl -fsSL https://go.dev/dl/go${go_version}.linux-amd64.tar.gz -o /tmp/go.tgz ` + '&& sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf /tmp/go.tgz'); run('node', `curl -fsSL https://deb.nodesource.com/setup_${node_version}.x | sudo -E bash - ` + '&& sudo apt-get install -y nodejs'); run('rust', `curl -fsSL https://sh.rustup.rs | sh -s -- -y --default-toolchain ${rust_channel}`);
// verify, and fail loudly — a half-prepared machine is worse than a bare one const versions = run('verify', 'set -e; /usr/local/go/bin/go version; node --version; ' + '$HOME/.cargo/bin/rustc --version; cc --version | head -1'); return { host, versions: versions.trim().split('\n') }; } finally { ssh.close({ clientId: c.clientId }); }}kis script run prepare-build-machine.js --env builders.yamlPrefer the flow for more than one machine. Each toolchain is its own node, so a failure names the toolchain, and re-running skips what already succeeded. The script’s single connection is faster for one host and loses that.
Run it:
kis flow -f prepare-build-machine.yamlCheck what it would do before touching a host:
kis flow -f prepare-build-machine.yaml --dryrunRe-run one task after fixing something, instead of the whole flow:
kis flow -f prepare-build-machine.yaml -t rustVerify
Section titled “Verify”The flow verifies itself — the last task prints BUILD MACHINE READY only when every check
passed. If you want the machine to prove it independently, build something small on it before
trusting it with a release.
Adapt it
Section titled “Adapt it”| Change | Where |
|---|---|
| A different distribution | The apt-get lines. The structure holds; only the package manager changes |
| More or fewer toolchains | Add or remove a task. Keep one toolchain per task |
| Pinned Node minor | node_version, and switch the NodeSource line for a tarball install |
| Several machines at once | See Run a flow across many hosts |
| Per-environment values | Keep them in an environment file and pass -e env.yaml -n staging |
Related
Section titled “Related”- Automate — the engine behind
kis flow - Execution tasks —
shell,sshand their options - CLI reference — every
kis flowflag