Almacenamiento seguro y rotación de secretos en CI/CD: integración de HashiCorp Vault con GitHub Actions y Laravel
Introducción: por qué almacenar secretos en .env y variables de entorno es peligroso
La mayoría de los proyectos PHP siguen almacenando credenciales de bases de datos, claves API y tokens en archivos .env o en variables de entorno del sistema CI/CD. A primera vista parece cómodo: Laravel lee .env de forma nativa, y GitHub Actions permite definir Secrets en la configuración del repositorio. Sin embargo, este enfoque tiene vulnerabilidades sistémicas.
- Estáticos por naturaleza. Un secreto emitido una vez puede vivir durante meses o años. La filtración de un solo token abre el acceso a toda la infraestructura.
- Sin auditoría. No sabes quién de tu equipo copió el valor de una variable, qué worker leyó la contraseña de la base de datos ni cuándo.
- Propagación de secretos. Los Secrets acaban en logs, en artefactos de compilación y en imágenes Docker a través de instrucciones
ARG. - Rotación manual. Cambiar la contraseña de la base de datos exige actualizar de forma sincronizada las variables en todos los entornos, lo que siempre conlleva riesgo de downtime.
La solución es una gestión centralizada de secretos con emisión dinámica y rotación automática. El estándar de facto en este campo es HashiCorp Vault. En este artículo veremos cómo integrar Vault con GitHub Actions mediante OIDC y cómo una aplicación Laravel obtiene DATABASE_URL, REDIS_PASSWORD y APP_KEY directamente desde Vault en cada despliegue.
Arquitectura de HashiCorp Vault: secretos dinámicos, lease y revocación
Vault es un gestor de secretos con HTTP API que soporta múltiples secrets engines (motores de secretos) y métodos de autenticación. Estos son los conceptos clave que debes comprender antes de la integración:
Secrets Engines
Vault no es simplemente un almacén de clave-valor. Los motores de secretos pueden generar credenciales al vuelo. Para nuestro caso, los más relevantes son:
- KV v2 — almacén de secretos estáticos con versionado. Ideal para
APP_KEYy claves API de servicios externos. - Database Secrets Engine — crea dinámicamente usuarios temporales en PostgreSQL, MySQL y otros SGBD con tiempo de vida limitado.
- Transit — cifrado de datos sin almacenarlos en Vault (encryption-as-a-service).
Lease y Revocación
Cada secreto dinámico se emite con un lease — tiempo de vida (TTL). Al expirar el TTL, Vault revoca automáticamente el secreto (elimina al usuario temporal de PostgreSQL, invalida el token). La aplicación puede renovar el lease a través de la API, pero solo dentro del límite de max_ttl. Esto significa que la exposición de un secreto dinámico está acotada en el tiempo: una diferencia fundamental respecto a las contraseñas estáticas.
Políticas de acceso
Vault utiliza políticas en HCL para gestionar los permisos. Ejemplo de política para una tarea de CI en GitHub Actions:
# policy: github-actions-deploy.hcl
# Lectura de secretos estáticos de la aplicación
path "secret/data/myapp/*" {
capabilities = ["read"]
}
# Obtención de credenciales dinámicas para PostgreSQL
path "database/creds/myapp-deploy-role" {
capabilities = ["read"]
}
# Renovación de lease
path "sys/leases/renew" {
capabilities = ["update"]
}
Integración de Vault con GitHub Actions: autenticación OIDC sin tokens estáticos
El enfoque tradicional consiste en almacenar el token de Vault en GitHub Secrets. Esto vuelve a ser un secreto estático, pero ahora da acceso a todos los demás secretos. OIDC (OpenID Connect) resuelve este problema: GitHub Actions genera un JWT de corta duración para cada ejecución de job, Vault lo verifica a través del endpoint OIDC de GitHub y emite un token con permisos limitados.
Configuración de JWT Auth en Vault
# Habilitamos el método de autenticación JWT
vault auth enable jwt
# Configuramos el OIDC discovery a través de GitHub
vault write auth/jwt/config \
oidc_discovery_url="https://token.actions.githubusercontent.com" \
bound_issuer="https://token.actions.githubusercontent.com"
# Creamos un rol para el despliegue de un repositorio específico
vault write auth/jwt/role/github-actions-deploy \
role_type="jwt" \
bound_audiences="https://vault.example.com" \
user_claim="actor" \
bound_claims_type="glob" \
bound_claims='{
"sub": "repo:your-org/your-repo:environment:production"
}' \
policies="github-actions-deploy" \
ttl="15m"
Fíjate en bound_claims: vinculamos el rol a un repositorio y entorno de GitHub Environments concretos. Un token generado desde otro repositorio o rama no superará la validación.
Workflow de GitHub Actions con obtención de secretos desde Vault
name: Deploy Laravel to Production
on:
push:
branches: [main]
permissions:
id-token: write # Obligatorio para OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Import Secrets from Vault
uses: hashicorp/vault-action@v3
id: vault
with:
url: https://vault.example.com
method: jwt
role: github-actions-deploy
# audience debe coincidir con bound_audiences del rol
jwtGithubAudience: https://vault.example.com
secrets: |
secret/data/myapp/production app_key | APP_KEY ;
secret/data/myapp/production redis_password | REDIS_PASSWORD ;
database/creds/myapp-deploy-role username | DB_USERNAME ;
database/creds/myapp-deploy-role password | DB_PASSWORD
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- name: Install Composer dependencies
run: composer install --no-dev --optimize-autoloader
- name: Run database migrations
env:
DB_HOST: db.internal.example.com
DB_PORT: 5432
DB_DATABASE: myapp_prod
DB_USERNAME: ${{ steps.vault.outputs.DB_USERNAME }}
DB_PASSWORD: ${{ steps.vault.outputs.DB_PASSWORD }}
APP_KEY: ${{ steps.vault.outputs.APP_KEY }}
REDIS_PASSWORD: ${{ steps.vault.outputs.REDIS_PASSWORD }}
run: php artisan migrate --force
- name: Deploy application
# ... rsync, kubectl apply, etc.
run: echo "Deploying..."
Al finalizar el job, Vault revoca automáticamente las credenciales dinámicas de PostgreSQL. El usuario temporal deja de existir en la base de datos.
Ejemplo práctico: Laravel obtiene secretos desde Vault durante el despliegue
Veamos cómo configurar una aplicación Laravel para trabajar con secretos de Vault en un entorno de producción. Para el acceso en runtime a Vault, es conveniente usar el paquete vault-php o realizar solicitudes directas a la HTTP API.
Estructura de secretos en Vault KV v2
# Escribimos los secretos estáticos de la aplicación
vault kv put secret/myapp/production \
app_key="base64:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX=" \
redis_password="sup3r-s3cur3-r3d1s-p4ss"
# Verificamos
vault kv get secret/myapp/production
Configuración de Laravel: config/database.php
En el pipeline de CI/CD, los secretos ya han sido inyectados como variables de entorno por el paso vault-action. Laravel los lee de forma estándar mediante env():
// config/database.php
'pgsql' => [
'driver' => 'pgsql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'myapp'),
'username' => env('DB_USERNAME'), // usuario dinámico desde Vault
'password' => env('DB_PASSWORD'), // contraseña dinámica desde Vault
'charset' => 'utf8',
'sslmode' => env('DB_SSLMODE', 'require'), // siempre TLS en prod
],
Configuración de Redis
// config/database.php — sección redis
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'default' => [
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD'), // desde Vault KV
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_DB', 0),
],
],
Bootstrap en runtime: Vault SDK para PHP
Si la aplicación necesita acceder a Vault en runtime (por ejemplo, para cifrar datos mediante el motor Transit), utiliza el paquete:
composer require vault-php/vault-php
client = new Client(
new \GuzzleHttp\Client(['base_uri' => config('vault.address')])
);
// AppRole auth para acceso en runtime (no OIDC, ya que no hay contexto de GitHub)
$this->client->setAuthenticationStrategy(
new AppRoleAuthenticationStrategy(
config('vault.role_id'),
config('vault.secret_id')
)
);
$this->client->authenticate();
}
public function getSecret(string $path): array
{
$response = $this->client->read($path);
return $response->getData()['data'] ?? [];
}
}
Rotación automática de secretos de PostgreSQL mediante Vault Database Secrets Engine
El Database Secrets Engine es una de las funcionalidades más potentes de Vault. En lugar de un único usuario de aplicación en PostgreSQL, Vault crea un usuario temporal con credenciales únicas para cada solicitud.
Configuración del Database Secrets Engine
# Habilitamos el motor
vault secrets enable database
# Configuramos la conexión a PostgreSQL
vault write database/config/myapp-postgres \
plugin_name=postgresql-database-plugin \
allowed_roles="myapp-deploy-role,myapp-app-role" \
connection_url="postgresql://{{username}}:{{password}}@db.internal.example.com:5432/myapp_prod?sslmode=require" \
username="vault_root_user" \
password="vault_root_password"
# Creamos el rol para despliegue (TTL corto — solo para migraciones)
vault write database/roles/myapp-deploy-role \
db_name=myapp-postgres \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
GRANT ALL PRIVILEGES ON DATABASE myapp_prod TO \"{{name}}\"; \
GRANT ALL ON SCHEMA public TO \"{{name}}\";" \
revocation_statements="DROP ROLE IF EXISTS \"{{name}}\";" \
default_ttl="15m" \
max_ttl="30m"
# Creamos el rol para la aplicación (TTL más largo, permisos limitados)
vault write database/roles/myapp-app-role \
db_name=myapp-postgres \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO \"{{name}}\"; \
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO \"{{name}}\";" \
revocation_statements="DROP ROLE IF EXISTS \"{{name}}\";" \
default_ttl="1h" \
max_ttl="4h"
Prueba de la emisión dinámica
# Solicitamos credenciales dinámicas
vault read database/creds/myapp-deploy-role
# Respuesta:
# Key Value
# --- -----
# lease_id database/creds/myapp-deploy-role/AbCdEf123...
# lease_duration 15m
# lease_renewable true
# password A1b2C3d4E5f6-unique-per-request
# username v-github-myapp-QrStUv
Cada solicitud de credenciales devuelve un par único de usuario y contraseña. Tras expirar el lease_duration, el usuario se elimina automáticamente de PostgreSQL mediante el revocation statement.
Monitorización y auditoría: quién y cuándo solicitó secretos
Vault soporta varios tipos de audit devices. Habilitar el audit log es un paso obligatorio en entornos de producción.
Configuración del File Audit Device
# Habilitamos el registro de auditoría en un archivo
vault audit enable file file_path=/var/log/vault/audit.log
# Verificamos
vault audit list
Cada operación queda registrada en formato JSON: hora, método de autenticación, ruta, resultado (success/deny) y dirección IP del cliente. Ejemplo de entrada:
{
"time": "2024-11-15T14:23:01.123Z",
"type": "response",
"auth": {
"client_token": "hmac-sha256:...",
"accessor": "hmac-sha256:...",
"display_name": "jwt-github-actions",
"policies": ["default", "github-actions-deploy"],
"metadata": {
"actor": "john-doe",
"repository": "your-org/your-repo",
"workflow": "Deploy Laravel to Production"
}
},
"request": {
"operation": "read",
"path": "database/creds/myapp-deploy-role"
},
"response": {
"data": {"username": "hmac-sha256:...", "password": "hmac-sha256:..."}
}
}
Importante: Vault nunca escribe los secretos en el audit log en texto plano, solo como HMAC. Pero los metadatos (quién, qué y cuándo se solicitó) sí quedan registrados completamente. Esto permite integrar los logs con sistemas SIEM (Splunk, Elasticsearch) para alertas sobre anomalías.
Métricas mediante Prometheus
Vault exporta métricas en formato Prometheus en la ruta /v1/sys/metrics. Las métricas clave para monitorizar son:
vault_core_active— actividad del nodovault_secret_kv_count— número de secretosvault_token_count— número de tokens activosvault_audit_log_response_failure— fallos de auditoría (si el audit device no está disponible, Vault bloquea las solicitudes)
Errores comunes y buenas prácticas
Errores más frecuentes
- Almacenar VAULT_TOKEN en GitHub Secrets. Esto nos devuelve al punto de partida de los secretos estáticos. Usa OIDC.
- Políticas demasiado amplias. Una política con
path "*" { capabilities = ["read"] }en producción es una vulnerabilidad grave. Principio de mínimo privilegio: cada job accede solo a las rutas que necesita. - Ignorar max_ttl. Sin un límite de
max_ttl, la aplicación puede renovar el lease indefinidamente, convirtiendo un secreto dinámico en uno efectivamente estático. - Vault sin HA en producción. Un Vault de nodo único es un punto único de fallo. Usa Raft Integrated Storage con al menos tres nodos o un backend Consul.
- Audit log deshabilitado. Si Vault se ejecuta sin un audit device, pierdes todo el rastro de accesos. Esto es crítico para el cumplimiento normativo (SOC 2, PCI DSS).
- Imágenes Docker con secretos en las capas. No pases secretos mediante
ARGen el Dockerfile; quedan almacenados en el historial de la imagen. Pásalos a través del entorno en tiempo de ejecución.
Buenas prácticas
- Usa GitHub Environments con revisores obligatorios para los despliegues a producción. El claim OIDC
subincluye el nombre del entorno: úsalo para vincular los roles de Vault. - Configura Vault Agent Sidecar para despliegues en Kubernetes: el agente actualiza automáticamente los secrets en el sistema de archivos del pod y gestiona el lease.
- Versiona los secretos con KV v2. En caso de incidente, podrás revertir a una versión anterior y consultar el historial de cambios.
- Separa los secretos por entorno:
secret/myapp/staging/*,secret/myapp/production/*. Diferentes políticas, diferentes roles. - En Laravel, usa config:cache con precaución: la configuración cacheada fija los valores de los secretos en el momento del cacheo. Al rotar, es necesario invalidar el caché.
- Ejecuta regularmente
vault operator key-statusy rota las claves de cifrado de Vault (encryption key rotation).
Conclusión
Un despliegue seguro de aplicaciones PHP en 2024 es imposible sin una gestión centralizada de secretos. La combinación de HashiCorp Vault + GitHub Actions OIDC + Laravel te proporciona:
- Cero tokens estáticos en CI/CD: cada job se autentica mediante un JWT de corta duración.
- Credenciales dinámicas para PostgreSQL y otras bases de datos: la exposición de un secreto está acotada por el tiempo de lease.
- Un rastro de auditoría completo: sabes exactamente qué workflow, de qué usuario de GitHub, solicitó qué secreto y cuándo.
- Rotación automática sin downtime: Vault crea un nuevo usuario antes de revocar el anterior.
Implementar este esquema requiere una inversión inicial en la infraestructura de Vault (clúster HA, configuración de políticas, integración con sistemas existentes), pero se amortiza rápidamente gracias a la reducción de riesgos y la simplificación operativa. La rotación de contraseñas de PostgreSQL, que antes requería coordinación entre equipos y arriesgados periodos de downtime, se convierte en un proceso automático en segundo plano.
Empieza con algo pequeño: despliega Vault en Docker para pruebas locales, migra un secreto no crítico mediante OIDC en el pipeline de staging y verifica que los logs de auditoría sean correctos. Después, escala el enfoque a todos los entornos y servicios.
Tecnologías
Etiquetas
Ruslan Ismailov
Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →