Connect HashiCorp Vault to Laravel with Traefik and Docker Compose

Published

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.

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

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:

  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.

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:

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
Store unseal keys and root token in a password manager. You need 3 of 5 unseal keys to unseal. Lose all five and your encrypted data is unrecoverable.

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:

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

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

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

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
    {
        //
    }
}
Development: Keep 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 .
Backup data + unseal keys together — data without keys is ciphertext; keys without data is useless

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:

  1. docker exec vault vault status — confirm Sealed: true
  2. Run vault operator unseal three times with three different keys
  3. Confirm Sealed: false before 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

  1. https://vault.example.com shows the Vault UI
  2. docker exec vault vault statusSealed: 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. vault.hcl: api_addr = public HTTPS URL; cluster_addr = http://127.0.0.1:8201; disable_mlock = true
  2. Compose: cap_add: [IPC_LOCK], healthcheck, VAULT_ADDR=http://127.0.0.1:8200 in Vault container env
  3. Traefik: traefik.enable=true, service port 8200, shared network, Let's Encrypt resolver
  4. Unseal keys: stored securely; unseal procedure documented; tested after restart
  5. Root token: not used in Laravel; revoked or locked down after AppRole setup
  6. Policy: secret/data/laravel/* read-only; name laravel matches AppRole token_policies
  7. AppRole: Role ID in .env; Secret ID injected at deploy; rotation scheduled
  8. Laravel VAULT_ADDR: http://vault:8200 in Compose; https://vault.example.com on host
  9. Config timing: Config::set() in register(), not boot()
  10. Backup: vault_data volume backed up; restore tested with unseal keys
  11. Monitoring: alert on sealed Vault and unhealthy healthcheck
  12. No extra packages: use Illuminate\Support\Facades\Http (Guzzle already included)

Wrapping up

You now have a production-minded pattern:

  1. Vault — single-node file storage, correct cluster_addr, Docker-safe mlock settings
  2. Traefik — HTTPS with explicit service port and network labels
  3. Laravel — AppRole auth, KV v2 secret/data/ paths, secrets loaded in register()

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.