HashiCorp Vault Integration

Updated

September 12, 2026

HashiCorp Vault Integration

sops-nix and age cover static secrets in git. Large desks that already run Vault want short-lived database passwords and certs. The boring default is: Vault Agent on the node, templates to /run/vault, Nix only knows the paths — and if you do not already operate Vault, stay on sops-nix.

Mental model

Vault issues leases. The agent:

  1. Authenticates (AppRole, AWS, Kubernetes — pick one).
  2. Reads secrets / PKI.
  3. Renders a file on tmpfs.
  4. Signals the unit (SIGHUP or restart) when the file changes.

NixOS may ship services.vault-agentcheck your 26.05 pin (nixos-option services.vault-agent). If the option is missing, Case 1’s systemd.services.vault-agent-desk is the boring wrapper. The role-id can live in the store. The secret-id cannot. Vault Agent is a node sidecar, not a reason to put VAULT_TOKEN in desk-api’s Nix module.

Vault  --lease-->  vault-agent  --template-->  /run/vault/db.env
                                                      │
                                                      ▼
                                              desk-api EnvironmentFile=

Worked examples

Case 1: Agent module

Save as vault-agent.nix:

# vault-agent.nix
{ pkgs, ... }:

{
  systemd.services.vault-agent-desk = {
    description = "Vault Agent for desk-api";
    wantedBy = [ "multi-user.target" ];
    after = [ "network-online.target" ];
    serviceConfig = {
      ExecStart = "${pkgs.vault}/bin/vault agent -config /etc/vault-agent.hcl";
      Restart = "on-failure";
      DynamicUser = true;
      ProtectSystem = "strict";
      PrivateTmp = true;
      RuntimeDirectory = "vault-agent";
      LoadCredential = "secret-id:/run/secrets/vault-secret-id";
    };
  };

  environment.etc."vault-agent.hcl".text = ''
    vault { address = "https://vault.desk.internal:8200" }
    auto_auth {
      method "approle" {
        config = {
          role_id_file_path = "/etc/vault/role-id"
          secret_id_file_path = "/run/secrets/vault-secret-id"
        }
      }
    }
    template {
      source      = "/etc/vault/templates/db.ctmpl"
      destination = "/run/vault-agent/db.env"
      perms       = "0400"
    }
  '';
}

role-id is not a secret in many setups. secret-id is — put it in sops-nix, not in environment.etc.

Case 2: Template without secret values in Nix

Save as db.ctmpl (provisioned via environment.etc — this file has Go template placeholders, not passwords):

# db.ctmpl
{{ with secret "database/creds/desk-api" }}
DESK_DB_USER={{ .Data.username }}
DESK_DB_PASSWORD={{ .Data.password }}
{{ end }}

The rendered /run/vault-agent/db.env never enters /nix/store.

Case 3: Reload the app when the lease rotates

# vault-reload.nix
{
  systemd.services.desk-api = {
    serviceConfig.EnvironmentFile = "/run/vault-agent/db.env";
    unitConfig.StartLimitIntervalSec = 0;
  };

  systemd.paths.desk-api-reload = {
    wantedBy = [ "multi-user.target" ];
    pathConfig.PathModified = "/run/vault-agent/db.env";
  };

  systemd.services.desk-api-reload = {
    serviceConfig = {
      Type = "oneshot";
      ExecStart = "/run/current-system/sw/bin/systemctl reload-or-restart desk-api.service";
    };
  };
}

If the app cannot reload credentials without a restart, restart. Document that. Leases that expire while the process still holds the old password are outages.

Case 4: AppRole files

# vault-approle.nix
{ config, ... }:

{
  environment.etc."vault/role-id".text = "00000000-0000-0000-0000-000000000000";

  # secret-id from sops, not from etc
  sops.secrets."vault/secret-id" = {
    mode = "0400";
  };
}

Use a real UUID from vault read auth/approle/role/desk-api/role-id. The example zeros are a placeholder so this listing does not pretend to be a live role.

Case 5: When not to use Vault

If the desk has five hosts and two humans, Vault is a second production system. sops-nix + age is the boring stack. Add Vault when the org already requires it, or when credentials must rotate every hour.

The trap

The trap is a root Vault token in configuration.nix. That token is in the store and in every cache copy. AppRole + a sops-backed secret-id. Never VAULT_TOKEN in the flake.

The other trap is ignoring lease TTL. Agent templates that nobody reloads leave the DB password valid in Vault’s eyes for twenty minutes after the process started — then Postgres rejects it at 3 a.m.

The boring rule

  • Nix knows addresses, role ids, and paths. Bytes stay in Vault and /run.
  • AppRole (or cloud auth). No root tokens in git.
  • Reload or restart on template change.
  • secret-id via sops-nix. role-id may be public.
  • Skip Vault if sops-nix already solves the desk.

Try this

  1. nixos-option services.vault-agent (or rg vault-agent in nixpkgs) on a 26.05 pin and write down the module path your release actually ships.
  2. Render a dummy db.ctmpl with static {{ printf "x" }} locally using consul-template / vault agent -dev if you have a lab Vault. Confirm the output file is not a store path.
  3. git grep VAULT_TOKEN on the desk repo. It should be empty.
  4. Time a database lease TTL and confirm desk-api restarts before expiry in the lab.