Cómo hacer deploy en la nube… ☁️

Stack: AWS (EC2, Cloudwatch) + Django + Docker + Terraform + nginx

1 / 108N notas · F pantalla completa

Cómo hacer deploy en la nube… ☁️

Stack: AWS (EC2, Cloudwatch) + Django + Docker + Terraform + nginx

Agenda

La agenda para hoy incluye:

Introducción

El resultado final

Vamos a trabajar con dos proyectos basados en Django. Como asumiré que ya manejan este framework, podremos concentrarnos directamente en los aspectos del deploy (o despliegue). Gran parte de lo que veremos hoy también es aplicable a otros frameworks como React, Next.js o Astro.

El plan es:

  • Dockerizar ambos proyectos y desplegarlos en Amazon EC2
  • Configurar un dominio personalizado con certificado HTTPS
  • Gestionar toda la infraestructura usando Terraform
Introducción03
Introducción

Preparación del entorno de trabajo

Para este workshop vamos a necesitar:

  • uv (para instalar Python y las dependencias del proyecto)
  • AWS CLI (para conectarnos con Amazon Web Services)
  • GitHub CLI (gh) (para gestionar nuestros repositorios)

Además, usaremos nginx, Docker y Terraform, aunque no será necesario instalarlos localmente.

Abre tu terminal (PowerShell o CMD en Windows, Terminal en Mac/Linux) y verifica qué tienes ya instalado:

# Verifica si tienes uv
uv --version

# Verifica si tienes AWS CLI
aws --version

# Verifica si tienes GitHub CLI
gh --version
Introducción04
Introducción

¿Ves los números de versión?

La terminal muestra la versión de uv, AWS CLI y GitHub CLI

Si aparecen números de versión, ¡genial! Esa herramienta está lista. Si en cambio ves un error o el comando no existe, necesitas instalarla. Si ya tienes todo configurado, salta a la siguiente sección.

Introducción05
Introducción

¿Te faltan herramientas?

Tienes dos opciones (te recomiendo la primera):

  1. Usar mise. Instala y gestiona todas las dependencias con un solo comando, y cambia fácilmente de versión según el proyecto.
  2. Instalar cada herramienta manualmente desde su página oficial: docs.astral.sh/uv, aws.amazon.com/cli, cli.github.com
Introducción06

Proyecto #0: Django (boilerplate)

Django

Configuración inicial

Vamos a crear un proyecto base de Django desde cero. Servirá para entender la estructura y preparar el entorno.

  1. Crear la carpeta del proyecto:
mkdir proyecto1 && cd proyecto1
uv init --bare

Esto crea un pyproject.toml, el archivo que describe el proyecto y sus dependencias. Cada proyecto en su propio directorio evita mezclar archivos.

  1. Instalar las dependencias básicas:
uv add django gunicorn psycopg2-binary

uv crea el entorno virtual .venv y guarda las versiones exactas en uv.lock:

  • django: framework principal
  • gunicorn: servidor de producción
  • psycopg2-binary: conector para PostgreSQL

Así desarrollo y producción usan la misma base.

Django08
Django

Crear el proyecto y migrar

  1. Crear el proyecto Django:
uv run django-admin startproject proyecto1 .

Este comando genera la estructura inicial con los archivos de configuración y el servidor de desarrollo.

Antes de levantar el servidor local, aplicamos las migraciones:

uv run python manage.py migrate

Esto crea la base de datos inicial que Django necesita para usuarios, sesiones y otros componentes internos. Forma parte de la plantilla de Django; no vamos a tocar bases de datos todavía.

Django09
Django

Ejecutar localmente

Ejecutamos el servidor de desarrollo. No se usa en producción, pero permite probar la aplicación localmente:

uv run python manage.py runserver
La terminal muestra el servidor de desarrollo de Django en marcha

Prueba en el navegador: http://127.0.0.1:8000/. Si aparece la página de inicio de Django, el entorno está listo.

La página de inicio de Django en el navegador

Sube pyproject.toml y uv.lock al repositorio: con ellos, uv sync instala las mismas versiones en cualquier máquina.

Django10

Herramienta #1: Docker

Docker

¿Por qué usarlo?

Sin Docker:

  • Configurar manualmente cada servidor
  • Instalar dependencias (Python, nginx, etc.)
  • Ajustar según el sistema operativo
  • Inconsistencias entre entornos
  • "Funciona en mi máquina, pero no en producción"

Con Docker:

  • Empaquetas la app junto con sus dependencias
  • La misma imagen funciona en cualquier entorno
  • Despliegues consistentes
  • Aislamiento entre aplicaciones
Docker12
Docker

Conceptos básicos

Imagen: paquete inmutable con tu aplicación, sus dependencias, la configuración y el sistema operativo base. Se construye una vez con docker build.

Contenedor: instancia ejecutable de una imagen. Cada docker run crea un contenedor nuevo, y puedes tener varios de la misma imagen.

Dockerfile: archivo de texto con las instrucciones para construir una imagen paso a paso.

Un Dockerfile se construye en una imagen, y la imagen se ejecuta como contenedores
Docker13
Docker

El Dockerfile

FROM python:3.14-slim
COPY --from=ghcr.io/astral-sh/uv:0.13.6 /uv /bin/

# Puerto de la aplicación y entorno virtual que crea uv
ENV PORT=8080
ENV PATH="/app/.venv/bin:$PATH"

# Carpeta de trabajo dentro del contenedor
WORKDIR /app

