Ir al contenido

Estándares de Configuración y Variables de Entorno

La configuración — y en particular las variables de entorno — son el contrato invisible entre el código y el entorno donde corre. Un nombre mal elegido no rompe nada el día que se escribe, pero genera deuda operacional compuesta: bugs latentes de shell, secretos filtrados en git, credenciales imposibles de rotar, manifiestos inmantenibles, auditorías fallidas. Este documento define cómo HERA nombra, agrupa, almacena y consume configuración a lo largo de cinco capas coordinadas — desde el proceso dentro del pod hasta el secreto global en GCP Secret Manager.

La regla de oro del estándar es Context-Aware Naming: cuanto más arriba en la jerarquía de scope vive el recurso, más largo y calificado debe ser su nombre; cuanto más abajo (más cerca del código), más corto y limpio, porque el contexto ya está implícito en dónde vive.


Si estás revisando un Merge Request que toca variables de entorno, manifiestos K8s o .env.*, verifica estos 12 puntos antes de aprobar. Es la destilación del estándar — todo lo demás en esta página es contexto, justificación y ejemplos. El checklist completo con casillas marcables está al final.

ReglaQué verificarImpacto si se omite
R1Toda env var en SCREAMING_SNAKE_CASE estricto, solo [A-Z0-9_]Rompe parsers, valida POSIX
R2Sin guiones - en nombres de env varsecho $VAR-NAME falla en bash
R3Longitud máxima de 63 caracteres por nombreNo cabe en labels K8s
R4Ningún secreto en value: — todos via valueFrom.secretKeyRef con External Secrets OperatorCredenciales filtradas en git
R5Todo nombre termina en un sufijo de la lista cerrada (18 sufijos: _URL, _HOST, _PORT, _PASSWORD, _SECRET, etc.)Tipo ambiguo, impredecible
R6Booleanos literal true / false (lowercase)Ambiguo con enteros o yes/no
R7Duraciones con unidad en el nombre: _TIMEOUT_MS, _TTL_SECONDS, _INTERVAL_MINUTESBug clásico “¿ms o segundos?”
R8Listas con separador , (coma), nunca ;CORS y parsers estándar usan coma
R9URLs con esquema https:// y sin trailing slashEvita // al concatenar path
R10Inter-servicio: nombrada por el servicio destino (USER_SERVICE_URL), no por el consumidorClaridad del contrato
R11Env vars agrupadas con el orden canónico (Runtime → Database → Cache → Messaging → Storage → Auth → External APIs → Inter-service → Observability → Feature Flags → CORS / Security)Legibilidad + review + diffing
R12Sin prefijos de framework (NEXT_PUBLIC_*, VITE_*) fuera de su scopeMezcla client/server concerns

El estándar se construye sobre tres cuerpos de conocimiento externos y la taxonomía HERA definida en el portal:

FundamentoOrigenAportación al estándar
12-Factor App — Factor III (Config)12factor.net/configLa configuración vive en el entorno, no en el código. Un mismo binario funciona en DEV/QA/PRD sin recompilar
POSIX IEEE Std 1003.1 — Environment Variablespubs.opengroup.orgNombres válidos: [A-Z_][A-Z0-9_]*. Los guiones rompen bash, sh y scripts de CI/CD
OWASP ASVS 4.0 — Secrets Managementowasp.org/ASVSCero credenciales en código fuente ni en manifiestos versionados
Taxonomía HERAEstructura de Repositorios + Clasificación de ServiciosConvenciones propias del portalEl producto es la identidad permanente. El K8s namespace es el producto. El deployment es el repo

Los principios operativos que se derivan:

  1. Inmutabilidad del binario — el mismo artefacto (imagen Docker) se promueve de DEV a QA a PRD sin rebuild. Solo cambia qué GCP project sirve los secretos.
  2. Secretos fuera de git — ningún secreto (credencial, token, API key, clave criptográfica) vive en texto plano en un manifiesto versionado. Siempre se referencia vía valueFrom.secretKeyRef.
  3. Contexto implícito — el pod sabe en qué namespace vive y qué deployment es. Las env vars no repiten esa información.
  4. Trazabilidad total — dado cualquier nombre en cualquiera de las 5 capas, un ingeniero debe poder reconstruir las otras 4 sin documentación externa.
  5. Portabilidad — el manifiesto de un servicio es idéntico entre entornos. Las diferencias viven en la fuente de secretos (GCP project distinto por entorno).

Modelo Context-Aware Naming — 5 capas alineadas con la taxonomía HERA

Sección titulada «Modelo Context-Aware Naming — 5 capas alineadas con la taxonomía HERA»

Este estándar no inventa jerarquía: se apoya en la Estructura de Repositorios GitLab y la extiende hacia la capa de configuración y secretos. El producto sigue siendo la identidad permanente; el namespace K8s sigue siendo el producto; el deployment sigue siendo el repo. Las capas 0, 1 y 2 son datos observados de la infraestructura existente — las capas 3, 4 y 5 son lo que este estándar define.

CapaElementoScopeFormato canónicoDefinido por
0Jerarquía GitLab (source of truth)Global organizacionalgrupo-herdez/products/<dominio>/<producto>/<version>/<repo>Estructura de Repositorios
1GCP ProjectPor entorno (DEV/QA/PRD)ghdz-<tenant>-<capability>-<env>Convención pre-existente de Grupo Herdez
2aGKE ClusterPor entorno + capabilityNombre del cluster del GCP project del entornoInfraestructura existente
2bK8s NamespacePor producto (invariante entre entornos)<producto> — ej. tienda-herdez, sitio-donamaria, cms-corporativoProducto como Entidad Primaria
2cK8s DeploymentPor servicio dentro del producto<tipo>-<detalle> — ej. backend-checkout-service, frontend-web-store, bff-mobile-appClasificación de Servicios — una de las 13 categorías oficiales
3K8s SecretLocal al namespace<deployment>-<resource-type> — ej. backend-checkout-service-db-usersEste estándar
4GCP Secret ManagerGlobal en el GCP project<producto>-<deployment>-<resource-type>-<attribute> — kebab-caseEste estándar
5Pod Environment VariableLocal al proceso<DOMAIN>_<RESOURCE>_<ATTRIBUTE> en SCREAMING_SNAKE_CASEEste estándar (reglas R1–R12)

A partir de las capas fijas, las tres reglas de derivación son:

  • K8s namespace = nombre del producto — invariante entre entornos, versiones, partners y marcas. tienda-herdez en DEV es tienda-herdez en PRD. Cuando un producto migra de v1-partner-alpha a v2-partner-beta, el namespace no cambia: la URL, los certificados TLS, los ingress y los secretos permanecen intactos.
  • K8s deployment = nombre del repobackend-checkout-service en el repositorio se refleja 1:1 como deployment K8s en el cluster, sin transformaciones ni abreviaciones.
  • GSM secret prefix = <producto>-<deployment> — para unicidad global dentro del GCP project, que típicamente contiene N productos del mismo tenant/capability.
Trazabilidad End-to-End — Modelo Context-Aware Naming
Ejemplo siguiendo el servicio backend-checkout-service del producto tienda-herdez. Dado cualquier punto de la cadena, se reconstruyen los otros siete.
Capas fijas (infraestructura existente)
Capas definidas por este estándar
Capa 0
Jerarquía GitLab — Source of Truth
Global organizacional
grupo-herdez/products/ecommerce/tienda-herdez/v2-partner-beta/backend-checkout-service
Capa 1
GCP Project
Por entorno — DEV · QA · PRD
ghdz-grupo-ext-sites-<env>
ghdz-grupo-ext-sites-dev  ·  ghdz-grupo-ext-sites-qa  ·  ghdz-grupo-ext-sites-prd
Capa 2a
GKE Cluster
Cluster del GCP project del entorno
El estándar no depende del nombre específico del cluster.
Capa 2b
K8s Namespace = nombre del producto
Invariante entre entornos, versiones y partners
tienda-herdez
Capa 2c
K8s Deployment = nombre del repo
Formato <tipo>-<detalle> — una de las 13 categorías oficiales
backend-checkout-service
↓ aquí comienza lo que define este estándar
Capa 3
K8s Secret
Local al namespace · formato <deployment>-<resource-type>
backend-checkout-service-db-users
Contiene host, port, username, password, name como keys del Secret
↓ ExternalSecret sincroniza desde GCP Secret Manager
Capa 4
GCP Secret Manager
Global en el GCP project · formato <producto>-<deployment>-<resource>-<atributo>
tienda-herdez-backend-checkout-service-db-users-password
Y una entrada por cada atributo: -host, -port, -username, -name
↓ secretKeyRef en el Deployment
Capa 5
Pod Environment Variable
Local al proceso · SCREAMING_SNAKE_CASE
DB_USERS_PASSWORD
En el código: process.env.DB_USERS_PASSWORD
Mensaje clave Las capas 0 a 2c son datos de la infraestructura existente — el estándar no las redefine. Las capas 3, 4 y 5 son lo que el estándar de configuración HERA define y propaga. Un SRE que ve rotarse el secreto en la capa 4 sabe inmediatamente qué producto, deployment, recurso y variable afecta.

