HashiCorp Vault for Self-Hosters: KV, Transit, and AppRole Done Right

You’ve got secrets everywhere. Database passwords in .env files checked into Git. API keys copy-pasted into three different servers. A Kubernetes Secret that’s just base64-encoded plaintext, which is essentially the same as storing it in a sticky note on your monitor. If any of that sounds familiar, this article is for you.

HashiCorp Vault solves the secrets sprawl problem. It’s a centralized secrets store with fine-grained access control, audit logging, dynamic credentials, and encryption as a service. The open-source version is genuinely production-grade — no telemetry paywall, no "enterprise only" for the features you actually need on a homelab or small business setup.

Official GitHub: https://github.com/hashicorp/vault

We’re going to cover three core capabilities: KV (static secrets storage), Transit (encryption-as-a-service), and AppRole (machine authentication). By the end, your apps will pull secrets at runtime, encrypt sensitive data without ever handling keys, and authenticate without hardcoded tokens.


The Setup: Running Vault in Docker Compose

Vault can run as a single binary, but Docker Compose keeps things reproducible. We’ll run it in production mode with a file-based storage backend — perfectly adequate for a single-node setup handling dozens of services.

# docker-compose.yml
version: "3.9"

services:
  vault:
    image: hashicorp/vault:1.17
    container_name: vault
    restart: unless-stopped
    cap_add:
      # Required for memory locking (prevents secrets from swapping to disk)
      - IPC_LOCK
    ports:
      - "8200:8200"
    environment:
      VAULT_ADDR: "http://0.0.0.0:8200"
    volumes:
      - ./vault/config:/vault/config:ro
      - ./vault/data:/vault/data
      - ./vault/logs:/vault/logs
    command: vault server -config=/vault/config/vault.hcl

volumes:
  vault_data:

And the Vault configuration file:

# vault/config/vault.hcl

# Use file backend — simple, reliable, no external dependencies
storage "file" {
  path = "/vault/data"
}

listener "tcp" {
  address     = "0.0.0.0:8200"
  # In prod, point these to your TLS cert/key files
  # tls_cert_file = "/vault/tls/vault.crt"
  # tls_key_file  = "/vault/tls/vault.key"
  tls_disable = "true"  # Disable for local dev ONLY
}

# Disable Vault's default memory locking behavior at the config level
# (the IPC_LOCK cap_add above handles this at the OS level)
disable_mlock = false

# Required for the UI to work
ui = true

# Optional: set cluster name for audit log clarity
cluster_name = "homelab"

Gotcha — TLS is not optional in production. Running Vault over plain HTTP means your secrets transit the network unencrypted. Use a reverse proxy (Caddy, Nginx, Traefik) with a real cert in front of Vault, or point tls_cert_file and tls_key_file directly at your certs. If you’re on a LAN only, use a private CA — don’t skip this step.

Start it:

docker compose up -d
export VAULT_ADDR='http://127.0.0.1:8200'

Initialization and Unseal

Vault starts sealed — it can’t serve any secrets until it’s initialized and unsealed. This is intentional. The encryption keys are split using Shamir’s Secret Sharing.

# Initialize with 5 key shares, 3 required to unseal
vault operator init -key-shares=5 -key-threshold=3

You’ll get 5 unseal keys and a root token. Write these down and store them somewhere safe — not in Vault itself, not in your Git repo. A password manager like Bitwarden, an encrypted USB, or a printed piece of paper in a locked drawer are all legitimate options.

Unseal using any 3 of the 5 keys:

vault operator unseal  # Run this 3 times with different keys

Then authenticate:

vault login <your-root-token>

Gotcha — root token is nuclear. The root token bypasses every policy. Use it to bootstrap, then revoke it. You can always regenerate one via vault operator generate-root if you have the unseal keys.

Production-ready: Auto-unseal. If your container restarts, Vault re-seals and your services go down until you manually unseal. For production, configure auto-unseal using a cloud KMS (AWS KMS, GCP Cloud KMS, Azure Key Vault) or use Vault’s transit auto-unseal with a second Vault instance. This is one place where the "self-hosted" story requires upfront design work.


KV: Static Secrets Storage

KV (Key-Value) is the simplest and most-used secrets engine. Version 2 adds secret versioning and a soft-delete capability — use it.

Enable the Engine

# Enable KV v2 at the 'secret/' path
vault secrets enable -path=secret -version=2 kv

# Verify
vault secrets list

Writing and Reading Secrets

# Write a secret — key=value pairs
vault kv put secret/myapp/database \
  username="appuser" \
  password="s3cur3p4ss!" \
  host="postgres.internal" \
  port="5432"

# Read it back
vault kv get secret/myapp/database

# Get just one field (useful in scripts)
vault kv get -field=password secret/myapp/database

Output from kv get includes metadata: version, creation time, deletion time. With KV v2, every put creates a new version. You can roll back:

# See all versions
vault kv metadata get secret/myapp/database

# Roll back to version 1
vault kv rollback -version=1 secret/myapp/database

The Real-World Pattern: Namespacing Your Secrets

Don’t dump everything under one flat path. Structure your secrets tree like your infrastructure:

secret/
  prod/
    postgres/
      app1
      app2
    redis/
      cache
  staging/
    postgres/
      app1

This structure makes writing narrow policies trivial. More on that when we wire up AppRole.

Gotcha — KV v2 path changed. In KV v1, you read secret/myapp. In KV v2, the actual data path is secret/data/myapp, and the metadata is at secret/metadata/myapp. If you’re writing policies or using the API directly, you need the data/ prefix. The CLI (vault kv get) handles this transparently, but SDKs and direct API calls don’t.


Transit: Encryption as a Service

Transit is where Vault gets genuinely powerful. Your application never handles cryptographic keys. It sends plaintext to Vault, gets back ciphertext, and later sends ciphertext back to get plaintext. The keys live in Vault, rotated by Vault, and your application is decoupled from every crypto implementation detail.

This is the pattern you want for encrypting PII before it hits your database.

Enable and Configure

# Enable transit engine
vault secrets enable transit

# Create an encryption key for your application
vault write -f transit/keys/myapp-pii

# Inspect the key metadata (type, versions, rotation policy)
vault read transit/keys/myapp-pii

The key never leaves Vault. You never see the raw key bytes.

Encrypt and Decrypt

# Input must be base64-encoded
PLAINTEXT=$(echo -n "[email protected]" | base64)

# Encrypt
vault write transit/encrypt/myapp-pii plaintext="$PLAINTEXT"
# Returns: vault:v1:AbCdEf...

# Decrypt — pass the ciphertext back
vault write transit/decrypt/myapp-pii \
  ciphertext="vault:v1:AbCdEf..."
# Returns base64-encoded plaintext — decode it:
echo "base64value" | base64 -d

The v1 in the ciphertext prefix tells you which key version encrypted it. When you rotate the key, old ciphertexts still decrypt (Vault keeps all versions). You can then re-encrypt everything to the new version with a single operation:

# Rotate the key
vault write -f transit/keys/myapp-pii/rotate

# Re-wrap old ciphertexts to use the latest key version
vault write transit/rewrap/myapp-pii \
  ciphertext="vault:v1:AbCdEf..."
# Returns vault:v2:XyZ...

Gotcha — Transit is not a database. Vault doesn’t store your ciphertexts. You encrypt, get back the ciphertext, and store it in your own database. Vault just holds the key. If you lose the ciphertext, the data is gone. If Vault’s storage goes corrupt and you haven’t backed it up, the key is gone and your stored ciphertext is unrecoverable.

Production-ready: Key rotation policy. Set an automatic rotation schedule so keys rotate without manual intervention:

vault write transit/keys/myapp-pii/config \
  auto_rotate_period=720h  # Rotate every 30 days

Also set min_decryption_version to prevent decryption with very old key versions once all data has been re-wrapped.


AppRole: Machine Authentication Done Right

Tokens are fine for humans logging into the UI or CLI. For services and CI/CD pipelines, you want AppRole — a two-factor machine identity made of a Role ID (essentially a username) and a Secret ID (essentially a password).

