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.

StepFirst run: ./ship.rsLater runs
Check the machineTools present; logged in to gcloud, including the credentials Terraform usesSame
Validate.build() fills defaults (package and user named after the subsystem, working directory /var/lib/<user>) and rejects an invalid specSame
GenerateTerraform and NixOS configuration rendered from the specSame
InfrastructureCreates the Terraform state bucket, then terraform apply creates the VM and buckets, and nixos-anywhere installs NixOS on itNot touched. Run ./ship.rs apply to change it
ServicesInstalled with the systemnixos-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-rsdarksteel-forge
Unit you declareOne subsystem, on its own VMThe whole fleet of machines
Written inA Rust script next to the service's codeNix modules in one repository
Infrastructure configGenerated at deploy time, never committedCommitted and reviewed in pull requests
Deploy./ship.rs in the service's deploy/ directorygcp-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_KEY environment 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.rs runs 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.