Dado cualquier punto de la cadena, se reconstruyen los otros siete:

  • Un SRE que ve rotarse tienda-herdez-backend-checkout-service-db-users-password en GCP Secret Manager sabe inmediatamente que afecta al producto tienda-herdez, al deployment backend-checkout-service, a la base de datos lógica users, y al pod environment DB_USERS_PASSWORD.
  • Un desarrollador que ve DB_USERS_PASSWORD en el código sabe que viene del K8s Secret del deployment en el namespace del producto, que a su vez se sincroniza desde GCP Secret Manager del GCP project del entorno actual.

Schema de Variables de Entorno — Gramática

Sección titulada «Schema de Variables de Entorno — Gramática»

La gramática general de un nombre de variable de entorno es:

<DOMAIN>_<RESOURCE>_<ATTRIBUTE>
│ │ │
│ │ └─ Qué atributo específico: HOST, PORT, PASSWORD, CLIENT_ID, ...
│ └──────────── Qué recurso concreto: USERS, GOOGLE, PAYMENTS, CHECKOUT, ...
└─────────────────────── Qué dominio funcional: DB, JWT, OAUTH, MSG, BUCKET, SMTP, OBS, FEATURE, ...
DominioRecursoAtributoVariable resultante
DBUSERSHOSTDB_USERS_HOST
DBUSERSPASSWORDDB_USERS_PASSWORD
DBCHECKOUTHOSTDB_CHECKOUT_HOST
JWT(omitido — único)ISSUERJWT_ISSUER
JWT(omitido — único)SECRETJWT_SECRET
OAUTHGOOGLECLIENT_IDOAUTH_GOOGLE_CLIENT_ID
OAUTHGOOGLECLIENT_SECRETOAUTH_GOOGLE_CLIENT_SECRET
OAUTHGOOGLEREDIRECT_URIOAUTH_GOOGLE_REDIRECT_URI
OAUTHFACEBOOKAPP_IDOAUTH_FACEBOOK_APP_ID
MSGPUBSUBPROJECT_IDMSG_PUBSUB_PROJECT_ID
MSGPUBSUBTOPIC_MAILMSG_PUBSUB_TOPIC_MAIL
BUCKETUPLOADSNAMEBUCKET_UPLOADS_NAME
SMTP(omitido — único)HOSTSMTP_HOST
SMTP(omitido — único)USERNAMESMTP_USERNAME
FEATURENEW_CHECKOUTENABLEDFEATURE_NEW_CHECKOUT_ENABLED
(implícito)USER_SERVICEURLUSER_SERVICE_URL
(implícito)CATALOG_SERVICEURLCATALOG_SERVICE_URL

Las siguientes reglas son mandatorias y se verifican en code review. El incumplimiento bloquea el merge.

✅ DB_USERS_PASSWORD
✅ OAUTH_GOOGLE_CLIENT_ID
✅ FEATURE_NEW_CHECKOUT_ENABLED
❌ db_users_password (minúsculas)
❌ DbUsersPassword (camelCase)
❌ DB-USERS-PASSWORD (guiones — ver R2)

Solo se permiten caracteres [A-Z0-9_]. El primer carácter debe ser letra o underscore.

Los guiones rompen la expansión de variables en bash, sh, y en cualquier script POSIX:

Ventana de terminal
# Con RECIPE-JWT-SECRET="abc123" exportado, esto falla:
echo $RECIPE-JWT-SECRET
# Bash lo interpreta como: echo ${RECIPE}-JWT-SECRET → imprime "-JWT-SECRET"

Node.js y otros lenguajes de aplicación toleran guiones via process.env['RECIPE-JWT-SECRET'], pero los scripts de CI/CD, entrypoints de Docker y ConfigMaps de K8s que usen expansión de shell fallan silenciosamente. El estándar elimina el riesgo por construcción.

63 caracteres es el límite de un label DNS de K8s (RFC 1123). Aunque las env vars pueden ser más largas, mantenerlas bajo este límite garantiza que siempre se puedan usar como labels en métricas, dashboards y consultas.

✅ TIENDA_HERDEZ_BACKEND_CHECKOUT_SERVICE_DB_USERS_PASSWORD (56 caracteres — válido)
❌ TIENDA_HERDEZ_BACKEND_CHECKOUT_SERVICE_DB_USERS_PASSWORD_LONG (60 — prefiere refactorizar)

Estrategia de mitigación: si un nombre excede 63, probablemente viola la regla de oro (repite contexto que ya vive en el namespace/deployment). Acortar eliminando el prefijo de producto.

Los secretos (credenciales, tokens, API keys, claves criptográficas, certificados) se prohíben en manifiestos versionados. Se referencian desde K8s Secrets gestionados por External Secrets Operator que sincroniza desde GCP Secret Manager.

# ❌ PROHIBIDO
env:
- name: DB_USERS_PASSWORD
value: "MiP4ssw0rd!"
# ✅ CORRECTO — referencia a K8s Secret sincronizado desde GSM
env:
- name: DB_USERS_PASSWORD
valueFrom:
secretKeyRef:
name: backend-checkout-service-db-users
key: password

Ver la sección Patrón ExternalSecret CRD más abajo para la cadena completa GSM → K8s Secret → env var.

R5 — Sufijo obligatorio por tipo (lista cerrada)

Sección titulada «R5 — Sufijo obligatorio por tipo (lista cerrada)»

Todo nombre termina en uno de los 18 sufijos estándar, que comunican inequívocamente el tipo del valor:

SufijoTipo del valorEjemplo
_URLURL completa con esquemaUSER_SERVICE_URL=https://users.cloudherdez.com
_HOSTHostname o IP (sin puerto)DB_USERS_HOST=10.95.128.78
_PORTPuerto numéricoDB_USERS_PORT=5432
_NAMEIdentificador lógicoDB_USERS_NAME=db_users_prd
_USERNAMEUsuario de autenticación (nunca _USER)DB_USERS_USERNAME=svc_users
_PASSWORDContraseña (nunca _PASS, _PWD)DB_USERS_PASSWORD (secretKeyRef)
_SECRETClave criptográfica o de firmaJWT_SECRET (secretKeyRef)
_API_KEYAPI key de servicio externoPAYMENTS_API_KEY (secretKeyRef)
_CLIENT_IDOAuth/OIDC client id públicoOAUTH_GOOGLE_CLIENT_ID
_CLIENT_SECRETOAuth/OIDC client secretOAUTH_GOOGLE_CLIENT_SECRET (secretKeyRef)
_TOKENToken estático (webhook, service)SLACK_WEBHOOK_TOKEN (secretKeyRef)
_ENABLEDBoolean feature flagFEATURE_NEW_CHECKOUT_ENABLED=true
_TIMEOUT_MS / _TIMEOUT_SECONDSDuración con unidadDB_USERS_TIMEOUT_MS=5000
_TTL_SECONDS / _TTL_MINUTES / _TTL_HOURSTime-to-liveCACHE_SESSION_TTL_MINUTES=30
_MAX_RETRIES / _RETRY_COUNTContador de reintentosHTTP_MAX_RETRIES=3
_BATCH_SIZECardinalidad de loteMSG_PUBSUB_BATCH_SIZE=100
_REGIONRegión cloudBUCKET_UPLOADS_REGION=us-central1
_PROJECT_IDGCP project idMSG_PUBSUB_PROJECT_ID=ghdz-grupo-ext-sites-prd
✅ FEATURE_NEW_CHECKOUT_ENABLED=true
✅ OBS_TRACING_ENABLED=false
❌ FEATURE_NEW_CHECKOUT_ENABLED=1 (ambiguo — ¿entero?)
❌ FEATURE_NEW_CHECKOUT_ENABLED=yes (variante regional)
❌ FEATURE_NEW_CHECKOUT_ENABLED=True (casing incorrecto)

