Pull-based NixOS deployment tooling.
  • Rust 89.1%
  • Nix 10.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-05 16:49:12 +03:00
nix fix(agent): enqueue reboot without blocking 2026-09-05 16:49:12 +03:00
src fix(agent): enqueue reboot without blocking 2026-09-05 16:49:12 +03:00
.envrc chore(repo): scaffold ferry project 2026-05-25 18:34:56 +03:00
.gitignore chore(repo): scaffold ferry project 2026-05-25 18:34:56 +03:00
Cargo.lock fix(agent): verify system switch completion 2026-09-05 11:17:15 +03:00
Cargo.toml fix(agent): verify system switch completion 2026-09-05 11:17:15 +03:00
flake.lock chore(repo): scaffold ferry project 2026-05-25 18:34:56 +03:00
flake.nix fix(agent): enqueue reboot without blocking 2026-09-05 16:49:12 +03:00
README.md fix(agent): enqueue reboot without blocking 2026-09-05 16:49:12 +03:00

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, or when the requested deployment action changes; 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

By default generated manifests do not include a deployment action and agents apply them with switch-to-configuration switch. Pass --boot to plan or promote to write a boot deployment action into changed manifests:

ferry plan --boot
ferry promote --boot

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_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

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, then applies it with the action from the deployment manifest. The NixOS helper first sets /nix/var/nix/profiles/system to the target generation, then runs that generation's switch-to-configuration. Manifests without a deployment action use switch; manifests promoted with --boot use boot. In switch mode Ferry runs the optional healthcheck after switching. If switch or healthcheck fails, it switches back to the previous generation and restores the system profile. Before the healthcheck and state update, Ferry also verifies that the resolved /run/current-system path matches the target system; a mismatch triggers the same rollback. The privileged helper preserves the original switch-to-configuration exit code and uses the previous profile generation instead of creating another generation during rollback. In boot mode Ferry runs switch-to-configuration boot, writes the persistent $FERRY_STATE_DIR/pending-boot.json record, and keeps using boot mode on later runs until the host reboots. Deployment hooks, healthcheck, and the final state write are deferred until the target generation is active after reboot. A failed pre-activate hook or healthcheck switches back to the generation recorded before the boot deployment. If the boot command fails, the helper restores the previous system profile. If FERRY_REBOOT_WINDOW is set to a local-time window such as 03:00-05:00, Ferry reboots with FERRY_REBOOT_COMMAND when the current local time is inside that window; windows may cross midnight, for example 23:00-02:00. If the reboot window is unset, Ferry reboots immediately after a successful boot. The default reboot command uses systemctl --no-block reboot, so the agent queues the reboot without waiting for its own oneshot service to stop. 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 applied. Agent progress and failures are emitted through tracing, so systemd-journald/Loki can show each reconciliation step.

Целевая NixOS-конфигурация может объявить deploymentHooks.preActivate и deploymentHooks.postActivate. Ferry выполняет hooks непосредственно из целевого system closure. При switch pre-activate запускается после realise и до переключения, а post-activate — после переключения и до healthcheck. При boot оба hook откладываются до reboot и выполняются после загрузки целевой системы перед healthcheck и записью итогового state. Ошибка или timeout pre-activate останавливает деплой и запускает rollback; ошибка post-activate записывается как warning и не останавливает healthcheck. Общий timeout задаётся через deploymentHooks.timeout.

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, that the requested action is switch or boot, and updates the system profile before running the requested action.

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";
              };

              reboot.window = "03:00-05:00";
              healthcheckCommand = "systemctl is-active --quiet nginx.service";
              healthcheckTimeout = "60s";
              healthcheckAttempts = 5;
              deploymentHooks = {
                timeout = "5min";
                preActivate = ''
                  echo "Подготовка целевой конфигурации"
                '';
                postActivate = ''
                  echo "Завершение активации"
                '';
              };
              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