Skip to main content

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.

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.

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 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.
  • An API key with the Agent role attached. See Authenticate the agent.
  • 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 and the KV v2 docs. 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:

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.

  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.
  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.
HashiCorp Vault Self-Hosted provider setup

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.

  1. Go to Platform settings > Notifications > Channels, and click + New channel.
  2. Set Source to parameter, pick the resource (NRN) and any dimensions.
  3. Configure the agent command to run your handler. See the CLI tab for a working payload.
HashiCorp Vault Self-Hosted notification channel setup

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.

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:

{
"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. 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

#!/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:

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

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

Next steps