true y false en minúsculas coinciden con la representación nativa de YAML, JSON, Kubernetes y la mayoría de lenguajes. No hay ambigüedad de parsing.

R7 — Duraciones con unidad explícita en el nombre

Sección titulada «R7 — Duraciones con unidad explícita en el nombre»

El error recurrente “¿eran milisegundos o segundos?” se elimina incluyendo la unidad en el nombre de la variable:

✅ DB_USERS_TIMEOUT_MS=5000
✅ CACHE_SESSION_TTL_MINUTES=30
✅ JOB_CLEANUP_INTERVAL_SECONDS=300
❌ DB_TIMEOUT=5000 (¿ms o segundos?)
❌ CACHE_TTL=1800 (¿minutos? ¿segundos?)

Unidades permitidas en nombres: _MS, _SECONDS, _MINUTES, _HOURS, _DAYS.

✅ CORS_ALLOWED_ORIGINS=https://a.com,https://b.com,https://c.com
❌ CORS_ALLOWED_ORIGINS=https://a.com;https://b.com;https://c.com
❌ CORS_ALLOWED_ORIGINS=https://a.com https://b.com

La coma es el separador estándar de listas en HTTP (CORS, Accept, Allow), en CSV, en la mayoría de parsers de configuración. ; es usado por sistemas legacy y no es portable.

Si un valor individual contiene una coma, se usa encoding URL o un formato de configuración estructurado (YAML/JSON), no una cadena delimitada.

R9 — URLs completas con esquema, sin trailing slash

Sección titulada «R9 — URLs completas con esquema, sin trailing slash»
✅ USER_SERVICE_URL=https://users.cloudherdez.com
✅ PAYMENTS_API_URL=https://api.stripe.com/v1
❌ USER_SERVICE_URL=users.cloudherdez.com (sin esquema)
❌ USER_SERVICE_URL=https://users.cloudherdez.com/ (trailing slash — causa doble slash al concatenar)

El trailing slash produce URLs con doble slash como https://host.com//api/v1/users cuando el código concatena la URL base con un path que empieza en /. Estandarizar sin trailing slash y obligar al código a anteponer / al path elimina la ambigüedad.

R10 — Inter-servicio: nombrar por el servicio destino

Sección titulada «R10 — Inter-servicio: nombrar por el servicio destino»

Cuando un servicio consume a otro, la variable lleva el nombre del servicio destino, no del consumidor:

✅ USER_SERVICE_URL (apunta al User Service — consumido desde cualquier servicio)
✅ CATALOG_SERVICE_URL
✅ PAYMENTS_SERVICE_URL
❌ MY_USER_URL (¿"mi" qué?)
❌ DEPENDENCIES_USER (anticuado, prefijo vacío de significado)
❌ EXTERNAL_URL (no dice qué servicio)

Si dos servicios externos tienen nombres similares, se usa el prefijo de capability del GCP project: APIHUB_ORDERS_SERVICE_URL vs INTEGRATIONHUB_ORDERS_SERVICE_URL.

R11 — Secciones de agrupación en manifiestos con orden fijo

Sección titulada «R11 — Secciones de agrupación en manifiestos con orden fijo»

Los manifiestos K8s agrupan variables en bloques comentados con un orden canónico. Facilita code review, diffing entre entornos y detección de faltantes:

env:
# ── Runtime ──────────────────────────────────────────────────────
- name: NODE_ENV
value: "production"
- name: TZ
value: "America/Mexico_City"
- name: PORT
value: "3000"
# ── Database ─────────────────────────────────────────────────────
- name: DB_USERS_HOST
valueFrom:
secretKeyRef: { name: backend-checkout-service-db-users, key: host }
- name: DB_USERS_PORT
valueFrom:
secretKeyRef: { name: backend-checkout-service-db-users, key: port }
- name: DB_USERS_USERNAME
valueFrom:
secretKeyRef: { name: backend-checkout-service-db-users, key: username }
- name: DB_USERS_PASSWORD
valueFrom:
secretKeyRef: { name: backend-checkout-service-db-users, key: password }
- name: DB_USERS_NAME
valueFrom:
secretKeyRef: { name: backend-checkout-service-db-users, key: name }
# ── Cache ────────────────────────────────────────────────────────
- name: CACHE_SESSION_HOST
value: "redis.tienda-herdez.svc.cluster.local"
- name: CACHE_SESSION_PORT
value: "6379"
- name: CACHE_SESSION_TTL_MINUTES
value: "30"
# ── Messaging ────────────────────────────────────────────────────
- name: MSG_PUBSUB_PROJECT_ID
value: "ghdz-grupo-ext-sites-prd"
- name: MSG_PUBSUB_TOPIC_ORDERS
value: "orders-events"
# ── Storage ──────────────────────────────────────────────────────
- name: BUCKET_UPLOADS_NAME
value: "ghdz-tienda-herdez-uploads-prd"
- name: BUCKET_UPLOADS_REGION
value: "us-central1"
# ── Auth / Identity ──────────────────────────────────────────────
- name: JWT_ISSUER
value: "https://auth.cloudherdez.com"
- name: JWT_AUDIENCE
value: "tienda.clientes"
- name: JWT_SECRET
valueFrom:
secretKeyRef: { name: backend-checkout-service-jwt, key: secret }
- name: OAUTH_GOOGLE_CLIENT_ID
value: "70096587311-xyz.apps.googleusercontent.com"
- name: OAUTH_GOOGLE_CLIENT_SECRET
valueFrom:
secretKeyRef: { name: backend-checkout-service-oauth-google, key: client-secret }
- name: OAUTH_GOOGLE_REDIRECT_URI
value: "https://tienda.herdez.com/api/auth/google/callback"
# ── External APIs ────────────────────────────────────────────────
- name: PAYMENTS_SERVICE_URL
value: "https://api.stripe.com/v1"
- name: PAYMENTS_API_KEY
valueFrom:
secretKeyRef: { name: backend-checkout-service-payments, key: api-key }
# ── Inter-service Communication ──────────────────────────────────
- name: USER_SERVICE_URL
value: "http://platform-auth-service.platform.svc.cluster.local"
- name: CATALOG_SERVICE_URL
value: "http://backend-catalog-service.tienda-herdez.svc.cluster.local"
# ── Observability ────────────────────────────────────────────────
- name: OBS_LOG_LEVEL
value: "info"
- name: OBS_TRACING_ENABLED
value: "true"
- name: OBS_METRICS_PORT
value: "9090"
# ── Feature Flags ────────────────────────────────────────────────
- name: FEATURE_NEW_CHECKOUT_ENABLED
value: "true"
- name: FEATURE_LEGACY_PAYMENTS_ENABLED
value: "false"
# ── CORS / Security ──────────────────────────────────────────────
- name: CORS_ALLOWED_ORIGINS
value: "https://tienda.herdez.com,https://tienda.dev.hera.cloudherdez.com"

Orden canónico: Runtime → Database → Cache → Messaging → Storage → Auth / Identity → External APIs → Inter-service → Observability → Feature Flags → CORS / Security.

R12 — Sin prefijos de framework en servicios que no son de ese framework

Sección titulada «R12 — Sin prefijos de framework en servicios que no son de ese framework»

Los prefijos específicos de framework solo se usan dentro de ese framework:

PrefijoPermitido solo enRazón
NEXT_PUBLIC_*Servicios Next.jsNext.js inyecta estas variables al bundle cliente. Usarlas en backends confunde el origen
VITE_*Servicios ViteVite expone estas variables al cliente. Igual que Next
REACT_APP_*Create React AppLegacy CRA; no usar en servicios que no son CRA
ASTRO_*Servicios AstroAstro las reserva

Si un servicio backend consume un frontend que usa NEXT_PUBLIC_API_URL, el backend no debe replicar la variable con el mismo prefijo. El backend tiene su propia variable PUBLIC_API_URL o, mejor, no necesita conocer esa URL en absoluto (el frontend le hace las requests).


Kubernetes ofrece tres mecanismos para inyectar configuración a un pod. No son intercambiables — cada uno tiene un propósito distinto y elegir el incorrecto degrada la observabilidad, la seguridad o la mantenibilidad del servicio. El siguiente árbol de decisión es mandatorio:

