- Rust 91.6%
- Nix 8.4%
| nix | ||
| src | ||
| .envrc | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
ferry
Pull-based NixOS deployment tooling.
The project has one Rust CLI:
ferry plan: prints NixOS hosts whose promoted artifact would change.ferry promote: builds changed hosts, optionally pushes closures to Attic, and writes deployment manifests.ferry project: creates, prints, and updates repository-local promoter configuration.ferry agent run: performs one reconciliation pass for tests, manual checks,systemd.timer, or Kubernetes CronJob deployments.
Command flow
Create ferry.toml in the repository that contains flake.nix:
ferry project init \
--name example-infra \
--cache-url https://cache.example.invalid/main \
--cache-name main \
--manifest-repository git@example.invalid:infra/system-manifests.git \
--host host-a
If both --host and --exclude-host are omitted, project init discovers host
names from .#nixosConfigurations and writes them to [hosts].include. If only
--exclude-host is passed, project init writes [hosts].exclude and leaves
include empty, so later plan and promote keep discovering hosts and then
subtract the excluded names.
That writes:
[project]
name = "example-infra"
[cache]
url = "https://cache.example.invalid/main"
name = "main"
[manifests]
repository = "git@example.invalid:infra/system-manifests.git"
[hosts]
include = ["host-a"]
# exclude = ["host-b"]
Then daily use from the same repository is short:
ferry plan
ferry promote
ferry plan and ferry promote read ./ferry.toml by default. --repository
is intentionally not part of the CLI: both local runs and CI are expected to run
from the checkout root. CLI flags such as --host, --exclude-host,
--environment, --cache-url, and --manifest-dir override the file for one
run; promote also accepts --cache-name.
When a manifest repository is configured, plan and promote clone
manifests.ref into a temporary blobless, depth-one checkout under
$XDG_CACHE_HOME/ferry/checkouts or ~/.cache/ferry/checkouts. They evaluate
each selected host's target system path and compare it with the current manifest
artifact. A host is included only when artifact.system_path or
artifact.cache_url would change; source revisions alone do not cause a new
manifest. plan may run against dirty source changes, while promote requires
a clean tracked source checkout.
Manifest schema upgrades are explicit. Without --schema-version, existing
hosts keep the schema version from their current manifest, and new hosts use the
latest schema supported by the current ferry binary. Passing
--schema-version to plan or promote validates that the binary supports the
requested version and treats schema-only changes as planned work:
ferry plan --schema-version 1
ferry promote --host host-a --schema-version 1
ferry.toml does not store the current source repository path. The source
checkout is the current working directory. Generated manifests record the source
repository identity from git remote.origin.url, unless --source-repository
is passed explicitly.
manifests.ref defaults to main, and manifests.dir defaults to ".".
Dedicated manifest repositories therefore do not need either field.
By default, relative manifests are written under
<environment>/<host>.json inside the selected manifest directory. If neither
ferry.toml nor --host selects hosts, ferry evaluates .#nixosConfigurations
and uses all host attribute names.
Host selection is include or auto-discovery minus exclude. Excluded hosts
win if a host appears in both lists.
Update selected fields later:
ferry project set --host host-a --host host-b
ferry project set --exclude-host host-c
ferry project show
ferry agent run accepts the same runtime configuration as flags or
environment variables. NixOS deployments usually set the environment:
FERRY_HOST=host-a
FERRY_ENVIRONMENT=prod
FERRY_STATE_DIR=/var/lib/ferry
FERRY_DRY_RUN=false
FERRY_MANIFEST_REPOSITORY=git@example.invalid:infra/system-manifests.git
FERRY_MANIFEST_REF=main
FERRY_MANIFEST_DIR=.
FERRY_SSHOPTS="-i /var/lib/ferry/ssh/manifest-repository -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/etc/ssh/ssh_known_hosts"
FERRY_SWITCH_COMMAND_PREFIX='["/run/wrappers/bin/sudo","-n","/nix/store/...-ferry-switch-to-configuration/bin/ferry-switch-to-configuration"]'
FERRY_HEALTHCHECK_COMMAND="systemctl is-active --quiet nginx.service"
FERRY_HEALTHCHECK_TIMEOUT=60s
FERRY_HEALTHCHECK_ATTEMPTS=5
For manual checks the equivalent flags are available, for example:
ferry agent run \
--host host-a \
--manifest-repository git@example.invalid:infra/system-manifests.git \
--dry-run
The agent checks that the promoted system path exists in the configured cache
with nix path-info --store, realises it locally with nix-store --realise,
runs <system>/bin/switch-to-configuration switch, then runs the optional
healthcheck. Dependencies can still come from the host's normal substituters,
while the promoted system path is pinned to the manifest cache. Unsupported
manifest schema versions fail during manifest parsing before anything is copied
or switched. If healthcheckCommand is set, Ferry retries it until it succeeds
or healthcheckTimeout elapses. The default is 5 attempts within a 60 second
timeout, with exponential backoff between attempts. If switch or healthcheck
fails, it switches back to the previous /run/current-system generation. Agent
progress and failures are emitted through tracing, so systemd-journald/Loki
can show each reconciliation step.
The NixOS module runs the service as the ferry system user and delegates only
switching to root. Sudo is granted only for a root-owned helper from the Nix
store; the helper validates that the target is a
/nix/store/<hash>-nixos-system-*/bin/switch-to-configuration path and that the
requested action is switch.
NixOS Agent Module
{
inputs.ferry.url = "git+ssh://git@example.invalid/infra/ferry.git";
outputs =
{ ferry, ... }:
{
nixosConfigurations.host-a = nixpkgs.lib.nixosSystem {
modules = [
ferry.nixosModules.agent
{
nixpkgs.overlays = [ ferry.overlays.default ];
services.ferry.agent = {
enable = true;
environment = "prod";
host = "host-a";
cache = {
substituters = [ "https://cache.example.invalid/ferry" ];
trustedPublicKeys = [
"ferry:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
];
};
manifests = {
repository = "git@example.invalid:infra/system-manifests.git";
sshKeyFile = "/var/lib/ferry/ssh/manifest-repository";
knownHostsFile = "/etc/ssh/ssh_known_hosts";
};
healthcheckCommand = "systemctl is-active --quiet nginx.service";
healthcheckTimeout = "60s";
healthcheckAttempts = 5;
timer.onUnitActiveSec = "5min";
};
}
];
};
};
}
Development
direnv allow
cargo check
cargo test
Nix
nix develop
nix build
nix run . -- --help
nix run . -- project --help
nix run . -- promote --help
nix run . -- agent run --help