Durante años el deploy de este proyecto funcionó igual que el de medio internet: GitHub Actions guardaba una clave privada SSH como secret, se conectaba al servidor y corría los comandos. Funciona. Y arrastra cuatro problemas que no se ven hasta que se ven.
Este artículo cuenta la migración de ese esquema a uno sin llaves largas, y aprovecha para explicar los conceptos que la hacen posible: OIDC, SSM Run Command, Terraform en capas y remote_state. Incluye los dos bugs que rompieron todos los deploys en producción, porque enseñan más que cualquier ejemplo de manual.
El problema: una llave que nunca vence
Una clave SSH guardada como secret de CI tiene cuatro defectos estructurales:
- No expira. Si se filtra hoy, sirve mañana, el mes que viene y el año que viene. No hay reloj que la apague.
- Da shell completo. No existe forma de decir "esta clave solo puede correr el script de deploy". Quien la tiene, tiene la máquina.
- Rotarla es manual. Generar el par nuevo, tocar authorized_keys en el servidor, actualizar el secret en GitHub. Como duele, nadie lo hace.
- Obliga a exponer el puerto 22 a los runners de GitHub, que son IPs efímeras que no controlás.
Ninguno es un bug. Son propiedades del diseño. La única forma de arreglarlos es cambiar el diseño.
Concepto 1: OIDC, o cómo probar quién sos sin guardar un secreto
La idea que hay que entender primero es la diferencia entre tener una credencial y poder probar una identidad.
Una clave SSH es una credencial compartida: existe una copia en GitHub y otra en el servidor. Cualquiera de las dos copias que se filtre, compromete el sistema.
OIDC invierte el modelo. GitHub firma un documento que dice "este job viene del repositorio X, en el environment Y". AWS verifica esa firma contra una clave pública que GitHub publica, y si cierra, entrega credenciales temporales que duran minutos.
Nadie guardó nunca un secreto compartido. Es la diferencia entre darle a alguien una copia de tu llave y mostrarle tu pasaporte: el pasaporte prueba quién sos sin entregarle nada que después pueda usar sin vos.
Ese documento firmado es un JWT (JSON Web Token), y los dos campos que importan son:
- aud (audience): para quién es el token. Acá, sts.amazonaws.com.
- sub (subject): quién lo pide. GitHub lo arma con una forma fija que depende de cómo esté configurado el job.
El trust policy: la cerradura que describe exactamente qué llave acepta
Del lado de AWS creás un rol IAM cuya trust policy dice, literalmente, qué token acepta:
resource "aws_iam_role" "github_actions" {
name = "github-actions-factufacil"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = data.terraform_remote_state.platform.outputs.github_oidc_provider_arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
"token.actions.githubusercontent.com:sub" = "repo:${var.github_repo}:environment:${var.github_environment}"
}
}
}]
})
}
Leelo como una cerradura: solo abre si el token viene del repositorio exacto y del environment exacto. Un token del mismo repo pero de otra rama, o de un fork, no entra.
Del lado del workflow, la parte que reemplaza a toda la maquinaria de SSH son seis líneas:
permissions:
id-token: write # habilita que GitHub emita el token OIDC
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::<ACCOUNT_ID>:role/github-actions-factufacil
aws-region: us-east-1
Sin AWS_ACCESS_KEY_ID. Sin clave SSH. Sin nada que rotar.
Los dos bugs que rompieron todos los deploys
Acá viene la parte que no sale en los tutoriales. Después de aplicar todo lo anterior, cada deploy fallaba con el mismo error:
Not authorized to perform sts:AssumeRoleWithWebIdentity
El primer bug: la organización se había renombrado de FactuFacil-pe a factufacil-pe, todo en minúsculas, pero el trust policy seguía con el casing viejo. `StringEquals` de IAM es sensible a mayúsculas, así que el sub real del token nunca coincidía con el esperado. Un carácter.
Arreglamos el casing y siguió fallando exactamente igual. El segundo bug era más sutil, y es la trampa que vale la pena memorizar: cuando un job declara `environment:`, GitHub cambia la forma del claim `sub`.
- Un job sin environment recibe: repo:owner/repo:ref:refs/heads/main
- Un job con environment: production recibe: repo:owner/repo:environment:production
Nuestro trust policy chequeaba la forma con ref, pero el job declaraba environment: production para poder usar las protection rules de GitHub. Estaba comparando contra un claim que el token nunca iba a traer, sin importar desde qué rama se disparara.
Concepto 2: SSM Run Command, o ejecutar sin entrar
Resuelta la identidad, queda la pregunta de cómo correr el deploy en el servidor. La respuesta tradicional es SSH. La alternativa es AWS Systems Manager Run Command.
El cambio conceptual es la dirección de la conexión. Con SSH, vos entrás a la máquina: necesitás una ruta de red hacia ella, un puerto abierto y una credencial que el servidor reconozca. Con SSM, un agente que corre dentro de la instancia hace polling saliente contra el servicio de AWS y pregunta si hay trabajo pendiente.
Vos nunca entrás. La máquina sale a buscar la orden.
Eso cambia tres cosas a la vez:
- El puerto 22 puede estar cerrado. La instancia ni siquiera necesita IP pública.
- La autorización deja de vivir en un archivo authorized_keys y pasa a ser IAM, con el mismo lenguaje de permisos que el resto de tu infraestructura.
- Cada comando queda registrado en CloudTrail: quién lo mandó, cuándo y qué devolvió.
Los permisos del rol de CI quedan mínimos de verdad: puede mandar un comando a una instancia concreta, usando un documento concreto, y leer el resultado. Nada más.
{
Sid = "SsmSendCommand"
Effect = "Allow"
Action = "ssm:SendCommand"
Resource = [
"arn:aws:ec2:${var.region}:${account_id}:instance/${instance_id}",
"arn:aws:ssm:${var.region}::document/AWS-RunShellScript",
]
}
Compará eso con "tiene shell". No hay punto de comparación.
En el workflow, el envío se ve así:
payload="$(printf 'export DEPLOY_PROJECT_DOMAIN=%q\n' "$PROJECT_DOMAIN"; cat deploy/production-deploy.sh)"
b64="$(printf '%s' "$payload" | base64 -w0)"
command_id="$(aws ssm send-command \
--instance-ids "$SSM_INSTANCE_ID" \
--document-name AWS-RunShellScript \
--parameters commands="[\"echo ${b64} | base64 -d | bash\"]" \
--query Command.CommandId --output text)"
El base64 no es paranoia: los parámetros de SSM viajan dentro de un JSON, y un script de shell con comillas, saltos de línea y $ sin escapar es una fuente infinita de problemas de quoting. Codificarlo entero lo vuelve una sola cadena inerte que sobrevive el viaje.
Un detalle que se olvida seguido: send-command es asíncrono. Devuelve un CommandId y termina. Si tu pipeline no hace polling hasta un estado terminal, el job va a dar verde aunque el deploy haya explotado en el servidor.
Concepto 3: Terraform en dos capas y remote_state
Cuando varios proyectos comparten una misma EC2, aparece una pregunta que no es técnica sino de propiedad: ¿quién es dueño de qué recurso?
Si cada proyecto define la instancia y su rol, el primero que haga apply le pisa la configuración al resto. Si nadie los define, no existen. Y meter todo en un único Terraform gigante significa que tocar un permiso de una app implica poder destruir la infraestructura de todas.
La solución fue partirlo en dos capas con una regla clara:
- Layer 1 (repositorio aparte) es dueña de la EC2 compartida, del rol de la instancia y del OIDC provider de la cuenta.
- Layer 2 (dentro de este repositorio) es dueña únicamente de lo propio: el rol de CI, los parámetros del chatbot y la política que le da acceso a ellos.
Layer 2 lee las salidas de layer 1, pero nunca les hace apply. Eso se expresa con terraform_remote_state:
data "terraform_remote_state" "platform" {
backend = "s3"
config = {
bucket = "core-infra-tfstate-<ACCOUNT_ID>"
key = "platform/terraform.tfstate"
region = "us-east-1"
}
}
A partir de ahí, layer 2 consume ec2_role_name, github_oidc_provider_arn y ec2_instance_id como si fueran variables, sin poder modificarlos. Cada capa guarda su propio state con una key distinta en el mismo bucket, cifrado y con locking nativo de S3.
El patrón que hace que esto funcione: layer 2 adjunta una política inline al rol que es propiedad de layer 1. La identidad sigue perteneciendo al host, pero el permiso específico —"podés leer los secretos de esta app"— pertenece a la app. Cada proyecto agrega su propia política al mismo rol sin tocar las de los demás.
Concepto 4: ignore_changes, o cómo administrar un secreto sin versionarlo
Último problema, y de los más elegantes. Los secretos del chatbot viven en SSM Parameter Store. Queremos que Terraform sepa que existen —para poder darles permisos y para que nadie los borre por accidente— pero no queremos que un valor secreto esté escrito en un archivo .tf dentro del repositorio.
La solución es separar la definición del valor:
resource "aws_ssm_parameter" "chatbot" {
for_each = local.chatbot_secret_keys
name = "/factufacil/chatbot/${each.key}"
type = "SecureString"
value = "CHANGEME" # placeholder: el valor real se preserva abajo
lifecycle {
ignore_changes = [value]
}
}
Terraform es dueño del nombre y del tipo. El valor real se cargó fuera de banda una sola vez, entró al state con un terraform import, y desde entonces ignore_changes hace que Terraform lo ignore para siempre. El plan sale limpio y el secreto nunca aparece en el código.
La advertencia honesta: el valor sí queda en el state. Por eso el bucket del state se trata como material sensible, con acceso restringido. Esto no es magia, es mover el problema a un lugar donde ya lo estabas resolviendo.
Cuándo esto NO es la respuesta
Ningún patrón es gratis. Los límites de lo que conté:
- OIDC necesita que tu CI sea un proveedor de identidad conocido. GitHub, GitLab y CircleCI lo son. Si corrés Jenkins en una máquina propia, esto no aplica tal cual: vas a necesitar otro mecanismo.
- SSM exige el agente instalado y un rol de instancia. En una EC2 moderna viene de fábrica, pero si administrás servidores fuera de AWS, o un VPS de otro proveedor, no tenés Run Command. SSH sigue siendo la respuesta ahí.
- Este deploy hace `git pull` en el servidor. Es pragmático para un Laravel legacy con estado en disco, pero no es un deploy inmutable. No podés volver atrás cambiando un tag de imagen, y dos servidores pueden divergir. Si tu aplicación tolera contenedores inmutables, construir una imagen y rotarla es mejor: reproducible y con rollback real.
- Dos capas de Terraform tienen costo. Un remote_state acopla las capas: si layer 1 renombra una salida, layer 2 se rompe. Vale la pena cuando hay varios proyectos compartiendo infraestructura; para un solo proyecto es burocracia.
Lo que quedó
El resultado es un pipeline donde no existe ninguna credencial de larga vida: GitHub prueba su identidad con un token que dura minutos, AWS le entrega permisos para una sola acción sobre una sola instancia, y el script de deploy corre en el servidor sin que nadie abra una sesión.
Los tres archivos que lo sostienen son terraform/ para la infraestructura, .github/workflows/ci-cd.yml para el pipeline y deploy/production-deploy.sh para lo que efectivamente pasa en el host.
Y si tuviera que quedarme con una sola idea de todo el proceso, es la del segundo bug: en autenticación federada, el error casi nunca está en la criptografía. Está en que dos sistemas creen estar hablando del mismo identificador y no lo están.