- Rust 88.4%
- Nix 11.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| nix | ||
| src | ||
| .envrc | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
Ferry
Ferry promotes NixOS system closures from a source flake and reconciles hosts from Git-hosted deployment manifests.
ferry projectmanagesferry.tomlin the source checkout.ferry planshows hosts whose manifests need an update.ferry promotebuilds changed systems, optionally pushes closures to Attic, and publishes manifests.ferry agent runreconciles one host against its manifest. The NixOS module can run it on a systemd timer.
Requirements
- A source Git checkout with
flake.nixandnixosConfigurations. - A Git repository for deployment manifests, accessible to the promoter and agents.
- A binary cache that agents can read. To upload closures with Ferry, configure an Attic cache name and make the
atticCLI available to the promoter. Otherwise, upload closures before agents reconcile. - Nix, Git, and the
ferryCLI on the promotion machine.
Run promoter commands from the source checkout root. Ferry reads ./ferry.toml; it has no --repository option for selecting another source directory.
Configure a project
Create ferry.toml in the source checkout:
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
This example 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"]
cache.url is recorded in manifests and used by agents to verify the promoted system path. cache.name enables attic push during promotion; omit it when closures are uploaded separately. manifests.ref defaults to main, and manifests.dir defaults to ..
If neither --host nor --exclude-host is passed to project init, Ferry discovers hosts from .#nixosConfigurations and writes them to [hosts].include. If only --exclude-host is passed, it writes [hosts].exclude and leaves the include list empty, so later commands discover hosts each time. Exclusions take precedence over inclusions.
Use project set to replace a configured host list or update other fields. project show prints the current file:
ferry project set --host host-a --host host-b
ferry project set --exclude-host host-c
ferry project show
For environment-specific host lists, add a section to ferry.toml:
[environment.staging]
hosts = ["host-a", "host-b"]
When exactly one environment is configured, Ferry selects it by default; otherwise the default is prod. Pass --environment to select one explicitly.
Plan and promote
ferry plan
ferry promote
When a manifest repository is configured, Ferry clones its selected ref into a temporary, shallow checkout under $XDG_CACHE_HOME/ferry/checkouts or ~/.cache/ferry/checkouts. It compares each selected host's evaluated system path and cache URL with the manifest at <manifests.dir>/<environment>/<host>.json. It also compares the deployment action and any explicitly requested schema version. A source revision change alone does not update a manifest.
plan can inspect a source checkout with uncommitted changes. promote requires clean tracked source files. For each affected host, promote builds the closure when the artifact changes, pushes it to Attic when cache.name is configured, writes the manifest, commits it, and pushes the manifest ref. A schema-only or action-only update does not rebuild or push the closure.
If no hosts are configured or passed with --host, Ferry discovers all nixosConfigurations hosts. --host, --exclude-host, --environment, --cache-url, and --manifest-dir override the project settings for one run. promote also accepts --cache-name and --source-repository. By default, the source repository identity in a manifest comes from git remote.origin.url.
Existing manifests keep their schema version unless --schema-version is specified. New manifests use the latest schema supported by the running Ferry binary. Use the flag to plan and publish an explicit schema migration:
ferry plan --schema-version 1
ferry promote --host host-a --schema-version 1
By default, manifests request an immediate switch. Pass --boot to record a boot deployment action instead:
ferry plan --boot
ferry promote --boot
Run the agent
The agent reads the manifest for its configured environment and host from the manifest Git repository. A manual dry run checks repository access and the selected manifest without copying or switching a system:
ferry agent run \
--host host-a \
--manifest-repository git@example.invalid:infra/system-manifests.git \
--dry-run
The agent accepts CLI flags or FERRY_* environment variables. For a manual shell run, export the variables before starting the agent:
set -a
FERRY_HOST=host-a
FERRY_ENVIRONMENT=prod
FERRY_STATE_DIR=/var/lib/ferry
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_PRE_ACTIVATE_COMMAND_PREFIX='["/run/wrappers/bin/sudo","-n","/nix/store/...-ferry-run-pre-activate/bin/ferry-run-pre-activate"]'
FERRY_REBOOT_WINDOW=03:00-05:00
FERRY_REBOOT_COMMAND='["/run/wrappers/bin/sudo","-n","/nix/store/...-ferry-reboot/bin/ferry-reboot"]'
FERRY_HEALTHCHECK_COMMAND="systemctl is-active --quiet nginx.service"
FERRY_HEALTHCHECK_TIMEOUT=60s
FERRY_HEALTHCHECK_ATTEMPTS=5
FERRY_DEPLOYMENT_HOOK_TIMEOUT=5min
set +a
ferry agent run
The agent verifies the promoted system path in the manifest's cache with nix path-info --store, realises it locally with nix-store --realise, and applies the manifest's deployment action. Other dependencies may still come from the host's normal substituters. Unsupported manifest schema versions fail during parsing, before any system is copied or applied. Progress and failures are emitted through tracing for journald or other log collectors.
Switch and boot behavior
For switch, the privileged helper sets /nix/var/nix/profiles/system to the target generation and invokes its switch-to-configuration switch. Ferry verifies that /run/current-system resolves to the target, runs the optional healthcheck, and records the final state. A failed switch, generation check, or healthcheck triggers a rollback to the previous generation. The helper preserves the original switch exit code and restores the previous profile without creating another generation.
For boot, the helper sets the target boot generation and Ferry records $FERRY_STATE_DIR/pending-boot.json. It does not write final deployment state until the target system is active after a reboot. If FERRY_REBOOT_WINDOW is set, Ferry reboots only within that local-time window; windows can cross midnight, such as 23:00-02:00. Without a window, it queues a reboot immediately after a successful boot action. The default reboot command is systemctl --no-block reboot. If the boot command fails, the helper restores the previous system profile.
Deployment hooks
The target NixOS configuration can define services.ferry.agent.deploymentHooks.preActivate and postActivate. The module places executable hooks in the target system closure. In switch mode, Ferry runs preActivate after realisation and before switching, then postActivate after switching and before the healthcheck. In boot mode, both hooks run after reboot, once the target system is active, before the healthcheck and final state write.
Each hook has the timeout configured by deploymentHooks.timeout or FERRY_DEPLOYMENT_HOOK_TIMEOUT. A failing or timed-out pre-activation hook stops deployment; after a boot reboot, it triggers rollback. A post-activation failure is logged as a warning and does not stop the healthcheck. A failed post-reboot healthcheck rolls back to the system recorded before the boot deployment.
The NixOS module delegates pre-activation to a root-owned helper. It accepts only a target NixOS system closure path and runs only its ferry/pre-activate hook. Post-activation runs as the agent user.
NixOS agent module
Add ferry.nixosModules.agent to a host's module list and configure services.ferry.agent:
{
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/main" ];
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";
};
reboot.window = "03:00-05:00";
healthcheckCommand = "systemctl is-active --quiet nginx.service";
healthcheckTimeout = "60s";
healthcheckAttempts = 5;
deploymentHooks = {
timeout = "5min";
preActivate = ''
echo "Preparing the target configuration"
'';
postActivate = ''
echo "Activation complete"
'';
};
timer.onUnitActiveSec = "5min";
};
}
];
}
This is a module-list fragment: make the ferry flake input available in the surrounding flake output. The module installs the CLI, runs the agent as the ferry system user, and enables a systemd timer by default. It grants that user passwordless sudo only for the root-owned switch, pre-activation, and reboot helpers. The switch helper validates the target switch-to-configuration path and the requested switch or boot action.
Development
Enter the development environment and run the Rust checks:
nix develop
cargo check
cargo test
With direnv configured, direnv allow enters the same development shell. To inspect the packaged CLI or run the flake checks:
nix build
nix run . -- --help
nix flake check