The key property: the Role ID is not secret and can be baked into your container image or config. The Secret ID is secret, short-lived, and fetched at deploy time from a trusted system. This separation is the entire security model.

Create a Policy First

Vault uses policies to define what a token can do. Write the narrowest policy you can get away with.

# policies/myapp.hcl

# Read-only access to this app's secrets
path "secret/data/prod/postgres/app1" {
  capabilities = ["read"]
}

# Allow encrypting and decrypting with the app's key
path "transit/encrypt/myapp-pii" {
  capabilities = ["update"]
}

path "transit/decrypt/myapp-pii" {
  capabilities = ["update"]
}

# Allow the app to renew its own token
path "auth/token/renew-self" {
  capabilities = ["update"]
}

Upload it:

vault policy write myapp policies/myapp.hcl

Enable AppRole and Create a Role

# Enable AppRole auth method
vault auth enable approle

# Create a role bound to the policy
vault write auth/approle/role/myapp \
  token_policies="myapp" \
  token_ttl=1h \
  token_max_ttl=4h \
  secret_id_ttl=10m \     # Secret ID expires after 10 minutes
  secret_id_num_uses=1    # Secret ID can only be used once

secret_id_num_uses=1 is the key here. The Secret ID is a one-time-use credential. Even if it leaks in logs, it’s already been consumed.

The Login Flow

# Fetch the Role ID — this is not secret, bake it into config
vault read auth/approle/role/myapp/role-id
# role_id: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

# Generate a Secret ID — do this at deploy time from a trusted system
vault write -f auth/approle/role/myapp/secret-id
# secret_id: yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy

# Login with both — get a scoped token
vault write auth/approle/login \
  role_id="xxxxxxxx-..." \
  secret_id="yyyyyyyy-..."
# Returns: client_token: s.AbcDef...

Your app uses the client_token for all subsequent Vault API calls. It expires after 1 hour but can be renewed up to 4 hours.

Integrating in a Real Application

Here’s how this looks in a Python service using the hvac library:

import hvac
import os

# Role ID is safe to hardcode or put in an env var
ROLE_ID = os.environ["VAULT_ROLE_ID"]
# Secret ID comes from a secrets injection mechanism (e.g., Kubernetes mutating webhook,
# CI/CD vault-action, or a wrapper script that fetches and exports it at startup)
SECRET_ID = os.environ["VAULT_SECRET_ID"]

def get_vault_client():
    client = hvac.Client(url="http://vault:8200")
    
    # Authenticate via AppRole
    client.auth.approle.login(
        role_id=ROLE_ID,
        secret_id=SECRET_ID,
    )
    
    assert client.is_authenticated(), "Vault auth failed"
    return client

def get_db_credentials():
    client = get_vault_client()
    secret = client.secrets.kv.v2.read_secret_version(
        path="prod/postgres/app1",
        mount_point="secret",
    )
    return secret["data"]["data"]  # {"username": ..., "password": ...}

Gotcha — where does the Secret ID come from? This is the "secret zero" problem. If your app needs a Secret ID to get secrets, something has to deliver that Secret ID securely. Common patterns: a Kubernetes init container that calls Vault with a short-lived service account token, a CI/CD pipeline that injects it as a masked env var, or a wrapped Secret ID (Vault wraps the Secret ID in a single-use token, so even the transit medium can’t see the real value). Don’t solve this by baking a long-lived Secret ID into your image.

Gotcha — token renewal. A 1-hour token means your app needs to renew it before expiry. If it dies and restarts, it needs to re-authenticate with a fresh Secret ID. Build retry logic with exponential backoff into your Vault client initialization. hvac doesn’t auto-renew — you need to handle it or use the Vault Agent sidecar, which does auto-renewal for you.


Vault Agent: The Missing Piece for Real Deployments

Vault Agent is a daemon that runs alongside your app, handles authentication and token renewal automatically, and can render secrets into config files or populate environment variables. It’s the production answer to "my app shouldn’t know about Vault at all."

# vault-agent.hcl

