HashiCorp Vault + Laravel: Architecture Layers, Methodologies, and Production Parameters

95 min read

Published · Revised 30 August 2026 (Vault 1.17 lab; Vault 1.19+ token-file reference, Traefik v3.2, Laravel 10+)

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.

What you will have at the end:
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.

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 this part is for: name every layer in a Vault + Traefik + Laravel stack so later parameters have a home.
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

Host OS / disk
→ 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
LayerWhat it ownsIf it is wrong
1. Host / diskVolume data, swap, backups, Shamir/recovery custody, seal-token files, firewall on 80/443Ciphertext without its original seal dependency is unrecoverable; credentials without backups are also useless
2. DockerIPC_LOCK, network name, healthcheck, restart policy, read-only config mountContainer crash on mlock, 502 from Traefik, “connection refused” from PHP
3. Storage backendPersists encrypted Vault data (file, Raft, Consul, …)Data loss, no HA, or split-brain if you fake HA on file storage
4. Barrier + sealMaster key encrypts the encryption key; Shamir shares or KMS unwrap itSealed Vault: every API except unseal/status/init fails
5. Listeneraddress, TLS on or off, cluster listen addressTLS handshake errors, UI redirects to the wrong URL
6. Auth methodProves machine or human identity; mints a client token400/403 on login; never put the root token here
7. TokenTTL, max TTL, number of uses, attached policies, renewable or notPHP 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 engineKV versions, dynamic DB leases, transit keysWrong JSON shape (data.data), expired DB users, missed rotations
10. AuditWho 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. TraefikPublic HTTPS, Let’s Encrypt, Host rule, backend port 8200502, certificate errors; does not affect Compose-internal http://vault:8200
12. Laravel bootstrapWhen config is frozen vs when DB/Redis/mail first connectEmpty 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

  1. PHP-FPM (or Octane worker) starts or reuses a process. Service providers run register() then boot().
  2. If you implemented the pattern in Part 12, register() calls Vault POST /v1/auth/approle/login with Role ID + Secret ID and receives a client token (unless a token is already in process memory).
  3. Laravel sends GET /v1/secret/data/laravel/app with header X-Vault-Token.
  4. Vault checks seal → token validity → policy on that exact API path → KV engine → returns JSON data.data.
  5. Config::set('database.connections.mysql.password', …) runs before any provider opens MySQL.
  6. 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.
Octane / Swoole / RoadRunner: 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 this part is for: choose storage, seal, auth, and injection methods before you copy files.
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.

BackendWhen to useHow you configure itDo not use when
file (this lab)Single node, lab, small production with tested backupsstorage "file" { path = "/vault/file" } + a Docker volumeYou 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 nodesYou only have one VM and no plan to add two more
ConsulExisting Consul cluster; legacy Vault HAstorage "consul" { address = "..." path = "vault/" }You would be introducing Consul only for Vault — prefer Raft
MySQL / PostgreSQL / GCS / S3 / …Rare; constrained platformsVendor-specific storage "..." blockYou can use file or Raft — extra moving parts, same seal problem
File storage is not a cluster. Running two Vault containers on one file volume is corruption, not HA. For HA, use Raft (or Consul) and an odd number of nodes. This guide stays on file + one container on purpose.

Minimal Raft stanza (not used in the lab; shown so you know the upgrade shape):

READ — Raft example only. Do not mix this with the file storage lab.
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.

MethodHow it worksOperations costThis 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 sharesYes
Auto-unseal — cloud KMSAWS 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 — TransitA separate, self-hosted Unseal Vault’s Transit engine wraps the target Vault’s root keyYou operate and back up two independent Vaults; the Unseal Vault itself is commonly Shamir-unsealedComplete option in Part 6
Recovery keysAuto-unseal initialization creates recovery-key shares for quorum-authorized recovery operations; they do not unseal an auto-sealed VaultKeep them offline for break-glass operations such as generate-root and recovery-key rekeyCreated 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.

MethodIdentityFits Laravel whenImplement in this guide
AppRoleRole ID (not secret) + Secret ID (secret)VMs, Docker Compose, CI agents, anything that can keep a Secret IDPart 8 (lab) + wrapping
KubernetesService account JWT projected into the podThe app runs in Kubernetes (or OpenShift)Part 9
JWT / OIDCSigned JWT from your IdP or CI (GitHub OIDC, Keycloak, …)CI deploy jobs; human operators via OIDC (UI)Part 9 (shape)
TLS certificatesClient cert mapped to a roleYou already run an internal PKI and mTLS to VaultPart 9 (shape)
Cloud IAM (AWS/GCP/Azure)Instance/workload identity, no long-lived Secret IDApp runs on that cloud with an instance profile / workload identityNot in lab; same token-after-login pattern
Token (static)A token string you created onceNever for production appsAnti-pattern only
Userpass / LDAP / OIDC UIHumansOperators, not the PHP workersOut of scope for the app

Secret delivery into Laravel

Auth answers “who is this process?” Injection answers “how do PHP processes learn DB_PASSWORD?”

MethodPHP talks to Vault?When it is the right toolMain failure mode
HTTP pull in register()Yes, every worker/process (token cached in memory)Compose/VM; few secrets; you control bootstrap orderVault down ⇒ app boot fails; Octane token TTL; cache driver chicken-and-egg
Vault Agent templatesNo. Agent authenticates and writes a file; PHP reads the fileYou want PHP dumb; rotation by rewriting files + reload PHP-FPMStale 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 refreshesYou want pull semantics without AppRole in Laravel .envMust not commit the sink path; file permissions
Init container / entrypointNo. A sidecar writes env or a dotenv snippet before php-fpmKubernetes Jobs, one-shot deploysSecrets end up in process environment (/proc); rotation needs restart
Secrets Store CSINo. Kubelet mounts files from VaultKubernetes, many pods, you already run CSIDriver/version skew; still a file on disk inside the pod
Dynamic DB credentialsYes (database engine). Username/password are leasesYou want per-app DB users that expireLease expires while PDO is open; need renew or reconnect
Static token in .envYes, but identity is a long-lived tokenNeverToken leak = full policy of that token, often too broad
Lab choice (Parts 3–12): file + Shamir + AppRole + HTTP pull in 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

What this part is for: folders, DNS, and the three 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:

  1. Browser / Laravel on the host
    https://vault.example.com → Traefik (TLS) → http://vault:8200 inside Docker
  2. Laravel in the same Compose network
    http://vault:8200 directly — no Traefik, no TLS on the bridge
Public path: HTTPS → Traefik → Vault :8200
Compose path: Laravel → vault:8200 (HTTP on Docker network)

Prerequisites:

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.”

TopologyLaravel VAULT_ADDRNetworkWhen to use
Same Compose (lab)http://vault:8200Docker bridgeLocal stack, CI integration tests
Laravel on prod, Vault on private LANhttps://vault.internal:8200 or http://10.x.x.x:8200VPC / wireguard / site-to-site — no public listener requiredPreferred production pattern
Laravel on prod, Vault on public HTTPShttps://vault.example.comTLS + firewall allowlist (Laravel egress IP only) + rate limitsNo 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:

RUN — paste into a shell on the host this section describes.
mkdir vault-stack && cd vault-stack
mkdir -p vault/config vault/policies

Part 4 — Vault configuration (config.hcl) and every server parameter

What this part is for: get a ready template for: Vault configuration (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):

COPY FILE — create the config file named in the section (edit values first).
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:

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):

READ — optional; do not paste into the lab file unless you mean to.
# 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 this part is for: cover: 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):

RUN — paste into a shell on the host this section describes.
docker network create edge

Optional dynamic Traefik file (HTTP → HTTPS redirect middleware referenced as https-redirect@file):

COPY FILE — traefik/dynamic/redirect.yml
http:
  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):

COPY FILE — create the config file named in the section (edit values first).
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
Production: Lock down or remove --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.
Port already in use / dual config: If you start Vault with 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:

RUN — paste into a shell on the host this section describes.
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 this part is for: one operational sequence from empty disk to “Laravel can AppRole-login and read KV.”
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).

READ — Bootstrap chain (no paste).
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

  1. 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.
  2. Start stack: docker compose up -d. Expect Vault healthy while sealed if the API listens (Part 5 healthcheck accepts exit 0 and 2).
  3. Verify: docker compose logs vault shows config loaded, no crash loop.

Gate 1 — Initialize and unseal

RUN — only when 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

RUN — as authenticated operator (root during bootstrap).
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

RUN — proves policy paths before touching Laravel.
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).

Auto-unseal path: If you use Transit (Part 6 legacy section below), replace Gate 1 init/unseal with recovery-key init and dependency checks — the KV → policy → AppRole chain (Gate 2–3) is identical.

Part 6 — Initialize, unseal, and self-hosted auto-unseal

What this part is for: initialize the Shamir lab correctly, or build a completely self-hosted Transit auto-unseal dependency with no cloud KMS.
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

RUN — once, against empty lab storage.
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.

RUN — submit three different shares; interactive input prevents shell-history leakage.
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.

RUN — verify, then authenticate for bootstrap only.
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

Unseal Vault (independent storage, TLS, Shamir custodians)
→ Transit engine transit/ → key autounseal
→ narrow periodic token in a root-owned file
→ target Vault seal "transit" → automatic restart unseal

The 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.

  1. Build and initialize https://unseal-vault.internal:8200 with Shamir; unseal it manually and log in for bootstrap.
  2. Enable Transit, create a non-exportable key, write a least-privilege policy, and issue an orphan periodic service token.
  3. Place only that token and the Unseal Vault CA on every target node with restrictive ownership and mounts.
  4. Add the Transit seal stanza before initializing a fresh target, or follow the migration sequence for existing storage.
RUN — on the already initialized and unsealed Unseal Vault.
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.

Token custody: create one token per target cluster so accessors can be audited and revoked independently. Do not put the token literal in 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.
COPY FILE — target node /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"
}
Version boundary: Transit has no 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.
RUN — on each target host; adapt owner to the UID running Vault.
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

RUN — once, only after the target starts with the Transit stanza and empty storage.
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.

RUN — prove automatic unseal after a normal restart.
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

Migrate an existing Shamir target to Transit

Migration is ordered and outage-bearing. Take a verified storage backup first and keep the original Shamir shares. Every unseal submission during migration must include -migrate; mixing ordinary and migration submissions resets progress. Do not run operator init.
  1. 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.
  2. Back up the target storage and configuration. Record vault status, verify the old Shamir threshold and version, and schedule downtime.
  3. Add the Transit seal stanza and protected files while the target still runs, then stop the target cleanly.
  4. Start the target with the new stanza. It remains sealed in migration mode; that is expected.
  5. Submit the existing Shamir shares, using -migrate on every command, until the threshold is met.
  6. Verify Seal Type transit, Recovery Seal Type shamir, Sealed false, and then perform a controlled restart to prove auto-unseal.
RUN — existing single-node target, after restart with the Transit stanza.
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.

RUN — Raft backup and peer check from an authenticated operator shell.
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.

READ — conceptual Enterprise example; vendor library, slot, labels, and mechanism are HSM-specific.
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.
}

Part 7 — KV v2 secrets engine, paths, and policies

What this part is for: cover: KV v2 secrets engine and policy.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

Enable KV version 2 at path secret:

RUN — paste into a shell on the host this section describes.
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:

RUN — paste into a shell on the host this section describes.
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:

RUN — paste into a shell on the host this section describes.
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.