Criterioenv var directa (en el Deployment)ConfigMapK8s Secret (+ ExternalSecret)
SensibilidadPública o interna (no sensible)Pública o internaSensible (credenciales, tokens, PII)
Tamaño del valorPequeño (hasta ~1 KB)Pequeño o mediano (hasta ~1 MB)Pequeño a mediano
Frecuencia de cambioEstable por entornoOcasionalOcasional (rotación periódica)
Consumo multi-servicioNo (por servicio) (un ConfigMap para N deployments) (un Secret para N deployments del mismo namespace)
Forma de montajeenv:envFrom: o volumeMountsvalueFrom.secretKeyRef o volumeMounts
Dónde vive el valorEn el manifiesto del DeploymentEn un objeto ConfigMap del namespaceEn GCP Secret Manager (via ExternalSecret)
VersionadoGit (manifiesto)Git (manifiesto del ConfigMap)GSM (versiones nativas del secreto)
RotaciónRequiere redeploy del manifiestoRequiere apply + pod restartAutomática via ESO + pod restart

env var directa en el Deployment — usa esto para configuración específica del servicio que:

  • NO es sensible
  • Es relativamente estable pero puede variar entre entornos (DEV/QA/PRD)
  • No se comparte con otros deployments
env:
- name: OBS_LOG_LEVEL
value: "info"
- name: HTTP_TIMEOUT_MS
value: "5000"
- name: FEATURE_NEW_CHECKOUT_ENABLED
value: "true"

ConfigMap — usa esto cuando la configuración:

  • NO es sensible
  • Se comparte entre múltiples deployments del mismo namespace (ej. configuración de logging común a todo el producto)
  • Es grande o estructurada (archivos de configuración YAML/JSON/properties)
  • Cambia ocasionalmente sin requerir un redeploy del manifiesto del servicio
# ConfigMap compartido en el namespace tienda-herdez
apiVersion: v1
kind: ConfigMap
metadata:
name: shared-observability-config
namespace: tienda-herdez
data:
log-level: "info"
tracing-sampling-rate: "0.1"
metrics-port: "9090"
---
# Deployment que lo consume
spec:
containers:
- name: app
envFrom:
- configMapRef:
name: shared-observability-config

K8s Secret + ExternalSecret — usa esto para cualquier valor sensible:

  • Credenciales (DB, SMTP, API keys de terceros)
  • Tokens de firma (JWT secrets, webhook secrets)
  • Certificados TLS gestionados manualmente
  • Cualquier cosa que requiera auditoría de acceso

Ver las secciones siguientes (Naming en GCP Secret Manager y Patrón ExternalSecret CRD) para el detalle operacional.


Los nombres de secretos en GCP Secret Manager (GSM) son globalmente únicos dentro del GCP project. Como el project contiene N productos del mismo tenant/capability, el prefijo completo es obligatorio para evitar colisiones.

<producto>-<deployment>-<resource-type>-<attribute>
  1. Kebab-case obligatorio — GSM solo acepta [a-z0-9-]. El underscore y las mayúsculas no están permitidos.
  2. Producto y deployment son los nombres exactos del K8s namespace y del K8s deployment respectivamente (ambos derivados de la taxonomía HERA).
  3. Resource-type agrupa secretos relacionados: db-<lógico>, jwt, oauth-<provider>, payments, smtp, etc.
  4. Attribute es el campo concreto: password, secret, client-secret, api-key, token, private-key.
  5. Longitud máxima 255 caracteres (límite de GSM), pero se recomienda no superar 100 para legibilidad.
tienda-herdez-backend-checkout-service-db-users-host
tienda-herdez-backend-checkout-service-db-users-port
tienda-herdez-backend-checkout-service-db-users-username
tienda-herdez-backend-checkout-service-db-users-password
tienda-herdez-backend-checkout-service-db-users-name
tienda-herdez-backend-checkout-service-jwt-secret
tienda-herdez-backend-checkout-service-payments-api-key
tienda-herdez-bff-mobile-app-oauth-google-client-secret
tienda-herdez-bff-mobile-app-oauth-facebook-app-secret
sitio-donamaria-backend-forms-service-smtp-password
sitio-barcel-frontend-web-store-cdn-api-key
cms-corporativo-backend-strapi-admin-jwt-secret

Cada entorno (DEV/QA/PRD) vive en un GCP project separado. Los nombres de los secretos no llevan prefijo de entorno porque el entorno es implícito en el GCP project:

ghdz-grupo-ext-sites-dev → tienda-herdez-backend-checkout-service-db-users-password
ghdz-grupo-ext-sites-qa → tienda-herdez-backend-checkout-service-db-users-password
ghdz-grupo-ext-sites-prd → tienda-herdez-backend-checkout-service-db-users-password

Mismo nombre lógico, valor distinto por entorno. El manifiesto K8s es idéntico entre entornos — cambia solo qué GCP project apunta el ClusterSecretStore.


External Secrets Operator (ESO) es el mecanismo oficial HERA para sincronizar secretos de GCP Secret Manager a Kubernetes Secrets. Un ClusterSecretStore define la conexión al GCP project del entorno; los ExternalSecret declaran qué secretos sincronizar y cómo mapearlos.

Configuración del ClusterSecretStore (una vez por cluster)

Sección titulada «Configuración del ClusterSecretStore (una vez por cluster)»
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: gcp-secret-manager
spec:
provider:
gcpsm:
# Project que corresponde al entorno actual del cluster
projectID: ghdz-grupo-ext-sites-prd
auth:
workloadIdentity:
clusterLocation: us-central1
clusterName: <cluster-name>
serviceAccountRef:
name: external-secrets-sa
namespace: external-secrets

Un ExternalSecret sincroniza un único K8s Secret. Si un deployment necesita acceder a DB + JWT + OAuth Google, crea tres ExternalSecret:

# 1. Credenciales de base de datos "users"
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: backend-checkout-service-db-users
namespace: tienda-herdez
spec:
refreshInterval: 1h
secretStoreRef:
name: gcp-secret-manager
kind: ClusterSecretStore
target:
name: backend-checkout-service-db-users # ← K8s Secret resultante
creationPolicy: Owner
data:
- secretKey: host
remoteRef:
key: tienda-herdez-backend-checkout-service-db-users-host
- secretKey: port
remoteRef:
key: tienda-herdez-backend-checkout-service-db-users-port
- secretKey: username
remoteRef:
key: tienda-herdez-backend-checkout-service-db-users-username
- secretKey: password
remoteRef:
key: tienda-herdez-backend-checkout-service-db-users-password
- secretKey: name
remoteRef:
key: tienda-herdez-backend-checkout-service-db-users-name
---
# 2. Firma JWT del servicio
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: backend-checkout-service-jwt
namespace: tienda-herdez
spec:
refreshInterval: 1h
secretStoreRef:
name: gcp-secret-manager
kind: ClusterSecretStore
target:
name: backend-checkout-service-jwt
creationPolicy: Owner
data:
- secretKey: secret
remoteRef:
key: tienda-herdez-backend-checkout-service-jwt-secret
---
# 3. OAuth Google (client secret sensible, client ID público)
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: backend-checkout-service-oauth-google
namespace: tienda-herdez
spec:
refreshInterval: 1h
secretStoreRef:
name: gcp-secret-manager
kind: ClusterSecretStore
target:
name: backend-checkout-service-oauth-google
creationPolicy: Owner
data:
- secretKey: client-secret
remoteRef:
key: tienda-herdez-backend-checkout-service-oauth-google-client-secret

El Deployment referencia los K8s Secrets creados por ESO via valueFrom.secretKeyRef:

apiVersion: apps/v1
kind: Deployment
metadata:
name: backend-checkout-service
namespace: tienda-herdez
spec:
template:
spec:
containers:
- name: app
image: <artifact-registry>/backend-checkout-service:<sha>
env:
# ── Database ──
- name: DB_USERS_HOST
valueFrom:
secretKeyRef: { name: backend-checkout-service-db-users, key: host }
- name: DB_USERS_PORT
valueFrom:
secretKeyRef: { name: backend-checkout-service-db-users, key: port }
- name: DB_USERS_USERNAME
valueFrom:
secretKeyRef: { name: backend-checkout-service-db-users, key: username }
- name: DB_USERS_PASSWORD
valueFrom:
secretKeyRef: { name: backend-checkout-service-db-users, key: password }
- name: DB_USERS_NAME
valueFrom:
secretKeyRef: { name: backend-checkout-service-db-users, key: name }
# ── JWT ──
- name: JWT_SECRET
valueFrom:
secretKeyRef: { name: backend-checkout-service-jwt, key: secret }
- name: JWT_ISSUER
value: "https://auth.cloudherdez.com"
- name: JWT_AUDIENCE
value: "tienda.clientes"
# ── OAuth ──
- name: OAUTH_GOOGLE_CLIENT_ID
value: "70096587311-xyz.apps.googleusercontent.com"
- name: OAUTH_GOOGLE_CLIENT_SECRET
valueFrom:
secretKeyRef: { name: backend-checkout-service-oauth-google, key: client-secret }

