Self-Hosting HashiCorp Vault with Docker Compose

What Is HashiCorp Vault?

HashiCorp Vault is the most widely deployed secrets management platform in the industry. It stores secrets, generates dynamic credentials for databases and cloud providers, handles encryption as a service, and issues TLS certificates through its built-in PKI engine. If you manage more than a handful of API keys or database passwords, Vault replaces scattered .env files and shared spreadsheets with a centralized, audited, access-controlled system.

Vault is licensed under the BSL 1.1 (Business Source License) since August 2023. All core features — secret engines, dynamic credentials, transit encryption, PKI — remain available. The community fork OpenBao exists under MPL-2.0 if the BSL concerns you.

Prerequisites

  • A Linux server (Ubuntu 22.04+ recommended)
  • Docker and Docker Compose installed (guide)
  • 1 GB of free RAM (512 MB minimum for dev mode)
  • 2+ CPU cores recommended for production
  • A domain name (recommended for TLS)

Docker Compose Configuration

Vault supports multiple storage backends. Raft integrated storage is recommended for self-hosting — it’s built-in, requires no external dependencies, and handles leader election automatically.

Production Setup (Raft Storage)

Create a directory for Vault:

mkdir -p vault && cd vault

Create config.hcl:

# Vault server configuration
storage "raft" {
  path = "/vault/data"
  node_id = "vault-1"
}

listener "tcp" {
  address     = "0.0.0.0:8200"
  tls_disable = 1  # Set to 0 and configure TLS for production
}

api_addr     = "http://vault:8200"
cluster_addr = "http://vault:8201"

ui = true

# Disable memory locking if running in Docker without IPC_LOCK
disable_mlock = false

Create docker-compose.yml:

services:
  vault:
    image: hashicorp/vault:1.21.4
    container_name: vault
    ports:
      - "8200:8200"   # API and web UI
      - "8201:8201"   # Cluster communication (Raft)
    volumes:
      - vault-data:/vault/data
      - ./config.hcl:/vault/config/config.hcl:ro
    cap_add:
      - IPC_LOCK      # Required for memory locking (mlock)
    environment:
      VAULT_ADDR: "http://127.0.0.1:8200"
    command: vault server -config=/vault/config/config.hcl
    healthcheck:
      test: ["CMD", "vault", "status"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s
    restart: unless-stopped

volumes:
  vault-data:

Start the stack:

docker compose up -d

The IPC_LOCK capability is critical — Vault uses mlock to prevent sensitive data from being swapped to disk. Without it, secrets could leak to swap partitions.

Development Setup (Quick Start)

For testing and learning, dev mode runs entirely in memory:

services:
  vault:
    image: hashicorp/vault:1.21.4
    container_name: vault-dev
    ports:
      - "8200:8200"
    environment:
      VAULT_DEV_ROOT_TOKEN_ID: dev-root-token  # Change this
      VAULT_DEV_LISTEN_ADDRESS: "0.0.0.0:8200"
    cap_add:
      - IPC_LOCK
    command: vault server -dev
    restart: unless-stopped

Dev mode is pre-initialized, auto-unsealed, and has an in-memory backend. All data is lost on restart. Never use this for real secrets.

Initial Setup

Vault requires a one-time initialization that generates unseal keys and a root token.

Initialize Vault

docker exec -it vault vault operator init

This outputs 5 unseal keys and 1 root token. Save these immediately — losing the unseal keys means losing access to all secrets. Store them in separate secure locations (password manager, physical safe, trusted colleagues).

Unseal Vault

Vault starts sealed — it knows encrypted data exists but can’t read it. You need 3 of the 5 unseal keys (the default threshold) to unseal:

docker exec -it vault vault operator unseal <key-1>
docker exec -it vault vault operator unseal <key-2>
docker exec -it vault vault operator unseal <key-3>

After three valid keys, Vault transitions to unsealed state and can serve requests.

Login

docker exec -it vault vault login <root-token>

Then access the web UI at http://your-server:8200. Log in with the root token.

Create a non-root admin policy and token immediately. The root token should only be used for initial setup and break-glass scenarios.

Key Features

Secret Engines

Vault organizes secrets into engines, each with different capabilities:

EnginePurposeExample
KV v2Static secrets with versioningAPI keys, passwords
DatabaseDynamic credentialsTemporary PostgreSQL users
TransitEncryption as a serviceEncrypt data without managing keys
PKICertificate authorityIssue TLS certificates
SSHSSH certificate signingShort-lived SSH access
AWS/GCP/AzureCloud IAM credentialsTemporary cloud access keys

Dynamic Secrets

Instead of storing a database password, Vault generates a temporary PostgreSQL user with a 1-hour TTL:

# Enable database engine
vault secrets enable database

# Configure PostgreSQL connection
vault write database/config/myapp \
    plugin_name=postgresql-database-plugin \
    allowed_roles="readonly" \
    connection_url="postgresql://{{username}}:{{password}}@db:5432/myapp" \
    username="vault_admin" \
    password="vault_admin_password"

# Create a role that generates temporary users
vault write database/roles/readonly \
    db_name=myapp \
    creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
    default_ttl="1h" \
    max_ttl="24h"

# Generate a credential
vault read database/creds/readonly

Each credential is unique, time-limited, and automatically revoked when it expires. If compromised, you revoke just that credential — not a shared password used everywhere.

Transit Encryption

Encrypt application data without your app handling key management:

vault secrets enable transit
vault write -f transit/keys/my-app-key

# Encrypt
vault write transit/encrypt/my-app-key plaintext=$(echo "sensitive data" | base64)

# Decrypt
vault write transit/decrypt/my-app-key ciphertext="vault:v1:..."

Your application never sees the encryption key. Vault handles key rotation, versioning, and access control.

Configuration

Auto-Unseal

Manual unsealing is impractical for automated deployments. Vault supports auto-unseal using external key management:

MethodServiceNotes
TransitAnother Vault instanceUse one Vault to unseal another
AWS KMSAmazon KMSMost popular cloud option
Azure Key VaultMicrosoft AzureFor Azure environments
GCP Cloud KMSGoogle CloudFor GCP environments

For self-hosted setups, Transit auto-unseal with a second Vault instance is the most practical option. Alternatively, store unseal keys in a password manager and script the unseal process.

Audit Logging

Enable audit logging to track every operation:

vault audit enable file file_path=/vault/logs/audit.log

Every secret read, write, and token creation is logged with timestamps, client identity, and request details. This is essential for compliance and incident response.

Authentication Methods

MethodUse Case
TokenDefault, programmatic access
AppRoleCI/CD pipelines, services
LDAPActive Directory integration
OIDCSSO with Authentik, Keycloak
KubernetesPod-level secret injection
UserpassSimple username/password

Reverse Proxy

Place Vault behind a reverse proxy for TLS termination:

# Nginx configuration
server {
    listen 443 ssl;
    server_name vault.example.com;

    location / {
        proxy_pass http://vault:8200;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

See Reverse Proxy Setup for complete configuration with Nginx Proxy Manager, Traefik, or Caddy.

Backup

Vault’s Raft storage supports integrated snapshots:

# Create a snapshot
docker exec -it vault vault operator raft snapshot save /vault/data/backup.snap

# Copy to host
docker cp vault:/vault/data/backup.snap ./vault-backup-$(date +%Y%m%d).snap

Schedule daily snapshots via cron. Test restores periodically — an untested backup is not a backup.

See Backup Strategy for a complete 3-2-1 backup approach.

Troubleshooting

Vault is sealed after restart

Symptom: After a Docker restart, Vault shows “sealed” and rejects all requests.

Fix: Vault seals itself on restart by default. You must unseal it again with 3 of 5 unseal keys. For automated environments, configure auto-unseal (Transit, AWS KMS, etc.).

Permission denied errors

Symptom: permission denied on secret operations despite being authenticated.

Fix: Check your token’s policies. The default policy grants minimal access. Create policies that match your access patterns:

vault policy write my-app - <<EOF
path "secret/data/my-app/*" {
  capabilities = ["create", "read", "update", "delete", "list"]
}
EOF

Container exits with “mlock not supported”

Symptom: Vault container exits immediately with mlock errors.

Fix: Add cap_add: [IPC_LOCK] to your Docker Compose, or set disable_mlock = true in config.hcl (less secure — allows secrets to be swapped to disk).

Resource Requirements

  • RAM: 512 MB idle, 1-2 GB under load with multiple secret engines
  • CPU: Low for typical usage; password hashing and PKI certificate generation spike CPU
  • Disk: 1 GB for Vault itself, grows with Raft storage and audit logs

Verdict

HashiCorp Vault is the gold standard for secrets management. Nothing else matches its breadth — dynamic secrets, PKI, transit encryption, and dozens of auth methods. The operational overhead of initialization and unsealing is the tradeoff for that security model.

For homelabs managing 10+ services, Vault replaces scattered .env files with centralized, audited secret access. The learning curve is steep but the payoff is real.

For simpler needs — just storing and sharing secrets with a web UI — Infisical is easier to set up and has a friendlier interface. If BSL licensing is a concern, OpenBao is a fully open-source Vault fork under MPL-2.0.

Comments