Dominios personalizados y TLS
Cómo están configurados los hostnames de prod (*.mseller.app) y dev (*.mseller.dev), y cómo agregar/cambiar uno nuevo.
Las APIs se sirven sobre dos dominios distintos según el entorno —
prod sobre *.mseller.app y dev sobre *.mseller.dev. La
infra del hostname está repartida así:
- Dominio
mseller.app(prod) — registrado en GoDaddy, DNS gestionado en cPanel (acceso víahttps://mobile-seller.com/cpanel). Ahí viven los CNAME y TXT de los hostnames de producción. - Dominio
mseller.dev(dev) — DNS gestionado en Cloudflare. Ahí viven los CNAME y TXT de los hostnames de dev. Inicialmente todos los registros van DNS-only (gray cloud); más adelante el deportal.api.mseller.devse pone en modo proxied para meter Cloudflare WAF / Access encima del UI y/swagger(ver sección al final). - Certificado TLS (prod) — emitido y renovado automáticamente por cPanel AutoSSL (emisor actual: DigiCert / GeoTrust, validez ~6 meses). El cert se sube a Azure Container Apps como custom cert; el terminador TLS sigue siendo Azure.
- Certificado TLS (dev) — cert administrado por Azure Container Apps (auto-emisión vía DigiCert/Let's Encrypt al validar el hostname). No hay cPanel ni AutoSSL en el medio para dev.
Azure no gestiona el cert de prod por su cuenta — esto es importante recordarlo cuando se hace troubleshooting: si el cert de prod vence y nadie sincronizó la renovación de cPanel a Azure, HTTPS deja de funcionar aunque el binding del hostname siga bien. En dev el cert es administrado por Azure, así que no requiere sincronía manual.
Hostnames vigentes
| Env | API | Hostname público | Container App Environment |
|---|---|---|---|
| dev | Portal | https://portal.api.mseller.dev | mseller-dev-cae (Central US) |
| dev | Ingestion | https://ingestion.api.mseller.dev | mseller-dev-cae (Central US) |
| dev | Consumo | https://consumo.api.mseller.dev | mseller-dev-cae (Central US) |
| prod | Portal | https://portal.api.mseller.app | mseller-prod-cae (East US 2) |
| prod | Ingestion | https://ingestion.api.mseller.app | mseller-prod-cae (East US 2) |
| prod | Consumo | https://consumo.api.mseller.app | mseller-prod-cae (East US 2) |
El patrón es {api}.api.mseller.app para prod y
{api}.api.mseller.dev para dev — el TLD distinto es lo que
indica el entorno. Esto hace obvio en qué env estás cuando mirás un
URL en logs o documentación.
Swagger en dev
Cada API de dev expone Swagger en /swagger sobre su hostname
público — útil para probar endpoints sin levantar el stack local:
- https://portal.api.mseller.dev/swagger
- https://ingestion.api.mseller.dev/swagger
- https://consumo.api.mseller.dev/swagger
En prod los /swagger están deshabilitados a propósito; usá los de
dev para explorar el contrato.
¿Y los FQDNs `*.azurecontainerapps.io`?
Los nombres mseller-{env}-{api}-api.{env-id}.{region}.azurecontainerapps.io
siguen funcionando como FQDN de fallback (Azure no los desactiva al
agregar custom domains). Útiles para debug o para apuntar tráfico
cross-env sin tocar DNS de mseller.app / mseller.dev. Pero todo
cliente real (mobile, portal admin, integraciones) tiene que usar el
hostname con dominio propio — protege contra lock-in al proveedor.
Cómo está configurado (resumen rápido)
Prod — mseller.app (cPanel + AutoSSL):
GoDaddy (registrar de mseller.app)
└─ Name servers → cPanel (mobile-seller.com/cpanel)
cPanel: Zone Editor de mseller.app
├─ portal.api CNAME → mseller-prod-portal-api.mangoocean-fef986ad.eastus2.azurecontainerapps.io.
├─ ingestion.api CNAME → mseller-prod-ingestion-api.mangoocean-fef986ad.eastus2.azurecontainerapps.io.
├─ consumo.api CNAME → mseller-prod-consumo-api.mangoocean-fef986ad.eastus2.azurecontainerapps.io.
└─ asuid.{cada-subdominio} TXT → <verification-id> (3 TXT records, mismo valor)
cPanel: SSL/TLS Status (AutoSSL)
├─ Emite cert (DigiCert / GeoTrust, ~6 meses) cubriendo los hosts de arriba
└─ Renovación automática vía AutoSSL (~30 días antes del vencimiento)
Azure Container Apps (mseller-prod-cae, East US 2)
├─ Verifica ownership leyendo asuid.{host} TXT == customDomainVerificationId
├─ Recibe el cert subido desde cPanel como custom certificate
└─ Bind del cert al Container App → SniEnabled (Azure termina TLS)Dev — mseller.dev (Cloudflare + cert administrado por Azure):
Cloudflare: DNS de mseller.dev
├─ portal.api CNAME → mseller-dev-portal-api.nicebeach-03e666f2.centralus.azurecontainerapps.io. (DNS-only / gray cloud)
├─ ingestion.api CNAME → mseller-dev-ingestion-api.nicebeach-03e666f2.centralus.azurecontainerapps.io. (DNS-only / gray cloud)
├─ consumo.api CNAME → mseller-dev-consumo-api.nicebeach-03e666f2.centralus.azurecontainerapps.io. (DNS-only / gray cloud)
└─ asuid.{cada-subdominio} TXT → <verification-id> (3 TXT records, mismo valor)
Azure Container Apps (mseller-dev-cae, Central US)
├─ Verifica ownership leyendo asuid.{host} TXT == customDomainVerificationId
├─ Emite cert administrado (managed certificate) automáticamente
└─ Bind del cert al Container App → SniEnabled (Azure termina TLS)El customDomainVerificationId es uno solo por tenant de Azure AD
— por eso los 6 TXT records (3 en cPanel + 3 en Cloudflare) tienen el
mismo valor. Si rotás el tenant o creás otro environment en otro
tenant, vas a tener un valor distinto.
En dev los registros inician en DNS-only (gray cloud). Más
adelante el de portal.api.mseller.dev se cambia a proxied (orange
cloud) para meter Cloudflare WAF / Access encima del UI y
/swagger (ver la sección final). consumo.api e ingestion.api
se quedan DNS-only para no agregar latencia ni romper integraciones
externas.
Agregar un nuevo hostname (por ejemplo, una API nueva)
Decidir el hostname siguiendo la convención
{api-nueva}.api.mseller.app para prod, {api-nueva}.api.mseller.dev
para dev. No agregues más niveles de subdominio salvo que tengas una razón
concreta.
Obtener el FQDN del Container App + verification ID
# CAE-id + región del Container App ya existente. En tu nuevo Container
# App van a venir de propiedades equivalentes.
APP_NAME=mseller-prod-<api>-api
RG=mseller-prod-rg
az containerapp show -n $APP_NAME -g $RG \
--query "{fqdn:properties.configuration.ingress.fqdn, verificationId:properties.customDomainVerificationId}" \
-o jsonAgregar los 2 registros DNS
Para prod — en cPanel → Zone Editor → mseller.app, agregar:
| Tipo | Nombre | Valor | TTL |
|---|---|---|---|
| CNAME | {api}.api | <fqdn-azure>. (con punto final) | 14400 |
| TXT | asuid.{api}.api | <verification-id> (64 hex chars) | 14400 |
El valor TXT NO debe llevar comillas (cPanel)
cPanel agrega las comillas alrededor del valor cuando sirve el record.
Si vos las ponés en el campo, el TXT termina con doble-comilla
(""5F6A..."") y Azure rechaza la verificación. Pegá el valor pelado.
Para dev — en Cloudflare → DNS → mseller.dev, agregar:
| Tipo | Nombre | Contenido | Proxy status | TTL |
|---|---|---|---|---|
| CNAME | {api}.api | <fqdn-azure> (sin punto final en Cloudflare) | DNS only (gray cloud) | Auto |
| TXT | asuid.{api}.api | <verification-id> (64 hex chars) | n/a | Auto |
Proxy debe estar OFF (gray cloud) al inicio
Si dejás Cloudflare proxy activo (orange cloud) antes de validar el
hostname y bindear el cert administrado en Azure, la verificación
falla porque Azure no ve directamente el FQDN del Container App.
Recién después de tener SniEnabled y el cert verde se puede
cambiar portal.api a proxied (ver sección final). El TXT
asuid.* siempre va DNS-only.
Verificar DNS antes de seguir
Esperá ~5-30 min (la propagación puede tardar más si el TTL anterior fue alto) y verificá:
EXPECTED_TXT="<verification-id>"
HOST="{api}.api.mseller.app" # o {api}.api.mseller.dev para dev
# CNAME debe apuntar a *.azurecontainerapps.io
dig +short CNAME $HOST @1.1.1.1
# TXT debe match exacto
[ "$(dig +short TXT asuid.$HOST @1.1.1.1 | tr -d '"')" = "$EXPECTED_TXT" ] \
&& echo "✓ ready" || echo "✗ TXT mismatch"Agregar el custom_hostname en Terraform
En envs/prod/main.tf (o envs/dev/main.tf), el bloque local.custom_hostnames
ya genera los nombres a partir de local.apis + var.apex_domain. Si
la API nueva ya está en la lista local.apis, no hace falta tocar nada
acá — el module.api la levanta automáticamente.
Si querés un override custom (por ejemplo un hostname con un nivel de
subdominio distinto), editá el local en main.tf:
custom_hostnames = local.apex_domain == null ? {} : {
portal = "portal.api.${local.apex_domain}"
ingestion = "ingestion.api.${local.apex_domain}"
consumo = "consumo.api.${local.apex_domain}"
nueva-api = "nuevaapi.${local.apex_domain}" # custom path, no usa el patrón api.*
}Apply de terraform
Si CI está activo, mergeá el PR → terraform-apply.yml corre solo.
Si lo hacés local:
cd envs/prod # o envs/dev
terraform plan -out=tfplan
terraform apply tfplanEl plan debe mostrar 1 add: module.api["<api>"].azurerm_container_app_custom_domain.this[0].
Asociar el cert y hacer el bind
Terraform crea el binding del hostname pero deja bindingType = Disabled
intencionalmente (ver el lifecycle.ignore_changes en
modules/api-container-app/main.tf). El cert se gestiona out-of-band
y la forma depende del entorno:
Prod (cert de cPanel AutoSSL):
-
Exportar el cert desde cPanel. En cPanel → SSL/TLS → Manage SSL Sites, ubicá el dominio
*.mseller.app(o el SAN específico del nuevo host) y copiá el bloque PEM del certificado y la private key. Guardalos comomseller-app.crtymseller-app.keylocalmente. Si el cert es nuevo y AutoSSL todavía no lo emitió, forzá una corrida desde AutoSSL antes de continuar. -
Subir a Azure y bindear. Cargá el cert al CAE y ataalo al hostname:
# 1) subir el cert al Container App Environment (una vez por cert) az containerapp env certificate upload \ --name <cae-name> -g <rg> \ --certificate-file mseller-app.crt \ --certificate-key mseller-app.key \ --certificate-name mseller-app-<yyyy-mm> # 2) bindear el hostname al cert subido az containerapp hostname bind \ --name <app-name> -g <rg> \ --hostname <hostname> \ --environment <cae-name> \ --certificate mseller-app-<yyyy-mm>
Tarda ~1-3 min en práctica (el WARNING dice "up to 20 minutes"; en
nuestra experiencia siempre fue mucho menos). Después de esto el cert
queda SniEnabled y HTTPS funciona.
Dev (cert administrado por Azure):
En dev no se sube cert manual — Azure emite y renueva un cert administrado por sí solo una vez que el hostname está validado y el CNAME (DNS-only en Cloudflare) resuelve al FQDN del Container App.
# bindear el hostname pidiendo un cert administrado
az containerapp hostname bind \
--name <app-name> -g <rg> \
--hostname <hostname> \
--environment <cae-name> \
--validation-method CNAMELa emisión tarda ~3-15 min. Después de esto el cert queda
SniEnabled y HTTPS funciona, y Azure se encarga de renovar
automáticamente.
Prod: cuando AutoSSL renueva, hay que repetir el paso 2
cPanel AutoSSL emite el cert de prod cada ~6 meses pero no lo empuja a Azure. La rotación necesita un upload + bind manual (o un script). Si HTTPS se rompe ~6 meses después de un setup que andaba en prod, esa es la causa #1. Ver la sección de troubleshooting. En dev el cert es administrado por Azure y no requiere esta sincronía.
Verificar HTTPS funcionando
# prod
curl -sI https://{api}.api.mseller.app/health
# dev
curl -sI https://{api}.api.mseller.dev/healthTiene que devolver HTTP/2 200. Si tira Could not resolve host o se
queda colgado, esperá unos minutos más (puede ser cache DNS) y volvé a
verificar el cert binding:
az containerapp show -n <app-name> -g <rg> \
--query "properties.configuration.ingress.customDomains[].{name:name, bindingType:bindingType}" \
-o tablebindingType debe ser SniEnabled. Si está Disabled todavía, el cert
no se emitió — revisar los logs del comando hostname bind.
Cambiar un hostname existente
No es muy común, pero si necesitás cambiar (por ejemplo, mover de
portal.api.mseller.app a api.mseller.app):
- Agregar primero el hostname nuevo siguiendo los pasos arriba. Va a convivir con el viejo durante la transición.
- Notificar a clientes (mobile, portal admin, otros consumers) que actualicen su base URL — dales una ventana de 7-30 días.
- Monitorear tráfico sobre el hostname viejo. Cuando llegue a ~0 reqs/min:
- Remover el hostname viejo — eliminar el bloque del Terraform
(o quitar la entry de
custom_hostnames), apply, y borrar los DNS records en cPanel.
Los certs viejos se borran solos cuando el hostname se desvincula.
Rollback / desactivar custom domains
Si por alguna razón querés volver a los FQDNs *.azurecontainerapps.io
(por ejemplo, debug profundo o el provider tiene un problema con custom
domains):
# Setear apex_domain = null en terraform.tfvars y aplicar
# Esto remueve los 3 azurerm_container_app_custom_domain.this resources
# pero deja los Container Apps + DNS intactos.
cd envs/prod
terraform applyDespués el hostname custom (portal.api.mseller.app en prod,
portal.api.mseller.dev en dev, etc.) deja de responder sobre HTTPS
porque Azure ya no tiene el binding. Los Container Apps siguen
sirviendo en <app>.<env-id>.<region>.azurecontainerapps.io como
siempre. Los registros DNS en cPanel (prod) o Cloudflare (dev) quedan
apuntando al FQDN viejo (pueden quedarse así o limpiarse).
Troubleshooting
Could not resolve host: portal.api.mseller.app (o .mseller.dev)
DNS no propagó todavía o el record falta. Verificá:
# prod
dig +short CNAME portal.api.mseller.app @1.1.1.1
dig +short CNAME portal.api.mseller.app @8.8.8.8 # otro resolver para comparar
# dev
dig +short CNAME portal.api.mseller.dev @1.1.1.1
dig +short CNAME portal.api.mseller.dev @8.8.8.8Si está vacío en uno y poblado en otro, esperá la propagación. Si
está vacío en ambos, falta el record — en cPanel para prod
(mseller.app) o en Cloudflare para dev (mseller.dev).
TLS handshake falla / curl: (35)
El binding del cert está Disabled. Probablemente el az containerapp hostname bind falló o nunca se corrió. Revisá:
az containerapp show -n <app> -g <rg> \
--query "properties.configuration.ingress.customDomains[]" -o jsonSi bindingType es Disabled, corré el comando hostname bind
nuevamente.
Cannot verify ownership of hostname
Azure no pudo leer el asuid TXT en el momento del binding. Causas comunes:
- TXT con valor mal copiado — debe ser exactamente el
customDomainVerificationId(64 hex chars), sin comillas, sin dots abreviados. - DNS no propagó — esperar y reintentar.
- TXT en el subdominio equivocado — debe ser
asuid.<host>, noasuidsolo ni<host>solo. - Cloudflare proxy activo en dev — si el CNAME de
{api}.api.mseller.devestá proxied (orange cloud) antes de validar, Azure no resuelve directo al Container App y la verificación falla. Dejá el record DNS-only hasta tener certSniEnabled.
El cert de prod andaba y dejó de funcionar ~6 meses después
cPanel AutoSSL renueva su cert automáticamente, pero la copia que vive en Azure es una snapshot del momento que se subió — no se sincroniza sola. Aplica sólo a prod (en dev el cert es administrado por Azure y se renueva solo). Verificá fechas:
# 1) Qué cert tiene Azure asociado al hostname
az containerapp env certificate list -g <rg> -n <cae-name> -o table
# 2) Qué cert está sirviendo el host ahora mismo
echo | openssl s_client -connect <hostname>:443 -servername <hostname> 2>/dev/null \
| openssl x509 -noout -dates -issuerSi el cert que sirve <hostname> está vencido o el notAfter de
Azure es viejo, repetí los dos pasos del bloque "Prod (cert de
cPanel AutoSSL)" con el cert nuevo de cPanel. Antes de eso,
confirmá en cPanel que AutoSSL ya emitió el nuevo cert (a veces hay
que forzar la corrida si fue justo cerca del vencimiento).
Cert status Failed en Azure después de un upload
Causas comunes:
- Cert subido no cubre el hostname — el SAN del cert tiene que incluir el host exacto. AutoSSL puede emitir certs separados por subdominio; revisá en cPanel que el SAN list cubra el host antes de exportar.
- CNAME del hostname cambió y rompió la verificación — revisá que el CNAME (cPanel para prod, Cloudflare para dev) siga apuntando al FQDN correcto del Container App.
Restringiendo el UI y Swagger de dev con Cloudflare
Por defecto los hostnames de dev resuelven directo a Azure (DNS-only,
gray cloud) — útil mientras se hace el setup inicial y para que mobile
e integraciones externas peguen sin Cloudflare en el medio. Pero el
Portal de dev expone /swagger y rutas de admin UI que no
deberían ser públicas; para eso se mete Cloudflare como WAF / Access
encima de portal.api.mseller.dev solamente.
Cambiar portal.api.mseller.dev a proxied
Una vez que el cert administrado de Azure está SniEnabled y HTTPS
funciona DNS-only, en Cloudflare → DNS de mseller.dev, editar
el registro portal.api y cambiar el proxy status a Proxied
(orange cloud).
consumo.api.mseller.dev se queda DNS-only (gray cloud) — la app
móvil pega directo a Azure, sin Cloudflare en el medio (latencia +
una capa menos que puede romper sesiones largas o subidas grandes).
ingestion.api.mseller.dev también se queda DNS-only, a menos que se
conozcan las IPs de origen de SAP / integraciones externas y se
puedan allow-listear en una regla. Si no, el riesgo de bloquear un
callback legítimo es mayor que el beneficio.
Configurar SSL/TLS mode "Full (strict)"
En Cloudflare → SSL/TLS → Overview de la zona mseller.dev,
poner el modo en Full (strict). Esto hace que Cloudflare verifique
el cert del origen (el cert administrado de Azure) en cada request.
Cualquier modo más laxo (Flexible, Full sin strict) deja huecos: el tráfico Cloudflare→Azure puede ir en HTTP o aceptar certs inválidos, lo que rompe el propósito de tener TLS end-to-end.
Bloquear /swagger y rutas de admin con Cloudflare Access
En Cloudflare → Zero Trust → Access → Applications, crear una
Self-hosted application sobre mseller.dev con:
- Application domain:
cloud.mseller.dev(portal admin de dev) yportal.api.mseller.dev(paths/swagger,/swagger/*,/swagger-ui,/swagger-ui/*, más rutas de admin UI específicas). - Identity provider: el que la cuenta de Cloudflare tenga conectado (Microsoft Entra ID, Google Workspace, o el One-Time PIN por email si no hay IdP).
- Access policy (Allow):
Emails ending in @mseller.app— el equipo interno.Emails ending in @itsoluclick.com— el equipo de ITSoluClick.
Cualquier otro email es rechazado y Cloudflare Access muestra la pantalla "You do not have permission". El usuario autorizado pasa el SSO una vez y la sesión vale ~24 h (configurable en la application).
Como respaldo, se puede crear una WAF custom rule en
Cloudflare → Security → WAF → Custom rules de mseller.dev que
bloquee las mismas rutas si el request no trae la cookie de
sesión de Cloudflare Access (CF_Authorization). Esto cubre el caso
en que un cambio en la app de Access se desconfigure o quede en
modo bypass.
Los emails que aparecen en la sesión de Access son los que el IdP devuelve verificados — no es necesario mantener allow-lists individuales: la regla por dominio se aplica al instante a cualquier nuevo empleado que se añada al directorio.
Documentar las reglas en algún lado
Las reglas Cloudflare hoy se configuran a mano en el dashboard. Si
en el futuro queremos infra-as-code para esto (replicar el setup en
otra zona, code review de cambios, rollback), hay que añadir el
provider terraform-provider-cloudflare
en mseller-infrastructure. Mientras tanto, screenshots o un export
de la regla en un issue son suficientes.
Referencias
docs/custom-domains.mdenmseller-infrastructure— guía paralela, escrita para el stack legacy de App Service. El patrón cPanel es el mismo; el binding del cert es lo único que cambia entre App Service (auto al verificar) y Container Apps (un comando az aparte).- Microsoft Learn: Custom domains and certificates in Azure Container Apps.