Helm Charts — cómo se aplica el estándar

Sección titulada «Helm Charts — cómo se aplica el estándar»

HERA despliega sus servicios en GKE usando Helm charts. El estándar de env vars y secretos sigue siendo mandatorio dentro del chart — solo cambia dónde se parametriza cada capa: los valores estables por servicio viven en values.yaml, los valores que varían por entorno viven en values-<env>.yaml (overrides), y los secretos siguen siendo ExternalSecret referenciados desde el template del Deployment.

charts/backend-checkout-service/
├── Chart.yaml
├── values.yaml # Valores por defecto + configuración inter-entorno estable
├── values-dev.yaml # Overrides DEV (URLs, timeouts, feature flags)
├── values-qa.yaml # Overrides QA
├── values-prd.yaml # Overrides PRD
└── templates/
├── deployment.yaml # Consume .Values.env.* con el orden canónico R11
├── service.yaml
├── external-secret.yaml # ExternalSecret CRDs (se mantiene el naming GSM)
└── _helpers.tpl # Funciones reusables (labels, selectors)

Patrón en values.yaml — agrupación canónica R11

Sección titulada «Patrón en values.yaml — agrupación canónica R11»

Las env vars se estructuran por sección dentro de .Values.env, manteniendo el orden canónico del estándar. Esto hace que values-dev.yaml pueda sobrescribir solo las claves que cambian por entorno sin tocar el resto:

charts/backend-checkout-service/values.yaml
image:
repository: ghdz-grupo-ext-sites-prd/backend-checkout-service
tag: "" # sobrescrito por CI con el SHA del commit
env:
runtime:
NODE_ENV: "production"
TZ: "America/Mexico_City"
PORT: "3000"
database:
# Los valores no-sensibles viven aqui; los sensibles vienen del Secret (ver abajo)
DB_USERS_PORT: "5432"
DB_USERS_NAME: "db_users"
cache:
CACHE_SESSION_HOST: "redis.tienda-herdez.svc.cluster.local"
CACHE_SESSION_PORT: "6379"
CACHE_SESSION_TTL_MINUTES: "30"
messaging:
MSG_PUBSUB_PROJECT_ID: "ghdz-grupo-ext-sites-prd"
MSG_PUBSUB_TOPIC_ORDERS: "orders-events"
interService:
USER_SERVICE_URL: "http://platform-auth-service.platform.svc.cluster.local"
USER_SERVICE_TIMEOUT_MS: "3000"
USER_SERVICE_MAX_RETRIES: "3"
CATALOG_SERVICE_URL: "http://backend-catalog-service.tienda-herdez.svc.cluster.local"
CATALOG_SERVICE_TIMEOUT_MS: "2000"
observability:
OBS_LOG_LEVEL: "info"
OBS_TRACING_ENABLED: "true"
OBS_METRICS_PORT: "9090"
featureFlags:
FEATURE_NEW_CHECKOUT_ENABLED: "true"
FEATURE_LEGACY_PAYMENTS_ENABLED: "false"
# Nombres de los K8s Secrets que el ExternalSecret poblara.
# Referenciados desde templates/deployment.yaml y templates/external-secret.yaml
secrets:
dbUsers:
k8sSecretName: backend-checkout-service-db-users
gsmPrefix: tienda-herdez-backend-checkout-service-db-users
jwt:
k8sSecretName: backend-checkout-service-jwt
gsmPrefix: tienda-herdez-backend-checkout-service-jwt
oauthGoogle:
k8sSecretName: backend-checkout-service-oauth-google
gsmPrefix: tienda-herdez-backend-checkout-service-oauth-google
charts/backend-checkout-service/values-dev.yaml
image:
repository: ghdz-grupo-ext-sites-dev/backend-checkout-service
env:
runtime:
NODE_ENV: "development"
database:
DB_USERS_NAME: "db_users_dev"
messaging:
MSG_PUBSUB_PROJECT_ID: "ghdz-grupo-ext-sites-dev"
observability:
OBS_LOG_LEVEL: "debug"
OBS_TRACING_ENABLED: "false"
featureFlags:
FEATURE_NEW_CHECKOUT_ENABLED: "true"
FEATURE_LEGACY_PAYMENTS_ENABLED: "true" # permite validar el legacy en DEV

Template del Deployment — iterar los grupos

Sección titulada «Template del Deployment — iterar los grupos»

El template de deployment.yaml itera sobre los grupos de .Values.env preservando el orden canónico. El helper mezcla las env vars no-sensibles con las referencias a secrets:

charts/backend-checkout-service/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend-checkout-service
namespace: {{ .Release.Namespace }}
spec:
template:
spec:
containers:
- name: app
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
env:
{{- /* ── Runtime ── */ -}}
{{- range $k, $v := .Values.env.runtime }}
- name: {{ $k }}
value: {{ $v | quote }}
{{- end }}
{{- /* ── Database (no-sensibles + secretKeyRef) ── */ -}}
{{- range $k, $v := .Values.env.database }}
- name: {{ $k }}
value: {{ $v | quote }}
{{- end }}
- name: DB_USERS_HOST
valueFrom:
secretKeyRef:
name: {{ .Values.secrets.dbUsers.k8sSecretName }}
key: host
- name: DB_USERS_USERNAME
valueFrom:
secretKeyRef:
name: {{ .Values.secrets.dbUsers.k8sSecretName }}
key: username
- name: DB_USERS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.secrets.dbUsers.k8sSecretName }}
key: password
{{- /* ── Cache ── */ -}}
{{- range $k, $v := .Values.env.cache }}
- name: {{ $k }}
value: {{ $v | quote }}
{{- end }}
{{- /* ── Inter-service ── */ -}}
{{- range $k, $v := .Values.env.interService }}
- name: {{ $k }}
value: {{ $v | quote }}
{{- end }}
{{- /* ── Observability ── */ -}}
{{- range $k, $v := .Values.env.observability }}
- name: {{ $k }}
value: {{ $v | quote }}
{{- end }}
{{- /* ── Feature flags ── */ -}}
{{- range $k, $v := .Values.env.featureFlags }}
- name: {{ $k }}
value: {{ $v | quote }}
{{- end }}
charts/backend-checkout-service/templates/external-secret.yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: {{ .Values.secrets.dbUsers.k8sSecretName }}
namespace: {{ .Release.Namespace }}
spec:
refreshInterval: 1h
secretStoreRef:
name: gcp-secret-manager
kind: ClusterSecretStore
target:
name: {{ .Values.secrets.dbUsers.k8sSecretName }}
creationPolicy: Owner
data:
- secretKey: host
remoteRef:
key: {{ .Values.secrets.dbUsers.gsmPrefix }}-host
- secretKey: username
remoteRef:
key: {{ .Values.secrets.dbUsers.gsmPrefix }}-username
- secretKey: password
remoteRef:
key: {{ .Values.secrets.dbUsers.gsmPrefix }}-password
Ventana de terminal
# DEV
helm upgrade --install backend-checkout-service ./charts/backend-checkout-service \
--namespace tienda-herdez \
--values ./charts/backend-checkout-service/values.yaml \
--values ./charts/backend-checkout-service/values-dev.yaml \
--set image.tag=$CI_COMMIT_SHORT_SHA
# PRD (con aprobación)
helm upgrade --install backend-checkout-service ./charts/backend-checkout-service \
--namespace tienda-herdez \
--values ./charts/backend-checkout-service/values.yaml \
--values ./charts/backend-checkout-service/values-prd.yaml \
--set image.tag=$CI_COMMIT_SHORT_SHA

Los dominios (DOMAIN en el schema <DOMAIN>_<RESOURCE>_<ATTRIBUTE>) forman una lista cerrada. Agregar un nuevo dominio requiere aprobación del equipo de Arquitectura HERA.