vault {
  address = "http://vault:8200"
}

auto_auth {
  method "approle" {
    config = {
      role_id_file_path   = "/run/secrets/role-id"
      secret_id_file_path = "/run/secrets/secret-id"
    }
  }

  sink "file" {
    config = {
      # Agent writes the token here; your app reads from this file
      path = "/run/vault-token"
    }
  }
}

template {
  source      = "/templates/db.env.tpl"
  destination = "/run/secrets/db.env"
  # Restart your app when secrets change
  command     = "kill -HUP {{ env \"APP_PID\" }}"
}

Template file:

{{- with secret "secret/data/prod/postgres/app1" -}}
DB_HOST={{ .Data.data.host }}
DB_USER={{ .Data.data.username }}
DB_PASS={{ .Data.data.password }}
{{- end }}

Agent populates /run/secrets/db.env on startup and re-renders it whenever the secret changes. Your app reads env vars from a file and never makes a Vault API call directly. Clean separation.


Audit Logging: The Feature You Can’t Skip

Vault has a built-in audit log that records every request and response. Enable it from day one.

# Log to file
vault audit enable file file_path=/vault/logs/audit.log

# Verify
vault audit list

The log is append-only and HMAC-hashed — the raw secret values are never written in plaintext, but you can see who accessed what, when, with what token, and whether it succeeded. For anything touching GDPR or SOC2, this is your paper trail.

Gotcha — Vault blocks requests if audit fails. If the audit log device becomes unavailable (disk full, permissions error), Vault refuses all requests to preserve audit integrity. Monitor your log volume. A simple logrotate config and a disk usage alert on /vault/logs will save you from a 3am incident.


Backup Strategy

The file storage backend stores everything under /vault/data. Back it up like a database — consistent snapshots, tested restores.

# While Vault is running, use the snapshot API (recommended over raw file copy)
vault operator raft snapshot save vault-backup-$(date +%Y%m%d).snap

# Restore
vault operator raft snapshot restore vault-backup-20260523.snap

Gotcha — the Raft snapshot API only applies to Integrated Storage (Raft) backend, not the file backend. If you’re using the file backend as shown in this article, you back it up with a filesystem snapshot or rsync while Vault is stopped (or during low-traffic with consistent state). For a homelab this is fine. For production, migrate to Integrated Storage (Raft) — it supports the snapshot API, HA clustering, and is the direction HashiCorp is going.


Quick Reference: The Paths That Trip Everyone Up

What you want CLI path API path (KV v2)
Write a secret vault kv put secret/foo PUT /v1/secret/data/foo
Read a secret vault kv get secret/foo GET /v1/secret/data/foo
List secrets vault kv list secret/ LIST /v1/secret/metadata/
Delete (soft) vault kv delete secret/foo DELETE /v1/secret/data/foo
Destroy (hard) vault kv destroy secret/foo PUT /v1/secret/destroy/foo
Policy path secret/data/foo (needs data/ prefix)

Where to Go From Here

You’ve got a working Vault instance with three of its most useful capabilities. What’s worth learning next:

Dynamic Secrets — instead of storing a static database password, Vault generates a fresh PostgreSQL user with a TTL, hands it to your app, and revokes it when the lease expires. Your database never has long-lived app credentials. This is Vault’s killer feature for database security.

PKI Secrets Engine — Vault becomes its own certificate authority. Apps request TLS certs at startup with 24-hour TTLs, auto-renewing via Agent. Short-lived certs are dramatically harder to exploit than 2-year certs.

Namespace-based multi-tenancy — relevant once you’re managing secrets for multiple teams or projects and want isolated Vault environments without running separate instances.

The open-source Vault binary and Docker image are free. What you’re paying for (if you go HashiCorp Enterprise) is HA across multiple clusters, HSM support, and support contracts. For a homelab or small team, the OSS version covers everything in this article and much more.

Set up audit logging and a backup cron job before you put anything real in there. Everything else can be wired up incrementally as your services migrate off .env files.

Leave a comment

👁 Views: 2,289 · Unique visitors: 1,646