Stack: AWS (EC2, Cloudwatch) + Django + Docker + Terraform + nginx
Stack: AWS (EC2, Cloudwatch) + Django + Docker + Terraform + nginx
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:
Para este workshop vamos a necesitar:
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
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.
Tienes dos opciones (te recomiendo la primera):
Vamos a crear un proyecto base de Django desde cero. Servirá para entender la estructura y preparar el entorno.
mkdir proyecto1 && cd proyecto1
uv init --bareEsto crea un pyproject.toml, el archivo que describe el proyecto y sus dependencias. Cada proyecto en su propio directorio evita mezclar archivos.
uv add django gunicorn psycopg2-binaryuv crea el entorno virtual .venv y guarda las versiones exactas en uv.lock:
Así desarrollo y producción usan la misma base.
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 migrateEsto 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.
Ejecutamos el servidor de desarrollo. No se usa en producción, pero permite probar la aplicación localmente:
uv run python manage.py runserver
Prueba en el navegador: http://127.0.0.1:8000/. Si aparece la página de inicio de Django, el entorno está listo.

Sube pyproject.toml y uv.lock al repositorio: con ellos, uv sync instala las mismas versiones en cualquier máquina.
Sin Docker:
Con Docker:
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.

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.
| Comando | Descripción |
|---|---|
FROM | Imagen base del sistema operativo y runtime. Ejemplo: python:3.14-slim |
WORKDIR | Directorio de trabajo dentro del contenedor. Lo crea si no existe |
ENV | Variables de entorno disponibles durante el build y la ejecución |
COPY | Copia archivos desde tu máquina al contenedor. Ejemplo: COPY . /app/ |
RUN | Ejecuta comandos durante la construcción. Ejemplo: uv sync --locked |
CMD | Comando que se ejecuta al iniciar el contenedor. Solo puede haber uno |
EXPOSE | Documenta el puerto que usa la aplicación. No lo publica automáticamente |
Build time (docker build):
FROM, WORKDIR, COPY, RUN, ENVRuntime (docker run):
CMDdocker run crea un contenedor nuevoCMDCon el Dockerfile listo, construye la imagen:
docker build -t proyecto1-ec2:latest .Y ejecuta el contenedor:
docker run -d -p 8000:8080 proyecto1-ec2:latestEsto 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>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):
Con código (recomendado):
Hoy lo haremos con código. Primero, instala AWS CLI en tu PC y verifica la instalación:
aws --version
Antes de subir la imagen a ECR, iniciamos sesión en AWS desde la terminal.









aws configureTe pedirá tu AWS Access Key ID y tu AWS Secret Access Key, obtenidas en el paso anterior. Como región, escribe "us-east-2".
aws sts get-caller-identity # Muestra tu ID de usuario, cuenta y ARN
Con la cuenta configurada, creamos un repositorio en ECR:
aws ecr create-repository --repository-name proyecto1-ec2Devuelve 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" }
}
}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.
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_URIEtiqueta 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.
Busca la imagen en el repositorio desde la consola de ECR:

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…
Imagina que configuraste tu aplicación Django manualmente en AWS:
Genial, pero ahora necesitas:
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:
terraform applyDescárgalo desde la guía de instalación de Terraform.

Nuestro objetivo final es:
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.
Suficiente para empezar a ver código…
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).
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.
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"
}
}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
}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.
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.
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" }
}]
})
}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.
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.
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¿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 🐧
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.
Tangente: la seguridad es importante.
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"
}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.passwordTerraform detecta automáticamente las variables de entorno con el prefijo TF_VAR_:
export TF_VAR_username="admin"
export TF_VAR_password="password123"
terraform applyEn 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.
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.backupEs 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.
Todo eso se ve complicado. ¿AWS ofrece alguna forma de gestionar los secretos?
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:

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

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

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

Crea un archivo con las credenciales sensibles, en este caso db-creds.yml:
username: root
password: password123Cí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.encReemplaza <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?).
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
}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"
}

Asigna un nombre para identificar tu secreto. En este ejemplo, "db-creds".
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 initRevisa los cambios que se aplicarán, sin ejecutarlos todavía:
terraform planAplica los cambios y despliega la infraestructura:
terraform applyRevisa en la consola de AWS que cada componente se creó correctamente. Algunos lugares para revisar:
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.
Listo, ya tenemos acceso al servidor Django desplegado desde AWS 🎉
Mmmm… veo un error al acceder 🤔
Demo: ver pantalla
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:
proyecto1/settings.py.ALLOWED_HOSTS.ALLOWED_HOSTS = ["XX.XXX.XX.XXX", "*"] # Escribe la IP pública de tu instancia EC2 aquíHasta ahora, Django ha usado SQLite, una base de datos en un solo archivo. Sirve para desarrollo, pero no para producción:
La solución es un servicio de base de datos gestionado como Amazon RDS:
database.tf para definir una instancia PostgreSQL en RDS.settings.py para que Django se conecte a RDS.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.
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).
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:
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).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"),
}
}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
EOFaws_db_instance.default.address es el endpoint de la base de datos; Terraform lo obtiene al crear la instancia.
settings.py actualizado:docker build -t proyecto1-ec2 .
docker tag ...
docker push ...export TF_VAR_db_password="ContraseñaSegura123"
terraform apply --auto-approveAWS tarda varios minutos en crear la base de datos. Buen momento para un descanso o un poco de aire fresco.
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
}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 createsuperuserEn 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.
Demo: ver pantalla
Lo logramos: ya podemos acceder e iniciar sesión en nuestro sitio Django 🎉
Por defecto, los logs de la aplicación (stdout y stderr) se guardan dentro del contenedor. Esto trae varios problemas:
docker logs <id>.La solución es enviar los logs a un servicio centralizado, como Amazon CloudWatch.

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 ...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.
Tras terraform apply, todos los print() y errores de Django aparecen en CloudWatch. Para verlos:
django-app-logs.Desde ahí puedes buscar errores, filtrar por fecha y configurar alarmas.

Cloudwatch puede enviar alertas por SMS, email o incluso Slack/WhatsApp (requiere configuración extra) 🏄
Cuando termines el proyecto, ejecuta:
terraform destroyTerraform 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.
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 (reverse proxy y archivos estáticos):
/static/).proxy_pass).Gunicorn (servidor de aplicación WSGI):
127.0.0.1:8000), solo accesible para nginx.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 collectstaticEsto genera la carpeta staticfiles.
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;
}
}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:8000Dockerfile: 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"]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)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-approveEn este workshop desplegaste una aplicación Django en la nube ☁️:

Documentación oficial:
¿Preguntas? Siéntete libre de preguntar