DominioPropósitoEjemplos de variables
DBBases de datos relacionales y NoSQLDB_USERS_HOST, DB_CHECKOUT_PASSWORD, DB_CATALOG_NAME
CACHERedis, Memcache, capas de cacheCACHE_SESSION_HOST, CACHE_SESSION_TTL_MINUTES
JWTTokens JWT emitidos por el servicioJWT_ISSUER, JWT_SECRET, JWT_AUDIENCE, JWT_TTL_MINUTES
OAUTHProveedores OAuth/OIDC (client side)OAUTH_GOOGLE_CLIENT_ID, OAUTH_FACEBOOK_APP_SECRET
MSGMessaging — PubSub, Kafka, RabbitMQMSG_PUBSUB_PROJECT_ID, MSG_PUBSUB_TOPIC_ORDERS
BUCKETCloud Storage, S3-compatibleBUCKET_UPLOADS_NAME, BUCKET_UPLOADS_REGION
SMTPEnvío de emailSMTP_HOST, SMTP_USERNAME, SMTP_PASSWORD
OBSObservabilidad — logs, traces, metricsOBS_LOG_LEVEL, OBS_TRACING_ENABLED, OBS_METRICS_PORT
FEATUREFeature flags booleanosFEATURE_NEW_CHECKOUT_ENABLED
HTTPConfiguración del servidor HTTPHTTP_MAX_RETRIES, HTTP_TIMEOUT_MS, HTTP_KEEPALIVE_SECONDS
CORSCross-Origin Resource SharingCORS_ALLOWED_ORIGINS, CORS_ALLOWED_METHODS
(sin dominio)Inter-servicio: <SERVICE>_SERVICE_URLUSER_SERVICE_URL, CATALOG_SERVICE_URL
(sin dominio)APIs externas: <PROVIDER>_API_KEY/URLPAYMENTS_API_KEY, STRIPE_API_URL

Cuando un servicio consume a otro (HTTP, gRPC, messaging), la variable sigue la regla R10 — nombrar por el servicio destino:

Dentro del mismo cluster (service discovery de K8s)

Sección titulada «Dentro del mismo cluster (service discovery de K8s)»
# backend-checkout-service consume a platform-auth-service
env:
- name: USER_SERVICE_URL
value: "http://platform-auth-service.platform.svc.cluster.local"
- name: CATALOG_SERVICE_URL
value: "http://backend-catalog-service.tienda-herdez.svc.cluster.local"
- name: PAYMENTS_SERVICE_URL
value: "http://backend-payments-service.tienda-herdez.svc.cluster.local"

Los DNS internos de K8s siguen el patrón <deployment>.<namespace>.svc.cluster.local. Para servicios del mismo namespace se puede abreviar a <deployment>, pero el estándar recomienda la forma completa para claridad.

env:
- name: APIHUB_ORDERS_SERVICE_URL
value: "https://apihub.cloudherdez.com/orders/v1"
- name: INTEGRATIONHUB_SAP_SERVICE_URL
value: "https://integrationhub.cloudherdez.com/sap/v2"
- name: PAYMENTS_SERVICE_URL
value: "https://api.stripe.com/v1"

Toda variable *_SERVICE_URL debe acompañarse de timeouts y política de reintentos explícitos:

env:
- name: USER_SERVICE_URL
value: "http://platform-auth-service.platform.svc.cluster.local"
- name: USER_SERVICE_TIMEOUT_MS
value: "3000"
- name: USER_SERVICE_MAX_RETRIES
value: "3"
- name: USER_SERVICE_RETRY_BACKOFF_MS
value: "500"

Un servicio que consume otro sin timeout explícito representa un riesgo operacional silencioso: el primer incidente transitorio en el servicio dependiente cascadea a todos sus consumidores.


Todo el contenido anterior asume Deployment — servicios HTTP de larga vida con un Service asociado. Los workloads de tipo Job, CronJob y StatefulSet siguen exactamente las mismas reglas R1–R12, pero tienen tres diferencias operacionales que afectan cómo consumen secretos e env vars:

1. Lifecycle corto vs. ExternalSecret refresh

Sección titulada «1. Lifecycle corto vs. ExternalSecret refresh»

El refreshInterval por defecto de un ExternalSecret es 1 hora. Un CronJob que corre cada 5 minutos puede leer el mismo K8s Secret durante muchas ejecuciones antes de que ESO reconcilie un cambio en GSM. En la práctica esto es aceptable (los secretos no rotan con frecuencia), pero si un operador rota un secreto en GSM y necesita que el siguiente job lo lea inmediatamente, debe forzar la resincronización:

Ventana de terminal
# Forzar refresh inmediato del ExternalSecret
kubectl annotate externalsecret backend-cleanup-job-db \
force-sync=$(date +%s) \
--overwrite \
-n tienda-herdez

2. Sin Service asociado → URLs internas siguen funcionando

Sección titulada «2. Sin Service asociado → URLs internas siguen funcionando»

Un Job/CronJob no expone puertos ni recibe tráfico, pero sí puede consumir servicios via *_SERVICE_URL. El DNS interno de K8s (<deployment>.<namespace>.svc.cluster.local) funciona igual dentro del pod del job. La regla R10 aplica idéntica: se nombra por el servicio destino.

# CronJob que consume el backend-catalog-service
apiVersion: batch/v1
kind: CronJob
metadata:
name: worker-catalog-reindex
namespace: tienda-herdez
spec:
schedule: "0 2 * * *"
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: reindexer
image: <artifact-registry>/worker-catalog-reindex:<sha>
env:
# ── Inter-service ──
- name: CATALOG_SERVICE_URL
value: "http://backend-catalog-service.tienda-herdez.svc.cluster.local"
- name: CATALOG_SERVICE_TIMEOUT_MS
value: "30000" # jobs pueden permitirse timeouts más largos que APIs sincronas
- name: CATALOG_SERVICE_MAX_RETRIES
value: "5"
# ── Database ──
- name: DB_CATALOG_PASSWORD
valueFrom:
secretKeyRef:
name: worker-catalog-reindex-db-catalog
key: password

3. Naming del job incluye frecuencia o propósito

Sección titulada «3. Naming del job incluye frecuencia o propósito»

Los deployments siguen <tipo>-<detalle>. Para jobs y cronjobs, el <tipo> es worker (una de las 13 categorías oficiales del portal, ver Clasificación de Servicios) y el <detalle> describe la frecuencia o la función:

TipoEjemploScope
worker-<función>worker-catalog-reindex, worker-loyalty-pointsCronJob o consumer de queue de larga vida
worker-<función>-<frecuencia>worker-emails-hourly, worker-reports-dailyOpcional si la frecuencia distingue entre jobs con función similar

El K8s Secret y el GSM prefix siguen el mismo patrón: worker-catalog-reindex-db-catalog en K8s, tienda-herdez-worker-catalog-reindex-db-catalog-password en GSM.


Validación de Variables de Entorno en el Código

Sección titulada «Validación de Variables de Entorno en el Código»

El estándar regula cómo se nombran las variables, pero también requiere que el código aplicativo valide el parsing al arranque del servicio. Una variable mal configurada, mal tipada o ausente debe producir un error explícito antes de que el servicio empiece a procesar requests — y no un valor undefined que haga fallar el primer request en producción.

Cada equipo implementa la validación en el lenguaje del servicio usando la librería idiomática:

StackLibrería recomendadaPatrón
TypeScript / Node.jsenvalidValidación + tipado estático
Java / Spring Boot@ConfigurationProperties + @ValidatedBeans fuertemente tipados
Pythonpydantic-settingsModel-based validation
GoenvconfigStruct tags

Ejemplo de referencia en TypeScript (stack principal HERA)

