ship-rs
One Rust file and one command to put a service on its own NixOS server on Google Cloud.
In January 2026, deploying panharmonicon's development environment meant running a
450-line shell script and keeping hand-written Terraform and
NixOS files in step with it: three things that had to agree, with
nothing checking that they did. ship-rs replaced all of that with about 80 lines of typed Rust and a
single command, ./ship.rs. It is the Ship DSL in the wayfinder crate
of our monorepo, and several services now deploy this way.
1. You describe the service, not the steps
A ship.rs script says what should exist: which program runs, when it runs, which secrets it gets and what storage it needs. It says nothing about creating a VM or writing NixOS configuration. ship-rs works those out.
Illustrative example, adapted from the wayfinder README: a nightly batch job with an API key and a bucket.
use wayfinder::prelude::*;
fn main() -> Result<()> {
// api_key: the encrypted file holding the worker's API key
let api_key = Secret::agenix("api.example.com/myapp/key.age");
// spec: everything "myapp" needs, checked, defaults filled in
let spec = Subsystem::new("myapp") // one unit, one VM
.in_region(EU) // where the VM runs
// the program, run as a systemd job
.service(Service::oneshot("worker")
.command("myapp-worker --batch")
.env(&api_key, "API_KEY")) // key → env variable
// run it nightly
.timer(Timer::for_service("worker").on_calendar(Daily))
.has(Bucket::default()) // plugin: a bucket
.build()?; // defaults + checks
Ship::new(spec).run() // plan, render, deploy
}
The secret is a Rust variable, so referring to one that was never
defined is a compile error. Real scripts wrap the path in age_path!, which also
fails the build if the encrypted file is missing. Either mistake stops before anything
touches Google Cloud. The check covers names and files, not whether the key itself is valid.
2. What one command does
The first run builds the server from nothing. Later runs only change what runs on it. Generated configuration is written to a working directory at deploy time and never committed.
| Step | First run: ./ship.rs | Later runs |
|---|---|---|
| Check the machine | Tools present; logged in to gcloud, including the credentials Terraform uses | Same |
| Validate | .build() fills defaults (package and user named after the subsystem, working directory /var/lib/<user>) and rejects an invalid spec | Same |
| Generate | Terraform and NixOS configuration rendered from the spec | Same |
| Infrastructure | Creates the Terraform state bucket, then terraform apply creates the VM and buckets, and nixos-anywhere installs NixOS on it | Not touched. Run ./ship.rs apply to change it |
| Services | Installed with the system | nixos-rebuild on the existing VM |
For the example: the first ./ship.rs creates the VM and bucket and installs NixOS
with the worker and its nightly timer. Change the worker's command, run it again, and only
the NixOS rebuild happens. ./ship.rs dry-run prints the expanded spec without
deploying; status, logs, ssh and destroy do what they say.
The Terraform state and its lock live in a Google Cloud bucket, so two people cannot apply
at once; ./ship.rs unlock clears a lock left by a crashed run.
3. ship-rs and darksteel-forge
Both end with NixOS on Google Cloud. They differ in what you declare and where it is kept.
| ship-rs | darksteel-forge | |
|---|---|---|
| Unit you declare | One subsystem, on its own VM | The whole fleet of machines |
| Written in | A Rust script next to the service's code | Nix modules in one repository |
| Infrastructure config | Generated at deploy time, never committed | Committed and reviewed in pull requests |
| Deploy | ./ship.rs in the service's deploy/ directory | gcp-colmena apply --on <machine>, or CI |
This page went the darksteel-forge way. Its domain, VM, DNS records and certificate were declared in that repository and approved by merging a pull request.
Glossary
- ship.rs script
- An executable Rust file in a service's
deploy/directory, run with rust-script, which compiles it on the fly. Running it deploys that service. - Subsystem and spec
- A subsystem is one deployable unit, such as "myapp".
.build()turns its description into a spec: the same description with defaults filled in and checked, ready to plan from. - Plugin
- An extra piece of infrastructure added with
.has(…): a bucket, Postgres, Redis, a Caddy web server with HTTPS, and others. - Secret (agenix)
- A file encrypted with age, decrypted only on the machine that needs it. ship-rs places the decrypted value where the service expects it, here as the
API_KEYenvironment variable. - Terraform
- A tool that creates cloud resources (VMs, buckets, firewall rules) from a description and records what it created.
- Terraform state
- Terraform's record of what exists, so the next run changes only the difference. ship-rs keeps it, and a lock on it, in a Google Cloud bucket.
- NixOS
- A Linux distribution configured entirely from a declarative file. The whole system, services included, is rebuilt from it.
- nixos-anywhere
- Installs NixOS over SSH on a machine running another Linux, such as a fresh cloud VM. ship-rs uses it only when the VM does not have NixOS yet.
- nixos-rebuild
- Applies a new NixOS configuration to a running machine. This is what later
./ship.rsruns do. - darksteel-forge
- Geosurge's infrastructure repository: every server declared in Nix and deployed with Colmena. ship-rs was written to bring the same idea to a single service.