Use promo code BETATEST1 for full access
seekrit
Docs/HashiCorp Nomad

HashiCorp Nomad

Nomad's secret block lets a job name a credential instead of holding one. seekrit ships a secret provider plugin for it, so a job file looks like this:

job "billing-api" {
  namespace = "billing"

  secret "seekrit" {
    provider = "seekrit"
    path     = "billing-prod"
  }

  group "api" {
    task "server" {
      driver = "docker"

      env {
        DATABASE_URL      = "${secret.seekrit.DATABASE_URL}"
        STRIPE_SECRET_KEY = "${secret.seekrit.STRIPE_SECRET_KEY}"
      }
    }
  }
}

Nothing in that file is a credential, and nothing in Nomad's state is a plaintext.

How it works

Nomad execs the plugin on the client node that will run the task, and reads one JSON object from its stdout. The plugin holds the service token, calls GET /v1/resolve, and decrypts in a process that lives for one fetch.

job `secret` block ─▶ Nomad client ──exec──▶ seekrit plugin ──/v1/resolve──▶ seekrit API
  (names a profile)    (your host)            (decrypts here)                (ciphertext only)
note

The seekrit API never sees plaintext, and neither does the Nomad server. Decryption stays on your client node, exactly as it does for the CLI and seekrit run.

Prerequisites

Nomad 1.11 or later — the secret block and the secret provider plugin framework arrived in that release.

A seekrit service token bound to the app environment the jobs need (see Service tokens):

seekrit token create --name nomad-billing --app billing --env production
# prints:  skt_XXXXXXXX_…    (save it now)

1. Install the plugin on every client

curl -fsSL https://nomad.seekrit.dev/install.sh | sh

or, with the CLI already installed:

seekrit nomad install
caution

The installed filename is the provider name. Nomad matches a job's provider value against the plugin executable's filename, so the file must be called seekrit — the installers handle this, but a hand-copied binary under its artifact name (seekrit-nomad-secrets) registers nothing. There is no error and no log line; jobs simply cannot resolve their secrets.

The plugin has to land in the secrets/ subdirectory of the agent's plugin directory, and the agent has to be told where that is:

# /etc/nomad.d/client.hcl
client {
  enabled           = true
  common_plugin_dir = "/opt/nomad/plugins"
}

/opt/nomad/plugins/secrets/seekrit

2. Give the client a credential

The plugin reads its configuration from /etc/seekrit/nomad.json on the client. seekrit nomad init prints the whole set of files for the machine you run it on:

seekrit nomad init --profile billing-prod --namespace billing

Profiles are named credentials the node operator declares. A job picks one by name; it cannot introduce one, widen one, or supply its own token.

{
  "profiles": {
    "billing-prod": {
      "token_file": "/etc/seekrit/tokens/billing",
      "namespaces": ["billing"],
      "jobs": ["billing-api", "billing-worker"]
    }
  }
}
# The token itself lives in its own file, not in the config.
sudo install -m 600 /dev/null /etc/seekrit/tokens/billing
echo "skt_…" | sudo tee /etc/seekrit/tokens/billing > /dev/null
sudo chmod 600 /etc/seekrit/nomad.json
caution

A plugin is shared by every job on the client. Without namespaces or jobs, any job scheduled on that node can use the profile — which is a weaker boundary than the job file suggests. Restrict every profile that holds something you would not give the whole client.

KeyMeaning
token_fileRead the token from this file. Preferred — it keeps the credential out of both the config and the process table.
token_envRead it from a named variable in the agent's environment.
tokenInline. Works; puts a credential in a file that would otherwise be safe to check in.
namespacesNomad namespaces allowed to use this profile. Absent = any. [] = none. A trailing * globs.
jobsThe same, matched against NOMAD_JOB_ID.
api_urlPer-profile API base.
branchResolve a branch config layered on the token's environment.
allow_branch_overrideLet a job choose the branch via SEEKRIT_BRANCH in its secret.env block. Off by default.
interpolateExpand ${OTHER_SECRET} references. On by default.

Unknown keys are a hard error, deliberately: a misspelled namespaces would otherwise parse as "no restriction" and silently open the fence.

3. Restart the agent

sudo systemctl restart nomad
caution

Plugins are fingerprinted once, at client startup. Installing into a running agent does nothing until it restarts — and, again, without an error anywhere.

4. Check your work

seekrit nomad check --path billing-prod --namespace billing --job billing-api
nomad plugin check
installed    ok — /opt/nomad/plugins/secrets/seekrit
fingerprint  ok — 0.1.0
fetch        ok — 8 secrets

check runs the plugin exactly the way Nomad does, including the namespace and job id it would pass, so a profile your job is fenced away from fails here rather than at 3am. It prints a count, never names or values.

Paths

The first segment of path is always a profile name, never a secret name.

pathResolves to
(empty)the default profile, whole environment
billing-prodthat profile, whole environment
billing-prod/STRIPE_API_KEYone secret from that profile

A whole-environment fetch is the usual choice, because Nomad interpolates ${secret.<block>.<KEY>} per key. Naming a single secret narrows what the job's templates can reach:

secret "stripe" {
  provider = "seekrit"
  path     = "billing-prod/STRIPE_SECRET_KEY"
}

Trying it on one client

For a single node, you can skip the config file entirely: put a token in the Nomad agent's own environment and use the default profile.

# /etc/systemd/system/nomad.service.d/seekrit.conf
[Service]
Environment=SEEKRIT_TOKEN=skt_…
secret "seekrit" {
  provider = "seekrit"
  # no `path` — the default profile, whole environment
}

Every job on that client can then read that one environment. Good for trying the plugin; not the shape for a shared cluster.

Availability

Nomad resolves secrets once per task placement, so a transient API blip during a placement storm would otherwise fail every task on the node at once. Turn on the last-known-good cache to survive that:

{
  "cache": { "dir": "/var/lib/seekrit/nomad-cache", "max_age": "15m" }
}

Only the still-encrypted resolve body is written to disk — the same bytes the API served, no more sensitive than the token next to it. Only a transport failure falls back: a 401 does not, because serving a cached payload after the API refused the token would quietly extend a credential that was revoked. max_age bounds how long that can last.

Telemetry

Nomad shows a plugin failure as one string on a task event and discards the plugin's stderr, so the plugin exports OpenTelemetry to your collector if you configure one:

Environment=OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318

It records the profile name, the calling namespace and job id, a secret count, whether the cache was used, and an error kind — never a value, a token, or ciphertext. See Telemetry.

Troubleshooting

Every way to get a Nomad plugin wrong fails silently, so work down this list rather than looking for an error message:

SymptomCause
The job reports an unknown providerThe executable is not named seekrit, or is not in <common_plugin_dir>/secrets/.
Nothing happens at allcommon_plugin_dir is unset in the agent's client block.
It worked before the installThe agent has not restarted; plugins are fingerprinted at startup.
may not use the "…" profileThe job's namespace or id is not in that profile's namespaces/jobs.
no seekrit profile named …The path's first segment does not match a profile on that client.

seekrit nomad check distinguishes the first three from the last two.

Reference