Sección titulada «Ejemplo de referencia en TypeScript (stack principal HERA)»
// src/env.ts — Validación y tipado de env vars al arranque del servicio.
// El servicio falla al iniciar si alguna variable requerida no esta presente
// o no cumple el tipo declarado. Nunca usar process.env directamente en el resto
// del codigo — siempre importar desde este modulo.
import { cleanEnv, str, num, bool, url, port } from 'envalid';
export const env = cleanEnv(process.env, {
// ── Runtime ──
NODE_ENV: str({ choices: ['development', 'qa', 'production'] }),
TZ: str({ default: 'America/Mexico_City' }),
PORT: port({ default: 3000 }),
// ── Database (users) ──
DB_USERS_HOST: str(),
DB_USERS_PORT: port({ default: 5432 }),
DB_USERS_NAME: str(),
DB_USERS_USERNAME: str(),
DB_USERS_PASSWORD: str(),
DB_USERS_TIMEOUT_MS: num({ default: 5000 }),
// ── JWT ──
JWT_ISSUER: url(),
JWT_AUDIENCE: str(),
JWT_SECRET: str(),
JWT_TTL_MINUTES: num({ default: 60 }),
// ── OAuth Google ──
OAUTH_GOOGLE_CLIENT_ID: str(),
OAUTH_GOOGLE_CLIENT_SECRET: str(),
OAUTH_GOOGLE_REDIRECT_URI: url(),
// ── Inter-service ──
USER_SERVICE_URL: url(),
USER_SERVICE_TIMEOUT_MS: num({ default: 3000 }),
USER_SERVICE_MAX_RETRIES: num({ default: 3 }),
CATALOG_SERVICE_URL: url(),
CATALOG_SERVICE_TIMEOUT_MS: num({ default: 2000 }),
// ── Observability ──
OBS_LOG_LEVEL: str({ choices: ['debug', 'info', 'warn', 'error'], default: 'info' }),
OBS_TRACING_ENABLED: bool({ default: true }),
OBS_METRICS_PORT: port({ default: 9090 }),
// ── Feature flags ──
FEATURE_NEW_CHECKOUT_ENABLED: bool({ default: false }),
FEATURE_LEGACY_PAYMENTS_ENABLED: bool({ default: false }),
});
// Resto del codigo importa desde aqui — nunca process.env directamente
// import { env } from './env';
// const host = env.DB_USERS_HOST; // tipo: string, garantizado no-undefined

Este patrón da tres ventajas inmediatas:

  1. Fail-fast al arrancar — si falta DB_USERS_PASSWORD, el proceso termina con un error claro en vez de fallar en el primer query a la base de datos.
  2. Tipado estáticoenv.DB_USERS_PORT es number, no string | undefined. El compilador TypeScript garantiza que el código no use parseInt en todos lados.
  3. Defaults explícitos — los valores opcionales declaran su fallback en un solo lugar, no repartido por el código.
// application.yaml (o application.properties)
// db.users.host: ${DB_USERS_HOST}
// db.users.port: ${DB_USERS_PORT:5432}
// db.users.password: ${DB_USERS_PASSWORD}
@ConfigurationProperties(prefix = "db.users")
@Validated
public class DbUsersProperties {
@NotBlank private String host;
@Min(1) @Max(65535) private int port = 5432;
@NotBlank private String name;
@NotBlank private String username;
@NotBlank private String password;
@Min(1000) private int timeoutMs = 5000;
// getters/setters
}

Spring falla al iniciar con un error de validación descriptivo si cualquier campo @NotBlank viene vacío.

src/config.py
from pydantic_settings import BaseSettings
from pydantic import HttpUrl, PositiveInt
class Settings(BaseSettings):
db_users_host: str
db_users_port: PositiveInt = 5432
db_users_name: str
db_users_username: str
db_users_password: str
db_users_timeout_ms: PositiveInt = 5000
jwt_issuer: HttpUrl
jwt_secret: str
user_service_url: HttpUrl
user_service_timeout_ms: PositiveInt = 3000
obs_log_level: str = "info"
obs_tracing_enabled: bool = True
model_config = {"case_sensitive": False}
settings = Settings() # falla al importar si falta algo requerido

Los servicios HERA corren en producción con secretos servidos por GCP Secret Manager vía External Secrets Operator. Para desarrollo local — donde el developer no tiene (ni debe tener) credenciales de producción — el flujo recomendado es docker-compose con un .env.local ignorado por git:

backend-checkout-service/
├── .env.example # Plantilla versionada, todos los nombres de vars con placeholders
├── .env.local # ← IGNORADO por git, valores reales para dev local
├── .gitignore # Contiene .env.local, .env.*.local, .env
├── docker-compose.yml # Levanta el servicio + dependencias locales (DB, Redis, etc.)
├── Dockerfile
└── src/
└── env.ts # Validacion con envalid (ver seccion anterior)
Ventana de terminal
# === Runtime ===
NODE_ENV=development
TZ=America/Mexico_City
PORT=3000
# === Database (users) ===
# Para dev local: apunta al Postgres del docker-compose
DB_USERS_HOST=postgres
DB_USERS_PORT=5432
DB_USERS_NAME=db_users_local
DB_USERS_USERNAME=dev
DB_USERS_PASSWORD=<rellenar en .env.local>
# === JWT ===
JWT_ISSUER=http://localhost:3000
JWT_AUDIENCE=local.clientes
JWT_SECRET=<rellenar en .env.local usar valor aleatorio de 32+ chars>
# === Inter-service ===
USER_SERVICE_URL=http://host.docker.internal:3001
USER_SERVICE_TIMEOUT_MS=3000
USER_SERVICE_MAX_RETRIES=3
# === Observability ===
OBS_LOG_LEVEL=debug
OBS_TRACING_ENABLED=false
OBS_METRICS_PORT=9090
# === Feature flags ===
FEATURE_NEW_CHECKOUT_ENABLED=true
FEATURE_LEGACY_PAYMENTS_ENABLED=true
# Env files con valores reales
.env
.env.local
.env.*.local

docker-compose.yml — servicio + dependencias

Sección titulada «docker-compose.yml — servicio + dependencias»
version: '3.9'
services:
app:
build: .
env_file:
- .env.local # ← lee las vars desde aqui, no las declara inline
ports:
- "3000:3000"
depends_on:
- postgres
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: db_users_local
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev # solo para local, nunca en prod
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
volumes:
postgres-data:
Ventana de terminal
# 1. Clonar el repo
git clone <repo-url>
cd backend-checkout-service
# 2. Copiar la plantilla y rellenarla
cp .env.example .env.local
$EDITOR .env.local # rellena DB_USERS_PASSWORD, JWT_SECRET, etc.
# 3. Levantar el stack
docker compose up -d
# 4. Verificar que el servicio arrancó (la validación de env.ts pasó)
curl http://localhost:3000/health

El estándar aplica a todo código escrito para HERA. Frameworks y herramientas de terceros que tienen convenciones propias establecidas se mantienen intactas dentro de su scope:

ExcepciónÁmbito permitidoRazón
NEXT_PUBLIC_*Solo dentro de servicios Next.jsNext.js las inyecta al bundle cliente — renombrarlas rompe el build
VITE_*Solo dentro de servicios ViteVite las expone al cliente via import.meta.env
REACT_APP_*Solo dentro de servicios Create React App (legacy)Convención de CRA, aún en uso en componentes legacy
ASTRO_*Solo dentro de servicios Astro (como este portal)Astro las reserva internamente
STRAPI_*, STRAPI_HEALTH_CHECK_*Solo dentro de instancias Strapi CMSStrapi las documenta como configuración oficial
DATABASE_URL, REDIS_URLFormato connection string oficial12-Factor App las codifica como estándar — se permiten pero se prefiere el schema HERA descompuesto (DB_*_HOST, DB_*_PORT, etc.)
NODE_ENV, TZ, PORT, HOST, HOME, PATH, PWDTodos los serviciosEstándar POSIX/Node — se heredan sin renombrar
KUBERNETES_*Inyectadas automáticamente por K8sEl kubelet las inyecta, no son configurables
GOOGLE_APPLICATION_CREDENTIALS, GOOGLE_CLOUD_PROJECTServicios que usan GCP SDKsEstándar Google Cloud SDK — se mantiene

Casos reales detectados en manifiestos del grupo, transformados al estándar HERA:

env:
- name: DB_HOST
value: "10.95.128.78"
- name: DB_NAME
value: "db_tensai_hoy_toca_2025"
- name: DB_USER
value: "usr_tensai_hoytoca2025"
- name: DB_PASS
value: "MiP4ssw0rd!2025"
# ── Database ──
- name: DB_USERS_HOST
valueFrom:
secretKeyRef: { name: backend-users-service-db-users, key: host }
- name: DB_USERS_PORT
valueFrom:
secretKeyRef: { name: backend-users-service-db-users, key: port }
- name: DB_USERS_USERNAME
valueFrom:
secretKeyRef: { name: backend-users-service-db-users, key: username }
- name: DB_USERS_PASSWORD
valueFrom:
secretKeyRef: { name: backend-users-service-db-users, key: password }
- name: DB_USERS_NAME
valueFrom:
secretKeyRef: { name: backend-users-service-db-users, key: name }

