Connect HashiCorp Vault to Laravel with Traefik and Docker Compose
Single-node Vault behind Traefik, AppRole auth, KV v2 — and a Laravel app that loads secrets before the database connection resolves. No root token in production code.
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.
We stand up a single-node HashiCorp Vault behind Traefik, expose it over HTTPS, and wire a Laravel application into it so secrets stop living in plain text on disk.
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()
Part 1 — Architecture, prerequisites, and VAULT_ADDR matrix
Vault holds secrets. Traefik terminates TLS for browsers and host clients. Laravel authenticates with AppRole and reads secrets at runtime — never with the root token.
Two paths to the same Vault listener:
- Browser / Laravel on the host
https://vault.example.com→ Traefik (TLS) →http://vault:8200inside Docker - Laravel in the same Compose network
http://vault:8200directly — no Traefik, no TLS on the bridge
Compose path: Laravel → vault:8200 (HTTP on Docker network)
Prerequisites:
- Docker and Docker Compose
- A domain (e.g.
vault.example.com) with DNS pointing to your server - Ports 80 and 443 open (Let's Encrypt HTTP challenge)
- Laravel 10+ (local or on the same server)
Three different VAULT_ADDR values — do not mix them up
Vault container CLI (docker exec vault vault ...): VAULT_ADDR=http://127.0.0.1:8200 — talks to the local listener inside the container.
Laravel in the same Compose network: VAULT_ADDR=http://vault:8200 — internal Docker DNS, plain HTTP, no TLS verification needed.
Laravel on the host (outside Docker): VAULT_ADDR=https://vault.example.com — public URL through Traefik with valid TLS.
Vault UI redirects use api_addr in vault.hcl (https://vault.example.com) — not cluster_addr.
Create the project folder:
mkdir vault-stack && cd vault-stack mkdir -p vault/config vault/policies
Part 2 — Vault configuration (vault.hcl)
Create vault/config/vault.hcl:
ui = true
disable_mlock = true
storage "file" {
path = "/vault/file"
}
listener "tcp" {
address = "0.0.0.0:8200"
tls_disable = 1
}
# Public URL — Traefik terminates TLS; used for UI redirects and external clients
api_addr = "https://vault.example.com"
# Internal cluster port — MUST match listener scheme (HTTP, not HTTPS)
# Single-node: loopback on the cluster port (8201 by convention)
cluster_addr = "http://127.0.0.1:8201"
log_level = "info"
Why cluster_addr must not be the public HTTPS URL
When tls_disable = 1, Vault listens on plain HTTP. Setting cluster_addr = "https://vault.example.com" tells Vault nodes to talk to each other over HTTPS on a port that only speaks HTTP — cluster RPC fails silently or logs TLS handshake errors.
api_addr vs cluster_addr
api_addr: What clients and the Vault UI advertise — your public HTTPS URL behind Traefik.
cluster_addr: How Vault nodes communicate with each other on the cluster port. On a single node with HTTP listener, use http://127.0.0.1:8201.
IPC_LOCK and disable_mlock in Docker
Vault normally calls mlock() to prevent secrets from being swapped to disk. Docker containers often lack the capability to lock memory. The fix is twofold:
cap_add: [IPC_LOCK]in Compose — grants the capability when the kernel allows itdisable_mlock = trueinvault.hcl— prevents startup failure when mlock still cannot succeed inside the container
Production trade-off: Without mlock, sensitive memory can be swapped. Mitigate with encrypted volumes, tight host access, and short-lived AppRole tokens — not a root token in Laravel.
Part 3 — Docker Compose with Traefik labels and healthcheck
Full docker-compose.yml:
services:
traefik:
image: traefik:v3.2
container_name: traefik
restart: unless-stopped
command:
- "--api.dashboard=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.letsencrypt.acme.email=you@example.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
networks:
- traefik_public
vault:
image: hashicorp/vault:1.17
container_name: vault
restart: unless-stopped
cap_add:
- IPC_LOCK
environment:
# In-container CLI — NOT the public HTTPS URL
VAULT_ADDR: "http://127.0.0.1:8200"
VAULT_API_ADDR: "https://vault.example.com"
volumes:
- ./vault/config:/vault/config:ro
- vault_data:/vault/file
command: server
healthcheck:
test: ["CMD", "vault", "status", "-format=json"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
networks:
- traefik_public
labels:
- "traefik.enable=true"
- "traefik.http.routers.vault.rule=Host(`vault.example.com`)"
- "traefik.http.routers.vault.entrypoints=websecure"
- "traefik.http.routers.vault.tls=true"
- "traefik.http.routers.vault.tls.certresolver=letsencrypt"
- "traefik.http.services.vault.loadbalancer.server.port=8200"
- "traefik.http.routers.vault.service=vault"
- "traefik.docker.network=traefik_public"
networks:
traefik_public:
name: traefik_public
volumes:
vault_data:
Start the stack:
docker compose up -d docker compose ps
Open https://vault.example.com — the UI loads but shows Sealed until you unseal (Part 4).
Traefik labels explained
traefik.enable=true
Required because exposedbydefault=false. Without it, Traefik ignores the container entirely.
traefik.http.routers.vault.rule=Host(`vault.example.com`)
Match HTTPS requests for your Vault hostname. Replace with your real domain.
traefik.http.routers.vault.entrypoints=websecure
Bind this router to the :443 entrypoint defined in Traefik's command flags.
traefik.http.routers.vault.tls.certresolver=letsencrypt
Obtain and renew a certificate via ACME HTTP challenge. The resolver name must match certificatesresolvers.letsencrypt in Traefik's command.
traefik.http.services.vault.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=traefik_public
Which Docker network Traefik uses to reach Vault. Both containers must attach to the same network.
Vault healthcheck
vault status -format=json runs inside the container using VAULT_ADDR=http://127.0.0.1:8200. Returns non-zero when sealed — Compose marks the container unhealthy but keeps it running. Useful for monitoring and orchestration dependencies.
Part 4 — Initialize, unseal, and unseal on every restart
Vault starts sealed. It will not serve secrets until unsealed.
docker exec -it vault vault operator init
Output includes five unseal keys and one initial root token:
Unseal Key 1: ... Unseal Key 2: ... Unseal Key 3: ... Unseal Key 4: ... Unseal Key 5: ... Initial Root Token: hvs.xxxxx
Unseal (repeat until Sealed: false):
docker exec -it vault vault operator unseal # paste Unseal Key 1 docker exec -it vault vault operator unseal # paste Unseal Key 2 docker exec -it vault vault operator unseal # paste Unseal Key 3
Verify:
docker exec vault vault status
Login with root token for setup only:
docker exec -it vault vault login # paste Initial Root Token
Never use the root token in Laravel. It has unlimited privileges. Use AppRole with a least-privilege policy (Part 6). Revoke or tightly guard the root token after initial configuration.
Unseal on every container restart
Vault always seals itself on restart — by design. After docker compose restart vault or a host reboot, you must unseal again with 3 of 5 keys. A sealed Vault means Laravel cannot authenticate and every secret-dependent service fails.
Options for production:
- Manual unseal — acceptable for a single admin; run three
vault operator unsealcommands after each restart - Scripted unseal — a systemd oneshot or init container that reads keys from a secure store and unseals automatically
- Auto-unseal — cloud KMS (AWS KMS, GCP CKMS, Azure Key Vault) holds the master key; Vault unseals itself on start (more setup, no manual step)
# Example post-restart check docker compose restart vault docker exec vault vault status # Sealed: true until you unseal # Run unseal three times with different keys...
Part 5 — KV v2 secrets engine and policy
Enable KV version 2 at path secret:
docker exec -it vault vault secrets enable -path=secret kv-v2
Store a sample secret:
docker exec -it vault vault kv put secret/laravel/app \ DB_PASSWORD="super-secret-db-password" \ MAIL_PASSWORD="smtp-password-here"
Verify:
docker exec -it vault vault kv get secret/laravel/app
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.
Create vault/policies/laravel-policy.hcl on the host:
path "secret/data/laravel/*" {
capabilities = ["read"]
}
Apply the policy (name must match AppRole token_policies in Part 6):
docker cp vault/policies/laravel-policy.hcl vault:/tmp/laravel-policy.hcl docker exec -it vault vault policy write laravel /tmp/laravel-policy.hcl
Part 6 — AppRole authentication for Laravel
Enable AppRole:
docker exec -it vault vault auth enable approle
Create the role — token_policies must exactly match the policy name from vault policy write laravel:
docker exec -it vault vault write auth/approle/role/laravel \ token_policies="laravel" \ token_ttl=1h \ token_max_ttl=4h \ secret_id_ttl=0
Get the Role ID (safe in .env):
docker exec vault vault read -field=role_id auth/approle/role/laravel/role-id
Generate a Secret ID (treat like a password — never commit to git):
docker exec vault vault write -f -field=secret_id auth/approle/role/laravel/secret-id
AppRole credentials for Laravel
Role ID: Identifies the role — low sensitivity, can live in .env.
Secret ID: Proves identity — inject via deploy pipeline or server environment, rotate periodically.
Client token: Short-lived, obtained at runtime via POST /v1/auth/approle/login — never hardcode.
Part 7 — Laravel VaultService and HTTP client
Laravel already ships with Guzzle via Illuminate\Http\Client. No extra composer require is needed unless you prefer the raw Guzzle client directly.
Environment variables
Laravel in the same Compose stack (add a laravel service on traefik_public):
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
Laravel on the host (PHP runs 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
Create config/vault.php:
<?php
return [
'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' => env('VAULT_VERIFY_TLS', true),
];
App on host vs app in Compose
http://vault:8200 — traffic stays on the Docker bridge, TLS is unnecessary, no certificate verification issues.On host:
https://vault.example.com — goes through Traefik with Let's Encrypt; set VAULT_VERIFY_TLS=true in production.
Create app/Services/VaultService.php using Laravel's HTTP client:
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class VaultService
{
private ?string $token = null;
private function baseUrl(): string
{
return rtrim(config('vault.address'), '/');
}
public function authenticate(): string
{
if ($this->token) {
return $this->token;
}
$response = Http::timeout(10)
->withOptions(['verify' => config('vault.verify_tls')])
->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('Vault AppRole login failed: ' . $response->body());
}
$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 = Http::timeout(10)
->withOptions(['verify' => config('vault.verify_tls')])
->withHeaders(['X-Vault-Token' => $token])
->get("{$this->baseUrl()}/v1/{$path}");
if (! $response->successful()) {
throw new RuntimeException("Vault read failed for {$path}: " . $response->body());
}
return $response->json('data.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():
public function register(): void
{
$this->app->singleton(\App\Services\VaultService::class);
}
Quick test:
php artisan tinker >>> app(\App\Services\VaultService::class)->getSecret(); // ["DB_PASSWORD" => "...", "MAIL_PASSWORD" => "..."]
Part 8 — AppServiceProvider register() pattern (not boot())
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(), fetch secrets from Vault and Config::set() the password (and related keys) as early as possible — before boot() phases and before request handling.
<?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 ($this->app->environment('production')) {
$vault = $this->app->make(VaultService::class);
Config::set('database.connections.mysql.password', $vault->get('DB_PASSWORD'));
Config::set('mail.mailers.smtp.password', $vault->get('MAIL_PASSWORD'));
}
}
public function boot(): void
{
//
}
}
DB_PASSWORD in .env locally and skip the Vault block with environment('production') — or use a dedicated VAULT_ENABLED flag.On-demand: For non-critical secrets,
app(VaultService::class)->get('API_KEY') anywhere in your code still works.
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.
Part 9 — Day-two operations: backup, unseal, rotation
Backup the Vault data volume
Secrets live in the vault_data Docker volume at /vault/file inside the container. Back it up regularly:
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 .
Test restore on a staging host before you need it in an emergency.
Unseal after every restart
Document your unseal procedure. After any Vault container restart:
docker exec vault vault status— confirmSealed: true- Run
vault operator unsealthree times with three different keys - Confirm
Sealed: falsebefore deploying or restarting Laravel
Rotate the AppRole Secret ID
docker exec vault vault write -f -field=secret_id auth/approle/role/laravel/secret-id
Update VAULT_SECRET_ID in your deploy environment. No code changes. Consider a calendar reminder every 90 days.
Rotate application secrets in KV
docker exec -it vault vault kv put secret/laravel/app \ DB_PASSWORD="new-rotated-password" \ MAIL_PASSWORD="new-smtp-password"
Clear Laravel's cache so it picks up new values:
php artisan cache:forget vault.secrets
Start small
Vault high-value secrets first: database passwords, payment API keys, encryption keys. Leave APP_NAME and APP_ENV in .env. You do not need to move your entire config on day one.
Part 10 — Troubleshooting and production checklist
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.loadbalancer.server.port=8200, both containers on traefik_public, and traefik.docker.network=traefik_public. 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
Usual cause: container restarted. File storage keeps data sealed until you unseal again.
Fix: vault operator unseal three times with three different unseal keys. Confirm vault status shows Sealed: false.
Compose marks Vault unhealthy
The healthcheck runs vault status. While sealed, that command exits non-zero, so Compose shows unhealthy even though the process is up. After a successful unseal it should flip to healthy. If it stays unhealthy after unseal, check VAULT_ADDR=http://127.0.0.1:8200 inside the container and that the listener is bound.
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 vault.hcl and cap_add: [IPC_LOCK] in Compose (both, as in Part 2–3).
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.
End-to-end verification
https://vault.example.comshows the Vault UIdocker exec vault vault status→Sealed: falsedocker exec vault vault kv get secret/laravel/appreturns your keys- From the app:
app(VaultService::class)->getSecret()succeeds - App authenticates to MySQL with the Vault-sourced password
Production checklist
vault.hcl:api_addr= public HTTPS URL;cluster_addr=http://127.0.0.1:8201;disable_mlock = true- Compose:
cap_add: [IPC_LOCK], healthcheck,VAULT_ADDR=http://127.0.0.1:8200in Vault container env - Traefik:
traefik.enable=true, service port 8200, shared network, Let's Encrypt resolver - Unseal keys: stored securely; unseal procedure documented; tested after restart
- Root token: not used in Laravel; revoked or locked down after AppRole setup
- Policy:
secret/data/laravel/*read-only; namelaravelmatches AppRoletoken_policies - AppRole: Role ID in
.env; Secret ID injected at deploy; rotation scheduled - Laravel VAULT_ADDR:
http://vault:8200in Compose;https://vault.example.comon host - Config timing:
Config::set()inregister(), notboot() - Backup:
vault_datavolume backed up; restore tested with unseal keys - Monitoring: alert on sealed Vault and unhealthy healthcheck
- No extra packages: use
Illuminate\Support\Facades\Http(Guzzle already included)
Wrapping up
You now have a production-minded pattern:
- Vault — single-node file storage, correct
cluster_addr, Docker-safe mlock settings - Traefik — HTTPS with explicit service port and network labels
- Laravel — AppRole auth, KV v2
secret/data/paths, secrets loaded inregister()
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.