Migrating from sops-nix to Basil 

    If you run services on NixOS and deliver secrets with sops-nix or agenix, you can move to Basil one secret at a time. You do not have to migrate everything at once. sops-nix and Basil can coexist while you move the secrets that benefit most from brokered access, live rotation, and in-place custody.

    The goal is not to turn every secret file into a different secret file. Start with a value that is easy to move, then graduate keys to Basil's stronger model: the workload asks for an operation, and the key stays in the backend.

    What changes 

    sops-nix decrypts secrets into files at activation time, typically under /run/secrets/..., and hands each service a value on disk. That works well for static boot-time material, but it means:

    • the decrypted secret is a file a compromised service, backup job, or accidental cat can read;
    • rotation means editing the encrypted source and rebuilding or switching the system;
    • the age or GPG host key that decrypts broad sets of secrets lives on the host;
    • authorization is file ownership and mode, not a per-operation policy decision;
    • there is no broker audit trail for who read or used a secret.

    Basil gives you two migration levels:

    LevelWhat the workload getsCustody modelBest for
    Tier 1: value accessA secret value fetched on demandSecret stays in the backend until Basil fetches it for an authorized callerDB passwords, API tokens, existing apps that still need a value
    Tier 2: operation accessA result from sign, encrypt, decrypt, issue-cert, or mintingKey is used in place by a transit, KMS, PKI, or NATS backendTLS keys, signing keys, encryption keys, workload identities

    Tier 1 is the smallest change. It is still a value, but it is off the Nix store, policy-gated, audited, and rotatable without a rebuild.

    Tier 2 is the security win. For TLS keys, signing keys, encryption keys, certificates, and minted identity credentials, Basil brokers the operation rather than handing out the private material.

    Side-by-side 

    sops-nix todayBasil Tier 1: valueBasil Tier 2: operation
    Where the secret livesDecrypted file on diskBackend value, fetched on demandBackend key, never leaves
    RotationEdit encrypted source + rebuildbasil rotate or basil set livebasil rotate live, with grace window
    Who can read itAnything running as the ownerOnly the granted subject, auditedNobody reads the key material
    AuthorizationFile ownership and modeDefault-deny policy per subjectDefault-deny policy per subject
    AuditNo broker auditEvery access loggedEvery operation logged
    App change neededNoneSmall: fetch from Basil instead of a fileApp calls sign, encrypt, decrypt, etc.

    Concept mapping 

    sops-nixBasil
    sops.secrets."app/db_password"catalog key app.db_password with class = "value" and engine = "kv2"
    owner = "app" and file modepolicy subject for the app uid plus a rule granting op:get
    age or GPG host keyBasil's sealed bundle, unlocked once at boot
    edit encrypted file and rebuild to rotatebasil rotate --key-id app.db_password, or basil set for caller-supplied material
    no read auditaudit log entry for every read or operation

    Before: sops-nix 

    A typical service reads its database password from a decrypted file:

    sops.secrets."app/db_password" = {
      owner = "app";
      # Decrypted to /run/secrets/app/db_password at activation.
    };
    
    systemd.services.app = {
      serviceConfig = {
        User = "app";
        Environment = "DB_PASSWORD_FILE=/run/secrets/app/db_password";
      };
    };

    After, Tier 1: broker the value 

    Give the service its own uid, declare the secret as a catalog value, grant that uid op:get, and have the service fetch the value from Basil instead of reading a sops-nix file.

    # Import the Basil NixOS module from a Basil checkout.
    service.basil = {
      enable = true;
    
      catalog = {
        schemaVersion = 1;
        backends.bao = {
          implementation = (import ./nix/backend-capabilities.nix).OPENBAO_2_5;
          addr = "https://127.0.0.1:8200";
        };
        keys."app.db_password" = {
          class = "value";
          backend = "bao";
          engine = "kv2";
          path = "secret/data/app/db-password";
          writable = true;
          missing = "generate";
          generate = { format = "ascii-printable"; bytes = 24; };
        };
      };
    
      policy = {
        unixSubjects.svc-app = { user = "app"; };
    
        rules = [
          {
            id = "app-can-read-its-password";
            subjects = [ "svc-app" ];
            action = [ "op:get" ];
            target = [ "app.db_password" ];
            comment = "The app service may fetch only its own database password.";
          }
        ];
      };
    
      bundle = "/var/lib/basil/bundle.sealed";
      settings = {
        socket = "/run/basil/basil.sock";
        socketMode = "0660";
        socketGroup = "basil";
        vaultAddr = "https://127.0.0.1:8200";
        auditLog = "/var/lib/basil/audit.jsonl";
      };
    };
    
    users.users.app = { isSystemUser = true; group = "app"; };
    users.groups.app = {};
    
    systemd.services.app = {
      serviceConfig = {
        User = "app";
        Group = "app";
        SupplementaryGroups = [ "basil" ];
        RuntimeDirectory = "app";
      };
    
      preStart = ''
        ${pkgs.basil}/bin/basil --socket /run/basil/basil.sock \
          get --key-id app.db_password --format raw \
          > "$RUNTIME_DIRECTORY/db_password"
      '';
    };

    The application can keep reading $RUNTIME_DIRECTORY/db_password, but the value is no longer baked into the system configuration. Only the granted uid can obtain it, and each read is audited.

    ✅ Prefer fetching in process when you can

    Writing a runtime file is a compatibility step for applications that already expect one. If you own the application code, call Basil from the Rust or Go client at the moment the value is needed, so the secret lives only in process memory.

    After, Tier 2: broker the operation 

    If the secret is really a key, do not deliver it. Declare it as an in-place key and grant only the operation the workload needs:

    service.basil.catalog.keys."app.tls.signing_key" = {
      class = "asymmetric";
      keyType = "ed25519";
      backend = "bao";
      engine = "transit";
      path = "app-tls";
      writable = true;
      missing = "generate";
    };
    
    service.basil.policy.roles.signer = [ "sign" "verify" "get_public_key" ];
    
    service.basil.policy.rules = [
      {
        id = "app-can-sign";
        subjects = [ "svc-app" ];
        action = [ "role:signer" ];
        target = [ "app.tls.signing_key" ];
      }
    ];

    The service now calls Basil to sign, verify, or fetch the public key. The private key never touches the app's disk, memory, environment, or systemd credential store.

    The same pattern covers:

    • encrypt and decrypt for AEAD keys;
    • issue-cert for X.509 leaves from a backend PKI role;
    • NATS identity minting and JWT signing;
    • SPIFFE X.509-SVID and JWT-SVID issuance.

    Rotation without a rebuild 

    For generated values and in-place keys, rotation is a live broker operation:

    basil --socket /run/basil/basil.sock rotate --key-id app.db_password

    For value keys without a generate recipe, set new material explicitly:

    basil --socket /run/basil/basil.sock set --key-id app.db_password "$NEW_PASSWORD"

    Compare that with the sops-nix loop: edit the encrypted file, commit it, rebuild or switch the host, then restart whatever needs the new value.

    Secret zero 

    With sops-nix, the host key that can decrypt the secret set sits on the host. With Basil, the host-local credential is the sealed bundle, unlocked once at boot. The bundle holds the backend credential Basil needs, encrypted to one or more unlock slots.

    Choose the unlock method that fits the host:

    • passphrase for unattended startup through a systemd credential or protected file;
    • tpm for a TPM-sealed slot on hosts built with the unlock-tpm feature;
    • age-yubikey for a hardware-backed operator unlock;
    • bip39 for break-glass recovery.

    Create the bundle with basil bundle create, keep it mode 0600, and keep it outside the Nix store. See Unlock & the sealed bundle for the full model.

    Tradeoffs 

    • You need a backend. Basil fronts OpenBao, HashiCorp Vault, AWS KMS, Google Cloud KMS, 1Password, or db-keystore, depending on the custody model you choose. sops-nix needs no running broker.
    • Basil adds a local hop. The agent brokers access over a Unix socket and authorizes by kernel peer credentials. That is the point, but it is still another service to run.
    • Tier 2 needs an app change. The app calls the broker instead of reading a file. Tier 1 only changes where the value comes from.
    • One uid per workload matters. The uid is usually the workload identity. Two services sharing a uid share every grant.

    Try it before changing a host 

    Run the dev fixture first. It boots a throwaway OpenBao or Vault, writes a catalog and policy, seals a bundle, and prints the commands to drive the broker:

    scripts/prefill-test-store.sh --engine openbao

    That gives you the same control loop you will use on a real host: catalog, policy, sealed bundle, broker, and CLI calls over the Basil socket.

    Where to go next 

    • Quickstart: run Basil end to end with the dev fixture.
    • Make it your own: adapt the self-contained Nix example.
    • The catalog: declare value keys, transit keys, and custody choices.
    • The policy: grant op:get, roles, and per-workload authority.