Cambios aplicados: R4 (secretos fuera de value:), R5 (sufijos explícitos _USERNAME en vez de _USER, _PASSWORD en vez de _PASS), R11 (sección Database explícita), añadido _PORT que faltaba.

Ejemplo 2 — JWT con guiones (bug POSIX latente)

Sección titulada «Ejemplo 2 — JWT con guiones (bug POSIX latente)»
env:
- name: RECIPE-JWT-SECRET
value: "super-secret-jwt-key"
- name: RECIPE-JWT-ISSUER
value: "hoyToca-api-auth"
- name: RECIPE-JWT-AUDIENCE
value: "hoytoca-clientes"
# ── Auth / Identity ──
- name: JWT_ISSUER
value: "https://auth.cloudherdez.com"
- name: JWT_AUDIENCE
value: "hoy-toca.clientes"
- name: JWT_SECRET
valueFrom:
secretKeyRef: { name: backend-users-service-jwt, key: secret }

Cambios aplicados: R1/R2 (SCREAMING_SNAKE_CASE sin guiones), R4 (secreto fuera de value:), eliminado el prefijo RECIPE- que no correspondía al valor (hoyToca-api-auth), URL completa con esquema en JWT_ISSUER (R9).

Ejemplo 3 — OAuth Google con secretos filtrados

Sección titulada «Ejemplo 3 — OAuth Google con secretos filtrados»
env:
- name: AUTH-GOOGLE-CLIENT-ID
value: "70096587311-0ndm012ctrkh3o0590bsulj879aistts.apps.googleusercontent.com"
- name: AUTH-GOOGLE-CLIENTSECRET
value: "MiP4ssw0rd!2025"
- name: AUTH-GOOGLE-CLIENT-REDIRECT-URI
value: "https://..."
- name: OAUTH_GOOGLE_CLIENT_ID
value: "70096587311-0ndm012ctrkh3o0590bsulj879aistts.apps.googleusercontent.com"
- name: OAUTH_GOOGLE_CLIENT_SECRET
valueFrom:
secretKeyRef: { name: backend-users-service-oauth-google, key: client-secret }
- name: OAUTH_GOOGLE_REDIRECT_URI
value: "https://users.cloudherdez.com/api/auth/google/callback"

Cambios aplicados: R1 (SCREAMING_SNAKE_CASE), R2 (sin guiones), R4 (client secret via secretKeyRef), R5 (_CLIENT_SECRET en vez de _CLIENTSECRET), R9 (URL completa).

Ejemplo 4 — Lista de orígenes CORS con separador incorrecto

Sección titulada «Ejemplo 4 — Lista de orígenes CORS con separador incorrecto»
env:
- name: ALLOWED_ORIGINS
value: "https://a.dev.cloudherdez.com;https://b.dev.cloudherdez.com;http://c.dev.cloudherdez.com"
# ── CORS / Security ──
- name: CORS_ALLOWED_ORIGINS
value: "https://a.dev.cloudherdez.com,https://b.dev.cloudherdez.com,https://c.dev.cloudherdez.com"

Cambios aplicados: R8 (separador , estándar CORS en vez de ;), añadido dominio CORS_ para agrupar, eliminada mezcla http/https (ahora todos https).

Ejemplo 5 — Messaging con secretos y URLs mezcladas

Sección titulada «Ejemplo 5 — Messaging con secretos y URLs mezcladas»
env:
- name: PROJECT_ID
value: "ghdz-grupo-ext-sites-dev"
- name: TOPIC_ID
value: "mail-send"
- name: AUTH-PUBSUB-TOPIC
value: "mail-send"
# ── Messaging ──
- name: MSG_PUBSUB_PROJECT_ID
value: "ghdz-grupo-ext-sites-dev"
- name: MSG_PUBSUB_TOPIC_MAIL
value: "mail-send"

Cambios aplicados: prefijo MSG_PUBSUB_ para agrupar, eliminado duplicado, R11 (sección Messaging explícita).


Esta checklist es obligatoria en todo MR que toque manifiestos K8s, ConfigMaps, o archivos .env.*. Todo item debe estar marcado ✅ antes del merge a develop.

  • Toda env var cumple SCREAMING_SNAKE_CASE estricto (R1)
  • Ninguna env var contiene guiones - (R2)
  • Ninguna env var excede 63 caracteres (R3)
  • Toda env var termina en uno de los 18 sufijos del catálogo cerrado (R5)
  • Las duraciones incluyen unidad en el nombre (_MS, _SECONDS, _MINUTES) (R7)
  • Las URLs incluyen esquema https:// y no tienen trailing slash (R9)
  • Las variables inter-servicio llevan el nombre del servicio destino, no del consumidor (R10)
  • Ningún prefijo de framework (NEXT_PUBLIC_*, VITE_*, etc.) aparece fuera de su scope (R12)
  • Ningún secreto aparece en value: — todos vía valueFrom.secretKeyRef (R4)
  • Existe un ExternalSecret por cada K8s Secret referenciado, con remoteRef.key en GSM
  • El nombre del K8s Secret sigue el patrón <deployment>-<resource-type>
  • El nombre del secreto en GSM sigue el patrón <producto>-<deployment>-<resource-type>-<attribute> en kebab-case
  • Ningún .env, .env.production, .env.prd está siendo commitado (verificar .gitignore)
  • Booleanos son literal true / false (lowercase) (R6)
  • Listas usan separador , (coma), no ; (R8)
  • Las variables están agrupadas en secciones con el orden canónico Runtime → Database → Cache → Messaging → Storage → Auth → External APIs → Inter-service → Observability → Feature Flags → CORS / Security (R11)
  • Toda variable *_SERVICE_URL va acompañada de *_TIMEOUT_MS y *_MAX_RETRIES explícitos
  • Los servicios del mismo cluster usan DNS interno <deployment>.<namespace>.svc.cluster.local
  • Los servicios externos usan URL pública completa con https://
  • Toda variable nueva está documentada en el README.md del servicio con: nombre, tipo, obligatoria/opcional, valor por defecto, ejemplo
  • El .env.example del repo lista todas las variables sin valores reales (usar placeholders <...>)

Este portal mismo aplica el estándar. El objeto ECOSYSTEM_LINKS en astro.config.mjs consume variables como:

// Carga .env al process.env usando el built-in de Node 20.6+ (zero-deps).
try {
process.loadEnvFile('.env');
} catch {
// .env ausente o Node < 20.6: fallbacks hardcoded se aplican
}
const ECOSYSTEM_LINKS = {
calcK8s: process.env.CALC_K8S_SERVICE_URL ?? 'http://localhost:5173/',
// Futuras herramientas: añadir aquí + en .env.example
};

Siguiendo R10 (nombrar por servicio destino): CALC_K8S_SERVICE_URL apunta al servicio Calculadora K8s. Sin prefijo HERA_ porque el portal consume el servicio — el consumidor no debe calificarse a sí mismo.

Cuando un equipo inicia un nuevo servicio, los entregables obligatorios del primer MR incluyen:

  1. src/env.ts (o equivalente) — tipado/validación de las env vars (ej. con envalid)
  2. .env.example — plantilla con todas las variables del servicio, sin valores reales
  3. k8s/deployment.yaml — manifiesto con agrupación R11
  4. k8s/external-secrets.yaml — ExternalSecrets por recurso
  5. README.md — sección Configuración con tabla de variables

El flujo de rotación es:

  1. Operador genera el nuevo valor en GCP Secret Manager (nueva versión del secreto existente)
  2. ESO resincroniza en la próxima ventana (o se fuerza via kubectl)
  3. El K8s Secret se actualiza
  4. Los pods del deployment se reinician (manualmente o via argo rollout)
  5. Los pods leen el nuevo valor de la env var al arrancar

Ningún código de aplicación contiene lógica de reloading de secretos — es responsabilidad del runtime (K8s + ESO).



VersiónFechaCambio
1.02026-04-13Versión inicial — R1 a R12, 18 sufijos, 10 dominios, modelo Context-Aware Naming de 5 capas

Este estándar es la primera pieza de una serie más amplia de convenciones de configuración y naming del ecosistema HERA. Las páginas hermanas, que complementan sin contradecir lo establecido aquí, son: