---
sidebar_label: HashiCorp Vault Self-Hosted
toc_max_heading_level: 3
doc_id: 7c3f1d2a-8e5b-4f6a-9c1d-2b8e4f7a3c9d
description: >-
  Configure HashiCorp Vault as a parameter backend while keeping the Vault
  token entirely on your side. Nullplatform forwards the Vault connection
  metadata to your handler via parameter lifecycle events.
keywords:
  - hashicorp vault
  - self-hosted
  - parameters
  - external storage
  - secret management
  - kv v2
  - agent
---

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

# Set up HashiCorp Vault Self-Hosted as a parameter backend

This guide walks you through using HashiCorp Vault as a parameter backend while keeping the Vault token entirely on your side. You register the Vault address, mount, and base path in nullplatform; nullplatform forwards those values to your handler in every parameter lifecycle event, and your handler talks to Vault using a token sourced from your own environment.

## When to choose this variant

This is one of two agent-backed variants exposed under **Parameters & Secrets > Storage**:

- **HashiCorp Vault Self-Hosted** (this guide): a Vault-aware variant. You register the Vault address, mount, and base path in nullplatform, and those values reach your handler in every parameter lifecycle event. The Vault token stays in your infrastructure.
- **Other agent-backed Storage**: a generic marker that routes events to your handler with no specific backend in mind. Use it when your backend isn't Vault.

Pick Self-Hosted when you run Vault and want nullplatform to forward the connection metadata to your handler on each operation, while the rest of the platform keeps behaving as if parameter values lived in nullplatform's own database.

## How it works

Like Other agent-backed Storage, this variant doesn't store anything in nullplatform. It marks a `{NRN, dimensions}` tuple as "values for this tuple are handled outside nullplatform" and forwards every lifecycle event to a notification channel you configure. The extra piece, compared to the generic agent-backed variant, is the **provider configuration**: nullplatform passes the resolved provider object inside the notification context, so your handler reads `context.provider.attributes.setup.vault_address`, `mount`, and `secret_path` to know which Vault to talk to.

```mermaid
%%{init: {'theme':'base','themeVariables':{
  'fontFamily':'-apple-system, BlinkMacSystemFont, Segoe UI, sans-serif',
  'primaryColor':'#274a86',
  'primaryTextColor':'#ffffff',
  'actorBkg':'#274a86',
  'actorTextColor':'#ffffff',
  'actorLineColor':'#cdd5e3',
  'signalColor':'#0b1e3f',
  'signalTextColor':'#0b1e3f',
  'labelBoxBkgColor':'#00d4a8',
  'labelBoxBorderColor':'#00b894',
  'labelTextColor':'#0b1e3f',
  'noteBkgColor':'#eef3fb',
  'noteBorderColor':'#cdd5e3',
  'noteTextColor':'#0b1e3f',
  'sequenceNumberColor':'#ffffff'
}}}%%
sequenceDiagram
  autonumber

  participant Dev as 👤 Developer
  participant API as Parameters API
  participant Channel as Notification channel
  participant Handler as Your handler
  participant Vault as Your Vault

  rect rgb(238, 243, 251)
    Dev->>+API: np parameter-value create
  end

  rect rgb(232, 248, 243)
    API->>+Channel: parameter:store + provider config
    Channel->>+Handler: deliver action + context.provider
  end

  rect rgb(245, 240, 250)
    Handler->>+Vault: write secret using forwarded address/mount/path<br/>(token sourced locally)
    Vault-->>-Handler: ok
  end

  rect rgb(238, 243, 251)
    Handler-->>-API: { external_id, metadata }
    deactivate Channel
    API-->>-Dev: parameter value created
  end
```

Three pieces work together:

1. **The provider**, which you create with the slug `hashicorp-vault-self-hosted`. It carries the Vault connection metadata (`vault_address`, `mount`, `secret_path`) but no token.
2. **A notification channel** subscribed to the `parameter` source. It tells nullplatform where to send lifecycle events.
3. **Your handler**, reachable through the agent. It receives each event, reads the forwarded provider config, talks to Vault using a locally-sourced token, and returns the result to nullplatform.

The handler contract is the same as Other agent-backed Storage: four actions (`parameter:store`, `parameter:retrieve`, `parameter:delete`, `parameter:notify`) and a small JSON response shape. The only difference is what `context` contains, covered in [Step 3](#step-3-implement-the-handler).

## Prerequisites

Before you start, make sure you have:

- A reachable HashiCorp Vault instance with a KV v2 secrets engine enabled, accessible from where your handler runs.
- A Vault token available to your handler, with the policy described in [Required Vault token policy](#required-vault-token-policy) below. The token is whatever your environment provides: an env var, a customer-side secret manager, a sidecar, anything that doesn't require sending the token to nullplatform.
- A reachable agent running inside your infrastructure. See [Install the agent](/docs/agent/installation).
- An API key with the **Agent** role attached. See [Authenticate the agent](/docs/agent/authentication).
- The mount point and base path you want to use (defaults: `secret` mount, `nullplatform` path).

:::note
If you don't yet have Vault running, see HashiCorp's [Vault deployment guide](https://developer.hashicorp.com/vault/docs/install) and the [KV v2 docs](https://developer.hashicorp.com/vault/docs/secrets/kv/kv-v2). The rest of this page assumes Vault is up and your handler can reach it.
:::

## Required Vault token policy

The token your handler uses must be able to create, read, update, and delete secrets under the configured path, plus manage the metadata that sits alongside them. With the defaults (`mount: secret`, `secret_path: nullplatform`), the minimum policy is:

```hcl
path "secret/data/nullplatform/*" {
  capabilities = ["create", "read", "update", "delete"]
}

path "secret/metadata/nullplatform/*" {
  capabilities = ["create", "read", "update", "delete", "list"]
}

path "secret/metadata/nullplatform" {
  capabilities = ["read", "list"]
}
```

If you customize `mount` or `secret_path` in the provider, replace `secret` and `nullplatform` in the paths above with your values. The `metadata` paths are required because traceability metadata (parameter ID, NRN, dimensions, secret flag) is written next to each value.

## Step 1: Create the provider

Create a `hashicorp-vault-self-hosted` provider on the NRN where you want Vault to take over. The setup section captures the Vault connection metadata. There's no `security` section: the token never leaves your side.

<Tabs
defaultValue="vault-self-ui"
values={[
{ label: 'UI', value: 'vault-self-ui' },
{ label: 'CLI', value: 'vault-self-cli' },
{ label: 'OpenTofu', value: 'vault-self-tf' },
{ label: 'cURL', value: 'vault-self-api' },
]}>

<TabItem value="vault-self-ui">

1. Go to **Platform settings > Parameters & Secrets > Storage**, and click **+ New provider**.
2. Select **HashiCorp Vault Self-Hosted** and pick the resource (NRN) and any [dimensions](/docs/dimensions).
3. Fill in **Setup**:
   - **Mount Point**: KV v2 engine mount. Defaults to `secret`.
   - **Secret Path**: base path inside the mount. Defaults to `nullplatform`.
   - **Vault Address**: full URL reachable from your handler, e.g. `https://vault.acme-corp.io`.
4. Click **Create provider**.

<img alt="HashiCorp Vault Self-Hosted provider setup" src="/img/parameters/hashicorp-vault-self-hosted.png" width="100%" className="helper-image" />

</TabItem>

<TabItem value="vault-self-cli">

```bash
np provider create \
  --body '{
    "nrn": "organization=1:account=2:namespace=3:application=4",
    "specification_slug": "hashicorp-vault-self-hosted",
    "dimensions": {},
    "attributes": {
      "setup": {
        "vault_address": "https://vault.acme-corp.io",
        "mount": "secret",
        "secret_path": "nullplatform"
      }
    }
  }'
```

</TabItem>

<TabItem value="vault-self-tf">

```hcl
data "nullplatform_provider_specification" "vault_self_hosted" {
  slug = "hashicorp-vault-self-hosted"
}

resource "nullplatform_provider" "vault_self_hosted" {
  nrn              = "organization=1:account=2:namespace=3:application=4"
  specification_id = data.nullplatform_provider_specification.vault_self_hosted.id

  attributes = jsonencode({
    setup = {
      vault_address = "https://vault.acme-corp.io"
      mount         = "secret"
      secret_path   = "nullplatform"
    }
  })
}
```

</TabItem>

<TabItem value="vault-self-api">

```bash
curl -L -X POST 'https://api.nullplatform.com/provider' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer <token>' \
  -d '{
    "nrn": "organization=1:account=2:namespace=3:application=4",
    "specification_slug": "hashicorp-vault-self-hosted",
    "dimensions": {},
    "attributes": {
      "setup": {
        "vault_address": "https://vault.acme-corp.io",
        "mount": "secret",
        "secret_path": "nullplatform"
      }
    }
  }'
```

</TabItem>
</Tabs>

The provider on its own doesn't do anything. The next step wires it up to your agent.

## Step 2: Create the notification channel

Create an agent channel that listens for parameter lifecycle events and runs your handler scripts. The same channel handles all four actions: nullplatform passes the action name in the notification context, and your handler dispatches accordingly.

<Tabs
defaultValue="vault-self-channel-ui"
values={[
{ label: 'UI', value: 'vault-self-channel-ui' },
{ label: 'CLI', value: 'vault-self-channel-cli' },
{ label: 'cURL', value: 'vault-self-channel-curl' },
]}>

<TabItem value="vault-self-channel-ui">

1. Go to **Platform settings > Notifications > Channels**, and click **+ New channel**.
2. Set **Source** to `parameter`, pick the resource (NRN) and any [dimensions](/docs/dimensions).
3. Configure the agent command to run your handler. See the CLI tab for a working payload.

<img alt="HashiCorp Vault Self-Hosted notification channel setup" src="/img/parameters/vault-self-channel.png" width="100%" className="helper-image" />

</TabItem>

<TabItem value="vault-self-channel-cli">

```bash
np notification channel create \
  --body '{
    "nrn": "organization=1:account=2:namespace=3:application=4",
    "source": ["parameter"],
    "description": "Vault Self-Hosted handler",
    "type": "agent",
    "configuration": {
      "api_key": "AAAA.1234567890abcdef1234567890abcdefPTs=",
      "command": {
        "type": "exec",
        "data": {
          "cmdline": "$SERVICE_PATH/entrypoint --action=$NP_ACTION",
          "environment": {
            "NP_ACTION_CONTEXT": "${NOTIFICATION_CONTEXT}",
            "NP_ACTION": "${NOTIFICATION_ACTION}"
          }
        }
      },
      "selector": {
        "environment": "production"
      }
    },
    "filters": {}
  }'
```

</TabItem>

<TabItem value="vault-self-channel-curl">

```bash
curl -L 'https://api.nullplatform.com/notification/channel' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -H 'Authorization: Bearer <token>' \
  -d '{
    "nrn": "organization=1:account=2:namespace=3:application=4",
    "source": ["parameter"],
    "description": "Vault Self-Hosted handler",
    "type": "agent",
    "configuration": {
      "api_key": "AAAA.1234567890abcdef1234567890abcdefPTs=",
      "command": {
        "type": "exec",
        "data": {
          "cmdline": "$SERVICE_PATH/entrypoint --action=$NP_ACTION",
          "environment": {
            "NP_ACTION_CONTEXT": "${NOTIFICATION_CONTEXT}",
            "NP_ACTION": "${NOTIFICATION_ACTION}"
          }
        }
      },
      "selector": {
        "environment": "production"
      }
    },
    "filters": {}
  }'
```

</TabItem>
</Tabs>

A few things to note:

- `source: ["parameter"]` is what makes this channel pick up parameter events. Don't add unrelated sources here; mixing them makes the handler harder to reason about.
- `selector` matches a tag on the agent. Make sure at least one of your agents declares the same tag, otherwise the event has nowhere to go.
- `filters` can narrow events further (for example, only events where `parameter.secret = true`). Leave it empty until you have a concrete reason to narrow it.

For the full channel reference, see [Set up an agent notification channel](/docs/agent/agent-channel).

## Step 3: Implement the handler

The agent runs your script for each lifecycle event. It passes:

- `NP_ACTION`: the action being processed (`parameter:store`, `parameter:retrieve`, `parameter:delete`, `parameter:notify`).
- `NP_ACTION_CONTEXT`: a JSON blob with the full notification context. For this provider, the context includes a `provider` object with the Vault connection metadata you configured in Step 1.
- `EXTERNAL_ID`: for `retrieve` and `delete`, the identifier returned by the previous `store` for the same value.

### The `provider` block in the context

The notification context includes the resolved provider, exactly as nullplatform sees it:

```json
{
  "action": "parameter:store",
  "parameter_id": 12345,
  "parameter_name": "DB_PASSWORD",
  "value": "the-actual-secret",
  "secret": true,
  "entities": { "application": "advertising-api", "scope": "prod-us" },
  "dimensions": { "environment": "production" },
  "provider": {
    "id": "…",
    "nrn": "organization=1:account=2:namespace=3:application=4",
    "dimensions": {},
    "specification_id": "…",
    "attributes": {
      "setup": {
        "vault_address": "https://vault.acme-corp.io",
        "mount": "secret",
        "secret_path": "nullplatform"
      }
    }
  }
}
```

Your handler reads `provider.attributes.setup.vault_address`, `mount`, and `secret_path` to decide where to write in Vault. The token comes from your environment: pick whatever mechanism fits your setup (env var, customer-side secret manager, sidecar, etc.).

### What each action expects

The four actions follow the same contract as [Other agent-backed Storage](/docs/parameters/agent-backed-storage#what-each-action-expects). The differences are mechanical:

- **`parameter:store`**: build the Vault URL from `provider.attributes.setup.*`, write the value to KV v2, return `{ "external_id": "<path-or-id>" }`. Pick a deterministic external ID so the same `{parameter, scope, dimensions}` always resolves to the same Vault key.
- **`parameter:retrieve`**: read the value from Vault using `EXTERNAL_ID` + the forwarded `provider.attributes.setup.*`. Return `{ "value": "..." }`, or `{ "value": "value not found" }` if the secret is missing in Vault.
- **`parameter:delete`**: delete the secret in Vault. Return `{ "success": true }`. Treat a missing record as success: the value is already gone.
- **`parameter:notify`**: fires after a value is stored. Useful for audit trails. Return `{ "success": true }` if you don't need anything custom.

### A minimal store handler

```bash
#!/bin/bash
set -euo pipefail

# Read connection metadata forwarded by nullplatform.
VAULT_ADDR=$(jq -r '.provider.attributes.setup.vault_address' <<< "$NP_ACTION_CONTEXT")
MOUNT=$(jq -r '.provider.attributes.setup.mount // "secret"' <<< "$NP_ACTION_CONTEXT")
SECRET_PATH=$(jq -r '.provider.attributes.setup.secret_path // "nullplatform"' <<< "$NP_ACTION_CONTEXT")

# Read the token from wherever your environment keeps it.
VAULT_TOKEN="${VAULT_TOKEN_FROM_LOCAL_STORE:?token must be available in env}"

PARAMETER_NAME=$(jq -r '.parameter_name' <<< "$NP_ACTION_CONTEXT")
VALUE=$(jq -r '.value' <<< "$NP_ACTION_CONTEXT")
EXTERNAL_ID="${PARAMETER_NAME}-$(uuidgen)"

curl -fsS -X POST "${VAULT_ADDR}/v1/${MOUNT}/data/${SECRET_PATH}/${EXTERNAL_ID}" \
  -H "X-Vault-Token: ${VAULT_TOKEN}" \
  -H "Content-Type: application/json" \
  -d "{\"data\":{\"value\":${VALUE@Q}}}" \
  > /dev/null

jq -n --arg id "$EXTERNAL_ID" '{ external_id: $id }'
```

Adapt the same pattern for `retrieve`, `delete`, and `notify`. The four sibling scripts each produce the JSON described above on stdout. Anything written to stderr is captured by the agent and surfaced in nullplatform's notification logs.

## Step 4: Verify the flow

Create a parameter value under the NRN you configured:

```bash
np parameter-value create \
  --parameter <parameter-id> \
  --nrn organization=1:account=2:namespace=3:application=4 \
  --value "test-value"
```

Then check, in this order:

1. **The notification log**. Each action should show up as a delivered notification. Failures appear with the stderr captured from your script.
2. **Vault**. The value should be present under `{mount}/data/{secret_path}/...` for the NRN you targeted.
3. **Read it back**. `np parameter-value read <id>` returns the value as if it lived in nullplatform. The retrieve action runs in the background to fetch it from your Vault.

## Token rotation

Token rotation is entirely on your side. Update the token wherever your handler reads it from (env var, customer-side secret manager) and roll the handler if needed. Nullplatform doesn't cache the token, doesn't track its expiry, and won't notify you when it's about to expire: that part of the lifecycle stays inside your infrastructure.

If the Vault address itself changes, update the provider in nullplatform with the new `vault_address`. The resolver caches the configuration per `{NRN, dimensions}` tuple for up to 5 minutes; existing parameter operations keep working with the previous address until the cache refreshes.

## Troubleshooting

### The notification never reaches my agent

- Check the channel `selector` matches a tag on at least one agent. A channel without a matching agent silently swallows events.
- Check `source: ["parameter"]` is set. A channel subscribed only to `service` or `telemetry` won't see parameter events.
- Inspect the channel and the agent in **Platform settings > Notifications > Channels**.

### `parameter:store` succeeds but reads return null

The `external_id` your `store` script returned was empty or `null`. Nullplatform stored an empty reference, so the subsequent retrieve has nothing to look up. Make sure `store` always emits `{"external_id": "..."}` with a non-empty string.

### Handler fails with `Vault: permission denied`

The token your handler uses doesn't have write permission at the configured path. Confirm the policy attached to the token allows `create`, `update`, and `delete` on `{mount}/data/{secret_path}/*` and on `{mount}/metadata/{secret_path}/*`.

### Reads return `value not found` after a successful write

Check that the provider's `mount` and `secret_path` match what you actually have in Vault. The forwarded values are in `context.provider.attributes.setup.*`; log them in your handler to compare against a manual `vault kv get`.

### The provider is created but secrets are still going to nullplatform's database

Two common causes:

- The NRN you used on the parameter value is not under the NRN where you configured the provider, so [NRN inheritance](/docs/providers/overview#provider-inheritance) doesn't match.
- The dimensions on the value don't overlap with the dimensions on the provider.

List active providers for the same NRN to confirm coverage:

```bash
np provider list --nrn organization=1:account=2:namespace=3:application=4
```

## Next steps

- [Other agent-backed Storage](/docs/parameters/agent-backed-storage): the generic variant when your backend isn't Vault.
- [Provider inheritance](/docs/providers/overview#provider-inheritance): how nullplatform picks the best matching provider.
- [Set up an agent notification channel](/docs/agent/agent-channel): full reference for the channel resource.
