HashiCorp Vault + Laravel: Architecture Layers, Methodologies, and Production Parameters
95 min read
Single-node Vault behind Traefik, AppRole, KV v2, and a Laravel app that loads secrets before the database connection resolves — plus every layer, methodology, and parameter you need to choose the right pattern and not copy the wrong one.
If you have ever committed a .env file by accident, rotated an API key at 2 a.m., or wondered why production secrets live next to your APP_DEBUG flag — this guide is for you.
The lab path is unchanged: stand up a single-node HashiCorp Vault behind Traefik, expose it over HTTPS, and wire Laravel so secrets stop living in plain text on disk. Around that path, this revision documents the architecture layers, the methodologies (auth, seal, storage, secret delivery) with working implementations, and what each parameter does, what the default is, and how you should set it.
Vault in Docker Compose (file storage, healthcheck, IPC_LOCK) · Traefik routing
https://vault.example.com · AppRole policy scoped to secret/data/laravel/* · Laravel reading secrets in register() — not boot() · a complete no-cloud Transit auto-unseal and Raft operations path alongside the Shamir lab
How to read this guide (start here)
Every code block has a colored label. Do only what the label says.
- Read Diagram, formula, or explanation — do not paste into a terminal.
- Copy file Create this file on disk (path is in the label or first comment). Edit values for your environment.
- Run Paste into a shell on the machine this section describes.
- SQL Paste into a database client (
mariadb/mysql/ ProxySQL admin), not bash.
Follow parts in order unless a part says you can skip it. Change example IPs, passwords, and paths to match your lab.
Suggested path: layers + methodology → split-server topology → Part 5A bootstrap → KV layout → migrate secrets → Agent (10B) or HTTP pull → startup order → backup drill.
Part 1 — Architecture layers (host to Laravel bootstrap)
What you should do: read this; do not paste anything yet.
Vault is not “a password file with an HTTP API.” It is a stack. A misconfigured cluster_addr, a policy on the CLI path instead of the API path, or a secret loaded in boot() instead of register() are all layer mistakes. If you skip this part, the copy-paste lab still works — until the first restart, the first 403, or the first MySQL connection that used an empty password.
The stack, top to bottom
→ Docker engine (cgroups, capabilities, volumes, bridge DNS)
→ Vault process: storage backend → cryptographic barrier → seal
→ HTTP listener :8200 (and cluster port :8201)
→ Auth method (AppRole, Kubernetes, JWT, …) → client token
→ ACL policy (path + capabilities)
→ Secrets engine (KV v2, database, transit, …)
→ Optional audit device
→ Traefik (TLS terminate, Host router) — public path only
→ Laravel bootstrap: .env → config/*.php → register() → boot() → request
| Layer | What it owns | If it is wrong |
|---|---|---|
| 1. Host / disk | Volume data, swap, backups, Shamir/recovery custody, seal-token files, firewall on 80/443 | Ciphertext without its original seal dependency is unrecoverable; credentials without backups are also useless |
| 2. Docker | IPC_LOCK, network name, healthcheck, restart policy, read-only config mount | Container crash on mlock, 502 from Traefik, “connection refused” from PHP |
| 3. Storage backend | Persists encrypted Vault data (file, Raft, Consul, …) | Data loss, no HA, or split-brain if you fake HA on file storage |
| 4. Barrier + seal | Master key encrypts the encryption key; Shamir shares or KMS unwrap it | Sealed Vault: every API except unseal/status/init fails |
| 5. Listener | address, TLS on or off, cluster listen address | TLS handshake errors, UI redirects to the wrong URL |
| 6. Auth method | Proves machine or human identity; mints a client token | 400/403 on login; never put the root token here |
| 7. Token | TTL, max TTL, number of uses, attached policies, renewable or not | PHP workers fail after an hour; or a stolen token lives forever |
| 8. Policy (ACL) | Which API paths the token may read/create/… | 403 even with a valid token — almost always KV v2 /data/ vs CLI path |
| 9. Secrets engine | KV versions, dynamic DB leases, transit keys | Wrong JSON shape (data.data), expired DB users, missed rotations |
| 10. Audit | Who read which path, from where, when (HMAC of secrets by default) | You cannot prove a leak or a rotation; or you log raw secrets if you misconfigure |
| 11. Traefik | Public HTTPS, Let’s Encrypt, Host rule, backend port 8200 | 502, certificate errors; does not affect Compose-internal http://vault:8200 |
| 12. Laravel bootstrap | When config is frozen vs when DB/Redis/mail first connect | Empty MySQL password, APP_KEY already used, cache chicken-and-egg |
Namespaces (X-Vault-Namespace) are a Vault Enterprise / HCP feature. Open-source Vault has a single namespace: root. Do not set a namespace header on OSS — login will fail with a namespace error, not a useful “wrong path” message.
What happens on one Laravel request
- PHP-FPM (or Octane worker) starts or reuses a process. Service providers run
register()thenboot(). - If you implemented the pattern in Part 12,
register()calls VaultPOST /v1/auth/approle/loginwith Role ID + Secret ID and receives a client token (unless a token is already in process memory). - Laravel sends
GET /v1/secret/data/laravel/appwith headerX-Vault-Token. - Vault checks seal → token validity → policy on that exact API path → KV engine → returns JSON
data.data. Config::set('database.connections.mysql.password', …)runs before any provider opens MySQL.- Later in the same request, Eloquent uses the in-memory config. Changing Vault after the first connection in that process does not rewrite an already-open PDO handle.
register() runs once per worker, not once per HTTP request. A client token with token_ttl=1h will die while the worker is still alive. Either renew the token (periodic token / auth/token/renew-self) or keep TTL longer than worker lifetime and recycle workers, or use Vault Agent so PHP never holds a Vault token.Two networks, one Vault
Public layer: browser and host PHP → https://vault.example.com → Traefik TLS → Docker service vault:8200 over HTTP.
Private layer: Laravel container on the same Compose network → http://vault:8200. Traefik is not in that path. Mixing these two URLs is the most common VAULT_ADDR bug in this stack.
Part 2 — Methodologies: pick, then implement
What you should do: pick one cell from each table. The lab in Parts 3–8 and 11–12 implements: file storage + Shamir unseal + AppRole + HTTP pull in
register().A “methodology” here is a complete pattern, not a brand name. Combining the wrong two (for example Kubernetes auth on Docker Compose, or file storage with three Vault containers) looks like a Vault bug. It is a topology bug.
Storage backends
Storage is the disk (or cluster) that holds encrypted Vault data. Vault never writes plaintext secrets to the backend. Losing storage loses ciphertext; losing the Shamir shares or original auto-seal key/dependency loses the ability to decrypt it.
| Backend | When to use | How you configure it | Do not use when |
|---|---|---|---|
| file (this lab) | Single node, lab, small production with tested backups | storage "file" { path = "/vault/file" } + a Docker volume | You need automatic failover of Vault itself |
| Raft (integrated storage) | Production HA (3 or 5 Vault nodes) | storage "raft" { path = "..." retry_join { ... } } + cluster_addr reachable between nodes | You only have one VM and no plan to add two more |
| Consul | Existing Consul cluster; legacy Vault HA | storage "consul" { address = "..." path = "vault/" } | You would be introducing Consul only for Vault — prefer Raft |
| MySQL / PostgreSQL / GCS / S3 / … | Rare; constrained platforms | Vendor-specific storage "..." block | You can use file or Raft — extra moving parts, same seal problem |
Minimal Raft stanza (not used in the lab; shown so you know the upgrade shape):
storage "raft" {
path = "/vault/data"
node_id = "vault-1"
}
listener "tcp" {
address = "0.0.0.0:8200"
cluster_address = "0.0.0.0:8201"
tls_disable = 1
}
api_addr = "https://vault.example.com"
cluster_addr = "http://vault-1:8201"
On Raft you snapshot with vault operator raft snapshot save. On file storage you snapshot the volume (Part 13) — there is no Raft snapshot API.
Seal / unseal
At process start Vault begins sealed: storage is readable as ciphertext, but secret APIs refuse work. Shamir waits for operators to reconstruct the unseal key; an auto seal immediately asks its external seal service to unwrap the root key and normally becomes ready without human input.
| Method | How it works | Operations cost | This lab |
|---|---|---|---|
| Shamir (default) | The unseal key is split into N shares; any T shares reconstruct it and decrypt the root key. Default N=5, T=3. | After every restart, humans (or a tightly controlled script) enter T shares | Yes |
| Auto-unseal — cloud KMS | AWS KMS, GCP Cloud KMS, or Azure Key Vault wraps the master key. Vault calls KMS on start. | IAM/role to KMS; no Shamir on reboot. Protect the KMS key as you would the master key. | No — see auto-unseal |
| Auto-unseal — Transit | A separate, self-hosted Unseal Vault’s Transit engine wraps the target Vault’s root key | You operate and back up two independent Vaults; the Unseal Vault itself is commonly Shamir-unsealed | Complete option in Part 6 |
| Recovery keys | Auto-unseal initialization creates recovery-key shares for quorum-authorized recovery operations; they do not unseal an auto-sealed Vault | Keep them offline for break-glass operations such as generate-root and recovery-key rekey | Created only on the Transit path |
Scripted Shamir unseal (keys in a file on disk) is not auto-unseal. It is Shamir with a worse custody story: anyone who can read that file unseals Vault. If you automate, prefer KMS or at least a hardware/offline split (three people, three keys).
Authentication for machines and humans
Auth is how a client gets a token. The token then hits policies. Laravel should never authenticate as root.
| Method | Identity | Fits Laravel when | Implement in this guide |
|---|---|---|---|
| AppRole | Role ID (not secret) + Secret ID (secret) | VMs, Docker Compose, CI agents, anything that can keep a Secret ID | Part 8 (lab) + wrapping |
| Kubernetes | Service account JWT projected into the pod | The app runs in Kubernetes (or OpenShift) | Part 9 |
| JWT / OIDC | Signed JWT from your IdP or CI (GitHub OIDC, Keycloak, …) | CI deploy jobs; human operators via OIDC (UI) | Part 9 (shape) |
| TLS certificates | Client cert mapped to a role | You already run an internal PKI and mTLS to Vault | Part 9 (shape) |
| Cloud IAM (AWS/GCP/Azure) | Instance/workload identity, no long-lived Secret ID | App runs on that cloud with an instance profile / workload identity | Not in lab; same token-after-login pattern |
| Token (static) | A token string you created once | Never for production apps | Anti-pattern only |
| Userpass / LDAP / OIDC UI | Humans | Operators, not the PHP workers | Out of scope for the app |
Secret delivery into Laravel
Auth answers “who is this process?” Injection answers “how do PHP processes learn DB_PASSWORD?”
| Method | PHP talks to Vault? | When it is the right tool | Main failure mode |
|---|---|---|---|
HTTP pull in register() | Yes, every worker/process (token cached in memory) | Compose/VM; few secrets; you control bootstrap order | Vault down ⇒ app boot fails; Octane token TTL; cache driver chicken-and-egg |
| Vault Agent templates | No. Agent authenticates and writes a file; PHP reads the file | You want PHP dumb; rotation by rewriting files + reload PHP-FPM | Stale file if Agent dies; APP_KEY must exist before PHP starts if you inject it this way |
| Vault Agent sink (token file) | PHP still calls KV, but uses a token file Agent refreshes | You want pull semantics without AppRole in Laravel .env | Must not commit the sink path; file permissions |
| Init container / entrypoint | No. A sidecar writes env or a dotenv snippet before php-fpm | Kubernetes Jobs, one-shot deploys | Secrets end up in process environment (/proc); rotation needs restart |
| Secrets Store CSI | No. Kubelet mounts files from Vault | Kubernetes, many pods, you already run CSI | Driver/version skew; still a file on disk inside the pod |
| Dynamic DB credentials | Yes (database engine). Username/password are leases | You want per-app DB users that expire | Lease expires while PDO is open; need renew or reconnect |
Static token in .env | Yes, but identity is a long-lived token | Never | Token leak = full policy of that token, often too broad |
register().Production hardening often added later: audit device, wrapped Secret ID, Agent templates for
APP_KEY, auto-unseal, Raft if Vault must stay up during a node loss.
Part 3 — Prerequisites and the VAULT_ADDR matrix
VAULT_ADDR values you must not mix.What you should do: create the directories; keep the matrix next to your
.env.Vault holds secrets. Traefik terminates TLS for browsers and host clients. Laravel authenticates with AppRole and reads secrets at runtime — never with the root token.
Two paths to the same Vault listener:
- Browser / Laravel on the host
https://vault.example.com→ Traefik (TLS) →http://vault:8200inside Docker - Laravel in the same Compose network
http://vault:8200directly — no Traefik, no TLS on the bridge
Compose path: Laravel → vault:8200 (HTTP on Docker network)
Prerequisites:
- Docker and Docker Compose
- A domain (e.g.
vault.example.com) with DNS pointing to your server - Ports 80 and 443 open on the host for Traefik (lab defaults — not a production port map)
- Laravel 10+ (local or on the same server)
Three different VAULT_ADDR values — do not mix them up
Vault container CLI (docker exec vault vault ...): VAULT_ADDR=http://127.0.0.1:8200 — talks to the local listener inside the container.
Laravel in the same Compose network: VAULT_ADDR=http://vault:8200 — internal Docker DNS, plain HTTP, no TLS verification needed.
Laravel on the host (outside Docker): VAULT_ADDR=https://vault.example.com — public URL through Traefik with valid TLS.
Laravel on prod server (Vault on another host): VAULT_ADDR=https://vault.internal over private network, or https://vault.example.com with firewall allowlist on Laravel egress IP — see split-server topology.
Vault UI redirects use api_addr in config.hcl (https://vault.example.com) — not cluster_addr.
Dedicated Vault host vs Laravel production host
In production, Vault and Laravel often run on different machines. Vault holds ciphertext and the unseal dependency; Laravel holds the application. Treat them as separate failure domains with explicit network policy — not “same Compose stack on one VM.”
| Topology | Laravel VAULT_ADDR | Network | When to use |
|---|---|---|---|
| Same Compose (lab) | http://vault:8200 | Docker bridge | Local stack, CI integration tests |
| Laravel on prod, Vault on private LAN | https://vault.internal:8200 or http://10.x.x.x:8200 | VPC / wireguard / site-to-site — no public listener required | Preferred production pattern |
| Laravel on prod, Vault on public HTTPS | https://vault.example.com | TLS + firewall allowlist (Laravel egress IP only) + rate limits | No private link; accept higher exposure cost |
Firewall rules (public Vault): allow 443 (Traefik) only from Laravel’s outbound IP(s) and operator VPN — not 0.0.0.0/0. Vault’s Docker listener on 8200 must not be published to the internet; Traefik is the only edge. For Agent on the Laravel host, the Agent container needs the same egress path PHP would use.
TLS: On a private network you may terminate TLS on Traefik with an internal CA, or use plain HTTP only if the path is provably isolated (many teams still use TLS on private links). Laravel sets VAULT_VERIFY_TLS=true and mounts the internal CA when using HTTPS.
Decision rule
Prefer Vault reachable only from Laravel subnets. If you must use the public internet, combine TLS, source-IP restriction, short AppRole token TTL, and audit — never expose the raw 8200 port.
Create the project folder:
mkdir vault-stack && cd vault-stack mkdir -p vault/config vault/policies
Part 4 — Vault configuration (config.hcl) and every server parameter
config.hcl).What you should do: COPY files; edit for your host; RUN apply/restart as labeled.
Create a single file at vault/config/config.hcl (one file only — do not drop extra *.hcl / *.json next to it):
ui = true
disable_mlock = true
storage "file" {
path = "/vault/file"
}
listener "tcp" {
address = "0.0.0.0:8200"
tls_disable = 1
}
# Public URL — Traefik terminates TLS; used for UI redirects and external clients
api_addr = "https://vault.example.com"
# Internal cluster port — MUST match listener scheme (HTTP, not HTTPS)
# Single-node: loopback on the cluster port (8201 by convention)
cluster_addr = "http://127.0.0.1:8201"
log_level = "info"
Why cluster_addr must not be the public HTTPS URL
When tls_disable = 1, Vault listens on plain HTTP. Setting cluster_addr = "https://vault.example.com" tells Vault nodes to talk to each other over HTTPS on a port that only speaks HTTP — cluster RPC fails silently or logs TLS handshake errors.
api_addr vs cluster_addr
api_addr: What clients and the Vault UI advertise — your public HTTPS URL behind Traefik.
cluster_addr: How Vault nodes communicate with each other on the cluster port. On a single node with HTTP listener, use http://127.0.0.1:8201.
IPC_LOCK and disable_mlock in Docker
Vault normally calls mlock() to prevent secrets from being swapped to disk. Docker containers often lack the capability to lock memory. The fix is twofold:
cap_add: [IPC_LOCK]in Compose — grants the capability when the kernel allows itdisable_mlock = trueinconfig.hcl— prevents startup failure when mlock still cannot succeed inside the container
Production trade-off: Without mlock, sensitive memory can be swapped. Mitigate with encrypted volumes, tight host access, and short-lived AppRole tokens — not a root token in Laravel.
Listener, TTL, logging — what each knob does
The file above is the lab minimum. Below is every parameter that actually matters on this stack: what it does, default if omitted, what goes wrong, how to set it.
ui (boolean)
What it does: Serves the web UI on the same listener as the API.
Default: false (no UI).
What goes wrong: UI on the public internet plus a weak operator password (or a leaked root token) is a high-value target. AppRole does not need the UI.
How to set: true in this lab. In production, keep it but restrict by Traefik IP allowlist, VPN, or an internal Host name — not open 0.0.0.0 on the host.
disable_mlock (boolean)
What it does: Skips mlock(2), which would pin Vault’s memory so the kernel cannot swap it.
Default: false — Vault tries to lock memory and exits if it cannot.
What goes wrong: Docker without IPC_LOCK (and some WSL/restricted kernels even with it) → container restart loop. With true, secrets can hit swap on a memory-starved host.
How to set: true in Docker. Also set cap_add: [IPC_LOCK] so if the kernel allows lock, you still asked for it. Do not set false in Compose unless you have proven mlock works (docker logs shows a clean start).
storage "file" { path }
What it does: Directory for encrypted Vault data inside the container.
Default: none — you must set a backend.
What goes wrong: Path not a volume → data dies when the container is recreated. Two Vault processes on one path → corruption.
How to set: path = "/vault/file" matching the Compose volume mount. Permissions: the Vault process user must write here (official image does).
listener "tcp" { address }
What it does: Bind address for the HTTP API (and UI).
Default: if you omit a listener, Vault does not serve production traffic the way you expect — always set one.
What goes wrong: 127.0.0.1:8200 inside Docker means Traefik and Laravel on the bridge cannot connect. 0.0.0.0:8200 is correct for a container. Publishing 8200:8200 on the host without a firewall exposes Vault on HTTP.
How to set: address = "0.0.0.0:8200". Do not publish 8200 in Compose if Traefik is the only public ingress.
tls_disable
What it does: 1 / true = plaintext HTTP on the listener. TLS is then Traefik’s job (or you terminate elsewhere).
Default: TLS is expected if you do not disable it — Vault wants cert files.
What goes wrong: TLS enabled without certs → Vault will not start. TLS disabled while cluster_addr is https://... → cluster/RPC confusion (see cluster_addr). Laravel on the Docker network talking HTTP is then correct; Laravel on the host must use Traefik HTTPS, not http://server:8200 if you never published 8200.
How to set: Lab: tls_disable = 1. If you terminate TLS on Vault itself, set tls_cert_file and tls_key_file and leave tls_disable unset/false; then api_addr and cluster_addr must both be https:// with names that match the certs.
tls_cert_file / tls_key_file / tls_min_version
What they do: Vault-native TLS. tls_min_version defaults to TLS 1.2 in current Vault; do not lower it.
How to set: Only when Traefik is not terminating TLS. Mount certs read-only. This lab does not use them.
cluster_address (inside the listener block)
What it does: Where this process listens for Vault-to-Vault cluster traffic (default port 8201).
Default: derived from the API listener (port + 1) if omitted.
What goes wrong: On a single node you rarely need to set it. On Raft HA, other nodes must reach this address; 127.0.0.1:8201 as a listen address would block clustering.
How to set: Single-node file lab: omit it. HA: cluster_address = "0.0.0.0:8201" and advertise a reachable cluster_addr (next item).
api_addr
What it does: Advertised client URL (UI redirects, some identity/OIDC callbacks, CLI hints). Environment VAULT_API_ADDR overrides it.
Default: unset — redirects and some features misbehave (relative URLs, wrong host).
What goes wrong: Setting this to http://127.0.0.1:8200 makes the browser UI bounce to loopback on the operator’s laptop. Setting it to the Docker DNS name makes the public UI useless.
How to set: Public HTTPS name: https://vault.example.com. Laravel in Compose still uses VAULT_ADDR=http://vault:8200; that client setting is independent of api_addr.
cluster_addr
What it does: Advertised URL for other Vault nodes to reach this node’s cluster port. Scheme must match how cluster RPC actually speaks.
Default: unset.
What goes wrong: Public https://vault.example.com while the listener is HTTP → TLS handshake noise or silent cluster failure. See Why cluster_addr must not be the public HTTPS URL.
How to set: Single-node HTTP: http://127.0.0.1:8201. Raft node: http://<this-node-dns>:8201 (or HTTPS if cluster TLS is on).
log_level / log_format
What they do: log_level: trace, debug, info, warn, error. log_format: standard or json.
Default: info, standard text.
What goes wrong: trace in production is noisy and can include request metadata you then ship to a log stack. error only hides unseal and auth failures you needed.
How to set: Lab and production: info. Switch to debug briefly while chasing 403s. Use json if Fluent Bit / Loki parses JSON.
default_lease_ttl / max_lease_ttl
What they do: Server-wide ceiling for leases (dynamic secrets, tokens that use system default). Values are Go duration strings: 768h is the historical default max (32 days).
Default: default_lease_ttl 768h, max_lease_ttl 768h unless you change them.
What goes wrong: AppRole token_ttl=24h is rejected if it exceeds max_lease_ttl. Dynamic DB credentials inherit these unless the role sets its own TTL.
How to set: Leave defaults for the lab. For production, set max_lease_ttl to the longest you will ever allow (e.g. 768h) and keep AppRole tokens much shorter (1h–4h).
Optional production stanza you can add later (not required for Laravel):
# default_lease_ttl = "768h"
# max_lease_ttl = "768h"
# log_format = "json"
# telemetry {
# prometheus_retention_time = "30s"
# disable_hostname = true
# }
Prometheus telemetry is off unless you enable a telemetry block (and scrape the metrics endpoint with an appropriate listener/policy). Skip it until Vault is unsealed and Laravel reads secrets.
Part 5 — Docker Compose with Traefik labels and healthcheck
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Create the shared edge network once (Traefik and Vault both attach to it):
docker network create edge
Optional dynamic Traefik file (HTTP → HTTPS redirect middleware referenced as https-redirect@file):
traefik/dynamic/redirect.ymlhttp:
middlewares:
https-redirect:
redirectScheme:
scheme: https
permanent: true
Full docker-compose.yml (template — replace emails, tokens, hosts, and allowlists with your own; do not commit real secrets):
services:
traefik:
image: traefik:v3.2
container_name: traefik
hostname: traefik
restart: always
environment:
# DNS challenge credentials — use placeholders or a secrets manager, never real tokens in git
- CLOUDFLARE_EMAIL=you@example.com
- CF_DNS_API_TOKEN=replace-with-your-dns-api-token
command:
- --log.level=ERROR
- --log.format=json
- --api.dashboard=true
- --api=true
- --api.insecure=true
- --ping=true
- --accesslog=true
- --accesslog.fields.names.StartUTC=drop
- --accesslog.bufferingsize=250
- --providers.file.directory=/dynamic
- --providers.file.watch=true
- --providers.docker.endpoint=unix:///var/run/docker.sock
- --providers.docker.exposedbydefault=false
- --providers.docker.network=edge
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --certificatesresolvers.le.acme.dnschallenge=true
- --certificatesresolvers.le.acme.dnschallenge.provider=cloudflare
- --certificatesresolvers.le.acme.email=admin@example.com
- --certificatesresolvers.le.acme.storage=/acme/acme.json
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik/dynamic:/dynamic:ro
- traefik_acme:/acme
- ./traefik/usersfile:/usersfile:ro
healthcheck:
test: ["CMD", "traefik", "healthcheck", "--ping"]
interval: 5s
timeout: 5s
retries: 3
start_period: 5s
labels:
- traefik.enable=true
- traefik.docker.network=edge
- traefik.http.middlewares.web-auth.basicauth.usersfile=/usersfile
- traefik.http.middlewares.ip-whitelist.ipwhitelist.sourcerange=203.0.113.0/24,198.51.100.10
- traefik.http.routers.traefik.entrypoints=web
- traefik.http.routers.traefik.middlewares=https-redirect@file
- traefik.http.routers.traefik.service=api@internal
- traefik.http.routers.traefik-secure.entrypoints=websecure
- traefik.http.routers.traefik-secure.rule=Host(`traefik.example.com`)
- traefik.http.routers.traefik-secure.tls=true
- traefik.http.routers.traefik-secure.tls.options=default
- traefik.http.routers.traefik-secure.middlewares=web-auth@docker,ip-whitelist@docker
- traefik.http.routers.traefik-secure.tls.certresolver=le
- traefik.http.routers.traefik-secure.service=api@internal
networks:
- edge
vault:
image: hashicorp/vault:1.17
container_name: vault
restart: unless-stopped
cap_add:
- IPC_LOCK
volumes:
# Mount a directory that contains ONE config file only
- ./vault/config:/vault/config:ro
- vault_data:/vault/file
# Point at the single file — do NOT pass the directory (avoids dual-config / port bind)
entrypoint: ["vault", "server", "-config=/vault/config/config.hcl"]
environment:
# In-container CLI + healthcheck — NOT the public HTTPS URL
VAULT_ADDR: "http://127.0.0.1:8200"
VAULT_API_ADDR: "https://vault.example.com"
healthcheck:
# 0 = unsealed, 2 = sealed/uninitialized (API up). Traefik skips unhealthy backends.
test: ["CMD-SHELL", "vault status >/dev/null 2>&1; ec=$$?; [ $$ec -eq 0 ] || [ $$ec -eq 2 ]"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
networks:
- edge
labels:
- traefik.enable=true
- traefik.docker.network=edge
# HTTP → redirect middleware from file provider
- traefik.http.routers.vault.entrypoints=web
- traefik.http.routers.vault.rule=Host(`vault.example.com`)
- traefik.http.routers.vault.middlewares=https-redirect@file
- traefik.http.routers.vault.service=vault-secure
# HTTPS
- traefik.http.routers.vault-secure.entrypoints=websecure
- traefik.http.routers.vault-secure.rule=Host(`vault.example.com`)
- traefik.http.routers.vault-secure.tls=true
- traefik.http.routers.vault-secure.tls.options=default
- traefik.http.routers.vault-secure.middlewares=ip-whitelist@docker
- traefik.http.routers.vault-secure.tls.certresolver=le
- traefik.http.routers.vault-secure.service=vault-secure
- traefik.http.services.vault-secure.loadbalancer.server.port=8200
networks:
edge:
name: edge
external: true
volumes:
vault_data:
traefik_acme:
name: traefik_acme
--api.insecure=true / public dashboard exposure on Traefik. Restrict Vault UI (ui = true) to admin VPN/OIDC — public UI + AppRole is a high-value target. Put Cloudflare (or other DNS) tokens in Docker secrets / env files outside git. Pin images by digest in production (hashicorp/vault:1.17.6@sha256:…), not a floating minor tag.command: server (or -config=/vault/config as a directory), Vault loads every *.hcl / *.json in that folder and merges them. A leftover default listener plus your listener "tcp" both bind :8200 → bind: address already in use. Fix: keep exactly one file (vault/config/config.hcl) and start with entrypoint: ["vault", "server", "-config=/vault/config/config.hcl"]. Do not also set VAULT_LOCAL_CONFIG unless you intentionally want a second merged snippet.Start the stack:
docker compose up -d docker compose ps
Sample output — docker compose ps (Vault sealed but API up → healthy)
NAME IMAGE STATUS traefik traefik:v3.2 Up (healthy) vault hashicorp/vault:1.17 Up (healthy)
Meaning: the healthcheck accepts exit 0 (unsealed) and exit 2 (sealed / uninitialized) because the API is up. Compose shows healthy while sealed so Traefik can discover the backend and you can open the UI to unseal. Until the container is healthy, Traefik does not put Vault into routing discovery / does not send it edge traffic. If the container is Exit, restarting, or stays unhealthy, check docker compose logs vault (bad config, missing volume, or address already in use), not the seal state alone.
Open https://vault.example.com — the UI loads but shows Sealed until you unseal (Part 6).
Compose fields explained
Vault image
What it does: Pins the Vault binary and default user.
How to set: hashicorp/vault:1.17 matches this guide. Do not mix CLI examples from 1.8 (KV v1 defaults) with a 1.17 server. After upgrade, read the changelog for storage/unseal compatibility — never jump major versions without a backup. There is no hashicorp/vault:2.x image tag for this stack — use a real 1.x release.
entrypoint: ["vault", "server", "-config=/vault/config/config.hcl"]
What it does: Starts the server with one explicit HCL file. That is the correct pattern for this lab.
What goes wrong: command: server (directory load) or multiple files under /vault/config merges listeners → port already used / address already in use. Omitting a server start may land you in CLI / accidental dev mode depending on the image.
How to set: Mount ./vault/config → /vault/config, keep only config.hcl inside, pass that file path to -config=.
VAULT_ADDR on the Vault service
What it does: Tells the in-container CLI (docker exec vault vault … and the healthcheck) where the local API is.
How to set: Always http://127.0.0.1:8200 here. This is not Laravel’s VAULT_ADDR.
VAULT_API_ADDR
What it does: Overrides api_addr from HCL for this process.
How to set: Same public HTTPS URL as in config.hcl. Keep them identical to avoid “it works in HCL but env wins” debugging.
cap_add: [IPC_LOCK]
What it does: Grants the Linux capability Vault uses for mlock.
How to set: Always, even if disable_mlock = true. Harmless if unused; required if you later turn mlock back on.
Healthcheck (keep this exact test)
What it does: Runs vault status. Exit 0 = unsealed; exit 2 = sealed/uninitialized with API listening — both count as healthy. Any other exit (API down, crash loop) → unhealthy.
Traefik discovery: Until the Vault container is healthy, Traefik does not discover/route edge traffic to it. That is why this healthcheck must treat sealed (exit 2) as healthy — otherwise you could never reach the UI to unseal.
Default if omitted: no healthcheck — Compose never distinguishes “API up” from “still booting,” and Traefik may race a dead backend.
How to set: Keep the lab CMD-SHELL exactly. Laravel may use depends_on: condition: service_healthy for “API reachable,” but secret reads still fail until you unseal — add app-level readiness for that.
restart: unless-stopped
What it does: Restart on crash and on daemon restart, unless you explicitly stopped the container.
What goes wrong: After host reboot, Vault is up and sealed. Laravel will error until unseal. That is expected with Shamir.
Volumes ./vault/config:/vault/config:ro and vault_data:/vault/file
What they do: Config is read-only so a compromised Vault process cannot rewrite HCL from inside. Data volume is read-write ciphertext.
How to set: Keep :ro on config. Only config.hcl in that directory. Name the volume explicitly if you want a stable backup target: name: vault_data under volumes: so it is not prefixed with the project directory.
Traefik labels explained
traefik.enable=true
Required because exposedbydefault=false. Without it, Traefik ignores the container entirely.
traefik.http.routers.vault-secure.rule=Host(`vault.example.com`)
Match HTTPS requests for your Vault hostname. Replace with your real domain.
traefik.http.routers.vault-secure.entrypoints=websecure
Bind this router to the HTTPS entrypoint (:443 in the lab template; HTTP is web on :80). These are documentation defaults — pick your own published ports in real deployments.
traefik.http.routers.vault-secure.tls.certresolver=le
Obtain and renew a certificate via ACME DNS challenge (Cloudflare in the template). The resolver name must match certificatesresolvers.le in Traefik's command.
traefik.http.services.vault-secure.loadbalancer.server.port=8200
Critical. Vault listens on 8200. Traefik does not guess container ports — missing this label causes 502 Bad Gateway.
traefik.docker.network=edge
Which Docker network Traefik uses to reach Vault. Both containers must attach to the same external edge network.
Vault healthcheck + Traefik discovery
The CMD-SHELL test uses VAULT_ADDR=http://127.0.0.1:8200. Exit 0 or 2 → healthy (API up, sealed or not). Until healthy, Traefik does not discover Vault for routing. Keep this healthcheck — do not switch back to a check that fails on sealed status.
Part 5A — End-to-end bootstrap: cold Vault to Laravel-ready
What you should do: follow the gates in order; do not skip to AppRole before KV and policy exist.
Parts 4–8 explain each step in depth. This part is the single runbook an operator runs once on a new Vault node (and repeats after disaster recovery on empty storage — never run operator init twice on existing data).
config.hcl + storage volume → docker compose up (Vault sealed but healthy if API is up) → operator init (once) → operator unseal × threshold → login (root, bootstrap only) → secrets enable kv-v2 @ secret → policy write laravel → auth enable approle → write auth/approle/role/laravel → read role-id + write secret-id → kv put secret/laravel/... → approle login + kv get (smoke test) → revoke root / lock away → configure Laravel or Agent
Gate 0 — Configuration and storage
- Create
vault/config/config.hcl(Part 4):storage "file" { path = "/vault/file" }, listener,api_addr,cluster_addr. Only this one file in the config directory. - Start stack:
docker compose up -d. Expect Vaulthealthywhile sealed if the API listens (Part 5 healthcheck accepts exit 0 and 2). - Verify:
docker compose logs vaultshows config loaded, no crash loop.
Gate 1 — Initialize and unseal
vault status reports uninitialized.docker exec -it vault vault operator init -key-shares=5 -key-threshold=3 # Store shares offline. Then: docker exec -it vault vault operator unseal # repeat with 3 distinct shares docker exec vault vault status # Sealed: false docker exec -it vault vault login # root token, bootstrap only
Exit criterion: Initialized true, Sealed false, authenticated CLI session. If already initialized, skip init and only unseal after restarts.
Gate 2 — KV engine, policy, AppRole, secrets
docker exec vault vault secrets enable -path=secret kv-v2 docker cp vault/policies/laravel-policy.hcl vault:/tmp/laravel-policy.hcl docker exec vault vault policy write laravel /tmp/laravel-policy.hcl docker exec vault vault auth enable approle docker exec vault vault write auth/approle/role/laravel \ token_policies="laravel" token_ttl=1h token_max_ttl=4h secret_id_ttl=0 docker exec vault vault read -field=role_id auth/approle/role/laravel/role-id docker exec vault vault write -f -field=secret_id auth/approle/role/laravel/secret-id docker exec vault vault kv put secret/laravel/production/database \ DB_HOST=10.0.1.5 DB_PORT=3306 DB_DATABASE=app DB_USERNAME=app \ DB_PASSWORD='CHANGE_ME' docker exec vault vault kv put secret/laravel/production/redis \ REDIS_CACHE_HOST=10.0.1.10 REDIS_CACHE_PASSWORD='CHANGE_ME' \ REDIS_SESSION_HOST=10.0.1.11 REDIS_SESSION_PASSWORD='CHANGE_ME' \ REDIS_HORIZON_HOST=10.0.1.10 REDIS_HORIZON_PASSWORD='CHANGE_ME'
Gate 3 — Smoke test as Laravel will
export VAULT_ADDR=https://vault.example.com # or http://vault:8200 in lab read -r -s -p "Role ID: " ROLE_ID; echo read -r -s -p "Secret ID: " SECRET_ID; echo vault write auth/approle/login role_id="$ROLE_ID" secret_id="$SECRET_ID" # paste client_token: export VAULT_TOKEN=<client_token> vault kv get secret/laravel/production/database vault kv get secret/laravel/production/redis
Exit criterion: HTTP 200 on both KV reads; 403 here means policy path typo (secret/data/... vs CLI path). Then revoke root token or remove from active use (Part 8).
Part 6 — Initialize, unseal, and self-hosted auto-unseal
What you should do: choose either the Shamir lab path or the Transit target path. Never initialize the same storage twice.
A Vault process begins sealed. With the default Shamir seal, operators submit a threshold of unseal-key shares after every process restart. With an auto seal, Vault asks an external seal service to unwrap its root key and operators retain recovery-key shares for quorum-authorized recovery operations. Recovery shares do not manually unseal a Vault whose Transit dependency is unavailable.
operator init parameters (shares, threshold)
Initialization is a first-start operation against empty storage. Running init again returns “Vault is already initialized.” Back up before changing an existing seal; initialization is not a migration mechanism.
-key-shares / -key-threshold (Shamir)
What they do: split the Shamir unseal key into N shares and require T distinct shares after each restart. Defaults are 5 and 3.
Constraint: 1 ≤ threshold ≤ shares. A majority such as 3-of-5 is a common governance choice, not a Vault requirement and not automatically the right value. Choose holders, absence tolerance, compromise tolerance, and recovery time first.
What goes wrong: 1-of-1 is one theft or loss away from compromise or permanent loss. A threshold equal to all shares cannot tolerate one unavailable holder.
-recovery-shares / -recovery-threshold (auto-unseal)
What they do: create recovery-key shares when a supported auto-seal stanza is active. They authorize operations such as generate-root and recovery-key rekey; they are not Shamir unseal shares.
How to set: choose custody independently, often 5/3. If Transit is down, restore the Transit service/key/token path; entering recovery shares will not bypass it.
-pgp-keys / -root-token-pgp-key
What they do: encrypt each generated share and the initial root token to operator PGP public keys so plaintext does not appear in one terminal.
How to set: use one recipient per custodian in production. Plaintext output is acceptable only for a disposable lab; never paste real shares or tokens into tickets, chat, shell history, or this HTML file.
Path A — initialize the Shamir lab
docker exec -it vault vault operator init -key-shares=5 -key-threshold=3
Sample output — vault operator init with Shamir (values intentionally redacted)
Unseal Key 1: <redacted> Unseal Key 2: <redacted> Unseal Key 3: <redacted> Unseal Key 4: <redacted> Unseal Key 5: <redacted> Initial Root Token: <redacted> Vault initialized with 5 key shares and a key threshold of 3.
Meaning: this storage now uses Shamir. Distribute all five shares to their intended custodians and verify retrieval. The root token is bootstrap-only. These are real secrets in actual output; redaction here is deliberate, not Vault behavior.
docker exec -it vault vault operator unseal docker exec -it vault vault operator unseal docker exec -it vault vault operator unseal
Sample output — third vault operator unseal
Key Value --- ----- Seal Type shamir Initialized true Sealed false Total Shares 5 Threshold 3 Version 1.17.x Storage Type file HA Enabled false
Meaning: each of the first two submissions reports progress while remaining sealed; the third distinct valid share changes Sealed to false. Reusing one share does not advance progress. Never put a share directly on the command line.
docker exec vault vault status docker exec -it vault vault login
Sample output — successful vault login (token omitted)
Success! You are now authenticated. The token information displayed below is already stored in the token helper. Key Value --- ----- token <redacted> token_accessor <redacted> token_duration ∞ token_renewable false token_policies ["root"]
Meaning: authentication succeeded and the CLI token helper retained the token inside the container. The root policy is unrestricted; use it only to configure normal auth and policies, then revoke it or return it to offline custody. Never give it to Laravel.
Shamir restarts
Only the Shamir path requires T share submissions after every Vault process or host restart. A script that stores those shares beside Vault is not auto-unseal; it collapses custody onto that host. The remaining subsections implement real self-hosted auto-unseal.
Path B — self-hosted auto-unseal with a dedicated Unseal Vault
→ Transit engine
transit/ → key autounseal→ narrow periodic token in a root-owned file
→ target Vault
seal "transit" → automatic restart unsealThe Unseal Vault is a separate trust domain: separate VM(s), storage, listener, DNS, backup, root token, audit log, and failure budget. Bootstrap it with Shamir and keep it running before the target starts. Do not point a Vault at its own Transit engine: while sealed it cannot serve the decrypt request needed to unseal itself.
- Build and initialize
https://unseal-vault.internal:8200with Shamir; unseal it manually and log in for bootstrap. - Enable Transit, create a non-exportable key, write a least-privilege policy, and issue an orphan periodic service token.
- Place only that token and the Unseal Vault CA on every target node with restrictive ownership and mounts.
- Add the Transit seal stanza before initializing a fresh target, or follow the migration sequence for existing storage.
export VAULT_ADDR=https://unseal-vault.internal:8200
export VAULT_CACERT=/etc/vault-unseal/tls/ca.pem
vault secrets enable transit
vault write -f transit/keys/autounseal
cat > /tmp/autounseal-policy.hcl <<'EOF'
path "transit/encrypt/autounseal" {
capabilities = ["update"]
}
path "transit/decrypt/autounseal" {
capabilities = ["update"]
}
EOF
vault policy write autounseal /tmp/autounseal-policy.hcl
vault token create -orphan -period=24h -policy=autounseal
Sample output — enabling Transit, creating its key, policy, and periodic token
Success! Enabled the transit secrets engine at: transit/ Success! Data written to: transit/keys/autounseal Success! Uploaded policy: autounseal Key Value --- ----- token <redacted> token_accessor <redacted> token_duration 24h token_renewable true token_policies ["autounseal" "default"]
Meaning: the first three mutation commands succeeded. Vault’s success lines are their complete useful output; no secret payload is expected. Transit encrypt/decrypt endpoints are writes, so the required capability is update, not read. The orphan token has no revocable parent and remains valid only while the target Vault renews it before each 24-hour period expires.
config.hcl, Compose YAML, an image, or source control. A token file is still a high-value credential: root/Vault user read-only, no group/world access, read-only container mount, encrypted host storage, and excluded from backup logs./etc/vault.d/seal/transit.hcl; mount token and CA read-only.seal "transit" {
address = "https://unseal-vault.internal:8200"
key_name = "autounseal"
mount_path = "transit/"
token = "file:///vault/seal/token"
tls_ca_cert = "/vault/seal/unseal-vault-ca.pem"
tls_server_name = "unseal-vault.internal"
disable_renewal = "false"
}
token_file stanza field. Vault 1.19+ supports the secure indirect reference shown above: token = "file:///absolute/path". Indirect file values are not whitespace-trimmed, so the token file must contain exactly the token with no trailing newline. On the Vault 1.17 version used elsewhere in this article, upgrade before using this form or have the host service manager read the same protected file into VAULT_TOKEN; never replace it with a token literal in HCL.sudo install -d -o vault -g vault -m 0700 /etc/vault.d/seal sudo sh -c 'umask 077; tr -d "\r\n" < /secure-transfer/autounseal.token > /etc/vault.d/seal/token' sudo chown vault:vault /etc/vault.d/seal/token sudo install -o root -g vault -m 0640 /secure-transfer/unseal-vault-ca.pem /etc/vault.d/seal/unseal-vault-ca.pem # Docker mounts: # /etc/vault.d/seal/token:/vault/seal/token:ro # /etc/vault.d/seal/unseal-vault-ca.pem:/vault/seal/unseal-vault-ca.pem:ro
Keep certificate verification enabled. The CA must validate the Unseal Vault’s presented chain and tls_server_name must match a certificate SAN. Never use tls_skip_verify=true in production.
Fresh target initialization with recovery shares
docker exec -it vault vault operator init \ -recovery-shares=5 \ -recovery-threshold=3
Sample output — fresh target initialized with Transit (values intentionally redacted)
Recovery Key 1: <redacted> Recovery Key 2: <redacted> Recovery Key 3: <redacted> Recovery Key 4: <redacted> Recovery Key 5: <redacted> Initial Root Token: <redacted> Success! Vault is initialized
Meaning: this output has recovery keys, not unseal keys. The target used Transit to protect its root key and should become unsealed without share entry. Distribute recovery shares offline and protect the bootstrap root token.
docker compose restart vault docker exec vault vault status
Sample output — Transit target after restart
Key Value --- ----- Seal Type transit Recovery Seal Type shamir Initialized true Sealed false Total Recovery Shares 5 Threshold 3 Version 1.19.x Storage Type file HA Enabled false
Meaning: Seal Type transit and Sealed false after restart prove auto-unseal worked. Recovery Seal Type shamir describes how recovery authority is split; it does not mean operators should run operator unseal. If the target stays sealed, check target logs, TLS, token validity, policy, and Unseal Vault health.
Operational dependency and backup checks
- Availability: the Unseal Vault must be reachable whenever a target starts and whenever Transit-dependent seal operations occur. Existing unsealed target processes may continue through a brief outage, but a restart cannot recover without Transit.
- No circular dependencies: do not host the Unseal Vault only on infrastructure that needs the target Vault to boot (target-sourced TLS keys, database credentials, DNS credentials, storage unlock, or scheduler secrets). Give it an independent startup path.
- Backups: back up Unseal Vault storage and target storage independently. Preserve the Unseal Vault’s Shamir shares and the Transit key’s storage; a new key with the same name cannot decrypt old target wrapping material.
- Token lifecycle: monitor renewal failures and expiry. Test the narrow token with
vault token lookupon the Unseal Vault by accessor; rotate by writing a new token file and restarting/reloading targets in a controlled window. - Long target outage: a periodic token is renewable, not immortal. If every target is down longer than its period, the token expires because nothing renews it. Before the next cold start, an Unseal Vault operator must issue and securely install a replacement token; test this recovery path.
- Health: alert on Unseal Vault sealed/unavailable, Transit request errors, target
sealed=true, TLS expiry, audit write failures, and backup age. Test cold starts, not just a running target.
Migrate an existing Shamir target to Transit
-migrate; mixing ordinary and migration submissions resets progress. Do not run operator init.- Build, initialize, unseal, back up, and health-check the independent Unseal Vault. Create Transit key, policy, periodic token, CA file, and test TLS reachability from the target host.
- Back up the target storage and configuration. Record
vault status, verify the old Shamir threshold and version, and schedule downtime. - Add the Transit seal stanza and protected files while the target still runs, then stop the target cleanly.
- Start the target with the new stanza. It remains sealed in migration mode; that is expected.
- Submit the existing Shamir shares, using
-migrateon every command, until the threshold is met. - Verify
Seal Type transit,Recovery Seal Type shamir,Sealed false, and then perform a controlled restart to prove auto-unseal.
docker exec -it vault vault operator unseal -migrate docker exec -it vault vault operator unseal -migrate docker exec -it vault vault operator unseal -migrate docker exec vault vault status docker compose restart vault docker exec vault vault status
Sample output — final migration submission
Key Value --- ----- Seal Type transit Recovery Seal Type shamir Initialized true Sealed false Total Recovery Shares 5 Threshold 3 Migration false Storage Type file HA Enabled false
Meaning: migration completed and the old Shamir shares became recovery shares. Migration false means no migration remains pending. The later restart must return to Sealed false without share input before the change is accepted.
Self-hosted Raft clusters
Every Raft node must have the same Transit seal type, Unseal Vault address, mount path, key name, and trusted CA. Give each target cluster a dedicated token; each node needs access to the protected token file. Node-specific settings such as node_id, listener address, and cluster_addr remain unique.
- Fresh cluster: initialize only the first node with recovery shares. Auto-sealed peers using
retry_joinjoin and unseal automatically; never initialize peers independently. - Shamir-to-Transit migration: stop the entire Raft cluster, update the seal stanza and files on all nodes, then bring enough nodes up together for Raft quorum. Run the threshold of
operator unseal -migratesubmissions against the elected leader. The leader writes migration state to Raft and replicates it; do not repeat seal migration separately on each peer. Start/restart remaining peers and verify each auto-unseals. - Per-node verification: query each node’s local API address and confirm
Seal Type transit,Sealed false, expected peer membership, and one active leader. A load-balanced status check can hide a sealed peer.
vault operator raft list-peers vault operator raft snapshot save backups/vault-raft-$(date +%Y%m%d-%H%M%S).snap sha256sum backups/vault-raft-*.snap
Sample output — vault operator raft list-peers
Node Address State Voter ---- ------- ----- ----- vault-1 vault-1:8201 leader true vault-2 vault-2:8201 follower true vault-3 vault-3:8201 follower true
Meaning: the cluster has three voting peers and a leader. Snapshot save is normally silent on success: exit code 0 plus a non-empty snapshot and recorded checksum are the evidence. Store snapshots off-cluster with configuration, CA material, and recovery custody records—never only on a Raft node.
Restore into an isolated recovery environment first. Use the same Transit seal configuration and ensure the original Unseal Vault Transit key is available before restoring. Authenticate with sufficient privileges, then run vault operator raft snapshot restore backup.snap. Use -force only when deliberately bypassing seal-consistency checks during a documented disaster-recovery procedure; it is not a routine flag. After restore, verify all peer IDs/addresses, restart peers one at a time, check each node’s seal status, and test a non-destructive secret read.
PKCS#11 / on-premises HSM (Enterprise only)
seal "pkcs11" is the direct no-cloud HSM option, but PKCS#11 auto-unseal and seal wrapping require a Vault Enterprise edition/binary with HSM entitlement. It is not available in Vault Community Edition. For OSS, the practical self-hosted recommendation is the dedicated Transit architecture above.
seal "pkcs11" {
lib = "/opt/hsm/lib/libpkcs11.so"
slot = "0"
key_label = "vault-root"
hmac_key_label = "vault-hmac"
generate_key = "false"
# Supply the PIN through VAULT_HSM_PIN, not this file.
}
- Use a supported PKCS#11 v2.20+ vendor library and matching OS/CPU architecture; Vault does not replace the vendor’s HSM integration guide.
- Prefer pre-provisioned, backed-up, non-exportable HSM keys (
generate_key=false); some FIPS/CC modes require manual key creation. - Deploy an HA HSM service and test library reconnection. Enterprise seal wrap can require HSM availability during runtime, not only at startup, and adds latency.
- Keep PINs outside HCL (for example
VAULT_HSM_PINfrom a protected service manager), restrict library/config permissions, and document HSM key backup/replication and quorum administration. - Validate mechanism and data-size constraints with the exact HSM model. Never copy a mechanism numeric value from another vendor without compatibility testing.
Part 7 — KV v2 secrets engine, paths, and policies
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Enable KV version 2 at path secret:
docker exec -it vault vault secrets enable -path=secret kv-v2
Sample output — vault secrets enable -path=secret kv-v2
Success! Enabled the kv-v2 secrets engine at: secret/
Meaning: the mount now exists and uses KV v2. This is a mutation, so the success line is the useful output; it does not print engine data. “path is already in use” means inspect the existing mount with vault secrets list -detailed instead of enabling it again.
Store a sample secret:
docker exec -it vault vault kv put secret/laravel/app \ DB_PASSWORD="super-secret-db-password" \ MAIL_PASSWORD="smtp-password-here"
Sample output — first vault kv put
===== Secret Path ===== secret/data/laravel/app ======= Metadata ======= Key Value --- ----- created_time 2026-08-30T10:00:00.000Z custom_metadata <nil> deletion_time n/a destroyed false version 1
Meaning: Vault wrote version 1 and intentionally does not echo secret values. A later full kv put creates a new version and replaces the latest key map; verify with kv get without copying its secret-bearing output into logs.
Verify:
docker exec -it vault vault kv get secret/laravel/app
Sample output — vault kv get secret/laravel/app
======= Secret Path ======= secret/data/laravel/app ======= Metadata ======= Key Value --- ----- created_time 2026-08-30T10:00:00.000Z deletion_time n/a destroyed false version 1 ======= Data ======= Key Value --- ----- DB_PASSWORD super-secret-db-password MAIL_PASSWORD smtp-password-here
Meaning: the CLI path is secret/laravel/app but the storage path is secret/data/laravel/app (KV v2). Laravel’s HTTP client must use the /data/ path. version 1 is the first write; later kv put increments version. If you see No value found at secret/data/..., the mount is wrong or you wrote to a different path.
KV v2 path prefix: secret/data/
CLI write path: secret/laravel/app (the kv put command handles the mount).
HTTP API read path: secret/data/laravel/app — KV v2 inserts /data/ between mount and secret name.
Policy path: secret/data/laravel/* — policies match the API path, not the CLI shorthand.
KV v2 parameters and HTTP vs CLI paths
Enable is one command; the engine still has tunables. KV v1 (no versions, path without /data/) is not what this lab uses. If someone ran vault secrets enable kv without -version=2 / kv-v2, Laravel’s data.data parsing will be wrong.
Mount path -path=secret
What it does: Prefix of every API path for this engine. Default if you omit -path is also secret for kv-v2 in many examples — still set it explicitly.
How to set: Keep secret for the lab so every snippet matches. If you mount at kv, then CLI is kv put kv/laravel/app and HTTP is /v1/kv/data/laravel/app.
JSON shape on HTTP GET
KV v2 wraps your keys: response.data.data is the map you wrote (DB_PASSWORD, …). response.data.metadata has version, created_time, destroyed.
Laravel must use json('data.data'), not json('data'). Using the wrong level returns metadata nested with your secrets or an empty array.
max_versions (tune)
What it does: How many historical versions Vault keeps per key. Oldest versions drop when you exceed it.
Default: 10.
What goes wrong: Too low → you cannot roll back a bad rotation. Too high → storage growth (usually small for a few Laravel keys).
How to set:
docker exec vault vault kv metadata put -max-versions=20 secret/laravel/app # engine-wide default: docker exec vault vault write secret/config max_versions=20
cas_required (check-and-set)
What it does: Writes must include the current version (-cas=N) so two operators cannot clobber each other silently.
Default: false.
How to set: vault write secret/config cas_required=true for production KV used by automation. Laravel reads do not need CAS. Your rotation job must kv get then kv put -cas=<version>.
delete_version_after
What it does: Soft-deleted versions expire after a duration (Go duration, e.g. 24h).
Default: 0 (never auto-delete).
How to set: Only if you have a retention policy. Soft delete is not destroy: vault kv destroy is irreversible for that version.
Read a specific version (HTTP)
GET /v1/secret/data/laravel/app?version=2 — useful after a bad rotation. Policy read on secret/data/… is enough. CLI: vault kv get -version=2 secret/laravel/app.
Policy capabilities and path prefixes
Policies match the API path, not the CLI shorthand. Capabilities are the verbs Vault evaluates after auth.
| Capability | Meaning | Laravel AppRole needs it? |
|---|---|---|
read | GET a secret (and most “read this path” APIs) | Yes — KV data |
list | LIST keys under a prefix (UI browsing, kv list) | No, unless you list in code |
create / update | Write new / overwrite (POST/PUT). KV v2 often needs both for kv put | No for a read-only app |
patch | JSON merge patch (KV v2 partial update) | No |
delete | Soft-delete versions (KV v2 delete prefix) | No |
sudo | Root-protected paths (e.g. some sys/ endpoints) | Never |
deny | Explicit deny wins over allow | Use to fence a subpath if the glob is too wide |
API prefix (mount secret) | Used for | Policy example |
|---|---|---|
secret/data/laravel/* | Read/write secret payloads | read for Laravel |
secret/metadata/laravel/* | List, metadata, max-versions per key | list / read if operators use CLI list as that role |
secret/delete/laravel/* | Soft-delete versions | Rotation jobs only |
secret/undelete/laravel/* | Restore soft-deleted versions | Admins only |
secret/destroy/laravel/* | Irreversible destroy of versions | Admins only |
secret/subkeys/laravel/* | List field names without values (Vault 1.10+) | Usually omit |
A tighter lab policy (read data only — this is what Laravel needs):
vault/policies/laravel-policy.hcl.path "secret/data/laravel/*" {
capabilities = ["read"]
}
Apply the policy (name must match AppRole token_policies in Part 8):
docker cp vault/policies/laravel-policy.hcl vault:/tmp/laravel-policy.hcl docker exec -it vault vault policy write laravel /tmp/laravel-policy.hcl
Recommended KV layout for Laravel (multi-database, multi-Redis)
One flat secret/laravel/app key map does not scale when cache, session, and Horizon use different Redis instances (or passwords). Split by environment and concern so policies can scope reads and rotation jobs touch one surface at a time.
secret (KV v2).secret/laravel/production/database # MariaDB/MySQL credentials secret/laravel/production/redis # all Redis endpoints + passwords secret/laravel/production/mail # SMTP, API mailers secret/laravel/production/integrations # payment, webhooks, third-party APIs secret/laravel/production/app-key # APP_KEY only — Agent-friendly path secret/laravel/staging/... # parallel tree, tighter policies
HTTP API paths add /data/: e.g. secret/data/laravel/production/redis. Policy glob for the app role:
path "secret/data/laravel/production/*" {
capabilities = ["read"]
}
path "secret/data/laravel/staging/*" {
capabilities = ["read"]
}
Example Redis secret (one KV object, multiple logical clients):
docker exec vault vault kv put secret/laravel/production/redis \ REDIS_CACHE_HOST=redis-cache.internal \ REDIS_CACHE_PORT=6379 \ REDIS_CACHE_PASSWORD='cache-secret' \ REDIS_SESSION_HOST=redis-session.internal \ REDIS_SESSION_PORT=6379 \ REDIS_SESSION_PASSWORD='session-secret' \ REDIS_HORIZON_HOST=redis-cache.internal \ REDIS_HORIZON_PORT=6379 \ REDIS_HORIZON_PASSWORD='cache-secret'
Horizon often shares the cache Redis but uses a different database index — still one password if the instance is shared; use separate keys when the host or password differs.
Part 8 — AppRole authentication for Laravel (recommended for Compose)
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Enable AppRole:
docker exec -it vault vault auth enable approle
Sample output — vault auth enable approle
Success! Enabled approle auth method at: approle/
Meaning: the auth mount exists at auth/approle/. This command creates the method but no role; “path is already in use” means list auth mounts and reuse the existing one rather than enabling a duplicate.
Create the role — token_policies must exactly match the policy name from vault policy write laravel:
docker exec -it vault vault write auth/approle/role/laravel \ token_policies="laravel" \ token_ttl=1h \ token_max_ttl=4h \ secret_id_ttl=0
Sample output — creating the Laravel AppRole
Success! Data written to: auth/approle/role/laravel
Meaning: Vault accepted the role configuration and emitted no credential. Read the role back to verify TTLs and policies. Role ID and Secret ID are generated/read in the next commands; the success line itself is not a login.
Get the Role ID (safe in .env):
docker exec vault vault read -field=role_id auth/approle/role/laravel/role-id
Generate a Secret ID (treat like a password — never commit to git):
docker exec vault vault write -f -field=secret_id auth/approle/role/laravel/secret-id
AppRole credentials for Laravel
Role ID: Identifies the role — low sensitivity, can live in .env.
Secret ID: Proves identity — inject via deploy pipeline or server environment, rotate periodically.
Client token: Short-lived, obtained at runtime via POST /v1/auth/approle/login — never hardcode.
Every AppRole parameter — default, meaning, how to set
Zero means different things on different fields. Read this table before you copy secret_id_ttl=0 into production and assume it matches token_ttl=0 (it does not).
| Parameter | Default | What it does | How to set for Laravel |
|---|---|---|---|
token_policies | none | Policy names attached to the client token after login. Must match vault policy write <name> exactly (no file suffix). | laravel in this lab. Comma-separated if you need two policies. |
policies | — | Legacy alias. Prefer token_policies. | Do not mix both with different values. |
token_ttl | system default_lease_ttl if 0 or unset | Initial lifetime of the client token. Laravel must finish boot and (for Octane) renew or recycle before this elapses. | 1h for PHP-FPM (new process often). 2h–4h if Octane workers live long, or implement renew-self. |
token_max_ttl | system max_lease_ttl if 0 | Hard cap including renewals. Token cannot live longer than this even if you renew. | 4h in the lab. Must be ≥ token_ttl. |
token_explicit_max_ttl | 0 (off) | Absolute lifetime from creation, ignoring renew tricks. | Optional production hardening, e.g. 8h. |
period | empty (not periodic) | If set (e.g. 1h), token becomes a periodic token: renew resets TTL to period until max/explicit max. Survives Octane workers if you renew. | Use with a renew loop in Laravel or Agent. Do not set for simple PHP-FPM pull unless you renew. |
token_num_uses | 0 = unlimited | After N API calls the token dies. Login itself is not this counter; KV reads are. | 0 for the lab. A small number plus a high QPS app will 403 in production. |
token_type | default (service token) | service = stored, renewable. batch = lightweight, not stored, not renewable, shorter ops overhead. | Leave default (service) for Laravel pull. Batch is for high-churn jobs that never renew. |
token_bound_cidrs | none | Client token only works from these CIDRs (Vault sees the source IP after Traefik — often Traefik’s IP, not the browser). | Bind to the Docker network CIDR for Compose Laravel, not to your laptop IP. |
secret_id_ttl | 0 = no expiry for the Secret ID | How long a generated Secret ID can be used to login. Independent of token TTL. | Lab: 0 for convenience. Production: 24h or 168h and rotate from CI. Do not confuse with token_ttl=0. |
secret_id_num_uses | 0 = unlimited logins | Secret ID becomes invalid after N successful logins. | 1 if you wrap and unwrap once per deploy (excellent). 0 if many PHP processes login independently with the same Secret ID (this lab). |
bind_secret_id | true | If true, login requires a Secret ID. If false, Role ID alone is enough — anyone who reads .env is you. | Leave true. Never disable for Laravel. |
secret_id_bound_cidrs | none | Secret ID can only be used from these CIDRs at login time. | Same caution as token CIDRs (NAT/Traefik source IP). |
local_secret_ids | false | HA only: Secret IDs not replicated to other Vault nodes (performance / locality). | Leave false on a single node. |
token_ttl=0 vs secret_id_ttl=0: On the token, 0 means “use the server default lease” (often 32 days — too long for an app token). On the Secret ID, 0 means “this Secret ID does not expire.” That is why the lab sets token_ttl=1h explicitly and secret_id_ttl=0 for a stable bootstrap secret. Tighten Secret ID TTL when you have a rotation pipeline.Login API (what Laravel calls):
JSON body: { "role_id": "...", "secret_id": "..." }
Response: auth.client_token, auth.lease_duration, auth.renewable
read -r -p "Role ID: " ROLE_ID read -r -s -p "Secret ID: " SECRET_ID; echo vault write auth/approle/login role_id="$ROLE_ID" secret_id="$SECRET_ID" unset ROLE_ID SECRET_ID
Sample output — successful AppRole authentication (token redacted)
Key Value --- ----- token <redacted> token_accessor <redacted> token_duration 1h token_renewable true token_policies ["default" "laravel"]
Meaning: the credentials matched the role and Vault issued a one-hour renewable client token with the Laravel policy. This output is authentication material—do not capture it in CI logs. A 403 on the following KV read points to policy/path; a 400 invalid Secret ID points to AppRole credentials or their TTL/use count.
Production bootstrap: initial Secret ID delivery
Lab generates Secret ID on the Vault host. Production separates role creation (infrequent, admin) from Secret ID delivery (each deploy target, least exposure).
Admin (break-glass): - AppRole role laravel-prod exists; policy read-only on secret/data/laravel/production/* - Role ID stored in config management (not secret) CI / release pipeline (per environment): 1. Authenticate to Vault with human OIDC or CI JWT (Part 9) — NOT root 2. vault write -f -wrap-ttl=600 auth/approle/role/laravel-prod/secret-id 3. Deliver wrap token to target host via SSH vault unwrap or Ansible — one time 4. Unwrap → write /vault/agent/secret_id mode 0600, owner root 5. Start vault-agent; verify templates; delete plaintext Secret ID from shell history Runtime: - Agent reads role_id + secret_id files, logs in, renews token - remove_secret_id_file_after_reading=true only if orchestrator injects fresh ID on each Agent recreate Rotation (quarterly): - Generate new secret-id; deploy to agents; verify login; destroy old accessor - secret_id_num_uses=0 for multi-process Agent; use num_uses=1 only for single-login automation
vault write -f -wrap-ttl=10m -field=wrapping_token \ auth/approle/role/laravel-prod/secret-id # On target (within wrap TTL): vault unwrap -field=secret_id <wrapping_token> | install -m 600 /vault/agent/secret_id
Response wrapping for Secret ID
A wrapped Secret ID is a single-use token that unwraps to the real Secret ID. The wrap token can live in CI; the Secret ID never sits in a chat log. Laravel then has the unwrapped Secret ID in the process environment, or you unwrap on the server once per deploy.
# Create a wrapped Secret ID (wrap_ttl = how long the wrap token is valid) WRAP=$(docker exec vault vault write -wrap-ttl=5m -f -field=wrapping_token \ auth/approle/role/laravel/secret-id) # On the app host, unwrap once (VAULT_ADDR must reach Vault) # vault unwrap -field=secret_id "$WRAP" # Put the result in VAULT_SECRET_ID and discard the wrap token
If secret_id_num_uses=1, that unwrapped ID is good for one AppRole login. That fits “one Agent or one deploy job logs in and writes a token file,” not “fifty PHP-FPM children each login.” For FPM pull with one shared Secret ID, keep secret_id_num_uses=0 (unlimited) and rotate the Secret ID on a calendar.
Part 9 — Other auth methods (Kubernetes, JWT/OIDC, TLS, token)
What you should do: skip this part for the Compose lab. Use it when the identity source is not a Secret ID file.
Static token (anti-pattern)
Create a token with vault token create -policy=laravel -ttl=24h and put it in VAULT_TOKEN. Laravel would send it as X-Vault-Token with no login step. That token is the secret. It is easier than AppRole and worse in every incident: no Role ID/Secret ID split, easy to commit, hard to see which app it belongs to. Do not use this for production Laravel.
Kubernetes auth — implementation
Use this when Laravel runs in a cluster and the pod already has a service account token. Vault validates that JWT against the Kubernetes API (or a configured JWT issuer in newer setups).
docker exec -it vault vault auth enable kubernetes # In-cluster Vault (typical): # vault write auth/kubernetes/config \ # kubernetes_host="https://kubernetes.default.svc" \ # kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \ # token_reviewer_jwt=@/var/run/secrets/kubernetes.io/serviceaccount/token docker exec -it vault vault write auth/kubernetes/role/laravel \ bound_service_account_names=laravel \ bound_service_account_namespaces=production \ token_policies=laravel \ token_ttl=1h \ token_max_ttl=4h \ audience=vault
Kubernetes role parameters
bound_service_account_names: SA name(s). * is allowed but means any SA in the bound namespace — too wide.
bound_service_account_namespaces: Namespace(s). Bind production, not *, unless this is a lab cluster.
audience: Must match the projected token audience (aud). Mismatch is a common 403 after Kubernetes 1.21+ bound tokens.
alias_name_source: How Vault names the identity alias (service account name vs namespace/name). Leave default unless you have identity-entity design.
Laravel (or a tiny sidecar) then POSTs the projected JWT:
POST /v1/auth/kubernetes/login
{
"role": "laravel",
"jwt": "<contents of /var/run/secrets/kubernetes.io/serviceaccount/token>"
}
After login, KV reads are identical to AppRole: same X-Vault-Token, same secret/data/… policy. Only the login URL and body change. Do not also put AppRole Secret IDs in the same pod unless you are migrating; one identity source.
JWT / OIDC — implementation shape
OIDC is the right method for humans (Vault UI) and for CI (GitHub Actions OIDC, GitLab ID tokens). It is usually the wrong method for long-running PHP-FPM: you would need a JWT minted for every worker start.
vault auth enable jwt
vault write auth/jwt/config \
bound_issuer="https://token.actions.githubusercontent.com" \
oidc_discovery_url="https://token.actions.githubusercontent.com"
vault write auth/jwt/role/laravel-ci \
role_type="jwt" \
user_claim="sub" \
bound_audiences="https://github.com/YOUR_ORG" \
bound_claims_type="glob" \
bound_claims='{"ref":"refs/heads/main","repository":"YOUR_ORG/YOUR_REPO"}' \
token_policies="laravel" \
token_ttl=15m
The GitHub workflow then exchanges ACTIONS_ID_TOKEN_REQUEST_TOKEN for a JWT and calls POST /v1/auth/jwt/login with role + jwt. Use this to write KV or to mint a wrapped Secret ID during deploy — not as the PHP runtime identity unless each request carries a JWT (API gateway pattern).
TLS certificate auth — implementation shape
vault auth enable cert vault write auth/cert/certs/laravel \ display_name=laravel \ policies=laravel \ certificate=@/path/to/ca.pem \ ttl=1h
The HTTP client must present the client certificate (Http::withOptions(['cert' => ..., 'ssl_key' => ...])). Traefik-to-Vault HTTP in this lab does not pass client certs through unless you configure Traefik passthrough / mTLS to the backend. That is why AppRole is the Compose default.
Part 10 — Secret delivery methodologies (HTTP pull, Agent, dynamic DB)
What you should do: keep HTTP pull (Part 11–12) unless you need Agent or dynamic DB users.
Method A — HTTP pull (this guide’s lab)
PHP authenticates (AppRole) and GETs KV during register(). Implementation is Part 11–12. Use it when: Compose or a VM, a handful of static secrets, you can tolerate “Vault down means app down” at boot.
Method B — Vault Agent templates (PHP never calls Vault)
Production reference: Part 10B — Vault Agent: complete Compose reference (sidecar, templates, shared volume, Swoole/Octane entrypoint, APP_KEY). Use Part 10B instead of duplicating config here.
Method C — Dynamic database credentials
Vault’s database secrets engine creates a MySQL/MariaDB user on demand, with a lease. Laravel’s password is no longer a KV string you rotate by hand; it expires. You need a reconnect story.
docker exec -it vault vault secrets enable database
docker exec -it vault vault write database/config/mysql \
plugin_name=mysql-database-plugin \
connection_url="{{username}}:{{password}}@tcp(mariadb:3306)/" \
allowed_roles="laravel" \
username="vault-admin" \
password="vault-admin-password"
docker exec -it vault vault write database/roles/laravel \
db_name=mysql \
creation_statements="CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}'; GRANT SELECT,INSERT,UPDATE,DELETE ON appdb.* TO '{{name}}'@'%';" \
default_ttl="1h" \
max_ttl="24h"
Database engine parameters
connection_url: Vault-to-database, not Laravel-to-database. In Compose use the DB service DNS name.
allowed_roles: Which roles may use this connection. Misspelled name → 400 when you read creds.
creation_statements: Executed as the plugin user. Must be valid for your MariaDB/MySQL version. {{name}} and {{password}} are Vault templates, not Laravel Blade.
default_ttl / max_ttl: Lease of the generated user. When the lease expires Vault drops the user (revoke). Open PDO connections start failing with “Access denied.”
Policy for Laravel: path "database/creds/laravel" { capabilities = ["read"] } — not the KV path.
Read credentials: GET /v1/database/creds/laravel returns data.username, data.password, and a lease ID. PHP-FPM: one login per process per TTL is usually fine if default_ttl is longer than the request. Octane/queue workers: renew sys/leases/renew with the lease ID, or reconnect when you get SQLSTATE 1045. Static KV passwords are simpler; use dynamic creds when the compliance requirement is “no long-lived DB password.”
Config::set a KV DB_PASSWORD and also use dynamic creds, you will not know which user MySQL is seeing. Pick one source of database identity per connection name.Part 10A — Migrating existing Laravel secrets into Vault
.env without moving non-secrets or breaking boot order.What you should do: inventory, classify, write KV, verify, then cut Laravel over — never delete
.env entries until rollback is tested.What to move vs what to leave in Compose / .env
| Move to Vault | Keep in .env / Compose (non-secret config) | Do not put in Vault |
|---|---|---|
DB_* password (and optionally username if treated as secret) | DB_HOST, DB_PORT, DB_DATABASE if non-sensitive | APP_DEBUG=true, log levels |
REDIS_* passwords; dedicated host if sensitive | REDIS_CLIENT, CACHE_DRIVER, SESSION_DRIVER, DB index numbers | Public URLs, feature flags |
MAIL_PASSWORD, API keys, webhook secrets, PSP keys | MAIL_HOST, MAIL_PORT, MAIL_ENCRYPTION | Build-time args baked into images |
APP_KEY (via Agent template — Part 10B) | APP_NAME, APP_ENV, APP_URL | Vault Role ID (not secret); Secret ID only via secure inject |
| OAuth client secrets, JWT signing keys | VAULT_ADDR, VAULT_ROLE_ID, VAULT_ENABLED | Root token, long-lived admin tokens |
See also Part 13A — runtime secrets vs non-sensitive configuration for the full split.
First import procedure
- Inventory — Export a redacted list from production
.env(names only in tickets; values never in chat). - Map paths — Assign each secret to a KV path (layout above). One
kv putper concern. - Write KV — From an operator shell with admin policy, not from Laravel:
docker exec vault vault kv put secret/laravel/production/database \ DB_PASSWORD="$(grep ^DB_PASSWORD= .env.production | cut -d= -f2-)" docker exec vault vault kv put secret/laravel/production/redis \ REDIS_CACHE_PASSWORD='...' \ REDIS_SESSION_PASSWORD='...' \ REDIS_HORIZON_PASSWORD='...' docker exec vault vault kv put secret/laravel/production/mail \ MAIL_PASSWORD='...' docker exec vault vault kv put secret/laravel/production/app-key \ APP_KEY='base64:...'
- Extend AppRole policy if paths changed from the lab glob.
- Dual-run — Enable Vault in staging; keep
.envfallback withVAULT_ENABLED=falselocally. - Cutover — Set
VAULT_ENABLED=true, remove migrated keys from production.envonly after smoke tests (DB, cache, session, queue, mail). - Rotate — Treat import as potentially exposed; rotate DB/Redis passwords after cutover if
.envever lived in backup logs or git.
Part 10B — Vault Agent: complete Compose reference
What you should do: use this instead of HTTP pull when PHP must not hold Vault tokens (especially Octane/Swoole).
Agent authenticates with AppRole, renders files into a shared volume, and optionally runs a reload command. Laravel never calls Vault at runtime if all secrets are templated.
Vault on a separate host: set vault-agent VAULT_ADDR to the same URL Laravel would use (https://vault.internal or allowlisted public URL). Mount the internal CA into the Agent container; do not rely on the in-stack http://vault:8200 DNS name unless Vault runs on the same Compose network. See split-server topology.
docker-compose.yml excerpt (Vault + Agent + Laravel).services:
vault:
image: hashicorp/vault:1.17
# ... Part 5 vault service ...
networks: [app_net]
vault-agent:
image: hashicorp/vault:1.17
container_name: vault-agent
restart: unless-stopped
command: agent -config=/vault/agent/agent.hcl
environment:
VAULT_ADDR: "http://vault:8200" # or https://vault.internal from Laravel host
volumes:
- ./vault/agent:/vault/agent:ro
- vault_secrets:/run/secrets
depends_on:
vault:
condition: service_started
networks: [app_net]
laravel:
build: ./laravel
restart: unless-stopped
env_file: .env.nonsecret # APP_ENV, VAULT_* absent from secrets file
volumes:
- vault_secrets:/run/secrets:ro
depends_on:
vault-agent:
condition: service_started
entrypoint: ["/bin/sh", "/docker/entrypoint.sh"]
networks: [app_net]
volumes:
vault_secrets:
networks:
app_net:
vault/agent/agent.hclpid_file = "/tmp/agent.pid"
exit_after_auth = false
vault {
address = "http://vault:8200"
}
auto_auth {
method "approle" {
mount_path = "auth/approle"
config = {
role_id_file_path = "/vault/agent/role_id"
secret_id_file_path = "/vault/agent/secret_id"
remove_secret_id_file_after_reading = false
}
}
}
template {
source = "/vault/agent/laravel.env.ctmpl"
destination = "/run/secrets/laravel.env"
perms = 0600
error_on_missing_key = true
}
template {
source = "/vault/agent/app-key.ctmpl"
destination = "/run/secrets/app_key"
perms = 0600
}
vault/agent/laravel.env.ctmpl{{ with secret "secret/data/laravel/production/database" }}
DB_PASSWORD={{ .Data.data.DB_PASSWORD }}
{{ end }}
{{ with secret "secret/data/laravel/production/redis" }}
REDIS_CACHE_PASSWORD={{ .Data.data.REDIS_CACHE_PASSWORD }}
REDIS_SESSION_PASSWORD={{ .Data.data.REDIS_SESSION_PASSWORD }}
REDIS_HORIZON_PASSWORD={{ .Data.data.REDIS_HORIZON_PASSWORD }}
{{ end }}
{{ with secret "secret/data/laravel/production/mail" }}
MAIL_PASSWORD={{ .Data.data.MAIL_PASSWORD }}
{{ end }}
vault/agent/app-key.ctmpl{{ with secret "secret/data/laravel/production/app-key" }}{{ .Data.data.APP_KEY }}{{ end }}
laravel/docker/entrypoint.sh#!/bin/sh
set -eu
# Wait until Agent rendered secrets (Compose start order is not enough if Vault was sealed)
i=0
while [ ! -f /run/secrets/laravel.env ] || [ ! -f /run/secrets/app_key ]; do
i=$((i + 1))
if [ "$i" -gt 60 ]; then
echo "vault-agent secrets not ready after 60s" >&2
exit 1
fi
sleep 1
done
export APP_KEY="$(cat /run/secrets/app_key)"
set -a
. /run/secrets/laravel.env
set +a
exec "$@"
Compose command for Octane:
docker-compose.yml laravel command.command: ["php", "artisan", "octane:start", "--server=swoole", "--host=0.0.0.0", "--port=8000", "--workers=4"]
Mount entrypoint.sh executable. APP_KEY is exported before php artisan octane:start — satisfying encrypter bootstrap (Part 12). Omit --workers and Octane defaults to one worker per CPU core; set it explicitly in production (e.g. --workers=4). Each worker bootstraps Laravel once — see secret lifecycle.
Loading Agent secrets before Swoole / Octane
Swoole workers inherit the parent process environment established at container start. Secrets must be on disk and sourced in the entrypoint, not inside AppServiceProvider, for:
APP_KEY(required before config cache / encrypter)- Any value read from
env()duringconfig/*.phpload if you mirror secrets into env vars
Flow:
- Agent renders
/run/secrets/laravel.envand/run/secrets/app_key. - Entrypoint exports
APP_KEY, sourceslaravel.env, execs Octane. config/database.phpusesenv('DB_PASSWORD')which now resolves from the sourced file.- Optional:
AppServiceProvider::register()still maps multi-Redis hosts from env if those keys were templated.
Secret lifecycle under Octane / Swoole
| Event | What holds secrets | What you must do |
|---|---|---|
| Container start | Entrypoint exports env; workers fork with copy | Ensure Agent finished render before Octane |
Multiple Octane workers (--workers=N) | N processes; each runs register() once at worker start | Agent/entrypoint env is inherited by all workers; HTTP pull means N AppRole logins unless you use Agent; rotation/recycle affects every worker |
| Worker process lifetime | In-memory Config, open Redis/DB pools | Treat rotation as requiring worker recycle |
| KV secret rotated in Vault | Workers still use old passwords until restart | Agent re-render → restart Octane container (or octane:reload if you add SIGHUP hook) |
| AppRole token (HTTP pull only) | Token in worker memory until TTL | Prefer Agent so PHP has no Vault token; else renew or match TTL to worker max life |
APP_KEY changed | All encrypted cookies/sessions invalid | Plan maintenance window; never rotate casually |
Agent + template reload: Add command = "..." on the template stanza to signal Octane only after a successful re-render (e.g. touch a reload flag consumed by a side process). Simplest production pattern: redeploy or docker compose restart laravel after KV rotation.
APP_KEY path with Agent (end-to-end)
- Store
APP_KEYatsecret/laravel/production/app-key(never commit). - Agent template writes one line to
/run/secrets/app_key. - Entrypoint:
export APP_KEY="$(cat /run/secrets/app_key)"before any PHP. - Laravel
config/app.phpkeeps'key' => env('APP_KEY')— unchanged. - Do not fetch
APP_KEYvia HTTP pull inregister(); it is too late for session/crypto already initialized in edge cases.
Laravel configuration: MariaDB + three Redis clients
With secrets in env (Agent-sourced or Config::set from Vault HTTP pull), wire explicit Redis connections:
config/database.php excerpts.'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'default' => [ // Horizon often uses default or horizon connection
'host' => env('REDIS_HORIZON_HOST', env('REDIS_CACHE_HOST', '127.0.0.1')),
'password' => env('REDIS_HORIZON_PASSWORD', env('REDIS_CACHE_PASSWORD')),
'port' => env('REDIS_HORIZON_PORT', 6379),
'database' => env('REDIS_HORIZON_DB', 0),
],
'cache' => [
'host' => env('REDIS_CACHE_HOST', '127.0.0.1'),
'password' => env('REDIS_CACHE_PASSWORD'),
'port' => env('REDIS_CACHE_PORT', 6379),
'database' => env('REDIS_CACHE_DB', 1),
],
'session' => [
'host' => env('REDIS_SESSION_HOST', '127.0.0.1'),
'password' => env('REDIS_SESSION_PASSWORD'),
'port' => env('REDIS_SESSION_PORT', 6379),
'database' => env('REDIS_SESSION_DB', 2),
],
],
config/cache.php and config/session.php.// config/cache.php
'stores' => [
'redis' => [
'driver' => 'redis',
'connection' => 'cache',
],
],
// config/session.php
'driver' => env('SESSION_DRIVER', 'redis'),
'connection' => 'session',
config/horizon.php.'use' => 'default', // uses redis.default connection above
HTTP pull in register() alternative — map KV into config without env:
AppServiceProvider::register() multi-path pull.$db = $vault->getSecret('secret/data/laravel/production/database');
$redis = $vault->getSecret('secret/data/laravel/production/redis');
Config::set('database.connections.mysql.password', $db['DB_PASSWORD'] ?? '');
Config::set('database.redis.cache.password', $redis['REDIS_CACHE_PASSWORD'] ?? '');
Config::set('database.redis.session.password', $redis['REDIS_SESSION_PASSWORD'] ?? '');
Config::set('database.redis.default.password', $redis['REDIS_HORIZON_PASSWORD'] ?? '');
Part 11 — Laravel VaultService and HTTP client
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Laravel already ships with Guzzle via Illuminate\Http\Client. No extra composer require is needed unless you prefer the raw Guzzle client directly. Official HashiCorp libraries are Go/Java/.NET/Ruby — there is no first-party Laravel SDK; HTTP is the supported contract.
Laravel env / config/vault.php parameters
| Env var | Config key | Default | What it does / how to set |
|---|---|---|---|
VAULT_ADDR | address | http://127.0.0.1:8200 | API base URL, no trailing path, no /v1. Compose: http://vault:8200. Host: https://vault.example.com. Never http://127.0.0.1:8200 from a Laravel container (that is the app’s own loopback). |
VAULT_ROLE_ID | role_id | empty | AppRole Role ID. Missing ⇒ login 400. |
VAULT_SECRET_ID | secret_id | empty | AppRole Secret ID. Treat as a password. Inject at deploy; do not commit. |
VAULT_SECRET_PATH | secret_path | secret/data/laravel/app | HTTP path after /v1/. Must include /data/ for KV v2. No leading slash. |
VAULT_VERIFY_TLS | verify_tls | true | Guzzle verify. true on HTTPS. Irrelevant on HTTP. Never false in production. |
VAULT_TIMEOUT | timeout | 10 | Seconds for connect+request. Too low: flaky boots. Too high: PHP workers stuck if Vault hangs. |
VAULT_RETRIES | retries | 2 | Extra attempts on connection errors / 5xx. Does not retry 403 (that is policy, not flake). |
VAULT_ENABLED | enabled | true in production only if you set it | Feature flag. Prefer this over guessing environment('production') if staging also uses Vault. |
VAULT_NAMESPACE | namespace | empty | Sets X-Vault-Namespace. Enterprise/HCP only. Leave empty on OSS. |
Laravel in the same Compose stack (add a laravel service on edge):
.env (Laravel in same Compose stack).VAULT_ADDR=http://vault:8200 VAULT_ROLE_ID=your-role-id-here VAULT_SECRET_ID=your-secret-id-here VAULT_SECRET_PATH=secret/data/laravel/app VAULT_VERIFY_TLS=true VAULT_TIMEOUT=10 VAULT_RETRIES=2 VAULT_ENABLED=true
Laravel on the host (PHP runs outside Docker):
.env (Laravel on the host, outside Docker).VAULT_ADDR=https://vault.example.com VAULT_ROLE_ID=your-role-id-here VAULT_SECRET_ID=your-secret-id-here VAULT_SECRET_PATH=secret/data/laravel/app VAULT_VERIFY_TLS=true VAULT_TIMEOUT=10 VAULT_RETRIES=2 VAULT_ENABLED=true
Create config/vault.php:
config/vault.php (or your Vault config path in Laravel).<?php
return [
'enabled' => filter_var(env('VAULT_ENABLED', false), FILTER_VALIDATE_BOOLEAN),
'address' => env('VAULT_ADDR', 'http://127.0.0.1:8200'),
'role_id' => env('VAULT_ROLE_ID'),
'secret_id' => env('VAULT_SECRET_ID'),
'secret_path' => env('VAULT_SECRET_PATH', 'secret/data/laravel/app'),
'verify_tls' => filter_var(env('VAULT_VERIFY_TLS', true), FILTER_VALIDATE_BOOLEAN),
'timeout' => (int) env('VAULT_TIMEOUT', 10),
'retries' => (int) env('VAULT_RETRIES', 2),
'namespace' => env('VAULT_NAMESPACE'), // Enterprise / HCP only
];
App on host vs app in Compose
http://vault:8200 — traffic stays on the Docker bridge, TLS is unnecessary, no certificate verification issues.On host:
https://vault.example.com — goes through Traefik with Let's Encrypt; set VAULT_VERIFY_TLS=true in production.
Create app/Services/VaultService.php using Laravel's HTTP client:
<?php
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class VaultService
{
private ?string $token = null;
private function baseUrl(): string
{
return rtrim((string) config('vault.address'), '/');
}
private function client(): PendingRequest
{
$request = Http::timeout((int) config('vault.timeout', 10))
->retry((int) config('vault.retries', 2), 200, throw: false)
->withOptions(['verify' => (bool) config('vault.verify_tls')]);
$namespace = config('vault.namespace');
if (is_string($namespace) && $namespace !== '') {
$request = $request->withHeaders(['X-Vault-Namespace' => $namespace]);
}
return $request;
}
private function vaultError(string $prefix, $response): string
{
$errors = $response->json('errors');
if (is_array($errors) && $errors !== []) {
return $prefix . ': ' . implode('; ', $errors);
}
return $prefix . ' (HTTP ' . $response->status() . ')';
}
public function authenticate(): string
{
if ($this->token) {
return $this->token;
}
$response = $this->client()->post("{$this->baseUrl()}/v1/auth/approle/login", [
'role_id' => config('vault.role_id'),
'secret_id' => config('vault.secret_id'),
]);
if (! $response->successful()) {
throw new RuntimeException($this->vaultError('Vault AppRole login failed', $response));
}
$this->token = $response->json('auth.client_token');
if (! $this->token) {
throw new RuntimeException('Vault AppRole login returned no client token.');
}
return $this->token;
}
public function getSecret(?string $path = null): array
{
$path = $path ?? config('vault.secret_path');
$token = $this->authenticate();
$response = $this->client()
->withHeaders(['X-Vault-Token' => $token])
->get("{$this->baseUrl()}/v1/{$path}");
if (! $response->successful()) {
throw new RuntimeException($this->vaultError("Vault read failed for {$path}", $response));
}
$data = $response->json('data.data');
if (! is_array($data)) {
throw new RuntimeException("Vault KV v2 payload missing data.data for {$path} — is the mount KV v2?");
}
return $data;
}
public function get(string $key, mixed $default = null): mixed
{
$secrets = Cache::remember('vault.secrets', 300, fn () => $this->getSecret());
return $secrets[$key] ?? $default;
}
}
Register the singleton in AppServiceProvider::register():
VaultService in your Laravel service provider.public function register(): void
{
$this->app->singleton(\App\Services\VaultService::class);
}
Quick test:
php artisan tinker >>> app(\App\Services\VaultService::class)->getSecret(); // ["DB_PASSWORD" => "...", "MAIL_PASSWORD" => "..."]
Part 12A — Startup order and behavior when Vault is sealed
What you should do: align orchestration health checks with this table — do not mark Laravel healthy if secrets never loaded.
1. Vault storage available (volume mounted) 2. Vault process up (may be sealed) 3. Operator unseal OR auto-unseal succeeds → Vault API ready 4. vault-agent starts, AppRole login, templates render 5. laravel entrypoint waits for /run/secrets/* , exports env 6. php-fpm / octane / queue workers start 7. Traefik routes traffic (only after app health OK)
| Component | Vault sealed | Vault down | Agent down |
|---|---|---|---|
| Vault container | Running, API 503 on secret paths; healthcheck still healthy (exit 2) | N/A | N/A |
| vault-agent | Retries login/render; no new templates | Retries with backoff; logs errors | N/A |
| Laravel (Agent mode) | Entrypoint waits, then exits non-zero if timeout — fail closed | Same — do not serve without secrets | Missing files → exit 1; orchestrator restarts |
| Laravel (HTTP pull) | Boot exception on AppRole login — worker crash loop | Connection timeout in register() | N/A |
| Queue workers | Same as app — do not process jobs without DB | Crash loop until Vault ready | Stuck until secrets file exists |
Do not treat Compose healthy as “secrets ready.” With the Part 5 healthcheck, sealed Vault is still healthy (API up) so Traefik can discover it for unseal. Prefer depends_on: condition: service_healthy only for “API reachable,” plus application-level checks that fail closed while sealed.
Part 12 — AppServiceProvider register() pattern (not boot())
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
To inject Vault secrets into the database config, set them before anything opens a MySQL connection. Laravel connections are lazy — the danger is any code that touches DB (or Eloquent) while your password in config is still empty.
Why prefer register() over boot()
Provider order: every register() runs, then every boot(). Many packages open the database (or cache a connection) in boot(). If you only call Config::set() in your own boot(), another provider may already have connected with the blank .env password.
Changing config after the first connection has no effect on that live connection.
Fix: In AppServiceProvider::register(), call $vault->getSecret() directly (not get() with Cache) so bootstrap never depends on Redis/database cache. Set Config::set() before any provider opens MySQL.
VaultService::get() uses Cache::remember(). If CACHE_DRIVER=database or Redis needs DB credentials you have not set yet, bootstrap fails. Use getSecret() in register().app/Providers/AppServiceProvider.php.<?php
namespace App\Providers;
use App\Services\VaultService;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(VaultService::class);
if (config('vault.enabled')) {
$vault = $this->app->make(VaultService::class);
$secrets = $vault->getSecret();
Config::set('database.connections.mysql.password', $secrets['DB_PASSWORD'] ?? '');
Config::set('mail.mailers.smtp.password', $secrets['MAIL_PASSWORD'] ?? '');
}
}
public function boot(): void
{
//
}
}
VAULT_ENABLED=false locally and keep DB_PASSWORD in .env. Staging/production set VAULT_ENABLED=true.On-demand: For non-critical secrets after boot,
app(VaultService::class)->get('API_KEY') still works — that path uses cache and must not run inside register().
Never put the root token in .env or Laravel. AppRole with a read-only policy is the correct machine identity. The root token is for break-glass admin only.
Laravel bootstrap layers and what cannot come from Vault late
Laravel boots in a fixed order. Vault HTTP pull can only safely patch config that is consumed lazily (first DB query, first mail send). Anything the framework needs while it is still constructing the application is too early for register().
| Bootstrap step | What runs | Vault HTTP pull OK? |
|---|---|---|
LoadEnvironmentVariables | Reads .env (skipped when APP_ENV file + cached config in some setups) | No — Vault is not in the picture. Agent/entrypoint must already have written env if you need it here. |
LoadConfiguration | Loads config/*.php. env() only belongs in those files. | No. APP_KEY is read into config('app.key') here. |
RegisterFacades / providers register() | Your AppServiceProvider::register() — this guide’s hook | Yes for database.connections.*.password, mail, third-party API keys not used during register. |
providers boot() | Other packages may already have resolved DB | Too late if a service already connected. |
| HTTP kernel / console kernel | Request or Artisan command | On-demand get() OK for non-boot secrets. |
APP_KEY
Cookie encryption, Crypt, and some auth cookies need APP_KEY as soon as the encrypter is resolved — during/after config load, before late register() patches. Put APP_KEY in Agent-rendered env (see APP_KEY path with Agent) or a non-git .env on the host. Do not fetch APP_KEY from KV via HTTP pull in register().
php artisan config:cache
Bakes config/*.php (including config/vault.php) into a single file. env() outside config files becomes null. Config::set in register() still runs every request and still overrides DB password. Do not call env('VAULT_ADDR') inside VaultService — always config('vault.address').
Queue workers, Scheduler, Octane
They bootstrap the same providers. A worker started before unseal will crash-loop until Vault is unsealed — that is correct. For Agent + Octane: secrets load in the container entrypoint before octane:start; see Agent → Swoole bootstrap and secret lifecycle under Octane. HTTP pull: workers hold the in-memory AppRole token until recycle — match token_ttl or renew. After KV rotation, restart FPM/Octane/queue; cache:forget vault.secrets only helps the get() helper, not Config::set already applied in that process.
CACHE_DRIVER during register()
Never call VaultService::get() (the cached helper) while loading DB credentials. If cache is database or Redis that needs a password you are about to set, bootstrap deadlocks. getSecret() uses only HTTP.
Part 13 — Day-two operations: backup, unseal, rotation, audit
What you should do: follow the branch matching your seal type and storage backend.
| Operation | Shamir target | Transit auto-unseal target |
|---|---|---|
| Normal restart | Submit the configured threshold of unseal shares | Do not submit recovery shares; verify automatic unseal and Transit reachability |
| Break glass | Unseal shares also support Shamir rekey workflows | Recovery shares authorize recovery workflows but cannot replace a failed Transit service |
| Backup dependency | Target data + target Shamir shares | Target data + Unseal Vault data/key + Unseal Vault Shamir shares + target recovery shares |
| Health signal | Sealed after a restart is expected until custodians act | Sealed after startup is an incident: inspect dependency, token, policy, or TLS |
Vault backup and recovery runbook (file storage)
Part 13 introduced backup; this is the full drill for single-node file backend on a dedicated Vault host.
Backup (scheduled)
- Announce maintenance or rely on HA read replica if you add Raft later.
docker compose stop vault— ensures consistent filesystem.- Archive volume:
tar czf vault-$(date +%F).tar.gzfrom ro mount (Part 13 command). - Record SHA256; copy off-host; include Shamir share inventory (not in the same bag as ciphertext).
docker compose start vault→ unseal → verifyvault status.
Recovery (disaster drill on staging)
- Provision clean VM; install Docker; restore
vault/configfrom git. - Create empty volume; extract tar into
/vault/filemount path. - Start Vault — expect
Sealed: true,Initialized: true. - Submit Shamir threshold; verify KV paths and AppRole login.
- Point staging Laravel Agent at restored Vault; run application smoke tests.
- Document RTO/RPO actually observed.
Backup the Vault data volume
Secrets live in the Docker volume mounted at /vault/file. Compose prefixes volume names with the project directory unless you set name:. Discover the real name before you copy-paste:
docker volume ls | grep vault # Example name: vault-stack_vault_data (project folder "vault-stack")
tar of a running file backend can capture a torn write. Prefer: stop Laravel traffic → docker compose stop vault → tar the volume → start Vault → unseal → start the app. Filesystem snapshots (ZFS, LVM, cloud disk snapshot) while Vault is paused are equivalent. Raft clusters should use vault operator raft snapshot save instead of volume tar.docker compose stop vault; replace the volume name from docker volume ls.mkdir -p backups docker run --rm \ -v vault-stack_vault_data:/data:ro \ -v "$(pwd)/backups:/backup" \ alpine tar czf /backup/vault-$(date +%Y%m%d).tar.gz -C /data . docker compose start vault # Shamir: submit the threshold. Transit: verify automatic unseal.
Expected result — stopped file-backend archive
# tar normally prints nothing on success $ echo $? 0 $ ls -lh backups/vault-20260830.tar.gz -rw-r--r-- 1 operator operator 12M Aug 30 14:20 backups/vault-20260830.tar.gz
Meaning: tar is silent when successful. Exit code 0 and a non-empty archive are evidence of creation, not proof of recoverability. Hash it, copy it off-host, and perform a test restore. A Transit target also requires a separately tested backup of the Unseal Vault; target recovery shares alone cannot decrypt a lost Transit key.
Transit restore set = target data + original Unseal Vault Transit key/storage + Unseal Vault Shamir shares + target recovery custody
Test restore on an isolated staging host before an emergency. For Shamir: extract to an empty volume, start a compatible Vault version, and use the same Shamir shares. For Transit: restore/verify the Unseal Vault first, configure the target with the same Transit key and CA trust, restore target data, and confirm it auto-unseals. Raft uses the snapshot save/restore procedure in Part 6, not a live volume tar.
Enable an audit device
Without audit, you cannot answer “who read secret/data/laravel/app last night?” Vault HMAC-hashes secret values in audit logs by default; you still protect the log file (it reveals paths, tokens as HMAC, and client IPs).
docker exec vault vault audit enable file file_path=/vault/file/audit.log
Sample output — enabling the file audit device
Success! Enabled the file audit device at: file/
Meaning: audit requests can now be written to that device. The success line does not prove future writes are durable: run a harmless authenticated read, verify a new HMAC-protected event appears, test log rotation, and alert if the device cannot write because Vault can refuse requests when all audit devices fail.
That path sits on the data volume (simple lab). Production often ships audit to a separate volume or socket so a full disk on audit cannot stall Vault — if the audit device cannot write, Vault refuses requests by design. Monitor disk. Do not log audit to the same place you consider “untrusted.”
Restart runbooks: choose by seal type
Shamir
- Query the specific node with
vault status; after process restart,Sealed: trueis expected. - Have the configured threshold of custodians submit different shares interactively. In a Shamir Raft cluster, each restarted node must be unsealed; shares are not broadcast between peers.
- Confirm
Seal Type shamirandSealed falsebefore restarting dependent Laravel workers.
Transit auto-unseal
- Confirm the Unseal Vault is already initialized, unsealed, healthy, and reachable from the target node.
- Restart the target; do not enter recovery shares.
- Confirm
Seal Type transitandSealed falseon that specific node. If sealed, inspect target logs for token,transit/decrypt/autounseal, CA, certificate-name, DNS, and timeout errors. - For Raft, check every node directly plus
raft list-peers; a healthy load balancer endpoint does not prove every peer unsealed.
Rotate the AppRole Secret ID
docker exec vault vault write -f auth/approle/role/laravel/secret-id
Sample output — generating a replacement Secret ID (values redacted)
Key Value --- ----- secret_id <redacted> secret_id_accessor <redacted> secret_id_num_uses 0 secret_id_ttl 0s
Meaning: this created an additional Secret ID; it did not revoke the old one. Deploy the new value, verify AppRole login, then destroy the old Secret ID by accessor. Use -field=secret_id only in a protected delivery pipeline because it prints just the secret with no context.
Update VAULT_SECRET_ID in your deploy environment. No code changes. Consider a calendar reminder every 90 days.
Rotate application secrets in KV
docker exec -it vault vault kv put secret/laravel/app \ DB_PASSWORD="new-rotated-password" \ MAIL_PASSWORD="new-smtp-password"
Sample output — rotating the KV v2 value
===== Secret Path ===== secret/data/laravel/app ======= Metadata ======= Key Value --- ----- created_time 2026-08-30T14:30:00.000Z deletion_time n/a destroyed false version 2
Meaning: version 2 is now current and Vault did not echo the secret values. Update the real database/service first or as one coordinated change, test the app, then recycle workers. A full kv put omitting a field removes that field from the latest version.
KV v2 put replaces the entire key map for that path. If you omit MAIL_PASSWORD, it disappears from the latest version (older versions still exist until max_versions drops them). After rotation: restart PHP-FPM / Octane / queue workers. cache:forget only helps the get() helper in processes that still have old Config::set values until they recycle.
php artisan cache:forget vault.secrets # then restart php-fpm or the app container
Rotate Vault’s barrier encryption key
This operation rotates Vault’s internal data-encryption key; it does not rotate Shamir shares, recovery shares, the Transit auto-unseal key, AppRole credentials, or application secrets. Existing ciphertext is rewrapped as touched; new writes use the new key version.
docker exec vault vault write -f sys/rotate docker exec vault vault read sys/key-status
Sample output — barrier-key rotation and status
Success! Data written to: sys/rotate Key Value --- ----- term 2 install_time 2026-08-30T14:40:00.000000000Z encryptions 0
Meaning: sys/rotate is a mutation and only confirms success; sys/key-status provides the evidence. The increased term is the new barrier key generation and encryptions counts encryptions under it. This is separate from vault write -f transit/keys/autounseal/rotate, which rotates the Unseal Vault’s Transit key and should be tested carefully with target cold starts and backups.
Start small
Vault high-value secrets first: database passwords, payment API keys, encryption keys. Leave APP_NAME and APP_ENV in .env. Keep APP_KEY out of late HTTP pull (Part 12). You do not need to move your entire config on day one.
Part 13A — Runtime secrets vs non-sensitive configuration
.env.What you should do: use this table in design review before expanding KV.
| Category | Examples | Store |
|---|---|---|
| Runtime secrets | DB/Redis passwords, API keys, APP_KEY, signing keys | Vault KV → Agent template or HTTP pull |
| Machine identity to Vault | AppRole Role ID, Secret ID file path | Role ID in config mgmt; Secret ID on disk via wrap, never git |
| Infrastructure wiring | DB_HOST, Redis hostnames, ports, DB indexes | Compose environment: or .env.nonsecret |
| Application mode | APP_ENV, APP_DEBUG, LOG_CHANNEL | Compose / .env — not Vault |
| Vault client settings | VAULT_ADDR, VAULT_ENABLED, timeouts | Compose env — not secret |
| Public URLs | APP_URL, asset CDN base | Compose / config files |
Rule: If compromise would grant data access or forgery (money, PII, sessions), it is a runtime secret. If it only describes topology or behavior, keep it in Compose and version control.
Part 14 — Master parameter reference
What you should do: use as a cheat sheet after you have read the matching part. Do not set every row on day one.
Server (config.hcl + process env)
| Parameter | Lab value | Notes |
|---|---|---|
ui | true | Restrict in production |
disable_mlock | true | Required in typical Docker |
storage.file.path | /vault/file | Must be a volume |
listener.address | 0.0.0.0:8200 | Not 127.0.0.1 in a container |
tls_disable | 1 | TLS at Traefik |
api_addr | https://vault.example.com | Public client URL |
cluster_addr | http://127.0.0.1:8201 | Must match listener scheme |
log_level | info | debug while chasing 403 |
default_lease_ttl / max_lease_ttl | omit (768h) | Ceiling for tokens and dynamic creds |
seal.transit.address / mount_path / key_name | optional self-hosted path | Dedicated Unseal Vault; same values on all Raft peers |
seal.transit.token | file:///vault/seal/token on 1.19+ | Indirect file value; no trailing newline. Vault 1.17 uses protected VAULT_TOKEN. |
seal.transit.tls_ca_cert | /vault/seal/unseal-vault-ca.pem | Keep verification enabled; SAN must match server name |
VAULT_ADDR (Vault container) | http://127.0.0.1:8200 | CLI + healthcheck only |
VAULT_API_ADDR | same as api_addr | Env overrides HCL |
Init / AppRole / KV / Laravel (short)
| Parameter | Lab value | See |
|---|---|---|
-key-shares / -key-threshold | 5 / 3 | Part 6 |
-recovery-shares / -recovery-threshold | Transit option: 5 / 3 | Recovery authority, not manual unseal |
| KV mount | secret kv-v2 | Part 7 |
| Policy path | secret/data/laravel/* read | Part 7 |
token_policies | laravel | Part 8 |
token_ttl / token_max_ttl | 1h / 4h | Part 8 — 0 means system default, not “forever” |
secret_id_ttl | 0 (no expiry) | Part 8 — tighten when CI rotates |
secret_id_num_uses | 0 (unlimited) | 1 only with wrap + single login |
VAULT_ADDR (Laravel) | Compose HTTP or host HTTPS | Part 3 and 11 |
VAULT_SECRET_PATH | secret/data/laravel/app | Must include /data/ |
VAULT_ENABLED | true when using Vault | Part 12 |
| Healthcheck | 10s / 5s / 3 / 15s start | Expected during Shamir’s operator window; an incident after Transit startup |
| Traefik backend port | 8200 | Missing label ⇒ 502 |
/data/ (layer 9) · Traefik labels (layer 11) · Laravel env and register() (layer 12).
Part 15 — Troubleshooting and production checklist
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Work top-down: Traefik reachability → Vault sealed state → policy/AppRole → Laravel VAULT_ADDR → config timing.
502 Bad Gateway from Traefik
Usual cause: Traefik cannot reach Vault on the Docker network, or the backend port is wrong.
Check: label traefik.http.services.vault-secure.loadbalancer.server.port=8200, both containers on edge, and traefik.docker.network=edge. Then docker compose logs vault and Traefik access logs.
TLS / Let's Encrypt fails in the browser
Usual cause: DNS A/AAAA for vault.example.com does not point at this host, ports 80/443 closed, or the certresolver name does not match Traefik's command flags.
Check: DNS from outside the server, Traefik ACME logs, and that HTTP challenge can hit :80.
Vault UI loads but everything is sealed / API returns sealed errors
First check: run vault status and read Seal Type. Storage type (file or raft) does not decide how to unseal.
Shamir: a restart leaves the node sealed until the configured threshold of distinct unseal shares is submitted. Confirm Sealed false.
Transit: do not submit recovery shares. Check whether the Unseal Vault is itself sealed/down, then inspect target logs for DNS/connectivity, expired token, missing update on encrypt/decrypt, CA trust, certificate SAN, or wrong key/mount path.
Transit target reports permission denied
Usual cause: the seal token policy uses read, targets the wrong mount/key, or the token expired after renewal failed.
Fix: on the Unseal Vault, policy paths must be exactly transit/encrypt/autounseal and transit/decrypt/autounseal with capabilities = ["update"]. Look up the token accessor and audit log without printing the token. Replace the protected token file and controlled-restart/reload the target if rotation is required.
Transit TLS or x509 failure
Confirm the target can read tls_ca_cert, the CA validates the complete chain, system time is correct, and tls_server_name matches a SAN on the Unseal Vault certificate. Do not “fix” production by setting tls_skip_verify=true.
Raft migration cannot elect a leader
Integrated storage requires quorum. For Shamir-to-Transit migration, stop the cluster, update all nodes, and bring a quorum back together; starting only one node is insufficient. Submit operator unseal -migrate to the elected leader with every submission carrying -migrate. Migration is replicated—do not independently migrate each peer.
Compose marks Vault unhealthy
The Part 5 healthcheck accepts exit 0 (unsealed) and 2 (sealed/uninitialized). If Compose still shows unhealthy, the API is not answering — check VAULT_ADDR=http://127.0.0.1:8200, that entrypoint points at /vault/config/config.hcl only, and logs for address already in use (dual config). Sealed alone must not keep the container unhealthy.
Vault container exits on start (mlock)
Usual cause: Vault cannot lock memory and disable_mlock is false, or Docker lacks IPC_LOCK.
Fix: disable_mlock = true in config.hcl and cap_add: [IPC_LOCK] in Compose (both, as in Parts 4–5).
Logs mention cluster / TLS handshake problems
Usual cause: cluster_addr set to the public HTTPS URL while the listener has tls_disable = 1.
Fix: single-node file storage should use cluster_addr = "http://127.0.0.1:8201". Keep the public HTTPS hostname only on api_addr.
permission denied / 403 reading secrets
Usual cause: KV v2 policy path wrong, or AppRole policy name mismatch.
Policy paths for reads must use secret/data/laravel/* (not secret/laravel/*). AppRole token_policies must equal the policy name you wrote (e.g. both laravel). Re-login with AppRole and retry vault kv get secret/laravel/app.
Laravel: connection refused to Vault
Usual cause: wrong VAULT_ADDR for where PHP runs.
Same Compose network → http://vault:8200. PHP on the host → https://vault.example.com. Do not point a Compose Laravel service at 127.0.0.1:8200 — that is loopback inside the app container, not Vault.
Laravel: TLS certificate verify failed
Only when using HTTPS from the host. Prefer a real Let's Encrypt cert via Traefik. For local/dev with a bad cert you can set VAULT_VERIFY_TLS=false — never in production. Inside Compose on plain HTTP, TLS verify does not apply.
Laravel connects to Vault but MySQL auth fails
Usual cause: password was applied too late, or the wrong config key was set.
Confirm Tinker returns the secret, that Config::set('database.connections.mysql.password', ...) runs in register(), and that you are not opening a DB connection earlier in another provider. Restart PHP-FPM / the app container after changing how secrets load. Confirm you did not kv put a partial map that dropped DB_PASSWORD.
AppRole login: invalid secret id / maximum use limit
Usual cause: Secret ID TTL expired, secret_id_num_uses exhausted, or you unwrapped twice. Generate a new Secret ID. If you set uses=1, only one process may login with that ID (Agent), not every PHP-FPM child.
Empty array from getSecret() / missing data.data
Usual cause: KV v1 mount, or HTTP path without /data/. Check vault secrets list -detailed for version 2. Path must be secret/data/laravel/app on the wire.
Namespace header errors on OSS Vault
Leave VAULT_NAMESPACE empty. Namespaces are Enterprise/HCP. A leftover header from a copied config fails login in a way that looks like “Vault is down.”
config:cache then Vault address is empty
You called env('VAULT_ADDR') in a class or provider instead of config('vault.address'). After config cache, env() outside config files is empty.
End-to-end verification
https://vault.example.comshows the Vault UIdocker exec vault vault status→Sealed: falsedocker exec vault vault kv get secret/laravel/appreturns your keys- From the app:
app(VaultService::class)->getSecret()succeeds - App authenticates to MySQL with the Vault-sourced password
Production checklist
config.hcl:api_addr= public HTTPS URL;cluster_addr=http://127.0.0.1:8201;disable_mlock = true- Compose:
cap_add: [IPC_LOCK], healthcheck (exit 0 or 2),entrypointwith-config=/vault/config/config.hcl,VAULT_ADDR=http://127.0.0.1:8200 - Traefik:
traefik.enable=true, service port 8200, sharededgenetwork, ACME resolver; no discovery until Vault is healthy - Seal branch selected: runbook explicitly says Shamir or Transit; operators never treat recovery shares as auto-unseal shares
- Shamir branch: unseal shares held by separate custodians; chosen threshold justified by absence/compromise tolerance; each-node Raft unseal and restart drill tested
- Transit branch: dedicated independently bootable Unseal Vault; TLS verification on; exact encrypt/decrypt
updatepolicy; periodic orphan token in a protected read-only file; cold-start drill tested - Transit custody: target recovery shares offline; Unseal Vault Shamir shares offline; token accessors recorded; no token literal in HCL, Compose, image, logs, or repository
- No circular dependency: Unseal Vault DNS, TLS, storage, host boot, and credentials do not require the target Vault
- Root token: not used in Laravel; revoked or locked down after AppRole setup
- Policy:
secret/data/laravel/*read-only; namelaravelmatches AppRoletoken_policies - AppRole: Role ID in
.env; Secret ID injected at deploy;token_ttlset explicitly (not 0); rotation scheduled - Laravel VAULT_ADDR:
http://vault:8200in Compose;https://vault.example.comon host - Laravel flag:
VAULT_ENABLED+config('vault.*')only (no rawenv()in services) - Config timing:
Config::set()inregister(), notboot();APP_KEYnot from late KV pull - Workers: restart FPM/Octane/queue after KV rotation; Octane token TTL vs worker lifetime
- Backup: stopped volume snapshot (file) or Raft snapshot; checksum/off-host retention; isolated restore tested with the matching Shamir or Transit dependency set
- Raft + Transit: identical seal stanza/key/CA on every node; unique node IDs; status checked per node; snapshot and quorum restore tested
- Audit: file (or socket) device enabled; disk monitored (failed audit blocks requests)
- Monitoring: Shamir alerts respect the documented operator window; Transit sealed-at-start alerts immediately and includes Unseal Vault, token renewal, TLS expiry, and Transit audit health
- No extra packages: use
Illuminate\Support\Facades\Http(Guzzle already included)
Wrapping up
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
You now have a production-minded pattern and a map of the alternatives:
- Layers — host, Docker, storage, seal, listener, auth, token, policy, engine, audit, Traefik, Laravel bootstrap
- Lab methodology — file storage, Shamir unseal, AppRole, HTTP pull in
register(), Traefik for HTTPS - Parameters — each important knob has a default, a failure mode, and a recommended setting (Parts 4, 8, 11, 14)
- Production options — Agent templates, Kubernetes/JWT auth, self-hosted Transit auto-unseal, Raft, dynamic DB leases, and Enterprise PKCS#11 where licensed
Your .env gets thinner. Secrets get a real home. And when someone asks where the production database password lives, you point at Vault — not a Slack message from six months ago.