CLI: vault kv put secret/laravel/app → API GET: /v1/secret/data/laravel/app → Policy: secret/data/laravel/*

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:

RUN — optional; paste on the Vault container after KV is enabled.
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.

CapabilityMeaningLaravel AppRole needs it?
readGET a secret (and most “read this path” APIs)Yes — KV data
listLIST keys under a prefix (UI browsing, kv list)No, unless you list in code
create / updateWrite new / overwrite (POST/PUT). KV v2 often needs both for kv putNo for a read-only app
patchJSON merge patch (KV v2 partial update)No
deleteSoft-delete versions (KV v2 delete prefix)No
sudoRoot-protected paths (e.g. some sys/ endpoints)Never
denyExplicit deny wins over allowUse to fence a subpath if the glob is too wide
API prefix (mount secret)Used forPolicy example
secret/data/laravel/*Read/write secret payloadsread for Laravel
secret/metadata/laravel/*List, metadata, max-versions per keylist / read if operators use CLI list as that role
secret/delete/laravel/*Soft-delete versionsRotation jobs only
secret/undelete/laravel/*Restore soft-deleted versionsAdmins only
secret/destroy/laravel/*Irreversible destroy of versionsAdmins 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):

COPY FILE — save as vault/policies/laravel-policy.hcl.
path "secret/data/laravel/*" {
  capabilities = ["read"]
}

Apply the policy (name must match AppRole token_policies in Part 8):

RUN — paste into a shell on the host this section describes.
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.

READ — Suggested paths under mount 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:

COPY FILE — production policy (read data only).
path "secret/data/laravel/production/*" {
  capabilities = ["read"]
}
path "secret/data/laravel/staging/*" {
  capabilities = ["read"]
}

Example Redis secret (one KV object, multiple logical clients):

RUN — after KV enabled.
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 this part is for: cover: AppRole authentication for Laravel.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

Enable AppRole:

RUN — paste into a shell on the host this section describes.
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:

RUN — paste into a shell on the host this section describes.
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):

RUN — paste into a shell on the host this section describes.
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):

RUN — paste into a shell on the host this section describes.
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).

ParameterDefaultWhat it doesHow to set for Laravel
token_policiesnonePolicy 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_ttlsystem default_lease_ttl if 0 or unsetInitial 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_ttlsystem max_lease_ttl if 0Hard cap including renewals. Token cannot live longer than this even if you renew.4h in the lab. Must be ≥ token_ttl.
token_explicit_max_ttl0 (off)Absolute lifetime from creation, ignoring renew tricks.Optional production hardening, e.g. 8h.
periodempty (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_uses0 = unlimitedAfter 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_typedefault (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_cidrsnoneClient 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_ttl0 = no expiry for the Secret IDHow 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_uses0 = unlimited loginsSecret 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_idtrueIf 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_cidrsnoneSecret ID can only be used from these CIDRs at login time.Same caution as token CIDRs (NAT/Traefik source IP).
local_secret_idsfalseHA 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):

POST {VAULT_ADDR}/v1/auth/approle/login
JSON body: { "role_id": "...", "secret_id": "..." }
Response: auth.client_token, auth.lease_duration, auth.renewable
RUN — optional login test from a protected operator shell; input is not echoed.
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).

READ — Scenario: new Laravel production server (Agent).
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
RUN — wrapped Secret ID (operator or CI with write on role).
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.

RUN — optional production flow; not required for the lab login test.
# 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 this part is for: implement or reject the auth methods you did not pick in Part 2.
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).

RUN — on Vault, only if Laravel is a Kubernetes workload.
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:

READ — shape of the login; path to the token file is the official SA mount.
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.

READ — CI-oriented JWT auth; not the Compose lab.
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

READ — requires Vault listener TLS and a client cert.
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 this part is for: implement the three delivery patterns that actually show up next to Laravel.
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.

RUN — optional; requires a DB Vault can reach (network + CREATE USER privilege).
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.”

Do not mix blindly: If you still 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

What this part is for: a first import from .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 VaultKeep 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-sensitiveAPP_DEBUG=true, log levels
REDIS_* passwords; dedicated host if sensitiveREDIS_CLIENT, CACHE_DRIVER, SESSION_DRIVER, DB index numbersPublic URLs, feature flags
MAIL_PASSWORD, API keys, webhook secrets, PSP keysMAIL_HOST, MAIL_PORT, MAIL_ENCRYPTIONBuild-time args baked into images
APP_KEY (via Agent template — Part 10B)APP_NAME, APP_ENV, APP_URLVault Role ID (not secret); Secret ID only via secure inject
OAuth client secrets, JWT signing keysVAULT_ADDR, VAULT_ROLE_ID, VAULT_ENABLEDRoot token, long-lived admin tokens

See also Part 13A — runtime secrets vs non-sensitive configuration for the full split.

First import procedure

  1. Inventory — Export a redacted list from production .env (names only in tickets; values never in chat).
  2. Map paths — Assign each secret to a KV path (layout above). One kv put per concern.
  3. Write KV — From an operator shell with admin policy, not from Laravel:
RUN — example first import (edit values).
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:...'
  1. Extend AppRole policy if paths changed from the lab glob.
  2. Dual-run — Enable Vault in staging; keep .env fallback with VAULT_ENABLED=false locally.
  3. Cutover — Set VAULT_ENABLED=true, remove migrated keys from production .env only after smoke tests (DB, cache, session, queue, mail).
  4. Rotate — Treat import as potentially exposed; rotate DB/Redis passwords after cutover if .env ever lived in backup logs or git.

Part 10B — Vault Agent: complete Compose reference

What this part is for: a working Agent sidecar — compose, config, templates, shared volume, Laravel entrypoint.
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.

COPY FILE — 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:
COPY FILE — vault/agent/agent.hcl
pid_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
}
COPY FILE — 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 }}
COPY FILE — vault/agent/app-key.ctmpl
{{ with secret "secret/data/laravel/production/app-key" }}{{ .Data.data.APP_KEY }}{{ end }}
COPY FILE — 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:

READ — 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:

Flow:

  1. Agent renders /run/secrets/laravel.env and /run/secrets/app_key.
  2. Entrypoint exports APP_KEY, sources laravel.env, execs Octane.
  3. config/database.php uses env('DB_PASSWORD') which now resolves from the sourced file.
  4. Optional: AppServiceProvider::register() still maps multi-Redis hosts from env if those keys were templated.

Secret lifecycle under Octane / Swoole

EventWhat holds secretsWhat you must do
Container startEntrypoint exports env; workers fork with copyEnsure Agent finished render before Octane
Multiple Octane workers (--workers=N)N processes; each runs register() once at worker startAgent/entrypoint env is inherited by all workers; HTTP pull means N AppRole logins unless you use Agent; rotation/recycle affects every worker
Worker process lifetimeIn-memory Config, open Redis/DB poolsTreat rotation as requiring worker recycle
KV secret rotated in VaultWorkers still use old passwords until restartAgent re-render → restart Octane container (or octane:reload if you add SIGHUP hook)
AppRole token (HTTP pull only)Token in worker memory until TTLPrefer Agent so PHP has no Vault token; else renew or match TTL to worker max life
APP_KEY changedAll encrypted cookies/sessions invalidPlan 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)

  1. Store APP_KEY at secret/laravel/production/app-key (never commit).
  2. Agent template writes one line to /run/secrets/app_key.
  3. Entrypoint: export APP_KEY="$(cat /run/secrets/app_key)" before any PHP.
  4. Laravel config/app.php keeps 'key' => env('APP_KEY') — unchanged.
  5. Do not fetch APP_KEY via HTTP pull in register(); 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:

COPY FILE — 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),
    ],
],
COPY FILE — 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',
COPY FILE — config/horizon.php.
'use' => 'default',  // uses redis.default connection above

HTTP pull in register() alternative — map KV into config without env:

READ — 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 this part is for: cover: 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 varConfig keyDefaultWhat it does / how to set
VAULT_ADDRaddresshttp://127.0.0.1:8200API 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_IDrole_idemptyAppRole Role ID. Missing ⇒ login 400.
VAULT_SECRET_IDsecret_idemptyAppRole Secret ID. Treat as a password. Inject at deploy; do not commit.
VAULT_SECRET_PATHsecret_pathsecret/data/laravel/appHTTP path after /v1/. Must include /data/ for KV v2. No leading slash.
VAULT_VERIFY_TLSverify_tlstrueGuzzle verify. true on HTTPS. Irrelevant on HTTP. Never false in production.
VAULT_TIMEOUTtimeout10Seconds for connect+request. Too low: flaky boots. Too high: PHP workers stuck if Vault hangs.
VAULT_RETRIESretries2Extra attempts on connection errors / 5xx. Does not retry 403 (that is policy, not flake).
VAULT_ENABLEDenabledtrue in production only if you set itFeature flag. Prefer this over guessing environment('production') if staging also uses Vault.
VAULT_NAMESPACEnamespaceemptySets X-Vault-Namespace. Enterprise/HCP only. Leave empty on OSS.

Laravel in the same Compose stack (add a laravel service on edge):

COPY FILE — add to .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):

COPY FILE — add to .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:

COPY FILE — 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

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:

COPY FILE — create the config file named in the section (edit values first).
<?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():

COPY FILE — register VaultService in your Laravel service provider.
public function register(): void
{
    $this->app->singleton(\App\Services\VaultService::class);
}

Quick test:

RUN — paste into a shell on the host this section describes.
php artisan tinker
>>> app(\App\Services\VaultService::class)->getSecret();
// ["DB_PASSWORD" => "...", "MAIL_PASSWORD" => "..."]

Part 12A — Startup order and behavior when Vault is sealed

What this part is for: dependency order across hosts and containers; expected failure modes.
What you should do: align orchestration health checks with this table — do not mark Laravel healthy if secrets never loaded.
READ — Recommended start sequence.
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)
ComponentVault sealedVault downAgent down
Vault containerRunning, API 503 on secret paths; healthcheck still healthy (exit 2)N/AN/A
vault-agentRetries login/render; no new templatesRetries with backoff; logs errorsN/A
Laravel (Agent mode)Entrypoint waits, then exits non-zero if timeout — fail closedSame — do not serve without secretsMissing files → exit 1; orchestrator restarts
Laravel (HTTP pull)Boot exception on AppRole login — worker crash loopConnection timeout in register()N/A
Queue workersSame as app — do not process jobs without DBCrash loop until Vault readyStuck 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 this part is for: cover: 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.

Cache trap: 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().
COPY FILE — edit 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
    {
        //
    }
}
Development: Leave 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 stepWhat runsVault HTTP pull OK?
LoadEnvironmentVariablesReads .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.
LoadConfigurationLoads 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 hookYes for database.connections.*.password, mail, third-party API keys not used during register.
providers boot()Other packages may already have resolved DBToo late if a service already connected.
HTTP kernel / console kernelRequest or Artisan commandOn-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 this part is for: back up, restart, audit, and rotate without confusing Shamir operations with auto-unseal operations.
What you should do: follow the branch matching your seal type and storage backend.
OperationShamir targetTransit auto-unseal target
Normal restartSubmit the configured threshold of unseal sharesDo not submit recovery shares; verify automatic unseal and Transit reachability
Break glassUnseal shares also support Shamir rekey workflowsRecovery shares authorize recovery workflows but cannot replace a failed Transit service
Backup dependencyTarget data + target Shamir sharesTarget data + Unseal Vault data/key + Unseal Vault Shamir shares + target recovery shares
Health signalSealed after a restart is expected until custodians actSealed 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)

  1. Announce maintenance or rely on HA read replica if you add Raft later.
  2. docker compose stop vault — ensures consistent filesystem.
  3. Archive volume: tar czf vault-$(date +%F).tar.gz from ro mount (Part 13 command).
  4. Record SHA256; copy off-host; include Shamir share inventory (not in the same bag as ciphertext).
  5. docker compose start vault → unseal → verify vault status.

Recovery (disaster drill on staging)

  1. Provision clean VM; install Docker; restore vault/config from git.
  2. Create empty volume; extract tar into /vault/file mount path.
  3. Start Vault — expect Sealed: true, Initialized: true.
  4. Submit Shamir threshold; verify KV paths and AppRole login.
  5. Point staging Laravel Agent at restored Vault; run application smoke tests.
  6. Document RTO/RPO actually observed.
Transit auto-unseal: Restoring target data without the original Unseal Vault Transit key and token path leaves ciphertext permanently locked. Backup Unseal Vault storage and Shamir shares independently (Part 6).

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:

RUN — paste into a shell on the host this section describes.
docker volume ls | grep vault
# Example name: vault-stack_vault_data  (project folder "vault-stack")
Live 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.
RUN — after 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.

Shamir restore set = target data + Shamir shares
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).

RUN — once, as root token, after unseal.
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

  1. Query the specific node with vault status; after process restart, Sealed: true is expected.
  2. 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.
  3. Confirm Seal Type shamir and Sealed false before restarting dependent Laravel workers.

Transit auto-unseal

  1. Confirm the Unseal Vault is already initialized, unsealed, healthy, and reachable from the target node.
  2. Restart the target; do not enter recovery shares.
  3. Confirm Seal Type transit and Sealed false on that specific node. If sealed, inspect target logs for token, transit/decrypt/autounseal, CA, certificate-name, DNS, and timeout errors.
  4. 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

RUN — paste into a shell on the host this section describes.
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

RUN — paste into a shell on the host this section describes.
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.

RUN — paste into a shell on the host this section describes.
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.

RUN — authenticated operator; schedule and audit this operation.
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

What this part is for: decide what belongs in Vault vs Compose env vs Laravel .env.
What you should do: use this table in design review before expanding KV.
CategoryExamplesStore
Runtime secretsDB/Redis passwords, API keys, APP_KEY, signing keysVault KV → Agent template or HTTP pull
Machine identity to VaultAppRole Role ID, Secret ID file pathRole ID in config mgmt; Secret ID on disk via wrap, never git
Infrastructure wiringDB_HOST, Redis hostnames, ports, DB indexesCompose environment: or .env.nonsecret
Application modeAPP_ENV, APP_DEBUG, LOG_CHANNELCompose / .env — not Vault
Vault client settingsVAULT_ADDR, VAULT_ENABLED, timeoutsCompose env — not secret
Public URLsAPP_URL, asset CDN baseCompose / 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 this part is for: lookup tables for the knobs already explained in context.
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)

ParameterLab valueNotes
uitrueRestrict in production
disable_mlocktrueRequired in typical Docker
storage.file.path/vault/fileMust be a volume
listener.address0.0.0.0:8200Not 127.0.0.1 in a container
tls_disable1TLS at Traefik
api_addrhttps://vault.example.comPublic client URL
cluster_addrhttp://127.0.0.1:8201Must match listener scheme
log_levelinfodebug while chasing 403
default_lease_ttl / max_lease_ttlomit (768h)Ceiling for tokens and dynamic creds
seal.transit.address / mount_path / key_nameoptional self-hosted pathDedicated Unseal Vault; same values on all Raft peers
seal.transit.tokenfile:///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.pemKeep verification enabled; SAN must match server name
VAULT_ADDR (Vault container)http://127.0.0.1:8200CLI + healthcheck only
VAULT_API_ADDRsame as api_addrEnv overrides HCL

Init / AppRole / KV / Laravel (short)

ParameterLab valueSee
-key-shares / -key-threshold5 / 3Part 6
-recovery-shares / -recovery-thresholdTransit option: 5 / 3Recovery authority, not manual unseal
KV mountsecret kv-v2Part 7
Policy pathsecret/data/laravel/* readPart 7
token_policieslaravelPart 8
token_ttl / token_max_ttl1h / 4hPart 8 — 0 means system default, not “forever”
secret_id_ttl0 (no expiry)Part 8 — tighten when CI rotates
secret_id_num_uses0 (unlimited)1 only with wrap + single login
VAULT_ADDR (Laravel)Compose HTTP or host HTTPSPart 3 and 11
VAULT_SECRET_PATHsecret/data/laravel/appMust include /data/
VAULT_ENABLEDtrue when using VaultPart 12
Healthcheck10s / 5s / 3 / 15s startExpected during Shamir’s operator window; an incident after Transit startup
Traefik backend port8200Missing label ⇒ 502
Layer → parameter map: storage path (layer 3) · seal shares (layer 4) · listener/TLS/api_addr/cluster_addr (layer 5) · AppRole TTLs (layers 6–7) · policy capabilities (layer 8) · KV mount and /data/ (layer 9) · Traefik labels (layer 11) · Laravel env and register() (layer 12).

Part 15 — Troubleshooting and production checklist

What this part is for: cover: 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

  1. https://vault.example.com shows the Vault UI
  2. docker exec vault vault status → Sealed: false
  3. docker exec vault vault kv get secret/laravel/app returns your keys
  4. From the app: app(VaultService::class)->getSecret() succeeds
  5. App authenticates to MySQL with the Vault-sourced password

Production checklist

  1. config.hcl: api_addr = public HTTPS URL; cluster_addr = http://127.0.0.1:8201; disable_mlock = true
  2. Compose: cap_add: [IPC_LOCK], healthcheck (exit 0 or 2), entrypoint with -config=/vault/config/config.hcl, VAULT_ADDR=http://127.0.0.1:8200
  3. Traefik: traefik.enable=true, service port 8200, shared edge network, ACME resolver; no discovery until Vault is healthy
  4. Seal branch selected: runbook explicitly says Shamir or Transit; operators never treat recovery shares as auto-unseal shares
  5. Shamir branch: unseal shares held by separate custodians; chosen threshold justified by absence/compromise tolerance; each-node Raft unseal and restart drill tested
  6. Transit branch: dedicated independently bootable Unseal Vault; TLS verification on; exact encrypt/decrypt update policy; periodic orphan token in a protected read-only file; cold-start drill tested
  7. Transit custody: target recovery shares offline; Unseal Vault Shamir shares offline; token accessors recorded; no token literal in HCL, Compose, image, logs, or repository
  8. No circular dependency: Unseal Vault DNS, TLS, storage, host boot, and credentials do not require the target Vault
  9. Root token: not used in Laravel; revoked or locked down after AppRole setup
  10. Policy: secret/data/laravel/* read-only; name laravel matches AppRole token_policies
  11. AppRole: Role ID in .env; Secret ID injected at deploy; token_ttl set explicitly (not 0); rotation scheduled
  12. Laravel VAULT_ADDR: http://vault:8200 in Compose; https://vault.example.com on host
  13. Laravel flag: VAULT_ENABLED + config('vault.*') only (no raw env() in services)
  14. Config timing: Config::set() in register(), not boot(); APP_KEY not from late KV pull
  15. Workers: restart FPM/Octane/queue after KV rotation; Octane token TTL vs worker lifetime
  16. Backup: stopped volume snapshot (file) or Raft snapshot; checksum/off-host retention; isolated restore tested with the matching Shamir or Transit dependency set
  17. Raft + Transit: identical seal stanza/key/CA on every node; unique node IDs; status checked per node; snapshot and quorum restore tested
  18. Audit: file (or socket) device enabled; disk monitored (failed audit blocks requests)
  19. 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
  20. No extra packages: use Illuminate\Support\Facades\Http (Guzzle already included)

Wrapping up

What this part is for: cover: 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:

  1. Layers — host, Docker, storage, seal, listener, auth, token, policy, engine, audit, Traefik, Laravel bootstrap
  2. Lab methodology — file storage, Shamir unseal, AppRole, HTTP pull in register(), Traefik for HTTPS
  3. Parameters — each important knob has a default, a failure mode, and a recommended setting (Parts 4, 8, 11, 14)
  4. 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.