# Instalar las versiones exactas de uv.lock
COPY pyproject.toml uv.lock /app/
RUN uv sync --locked --no-dev

# Copiar el proyecto
COPY . /app

EXPOSE ${PORT}
CMD ["gunicorn", "proyecto1.wsgi:application", "--bind", "0.0.0.0:8080"]

Cada línea es una capa. Docker cachea cada capa para acelerar los siguientes builds.

Crea también un archivo .dockerignore con la línea .venv, para que COPY . /app no copie tu entorno local encima del que instaló uv.

Docker14
Docker

Comandos del Dockerfile

ComandoDescripción
FROMImagen base del sistema operativo y runtime. Ejemplo: python:3.14-slim
WORKDIRDirectorio de trabajo dentro del contenedor. Lo crea si no existe
ENVVariables de entorno disponibles durante el build y la ejecución
COPYCopia archivos desde tu máquina al contenedor. Ejemplo: COPY . /app/
RUNEjecuta comandos durante la construcción. Ejemplo: uv sync --locked
CMDComando que se ejecuta al iniciar el contenedor. Solo puede haber uno
EXPOSEDocumenta el puerto que usa la aplicación. No lo publica automáticamente
Docker15
Docker

Build vs runtime

Build time (docker build):

  • Se ejecutan: FROM, WORKDIR, COPY, RUN, ENV
  • Resultado: una imagen inmutable
  • Se ejecuta una vez (o cuando cambias el Dockerfile)
  • Usa la caché de capas para acelerar

Runtime (docker run):

  • Se ejecuta: CMD
  • Resultado: un contenedor en ejecución
  • Cada docker run crea un contenedor nuevo
  • El contenedor se detiene cuando termina el proceso de CMD
Docker16
Docker

Comandos

Con el Dockerfile listo, construye la imagen:

docker build -t proyecto1-ec2:latest .

Y ejecuta el contenedor:

docker run -d -p 8000:8080 proyecto1-ec2:latest

Esto enlaza el puerto 8080 del contenedor con el puerto 8000 de tu máquina: la aplicación queda en http://localhost:8000.

Para detener el contenedor:

docker ps
docker kill <container_id>
Docker17

Herramienta #2: AWS CLI

AWS CLI

AWS ECR

Ahora que tenemos la imagen Docker en nuestro equipo, hay que subirla a la nube para que el servidor pueda descargarla y ejecutarla. En AWS, esto se hace con el Elastic Container Registry (ECR).

Si aún no tienes una cuenta en AWS, puedes crear una gratis en AWS Educate o en el AWS Free Tier.

Un registro en ECR se crea a mano desde la consola de AWS o con código:

Manual (desventajas):

  • No es repetible
  • No tiene historial de cambios
  • Es difícil de compartir con el equipo
  • No se revisa en code review

Con código (recomendado):

  • Es repetible
  • Se versiona con git
  • Se revisa en code review
  • El código sirve como documentación
AWS CLI19
AWS CLI

Instalar AWS CLI

Hoy lo haremos con código. Primero, instala AWS CLI en tu PC y verifica la instalación:

aws --version
El instalador de AWS CLI
AWS CLI20
AWS CLI

Iniciar sesión

Antes de subir la imagen a ECR, iniciamos sesión en AWS desde la terminal.

  1. Ve a la sección de usuarios del IAM Dashboard y crea un usuario, o revisa que tu usuario ya tenga credenciales. Si generas credenciales por primera vez, escoge "Log in as root user".
  2. Las siguientes diapositivas muestran el proceso paso a paso. Solo se hace una vez: podrás reutilizar las credenciales mientras trabajes en la misma región.
AWS CLI21
AWS CLI

Crear credenciales: la consola de IAM

La consola de IAM
AWS CLI22
AWS CLI

Crear credenciales: paso 1 de 8

Paso 1 de la creación de credenciales en IAM
AWS CLI23
AWS CLI

Crear credenciales: paso 2 de 8

Paso 2 de la creación de credenciales en IAM
AWS CLI24
AWS CLI

Crear credenciales: paso 3 de 8

Paso 3 de la creación de credenciales en IAM
AWS CLI25
AWS CLI

Crear credenciales: paso 4 de 8

Paso 4 de la creación de credenciales en IAM
AWS CLI26
AWS CLI

Crear credenciales: paso 5 de 8

Paso 5 de la creación de credenciales en IAM
AWS CLI27
AWS CLI

Crear credenciales: paso 6 de 8

Paso 6 de la creación de credenciales en IAM
AWS CLI28
AWS CLI

Crear credenciales: paso 7 de 8

Paso 7 de la creación de credenciales en IAM
AWS CLI29
AWS CLI

Crear credenciales: paso 8 de 8

Paso 8 de la creación de credenciales en IAM
AWS CLI30
AWS CLI

Configurar las credenciales

  1. Configura tus credenciales de AWS en la terminal:
aws configure

Te pedirá tu AWS Access Key ID y tu AWS Secret Access Key, obtenidas en el paso anterior. Como región, escribe "us-east-2".

  1. Verifica que la configuración es correcta:
aws sts get-caller-identity # Muestra tu ID de usuario, cuenta y ARN
La salida de aws sts get-caller-identity
AWS CLI31
AWS CLI

Crear un repositorio en ECR

Con la cuenta configurada, creamos un repositorio en ECR:

aws ecr create-repository --repository-name proyecto1-ec2

Devuelve un objeto JSON con el repositorio creado:

