SPIFFE & SPIRE on VMs and Bare Metal: Stop Hardcoding Secrets in Your Non-K8s Infra

Most zero-trust identity content assumes you’re running everything inside Kubernetes. You get service accounts, projected tokens, and half a dozen admission controllers doing the heavy lifting. That’s great — until your actual infrastructure is a mix of legacy VMs, bare-metal database servers, a handful of cloud instances, and maybe one K8s cluster that holds 20% of your workloads.

That’s the real world for most engineering teams. And in that world, service-to-service authentication usually degrades to one of three things: hardcoded API keys in environment files, shared TLS certificates that get rotated once a year if you’re lucky, or network-level controls that break the moment someone adds a new subnet.

SPIFFE and SPIRE solve this cleanly. They give every workload — regardless of where it runs — a cryptographically verifiable identity that rotates automatically, requires no secrets at deploy time, and works the same whether the service is on a Raspberry Pi in your lab or a VM on Azure.

This article is specifically about the non-Kubernetes path. If you’re only running K8s, there are simpler on-ramps (cert-manager + SPIRE integration, or just using the SPIRE K8s operator). Here we’re going bare metal.

Official project: github.com/spiffe/spire


What SPIFFE Actually Is (Fast Version)

SPIFFE (Secure Production Identity Framework For Everyone) is a spec, not software. It defines:

  • SPIFFE ID — a URI that uniquely identifies a workload: spiffe://your-trust-domain/service/payments-api
  • SVID (SPIFFE Verifiable Identity Document) — the identity credential. Either an X.509 certificate with the SPIFFE ID in the SAN, or a JWT. X.509 SVIDs are what you use for mTLS.
  • Workload API — a local Unix socket that workloads call to get their SVID without ever touching the network or managing credentials themselves.

SPIRE is the reference implementation. Two components:

  • SPIRE Server — the CA. Manages trust, signs SVIDs, stores registration entries. Runs centrally (or in HA).
  • SPIRE Agent — runs on every node. Attests its own node identity to the server, then serves the Workload API to local processes.

The key insight: a workload never needs to know any secret to get its identity. It calls the local socket, the agent verifies the process’s attributes (uid, pid, binary path, etc.), and if those match a registration entry, it hands over an SVID. That’s it.


Architecture for a Mixed VM/Bare-Metal Setup

Here’s the topology we’re building:

                        ┌─────────────────────┐
                        │    SPIRE Server      │
                        │  (dedicated VM or    │
                        │   your control node) │
                        │   :8081 (gRPC)       │
                        └─────────┬───────────┘
                                  │ mTLS (bootstrap)
               ┌──────────────────┼──────────────────┐
               │                  │                  │
        ┌──────▼──────┐   ┌───────▼─────┐   ┌───────▼─────┐
        │ SPIRE Agent │   │ SPIRE Agent │   │ SPIRE Agent │
        │  (VM-1)     │   │  (VM-2)     │   │ (bare-metal)│
        │ Unix socket │   │ Unix socket │   │ Unix socket │
        └──────┬──────┘   └──────┬──────┘   └──────┬──────┘
               │                 │                  │
        ┌──────▼──────┐   ┌──────▼──────┐   ┌──────▼──────┐
        │  service-a  │   │  service-b  │   │  postgres   │
        │  (your app) │   │  (your app) │   │  (sidecar)  │
        └─────────────┘   └─────────────┘   └─────────────┘

One server, one agent per node. Workloads talk only to the local Unix socket — they never touch the server directly.


Installing the SPIRE Server

Grab the latest release from GitHub. At time of writing, 1.10.x is current:

SPIRE_VERSION="1.10.3"
curl -sSL "https://github.com/spiffe/spire/releases/download/v${SPIRE_VERSION}/spire-${SPIRE_VERSION}-linux-amd64-musl.tar.gz" \
  | tar -xz -C /opt/

ln -sf /opt/spire-${SPIRE_VERSION}/bin/spire-server /usr/local/bin/spire-server
ln -sf /opt/spire-${SPIRE_VERSION}/bin/spire-agent  /usr/local/bin/spire-agent

Create the config directory and data path:

mkdir -p /etc/spire/server /var/lib/spire/server

Write the server config. This uses SQLite as the datastore — fine for a single server, swap to PostgreSQL for production HA:

# /etc/spire/server/server.conf

server {
  bind_address = "0.0.0.0"
  bind_port    = "8081"

  # Your trust domain. Use your real domain name. Never change this after bootstrap.
  trust_domain = "example.org"

  data_dir     = "/var/lib/spire/server"
  log_level    = "INFO"

  # SVIDs issued to workloads are valid for 1 hour; agents renew them automatically
  default_svid_ttl = "1h"

  # CA certificate lifetime — SPIRE rotates this automatically
  ca_ttl = "24h"
}

plugins {
  DataStore "sql" {
    plugin_data {
      database_type = "sqlite3"
      connection_string = "/var/lib/spire/server/datastore.sqlite3"
    }
  }

  # The KeyManager stores the server's signing keys
  KeyManager "disk" {
    plugin_data {
      keys_path = "/var/lib/spire/server/keys.json"
    }
  }

  # NodeAttestor tells the server HOW to verify joining agents.
  # join_token is simplest for getting started. See the production section
  # for better options (TPM, cloud IID).
  NodeAttestor "join_token" {
    plugin_data {}
  }
}

health_checks {
  listener_enabled = true
  bind_address     = "0.0.0.0"
  bind_port        = "8080"
  live_path        = "/live"
  ready_path       = "/ready"
}

Create a systemd unit:

# /etc/systemd/system/spire-server.service

[Unit]
Description=SPIRE Server
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/spire-server run -config /etc/spire/server/server.conf
Restart=on-failure
RestartSec=5
User=root
# Lock down the process — it only needs its data dir and config
ReadWritePaths=/var/lib/spire/server
ProtectSystem=strict

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now spire-server
systemctl status spire-server

Verify the server is healthy:

spire-server healthcheck -socketPath /tmp/spire-server/private/api.sock
# Server is healthy.

Provisioning Agents with Join Tokens

For each node that needs a SPIRE Agent, you generate a one-time join token on the server. The agent uses this token exactly once to bootstrap, after which it gets a proper X.509 agent SVID that it uses for all future communication.

On the server, generate a token with a TTL:

# Token valid for 10 minutes — enough to copy and use on the agent node
spire-server token generate \
  -socketPath /tmp/spire-server/private/api.sock \
  -spiffeID spiffe://example.org/agent/vm-1 \
  -ttl 600

# Output:
# Token: abc123def456...

The SPIFFE ID you assign here becomes the agent’s identity. Use something meaningful — hostname or role works well.


Installing and Configuring the SPIRE Agent

On each node (repeat for every VM/bare-metal host):

# Same binary, different component
mkdir -p /etc/spire/agent /var/lib/spire/agent
# /etc/spire/agent/agent.conf

agent {
  data_dir    = "/var/lib/spire/agent"
  log_level   = "INFO"
  trust_domain = "example.org"

  # Where to reach the SPIRE Server — use its internal IP or hostname
  server_address = "spire-server.internal"
  server_port    = "8081"

  # The Unix socket that workloads on this node will call
  socket_path = "/run/spire/agent.sock"

  # Trust bundle for verifying the server's identity on first contact.
  # Bootstrap trust — see gotchas below about how this is distributed.
  trust_bundle_path = "/etc/spire/agent/bootstrap.crt"
}

plugins {
  # Must match what the server has configured
  NodeAttestor "join_token" {
    plugin_data {
      # The token you generated above — only needed for first boot
      join_token = "abc123def456..."
    }
  }

  # WorkloadAttestor determines which workload is asking for an SVID.
  # unix: inspects the calling process via /proc
  WorkloadAttestor "unix" {
    plugin_data {
      discover_workload_dir = true
    }
  }

  KeyManager "disk" {
    plugin_data {
      directory = "/var/lib/spire/agent"
    }
  }
}

Get the bootstrap trust bundle from the server:

# Run this on the agent node, pointing at the server
spire-server bundle show \
  -socketPath /tmp/spire-server/private/api.sock \
  -format pem \
  > /etc/spire/agent/bootstrap.crt

Or, if you can’t reach the server socket directly, scp the bundle from the server host.

Create the systemd unit on each agent node:

# /etc/systemd/system/spire-agent.service

[Unit]
Description=SPIRE Agent
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/spire-agent run -config /etc/spire/agent/agent.conf
Restart=on-failure
RestartSec=5
User=root
RuntimeDirectory=spire

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now spire-agent
systemctl status spire-agent

After the agent starts, it connects to the server, uses the join token (one time), and gets its agent SVID. The token in the config is now inert — you can remove it and restart the agent; it will reuse its persisted agent SVID.


Registering Workloads

This is the step most people under-document. A registration entry tells the SPIRE Server: "any process on node X that matches these selectors should receive SVID Y."

Selectors are how SPIRE identifies which process is calling the Workload API. With the unix attestor you can match on:

  • unix:uid:<number> — the process’s effective UID
  • unix:gid:<number> — the effective GID
  • unix:path:<path> — the executable path

Register your first workload. This example gives an SVID to any process running as uid 1001 on the agent node vm-1:

spire-server entry create \
  -socketPath /tmp/spire-server/private/api.sock \
  -spiffeID    spiffe://example.org/service/payments-api \
  -parentID    spiffe://example.org/agent/vm-1 \
  -selector    unix:uid:1001 \
  -ttl         3600

For a more specific entry, combine selectors. All selectors must match:

spire-server entry create \
  -socketPath /tmp/spire-server/private/api.sock \
  -spiffeID    spiffe://example.org/service/payments-api \
  -parentID    spiffe://example.org/agent/vm-1 \
  -selector    unix:uid:1001 \
  -selector    unix:path:/opt/payments-api/bin/server

List entries to verify:

spire-server entry show \
  -socketPath /tmp/spire-server/private/api.sock

# Entry ID      : a1b2c3d4-...
# SPIFFE ID     : spiffe://example.org/service/payments-api
# Parent ID     : spiffe://example.org/agent/vm-1
# TTL           : 3600
# Selector      : unix:uid:1001

Testing: Fetching an SVID

On vm-1, switch to the user that matches your selector (uid 1001), then use the spire-agent CLI to fetch the SVID:

su -s /bin/bash -c \
  'spire-agent api fetch x509 -socketPath /run/spire/agent.sock' \
  paymentssvc

# Output:
# Received 1 svid after 12.345ms

# SPIFFE ID:    spiffe://example.org/service/payments-api
# SVID Valid After:  2026-05-24 10:00:00 +0000 UTC
# SVID Valid Until:  2026-05-24 11:00:00 +0000 UTC
# CA #1 Valid After: 2026-05-24 00:00:00 +0000 UTC
# CA #1 Valid Until: 2026-05-25 00:00:00 +0000 UTC

You can also dump the actual X.509 PEM files for inspection:

spire-agent api fetch x509 \
  -socketPath /run/spire/agent.sock \
  -write /tmp/svid-dump/

openssl x509 -in /tmp/svid-dump/svid.0.pem -noout -text | grep -A2 "Subject Alternative"
# URI:spiffe://example.org/service/payments-api

Wiring mTLS Between Two Services

The real payoff. Service A on vm-1 wants to call Service B on vm-2 with mutual TLS, no shared secrets, no certificate pinning config.

The cleanest way is spiffe-helper — a sidecar that watches the Workload API and writes the SVID to disk files, refreshing them before expiry. Your application reads regular PEM files and doesn’t need to know anything about SPIRE.

Install spiffe-helper on each node:

HELPER_VERSION="0.7.0"
curl -sSL "https://github.com/spiffe/spiffe-helper/releases/download/v${HELPER_VERSION}/spiffe-helper_${HELPER_VERSION}_linux_amd64.tar.gz" \
  | tar -xz -C /usr/local/bin/ spiffe-helper

Config for the payments-api service on vm-1:

# /etc/spiffe-helper/payments-api.conf

agent_address = "/run/spire/agent.sock"

# Where to write the rotating credentials
cert_dir = "/run/creds/payments-api"

# Filenames — your app reads these
svid_file_name     = "tls.crt"
svid_key_file_name = "tls.key"
svid_bundle_file_name = "ca.crt"

# Optionally run a command when certs rotate (e.g., reload nginx)
# cmd = "systemctl reload payments-api"
mkdir -p /run/creds/payments-api
chown paymentssvc:paymentssvc /run/creds/payments-api

Run spiffe-helper as a systemd service alongside your app:

# /etc/systemd/system/spiffe-helper-payments.service

[Unit]
Description=SPIFFE Helper for payments-api
After=spire-agent.service
PartOf=payments-api.service

[Service]
Type=simple
User=paymentssvc
ExecStart=/usr/local/bin/spiffe-helper -config /etc/spiffe-helper/payments-api.conf
Restart=on-failure

[Install]
WantedBy=payments-api.service

Your Go or Python service just reads /run/creds/payments-api/tls.crt, tls.key, and ca.crt to set up its TLS config. The helper refreshes them before they expire; your app needs only to reload its TLS credentials periodically (Go’s tls.Config with a custom GetCertificate callback is good for this).

To validate that Service B will only accept connections from the right SVID, configure it to require client certificate verification and check the peer’s SPIFFE ID in the SAN. Most mTLS libraries support this — the trust bundle is the ca.crt file, shared across the trust domain.


Gotchas

Bootstrap trust bundle distribution is a real problem. The agent needs to trust the server before it can get anything from it — which means you need to securely deliver bootstrap.crt before the agent starts. In Kubernetes this gets abstracted away. On VMs, you have to ship that file yourself. Use your configuration management tool (Ansible, Puppet, Salt) or a secrets manager. Never put it in a public-readable location.

Join tokens are single-use, but the config isn’t. Once the agent has attested with a join token, it stores its agent SVID on disk. If you accidentally redeploy the config with the old token, the agent will try to re-attest with an already-used token and fail. The fix: after first attestation, remove or empty the join_token field and rely on the persisted SVIDs in the agent’s data_dir.

Clock skew kills you silently. SVIDs are time-bounded X.509 certs. If a node’s clock drifts more than a minute or two, SVIDs will be rejected as not-yet-valid or already-expired. Run chrony or ntpd everywhere. This isn’t optional.

The unix workload attestor uses /proc — containers complicate this. If your workload runs in a Docker container on the same host, the PID namespace differs. You need the docker workload attestor plugin instead of unix, and you’ll match on container labels or image names. The unix attestor alone won’t work for containerized workloads.

TTL vs rotation cadence. A 1-hour SVID TTL means the agent renews it roughly every 30 minutes. During rotation there’s a brief window where both old and new SVIDs are valid — this is by design (SPIFFE calls it the "grace period"). If your TLS session is long-lived (e.g., a persistent database connection), make sure you handle cert re-loading or reconnection at expiry.

Firewall between agent and server. The agent needs outbound TCP to the server on port 8081. This is gRPC over TLS. If you’re running in a locked-down environment and your firewall drops connections, the agent logs a grpc connection refused and your workloads silently get no SVIDs. Check journalctl -u spire-agent -f immediately after startup.


Production Hardening

Swap join_token attestation for something cryptographic. Join tokens are convenient but require out-of-band delivery. For real production on cloud VMs, use the cloud-specific node attestors:

  • AWS: NodeAttestor "aws_iid" — verifies the EC2 Instance Identity Document signed by AWS
  • GCP: NodeAttestor "gcp_iit" — equivalent for GCE instances
  • Azure: NodeAttestor "azure_msi" — verifies the Azure MSI token

For bare metal with a TPM 2.0 chip, the tpm_devid attestor uses hardware-rooted identity — this is the gold standard for physical machines. Setup is more involved (requires manufacturing a DevID certificate), but means a node can only attest with its actual hardware.

PostgreSQL for the datastore. SQLite works fine for a single server but gives you nothing for HA. Switch to Postgres early:

DataStore "sql" {
  plugin_data {
    database_type       = "postgres"
    connection_string   = "dbname=spire user=spire password=... host=pg.internal sslmode=require"
  }
}

Run the server behind a load balancer with multiple replicas. SPIRE Server supports multiple active instances sharing a Postgres backend. The agents use DNS round-robin or a load balancer VIP to reach whichever server replica is available.

Scope your registration entries tightly. Matching only on unix:uid is convenient but broad — any process running as that user gets the SVID. Add unix:path to pin it to a specific binary. If you’re security-conscious, compute and register the SHA-256 hash of the binary: unix:sha256:<hash>. The attestor will verify the hash at runtime, so a replaced or modified binary gets no identity.

Monitor SVID expiry headroom. If your agent loses connectivity to the server during an SVID renewal window and the SVIDs expire, your workloads go dark. Alert on spire_agent_svid_expiry_seconds in Prometheus (the SPIRE Agent exposes metrics on port 8088 by default). Alert at 15 minutes remaining, not 0.

Audit who can register entries. The SPIRE Server admin socket (/tmp/spire-server/private/api.sock) is root-only by default. In a team environment, integrate SPIRE’s admin_ids feature or put a small wrapper API in front of it with proper authz. Otherwise any operator with server access can register arbitrary workload identities.


Wrapping Up

The initial setup — server, a couple of agents, a few registration entries — takes maybe an afternoon. The payoff is that you’ve replaced a class of problems (secret rotation, shared certs, "who can call what") with a system that’s cryptographically sound, operationally simple to reason about, and works identically whether your service is on a VM in Frankfurt, a bare-metal box in your colo, or inside K8s.

The part that trips people up isn’t the software, it’s the mental model shift: workloads don’t manage their own credentials anymore. They just exist, and the platform hands them an identity based on what they are. Once that clicks, SPIFFE stops feeling like a security product and starts feeling like infrastructure that should have existed ten years ago.

👁 Views: 112,655 · Unique visitors: 45,393