{
  "repository": {
    "repositoryArn": "arn:aws:ecr:us-east-2:***:repository/proyecto1-ec2",
    "registryId": "***",
    "repositoryName": "proyecto1-ec2",
    "repositoryUri": "***.dkr.ecr.us-east-2.amazonaws.com/proyecto1-ec2",
    "createdAt": "20XX-XX-XXTXX:XX:XX.XXX000-05:00",
    "imageTagMutability": "MUTABLE",
    "imageScanningConfiguration": { "scanOnPush": false },
    "encryptionConfiguration": { "encryptionType": "AES256" }
  }
}
AWS CLI32
AWS CLI

Guardar la URI del repositorio

ECR_REPO_URI sirve para los comandos de Docker que siguen. TF_VAR_ecr_repo_url le pasa el mismo valor a Terraform, que lo usará al crear el servidor.

AWS CLI33
AWS CLI

Subir nuestra imagen Docker a ECR

Inicia sesión en el repositorio de ECR:

aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin $env:ECR_REPO_URI

Etiqueta la imagen:

docker tag proyecto1-ec2:latest "$($env:ECR_REPO_URI):latest"

Sube la imagen al repositorio:

docker push "$($env:ECR_REPO_URI):latest"

Puede tomar un par de minutos. Puedes ir por un café o tomar un descanso.

AWS CLI34
AWS CLI

Verificar en la consola

Busca la imagen en el repositorio desde la consola de ECR:

La imagen proyecto1-ec2 en la consola de ECR
AWS CLI35

A continuación, configuraremos un servidor en AWS, descargaremos la imagen y verificaremos que el proyecto Django se ejecute al acceder a la dirección IP del servidor… Pero antes…

Herramienta #3: Terraform

Terraform

¿Por qué lo vamos a usar?

Imagina que configuraste tu aplicación Django manualmente en AWS:

  • Hiciste clic en 47 botones diferentes en la consola
  • Configuraste redes, subnets, security groups…
  • Todo funcionó perfectamente

Genial, pero ahora necesitas:

  • Replicarlo en producción (a escala: varias regiones, cuentas e instancias)
  • Que tu compañero de trabajo configure su entorno
  • Documentar qué hiciste exactamente para repetir el proceso
Terraform38
Terraform

La solución es Infraestructura como Código (IaC)

Terraform define y gestiona tu infraestructura con archivos .tf. Un ejemplo mínimo:

resource "aws_instance" "web" {
  ami           = "ami-0c101f26f147fa7fd"   // ID de la imagen de Amazon Linux 2023
  instance_type = "t3.micro"                // 1GB RAM, 2 vCPUs
}

Ventajas:

  • El mismo código genera el mismo resultado en AWS, Google Cloud, Azure, etc.
  • Se versiona con Git: ves qué cambió, cuándo y por qué (útil ante bugs o rollbacks)
  • La infraestructura se replica en segundos (no en 435345 clics) con terraform apply
  • El código ES la documentación

Descárgalo desde la guía de instalación de Terraform.

Terraform39
Terraform

Tu flujo de trabajo al final del workshop

Del código local a Docker, a ECR y a un servidor EC2

Nuestro objetivo final es:

  1. Migrar Django de la ejecución local a un contenedor Docker (algunos lo llaman dockerizar)
  2. Crear la imagen Docker y subirla a AWS ECR (Elastic Container Registry)
  3. Usar Terraform para crear un servidor EC2 que descargue y ejecute tu imagen (lo veremos en esta sección)
  4. Abrir tu aplicación desde el navegador en una IP pública
Terraform40
Terraform

Componentes de AWS

Desplegar significa crear la infraestructura donde vivirá tu aplicación. Piénsalo como construir un edificio: no puedes construir sin terreno, y en AWS pasa lo mismo.

[1] VPC (Virtual Private Cloud): tu terreno delimitado en AWS. Todo vive aquí dentro.

[2] Subnets: zonas dentro de la VPC para organizar recursos.

[3] Internet Gateway: la puerta que conecta tu edificio con internet.

[4] Route Tables: definen las rutas del tráfico dentro de la red.

[5] Security Groups: guardias que controlan quién entra y por dónde.

[6] EC2 Instance: el servidor donde corre tu aplicación; en este caso, Django.

Terraform crea todo esto automáticamente, en el orden correcto.

Terraform41

Suficiente para empezar a ver código…

Terraform

AWS: VPC [1]

Primero, indicamos a Terraform que usaremos AWS como proveedor:

provider "aws" {
  region = "us-east-2"
}

resource "aws_vpc" "default" {
  cidr_block = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = { Name = "Django_VPC" }
}

El segundo bloque define nuestra red privada (VPC).

enable_dns_support activa la resolución de nombres dentro de la VPC.

enable_dns_hostnames asigna nombres DNS internos a las instancias, de modo que podamos usar nombres como db.internal en lugar de direcciones IP (mucho más fácil de recordar).

Terraform43
Terraform

AWS: Internet Gateway [3]

El Internet Gateway da a los recursos de la VPC acceso a internet (por ejemplo, para actualizar paquetes o exponer una app web):

resource "aws_internet_gateway" "default" {
  vpc_id = aws_vpc.default.id

  tags = {
    Name = "Django_IGW"
  }
}

Ojito a aws_vpc.default.id. Terraform entiende las dependencias: no puedes crear el gateway sin la VPC primero.

Terraform44
Terraform

AWS: Subnets [2]

Creamos dos subnets públicas en zonas de disponibilidad distintas, para alta disponibilidad:

resource "aws_subnet" "public_a" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = "${var.aws_region}a"
  map_public_ip_on_launch = true

  tags = {
    Name = "Subnet-Public-A"
  }
}
resource "aws_subnet" "public_b" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.2.0/24"
  availability_zone       = "${var.aws_region}b"
  map_public_ip_on_launch = true

  tags = {
    Name = "Subnet-Public-B"
  }
}
Terraform45
Terraform

AWS: Route Tables [4]

La tabla de rutas da salida a internet a ambas subnets públicas:

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.gw.id
  }

  tags = { Name = "RT-Public-Django" }
}

Traducción: "si el destino NO está dentro de la VPC (10.0.0.0/16), envíalo a internet por el Internet Gateway".

# Asociar la subnet A con la tabla de rutas
resource "aws_route_table_association" "a" {
  subnet_id      = aws_subnet.public_a.id
  route_table_id = aws_route_table.public.id
}

# Asociar la subnet B con la tabla de rutas
resource "aws_route_table_association" "b" {
  subnet_id      = aws_subnet.public_b.id
  route_table_id = aws_route_table.public.id
}
Terraform46
Terraform

AWS: Security Groups [5] (ingress)

Un grupo de seguridad define qué tráfico entra a nuestras instancias EC2 dentro de la VPC:

resource "aws_security_group" "ec2_sg" {
  vpc_id = aws_vpc.default.id

  # Permitir tráfico HTTP desde cualquier lugar
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

También se puede añadir una regla que permita conectarnos a la instancia por SSH desde nuestra IP pública.

Terraform47
Terraform

AWS: Security Groups [5] (egress)

Las reglas de salida (egress) controlan el tráfico desde nuestras instancias hacia otros destinos, como internet:

resource "aws_security_group" "ec2_sg" {
  vpc_id = aws_vpc.default.id

  egress {
    from_port   = 0
    to_port     = 65535
    protocol    = "-1"              # Todos los protocolos
    cidr_blocks = ["0.0.0.0/0"]     # Cualquier destino en la red
  }
}

Aquí ingress y egress aparecen por separado para mayor claridad. En la práctica, ambas reglas se definen dentro del mismo recurso.

Terraform48
Terraform

AWS: EC2 Instance [6] (roles IAM)

Nuestra instancia EC2 necesita permisos para descargar imágenes Docker desde ECR. Para ello creamos un rol IAM (aws_iam_role) que la instancia puede asumir.

El rol otorga credenciales temporales con los permisos necesarios.

resource "aws_iam_role" "ec2_ecr" {
  name = "ec2_ecr_access"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }
    }]
  })
}
Terraform49
Terraform

AWS: EC2 Instance [6] (políticas y perfil)

Adjuntamos al rol una política de solo lectura en ECR y creamos el perfil de instancia que EC2 usará al iniciarse:

resource "aws_iam_role_policy_attachment" "ecr_read" {
  role       = aws_iam_role.ec2_ecr.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
}

resource "aws_iam_instance_profile" "ec2_profile" {
  name = "ec2_profile"
  role = aws_iam_role.ec2_ecr.name
}

aws_iam_role_policy_attachment da permisos de solo lectura a ECR: los mínimos, por seguridad.

aws_iam_instance_profile permite que la instancia EC2 use el rol del paso anterior.

Terraform50
Terraform

AWS: EC2 Instance [6] (creación, parte 1)

Finalmente, creamos la instancia EC2 donde se ejecutará nuestra aplicación Django:

resource "aws_instance" "django_server" {
  ami                         = "ami-0c101f26f147fa7fd"
  instance_type               = "t3.micro"
  subnet_id                   = aws_subnet.public_a.id
  vpc_security_group_ids      = [aws_security_group.ec2_sg.id]
  iam_instance_profile        = aws_iam_instance_profile.ec2_profile.name
  associate_public_ip_address = true

  user_data = <<-EOF
    # Aquí va el script que se ejecuta al iniciar la instancia
  EOF

  tags = { Name = "Servidor-Django-Andres" }
}

La instancia usa el rol IAM configurado para obtener acceso de solo lectura a ECR.

Terraform51
Terraform

AWS: EC2 Instance [6] (creación, parte 2)

Este script (user_data) se ejecuta UNA VEZ, cuando la instancia inicia. ${var.ecr_repo_url} es la URL completa del repositorio ECR que guardaste antes.

# 1. Instalar Docker
yum update -y && yum install -y docker && service docker start
# 2. Login a ECR
aws ecr get-login-password --region ${var.aws_region} \
  | docker login --username AWS --password-stdin ${var.ecr_repo_url}
# 3. Descargar y ejecutar tu imagen Django
docker run -d -p 80:80 --restart unless-stopped \
  -e SECRET_KEY='${var.secret_key}' \
  -e DB_NAME='${aws_db_instance.main.db_name}' \
  -e DB_USER='${aws_db_instance.main.username}' \
  -e DB_PASSWORD='${var.db_password}' \
  -e DB_HOST='${aws_db_instance.main.address}' \
  -e DB_PORT='${aws_db_instance.main.port}' \
  ${var.ecr_repo_url}:latest
Terraform52

¿Y si no uso Terraform? AWS tiene muchos componentes. Con Terraform o desde la consola, debes crear cada uno: red, VPC, gateway, subredes, grupos de seguridad, servidor y roles de IAM. Investiga cada parte para entender su función.

Recomendación: no memorices el proceso. Los archivos .tf estarán en el repositorio 🐧

Terraform

AWS: en resumen…

Acabas de definir la infraestructura AWS para desplegar tu aplicación Django en un contenedor:

Redes: una VPC con DNS interno. Una subred pública en us-east-2a que asigna IPs públicas. El Internet Gateway conecta la VPC a internet, y la tabla de rutas envía todo el tráfico no local (0.0.0.0/0) al gateway.

Seguridad: el security group actúa como firewall: deja entrar HTTP (puerto 80) desde cualquier origen y permite toda la salida. Un rol IAM con la política AmazonEC2ContainerRegistryReadOnly, asociado vía instance profile, da acceso a ECR sin credenciales en el código.

Servidor: una t3.micro con Amazon Linux 2023 (ami-0c101f26f147fa7fd). Su user_data instala Docker, se autentica en ECR con el rol IAM y levanta el contenedor Django.

Terraform55

Tangente: la seguridad es importante.

Terraform

Variables de entorno y secretos

Tarde o temprano, Terraform necesita credenciales sensibles (secrets): contraseñas de base de datos, tokens de API o claves de acceso a AWS.

Escribirlas directamente en el código es una mala práctica.

Esto es inseguro: las credenciales quedan expuestas en el código, en terraform.tfstate y quizá en el historial de Git. Aunque el repositorio sea privado, una filtración o un acceso local comprometería toda la infraestructura.

resource "aws_db_instance" "mydb" {
    engine         = "postgres"
    instance_class = "db.t3.micro"

    # 🚨 atención aquí:
    username       = "root"
    password       = "password123"
}
Terraform57
Terraform

Variables sensibles en Terraform

Terraform permite marcar variables como sensitive, para que sus valores no aparezcan en logs ni en la salida de los comandos.

variable "username" {
  type        = string
  sensitive   = true
}

variable "password" {
  type        = string
  sensitive   = true
}

En lugar de valores literales, el recurso usa las referencias var.username y var.password:

username = var.username
password = var.password
Terraform58
Terraform

Usando variables de entorno

Terraform detecta automáticamente las variables de entorno con el prefijo TF_VAR_:

export TF_VAR_username="admin"
export TF_VAR_password="password123"
terraform apply

En Windows (PowerShell):

$env:TF_VAR_username = "admin"
$env:TF_VAR_password = "password123"

Al ejecutar terraform apply, Terraform lee TF_VAR_username y TF_VAR_password y usa sus valores.

Así los valores no están en el código, pero tampoco es del todo seguro: quedan registrados en el historial de la terminal y visibles para otros procesos de tu ordenador.

Terraform59
Terraform

Usando archivos .tfvars locales

Otra práctica habitual es un archivo terraform.tfvars con las variables reales:

username = "admin"
password = "password123"

Este archivo no debe subirse al repositorio, así que lo agregamos al .gitignore:

terraform.tfvars
*.tfstate
*.tfstate.backup

Es simple y práctico para entornos locales o de desarrollo.

Pero en un equipo, cada miembro gestiona su propio archivo y debe mantenerlo fuera del control de versiones. Y no soluciona el problema anterior: un proceso malicioso en tu ordenador podría leer tus secretos.

Terraform60

Todo eso se ve complicado. ¿AWS ofrece alguna forma de gestionar los secretos?

Terraform

AWS KMS (cifrado)

Para entornos empresariales, se recomienda AWS Key Management Service (KMS) para cifrar secretos y datos sensibles. KMS gestiona las claves de cifrado de forma centralizada y segura en AWS. Cuesta alrededor de $1 USD al mes por clave.

Abre la consola de KMS y sigue estos pasos:

  1. Selecciona "Create key"
  2. Elige "Symmetric" como key type y "Encrypt and decrypt" como key usage
  3. Haz clic en "Next"
La configuración de una clave simétrica en KMS
Terraform62
Terraform

AWS KMS: nombre y permisos

Asigna un nombre para identificar tu clave. En este ejemplo, "key-aws".

El nombre de la clave en KMS

En los permisos, selecciona el mismo usuario que creamos en la sección de AWS CLI.

Los administradores de la clave en KMS
Terraform63
Terraform

AWS KMS: el ID de la clave

Guarda el ID de la clave generada por KMS. Lo necesitarás en los siguientes pasos.

La clave creada en KMS con su ID
Terraform64
Terraform

AWS KMS: cifrar las credenciales

Crea un archivo con las credenciales sensibles, en este caso db-creds.yml:

username: root
password: password123

Cífralo con AWS CLI y la clave KMS:

aws kms encrypt \
      --key-id <KEY_ID> \
      --region us-east-2 \
      --plaintext fileb://db-creds.yml \
      --output text \
      --query CiphertextBlob > db-creds.yml.enc

Reemplaza <KEY_ID> con el ID de tu clave. Si tu archivo YAML tiene otro nombre, cámbialo también en el comando.

El comando genera el archivo cifrado db-creds.yml.enc. Puedes compartirlo de forma segura e incluso incluirlo en los repositorios internos de tu empresa (¿recuerdas que añadimos archivos al .gitignore?).

Terraform65
Terraform

AWS KMS: integración con Terraform

Terraform descifra las credenciales durante terraform apply:

data "aws_kms_secrets" "creds" {
  secret {
    name    = "db"
    payload = file("${path.module}/db-creds.yml.enc")
  }
}
locals {
  db_creds = jsondecode(
    data.aws_kms_secrets.creds.secret[0].plaintext
  )
}

Ahora el recurso de la base de datos usa las credenciales descifradas:

resource "aws_example_instance" "mydb" {
  engine = "postgres"
  instance_class = "db.t3.micro"
  # Credenciales seguras mediante KMS:
  username = local.db_creds.username
  password = local.db_creds.password
}
Terraform66
Terraform

AWS Secrets Manager

AWS Secrets Manager es una alternativa completamente gestionada. Los secretos se guardan cifrados, pueden rotarse automáticamente y Terraform los lee como datos:

data "aws_secretsmanager_secret_version" "creds" {
  secret_id = "db-creds"
}
locals {
  db_creds = jsondecode(
    data.aws_secretsmanager_secret_version.creds.secret_string
  )
}

Crea un secreto nuevo en la consola de AWS, buscando "Secrets Manager", o desde aquí.

Como tipo de secreto, escoge "Other type of secret" y escribe:

{
  "username": "root",
  "password": "password123"
}
Terraform67
Terraform

AWS Secrets Manager: crear el secreto

El tipo de secreto en Secrets Manager
El nombre del secreto en Secrets Manager

Asigna un nombre para identificar tu secreto. En este ejemplo, "db-creds".

Terraform68
Terraform

Flujo de trabajo

Ya sabemos manejar secretos y cómo interactúan AWS y Terraform. Creemos la infraestructura.

Inicializa el directorio y descarga los providers de AWS (solo la primera vez):

terraform init

Revisa los cambios que se aplicarán, sin ejecutarlos todavía:

terraform plan

Aplica los cambios y despliega la infraestructura:

terraform apply
Terraform69
Terraform

Verificar los recursos

Revisa en la consola de AWS que cada componente se creó correctamente. Algunos lugares para revisar:

Terraform70
Terraform

Información útil después del deploy

Después de terraform apply, queremos saber la IP de nuestra app:

output "instance_ip" {
  description = "IP pública del servidor Django"
  value       = aws_instance.django.public_ip
}

output "app_url" {
  description = "URL de la aplicación"
  value       = "http://${aws_instance.django.public_ip}"
}

Resultado en terminal:

Outputs:

instance_ip = "99.999.99.999"
app_url     = "http://99.999.99.999"

Copia la URL y ábrela en tu navegador.

Terraform71

Listo, ya tenemos acceso al servidor Django desplegado desde AWS 🎉

Mmmm… veo un error al acceder 🤔

Demo: ver pantalla

Django

DisallowedHost

Este error es una medida de seguridad de Django: por defecto, solo acepta peticiones desde localhost. Al acceder desde una IP pública, Django la rechaza.

La solución es indicar explícitamente qué dominios o IPs pueden acceder a la aplicación:

  1. Abre el archivo proyecto1/settings.py.
  2. Busca la línea ALLOWED_HOSTS.
  3. Añade la IP pública de tu instancia EC2 entre comillas. Para permitir cualquier host (no recomendado en producción), usa un asterisco.
ALLOWED_HOSTS = ["XX.XXX.XX.XXX", "*"] # Escribe la IP pública de tu instancia EC2 aquí
Django75

Herramienta #4: AWS RDS

AWS RDS

De SQLite a PostgreSQL

Hasta ahora, Django ha usado SQLite, una base de datos en un solo archivo. Sirve para desarrollo, pero no para producción:

  • No soporta alta concurrencia (varios usuarios a la vez).
  • El archivo vive dentro del contenedor, así que se pierde si el contenedor se reinicia o se reemplaza.

La solución es un servicio de base de datos gestionado como Amazon RDS:

  • Copias de seguridad, parches y escalado sencillo.
  • Funciona de forma independiente del servidor de la aplicación (EC2).
  • Alta disponibilidad y durabilidad de los datos.
AWS RDS77
AWS RDS

El plan

  1. Crear database.tf para definir una instancia PostgreSQL en RDS.
  2. Actualizar settings.py para que Django se conecte a RDS.
  3. Pasar las credenciales de la base de datos al contenedor Docker de forma segura.
AWS RDS78
AWS RDS

Terraform (subnet)

En database.tf definimos primero la red de RDS. RDS necesita un grupo de subnets en al menos dos zonas de disponibilidad (availability zones):

resource "aws_db_subnet_group" "default" {
  name       = "django-db-subnet-group"
  subnet_ids = [
    aws_subnet.public.id,       // subnet #1
    aws_subnet.public_b.id,     // subnet #2
  ]
}

Cada subnet está en una zona distinta (a y b). Si una zona falla, RDS puede seguir operando en la otra.

AWS RDS79
AWS RDS

Terraform (security group)

resource "aws_security_group" "rds_sg" {
  name   = "django-rds-sg"
  vpc_id = aws_vpc.default.id

  ingress {
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"] // Desarrollo: cualquier IP
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

Un grupo de seguridad que solo admite conexiones entrantes en el puerto de PostgreSQL (5432).

AWS RDS80
AWS RDS

Terraform (instancia de la base de datos)

Definimos la instancia de PostgreSQL con aws_db_instance:

resource "aws_db_instance" "default" {
  engine               = "postgres"
  instance_class       = "db.t3.micro"
  db_name              = "django_db"
  username             = "admin"
  password             = var.db_password
  publicly_accessible  = true // Solo para migraciones desde local
}

Algunos detalles que vale la pena mencionar:

  • La instancia usa el subnet group y el security group que creamos antes.
  • publicly_accessible = true permite ejecutar migraciones desde tu ordenador; en producción debe ser false.
  • db_password debe manejarse como secret (ver la sección de secretos de Terraform).
AWS RDS81
AWS RDS

Conexión con Django

Actualizamos proyecto1/settings.py para que lea la configuración de la base de datos desde variables de entorno. Así las credenciales no quedan en el código.

Ahora el contenedor Django espera estas 5 variables de entorno para conectarse a la base de datos.

# ... otras configuraciones

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ.get("DB_NAME"),
        "USER": os.environ.get("DB_USER"),
        "PASSWORD": os.environ.get("DB_PASSWORD"),
        "HOST": os.environ.get("DB_HOST"),
        "PORT": os.environ.get("DB_PORT"),
    }
}
AWS RDS82
AWS RDS

Actualizar el script de inicio de EC2

Modificamos el user_data de main.tf (el script de inicio de la instancia EC2) para pasar las variables de entorno al contenedor. Terraform obtiene los valores de la instancia RDS recién creada:

user_data = <<-EOF
# ... (instalación de Docker y login a ECR) ...

docker run -d -p 80:8080 --restart unless-stopped \
  -e SECRET_KEY=${var.secret_key} \
  -e DB_NAME=${aws_db_instance.default.db_name} \
  -e DB_USER=${aws_db_instance.default.username} \
  -e DB_PASSWORD=${var.db_password} \
  -e DB_HOST=${aws_db_instance.default.address} \
  -e DB_PORT=${aws_db_instance.default.port} \
  YOUR_ECR_REGISTRY_URL/django-app:latest
EOF

aws_db_instance.default.address es el endpoint de la base de datos; Terraform lo obtiene al crear la instancia.

AWS RDS83
AWS RDS

Aplicar cambios

  1. Reconstruye y sube la imagen Docker con el settings.py actualizado:
docker build -t proyecto1-ec2 .
docker tag ...
docker push ...
  1. Aplica los cambios de Terraform. Esto crea la instancia RDS y actualiza EC2 para usar la nueva imagen con las variables de entorno correctas:
export TF_VAR_db_password="ContraseñaSegura123"
terraform apply --auto-approve

AWS tarda varios minutos en crear la base de datos. Buen momento para un descanso o un poco de aire fresco.

AWS RDS84
AWS RDS

Migrar la base de datos

  1. Ejecuta las migraciones de Django. La base de datos RDS está vacía; nos conectamos desde nuestra máquina para crear las tablas iniciales.

Añade este output a database.tf para ver la dirección de la base de datos tras terraform apply:

output "db_endpoint" {
  description = "Endpoint de la instancia de base de datos RDS"
  value       = aws_db_instance.default.endpoint
}
AWS RDS85
AWS RDS

Migrar desde tu máquina

Configura tu entorno local con todas las credenciales:

export DB_HOST="<ENDPOINT_DE_RDS_AQUÍ>"   # La salida de Terraform
export DB_NAME="django_db"                # El de database.tf
export DB_USER="admin"                    # El de database.tf
export DB_PORT="5432"                     # El puerto de PostgreSQL
export DB_PASSWORD="ContraseñaSegura123"  # La de 'terraform apply'

Y ejecuta los comandos de Django:

uv run python manage.py migrate
uv run python manage.py createsuperuser

En http://XX.XXX.XX.XXX/admin/login/ verás el inicio de sesión de Django. Entra con el usuario y la contraseña que acabas de crear.

AWS RDS86

Demo: ver pantalla

Lo logramos: ya podemos acceder e iniciar sesión en nuestro sitio Django 🎉

Herramienta #5: Amazon Cloudwatch

Cloudwatch

¿Por qué centralizar los logs?

Por defecto, los logs de la aplicación (stdout y stderr) se guardan dentro del contenedor. Esto trae varios problemas:

  • Si el contenedor se detiene o se reemplaza, los logs se pierden.
  • Tendrías que conectarte por SSH y ejecutar docker logs <id>.
  • Repetirlo en varios servidores es impráctico.

La solución es enviar los logs a un servicio centralizado, como Amazon CloudWatch.

Los contenedores envían sus logs a CloudWatch
Cloudwatch90
Cloudwatch

1. Configurar Docker

Docker envía los logs directamente a CloudWatch con el log driver awslogs.

Actualiza el docker run del script user_data en main.tf:

docker run -d -p 80:8080 --restart unless-stopped \
  --log-driver=awslogs \
  --log-opt awslogs-region=us-east-2 \
  --log-opt awslogs-group="django-app-logs" \
  --log-opt awslogs-create-group=true \
  # ... resto de variables -e ...
Cloudwatch91
Cloudwatch

2. Permisos IAM

resource "aws_iam_policy" "cloudwatch_logs" {
  name   = "EC2CloudWatchLogsPolicy"
  policy = jsonencode({
    Version   = "2012-10-17",
    Statement = [{
      Effect   = "Allow",
      Action   = ["logs:CreateLogStream", "logs:PutLogEvents"],
      Resource = "arn:aws:logs:*:*:*"
    }]
  })
}

resource "aws_iam_role_policy_attachment" "ec2_logs" {
  role       = aws_iam_role.ec2_ecr.name
  policy_arn = aws_iam_policy.cloudwatch_logs.arn
}

El servidor necesita permiso para escribir en CloudWatch. Creamos una política IAM y la adjuntamos al rol de la instancia EC2.

Cloudwatch92
Cloudwatch

Visualizar en la consola de AWS

Tras terraform apply, todos los print() y errores de Django aparecen en CloudWatch. Para verlos:

  1. En la consola de AWS, busca Cloudwatch.
  2. En el menú izquierdo, selecciona Logs > Log groups.
  3. Abre el grupo de logs django-app-logs.

Desde ahí puedes buscar errores, filtrar por fecha y configurar alarmas.

El grupo de logs django-app-logs en CloudWatch
Cloudwatch93

Cloudwatch puede enviar alertas por SMS, email o incluso Slack/WhatsApp (requiere configuración extra) 🏄

Terraform

Destrucción de recursos

Cuando termines el proyecto, ejecuta:

terraform destroy

Terraform elimina los recursos en orden inverso: primero la instancia EC2, luego los Security Groups, las Route Tables, las Subnets, el Internet Gateway y por último la VPC.

AWS cobra por los recursos activos, aunque no los uses. Una instancia EC2 cuesta unos $0.01 por hora (cerca de $7 al mes), y una IP elástica reservada sin uso, unos $0.005 por hora. Destruye siempre lo que no uses.

Terraform95

Herramienta #6: nginx

nginx

Proxy inverso

No conviene exponer Gunicorn directamente a internet. Gunicorn ejecuta Django, pero no está optimizado para tráfico HTTP, archivos estáticos ni SSL. Para eso usamos un reverse proxy como nginx.

nginx recibe el tráfico, sirve los estáticos y reenvía el resto a Gunicorn
nginx97
nginx

nginx y Gunicorn

nginx (reverse proxy y archivos estáticos):

  • Recibe el tráfico público en el puerto 80 (HTTP).
  • Sirve los archivos estáticos (/static/).
  • Reenvía las peticiones dinámicas a Gunicorn (proxy_pass).

Gunicorn (servidor de aplicación WSGI):

  • Ejecuta la aplicación Django.
  • Escucha en un puerto local no expuesto (127.0.0.1:8000), solo accesible para nginx.
  • Gestiona los procesos worker de Python.
nginx98
nginx

1. Configurar STATIC_ROOT en Django

Para que nginx sirva los archivos estáticos, Django los agrupa en un directorio. En proyecto1/settings.py, define dónde collectstatic debe reunirlos:

STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"

Luego ejecuta:

uv run python manage.py collectstatic

Esto genera la carpeta staticfiles.

nginx99
nginx

2. Crear el archivo nginx.conf

nginx.conf configura nginx para servir directamente los archivos estáticos de collectstatic (cuando el usuario accede a /static/) y reenviar el resto de las solicitudes a Gunicorn, como las páginas de tu aplicación Django.

server {
    listen 80;

    location /static/ {
        alias /app/staticfiles/;
    }

    # Redirigir todas las demás solicitudes a Gunicorn
    location / {
        proxy_pass http://127.0.0.1:8000;

        # Enviar encabezados importantes al servidor de aplicación
        proxy_set_header Host $host;
    }
}
nginx100
nginx

3. Un entrypoint.sh que inicie ambos servicios

Docker está pensado para un solo proceso principal por contenedor. En producción se recomienda un contenedor para Gunicorn y otro para nginx. Como estamos en desarrollo, usaremos un único contenedor para ambos.

En Linux, da permisos de ejecución al script:

chmod +x entrypoint.sh
#!/bin/bash
set -e

python manage.py collectstatic --noinput --clear

# Inicia nginx en segundo plano
nginx -g "daemon off;" &

# Inicia Gunicorn como proceso principal
exec gunicorn proyecto1.wsgi:application --bind 127.0.0.1:8000
nginx101
nginx

4. Actualizar el Dockerfile y 5. los puertos

  1. Actualiza el Dockerfile: instala nginx y copia los nuevos archivos.
RUN apt-get install -y nginx

# copiar los archivos
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY entrypoint.sh /app/entrypoint.sh

# establecer al entrypoint.sh como punto de entrada
ENTRYPOINT ["/app/entrypoint.sh"]
  1. Cambia el mapeo de puertos en Terraform para exponer solo los puertos públicos (80/443). En el user_data de aws_instance, el puerto mapeado debe ser 80:80:
# main.tf
docker run -d -p 80:80 --restart unless-stopped \
       # ... (variables de entorno)
nginx102
nginx

Desplegar la nueva versión

Por último, reconstruye la imagen, súbela a ECR y aplica los cambios con Terraform:

# 1. Construye la nueva imagen Docker
docker build -t proyecto1-ec2 .

# 2. Etiqueta la imagen para tu repositorio ECR
# (Reemplaza YOUR_ECR_REPO_URL con la URL de tu repo)
docker tag proyecto1-ec2:latest YOUR_ECR_REPO_URL:latest

# 3. Sube la imagen a ECR
docker push YOUR_ECR_REPO_URL:latest

# 4. Aplica los cambios de infraestructura (si los hay)
terraform apply --auto-approve
nginx103

En conclusión… 😮‍💨

TLDR

¿Qué aprendimos en estas dos horas?

En este workshop desplegaste una aplicación Django en la nube ☁️:

  • La aplicación está dockerizada y versionada en ECR
  • Corre en EC2, con la infraestructura como código (Terraform)
  • Está conectada a una base de datos PostgreSQL gestionada (RDS)
  • Envía sus logs a Amazon Cloudwatch para monitoreo
TLDR105
La arquitectura final: Docker, ECR, EC2, RDS, CloudWatch y nginx
Recursos adicionales

La mejor forma de aprender es construyendo

Recursos adicionales107

¡Gracias!

¿Preguntas? Siéntete libre de preguntar