Proyecto completo: chanchito de punta a punta
Una API Node + MongoDB llevada por las tres capas, en tu PC: Docker construye la imagen y sirve para desarrollar, Terraform prepara la plataforma en el cluster y Kubernetes corre la app.
Antes de empezar
Este proyecto combina las tres herramientas. Si alguna te resulta nueva, conviene hacer antes su guía de inicio (Docker: “Empezar desde cero”; Kubernetes: “Cluster local”; Terraform: “Práctica con Docker”).
- Requisitos
- Docker Desktop con Kubernetes activado,
kubectl(viene con Docker Desktop) y Terraform instalado. Todo corre en tu PC. - Plataforma
- Lo que la app necesita y cambia poco (acá: el namespace, el Secret con la contraseña y el ConfigMap). La crea Terraform.
- Namespace
- Una “carpeta” dentro del cluster. Todo el proyecto vive en el namespace
chanchito. - Secret / ConfigMap
- Objetos de Kubernetes con la contraseña (Secret) y la configuración no secreta (ConfigMap) que los Pods leen como variables de entorno.
- Provider kubernetes
- El plugin de Terraform que crea objetos dentro de un cluster de Kubernetes (en vez de en una nube).
- Probe /health
- Una ruta de la app que responde “ok”; Kubernetes la consulta para saber si el Pod está vivo y listo.
- NodePort
- Un tipo de Service que abre un puerto (acá el 30080) para entrar a la app desde tu navegador.
- Rolling update / rollback
- Actualizar a una versión nueva sin cortar el servicio, y volver a la anterior si falla.
Quién crea qué
Cada carpeta del repo la maneja una herramienta distinta, y cada una deja algo en el cluster.
¿Por qué este reparto? Terraform maneja lo que cambia poco y es “de plataforma” (namespaces, secretos, permisos). La app cambia seguido y se despliega con kubectl (o con CI).
Docker Desktop comparte las imágenes con su Kubernetes en el modo predeterminado (kubeadm): no hace falta registry en local. Si en Settings elegiste el modo kind, no se comparten y tenés que usar un registry, igual que en la nube.
La contraseña entra una sola vez (TF_VAR_mongo_password) y llega a Mongo y a la app a través del Secret.
Estructura del repositorio
Si ya tenés una app con Dockerfile y docker-compose, podés reorganizarla así.
Árbol
chanchito/
├── app/ # Docker
│ ├── app.js
│ ├── package.json
│ ├── package-lock.json
│ ├── Dockerfile
│ └── .dockerignore
├── docker-compose.yml # desarrollo local
├── .env # secretos de desarrollo (fuera de git)
├── infra/ # Terraform: la plataforma
│ ├── versions.tf
│ ├── providers.tf
│ ├── variables.tf
│ ├── main.tf
│ └── outputs.tf
├── k8s/ # Kubernetes: la app
│ ├── mongo.yaml
│ └── chanchito.yaml
└── .gitignore
.gitignore del proyecto
# Node
node_modules/
# secretos locales
.env
# Terraform
infra/.terraform/
infra/*.tfstate
infra/*.tfstate.*
infra/*.tfvars
infra/tfplan
infra/.terraform.lock.hcl sí va a git: fija las versiones de los providers.
Requisitos
- Docker Desktop con Kubernetes activado (Settings → Kubernetes).
kubectl(viene con Docker Desktop) apuntando adocker-desktop.- Terraform:
winget install Hashicorp.Terraform.
La app, su imagen y el entorno de desarrollo
Un cambio en app.js: la URL de Mongo llega por variable de entorno, así la misma imagen sirve en Compose y en Kubernetes.
app/app.js
import express from "express";
import mongoose from "mongoose";
const PORT = process.env.PORT ?? 3000;
const MONGO_URL = process.env.MONGO_URL; // viene de Compose o del Secret
const Animal = mongoose.model("Animal", new mongoose.Schema({
tipo: String,
estado: String,
}));
const app = express();
app.get("/", async (_req, res) => res.send(await Animal.find()));
app.get("/crear", async (_req, res) => {
await Animal.create({ tipo: "Chanchito", estado: "Feliz" });
res.send("ok");
});
app.get("/health", (_req, res) => res.send("ok")); // para las probes
await mongoose.connect(MONGO_URL);
app.listen(PORT, () => console.log(`escuchando en ${PORT}`));
Ya no hay contraseña escrita en el código. await en el nivel superior funciona porque el package.json tiene "type": "module".
app/Dockerfile y .dockerignore
FROM node:22-alpine
WORKDIR /app
# dependencias primero: capa cacheable
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV PORT=3000
EXPOSE 3000
USER node
CMD ["node", "app.js"]
# app/.dockerignore
node_modules
npm-debug.log
Dockerfile
.dockerignore
docker-compose.yml (desarrollo)
services:
chanchito:
build: ./app
ports:
- "3000:3000"
environment:
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@monguito:27017/miapp?authSource=admin
depends_on:
- monguito
monguito:
image: mongo:7
environment:
- MONGO_INITDB_ROOT_USERNAME=${MONGO_USER}
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASSWORD}
volumes:
- mongo-data:/data/db
volumes:
mongo-data:
# .env (fuera de git)
MONGO_USER=mati
MONGO_PASSWORD=<password>
La plataforma: namespace, secreto y configuración
Con el provider hashicorp/kubernetes, Terraform crea objetos dentro del cluster de Docker Desktop.
infra/versions.tf · providers.tf · variables.tf
terraform {
required_version = ">= 1.11"
required_providers {
kubernetes = {
source = "hashicorp/kubernetes"
version = "~> 2.36"
}
}
}
provider "kubernetes" {
config_path = "~/.kube/config"
config_context = "docker-desktop"
}
variable "namespace" {
type = string
default = "chanchito"
}
variable "mongo_user" {
type = string
default = "mati"
}
variable "mongo_password" {
type = string
sensitive = true # TF_VAR_mongo_password
}
variable "log_level" {
type = string
default = "info"
}
infra/main.tf · outputs.tf
locals {
ns = kubernetes_namespace_v1.app.metadata[0].name
}
resource "kubernetes_namespace_v1" "app" {
metadata {
name = var.namespace
labels = {
proyecto = "chanchito"
gestion = "terraform"
}
}
}
resource "kubernetes_secret_v1" "mongo" {
metadata {
name = "mongo"
namespace = local.ns
}
data = {
MONGO_INITDB_ROOT_USERNAME = var.mongo_user
MONGO_INITDB_ROOT_PASSWORD = var.mongo_password
MONGO_URL = "mongodb://${var.mongo_user}:${urlencode(var.mongo_password)}@monguito:27017/miapp?authSource=admin"
}
}
resource "kubernetes_config_map_v1" "app" {
metadata {
name = "chanchito-config"
namespace = local.ns
}
data = {
LOG_LEVEL = var.log_level
}
}
output "siguiente_paso" {
value = "kubectl apply -n ${local.ns} -f ../k8s/"
}
En data los valores van en texto plano: el provider los codifica en base64. urlencode() evita que una contraseña con @ o : rompa la URL.
El despliegue de la app
Los manifiestos usan el Secret y el ConfigMap que creó Terraform. Se aplican en el namespace con -n chanchito.
k8s/mongo.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mongo-data
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: monguito
spec:
replicas: 1
strategy:
type: Recreate # un solo Pod usa el disco
selector:
matchLabels: { app: monguito }
template:
metadata:
labels: { app: monguito }
spec:
containers:
- name: mongo
image: mongo:7
ports:
- containerPort: 27017
envFrom:
- secretRef: { name: mongo } # creado por Terraform
volumeMounts:
- name: datos
mountPath: /data/db
volumes:
- name: datos
persistentVolumeClaim:
claimName: mongo-data
---
apiVersion: v1
kind: Service
metadata:
name: monguito # host de MONGO_URL
spec:
selector: { app: monguito }
ports:
- port: 27017
k8s/chanchito.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: chanchito
spec:
replicas: 2
selector:
matchLabels: { app: chanchito }
template:
metadata:
labels: { app: chanchito }
spec:
containers:
- name: chanchito
image: chanchito:1.0.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3000
env:
- name: MONGO_URL
valueFrom:
secretKeyRef: { name: mongo, key: MONGO_URL }
envFrom:
- configMapRef: { name: chanchito-config }
readinessProbe:
httpGet: { path: /health, port: 3000 }
initialDelaySeconds: 3
livenessProbe:
httpGet: { path: /health, port: 3000 }
initialDelaySeconds: 15
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { memory: 256Mi }
---
apiVersion: v1
kind: Service
metadata:
name: chanchito
spec:
type: NodePort
selector: { app: chanchito }
ports:
- port: 80
targetPort: 3000
nodePort: 30080 # http://localhost:30080
Si chanchito arranca antes que Mongo, falla la conexión y Kubernetes lo reinicia hasta que Mongo responde. Un par de reinicios al principio es normal.
Paso a paso
En PowerShell, desde la raíz de chanchito/.
1 · Desarrollar con Compose
docker compose up --build.env. Probá localhost:3000/crear y localhost:3000.docker compose down2 · Construir la versión
docker build -t chanchito:1.0.0 ./app3 · Crear la plataforma
kubectl config use-context docker-desktop
$env:TF_VAR_mongo_password = "<password>"terraform -chdir=infra init
terraform -chdir=infra plan
terraform -chdir=infra apply4 · Desplegar la app
kubectl apply -n chanchito -f k8s/
kubectl get pods -n chanchito -w1/1 Running en los tres Pods (Ctrl+C para salir del watch).# http://localhost:30080/crear
# http://localhost:30080
kubectl logs -n chanchito -l app=chanchito -f5 · Bajar todo
kubectl delete -n chanchito -f k8s/
terraform -chdir=infra destroydelete -f k8s/ incluye el PVC: se pierden los datos de Mongo del cluster (los de Compose siguen en su volumen).
Día a día con el proyecto
Qué herramienta tocás según qué cambia.
Cambié el código
docker build -t chanchito:1.1.0 ./app
kubectl -n chanchito set image deploy/chanchito chanchito=chanchito:1.1.0
kubectl -n chanchito rollout status deploy/chanchitok8s/chanchito.yaml para que el repo refleje lo desplegado.kubectl -n chanchito rollout undo deploy/chanchitoCambié la configuración
terraform -chdir=infra apply -var="log_level=debug"
kubectl -n chanchito rollout restart deploy/chanchito~ en el plan); el restart hace que los Pods lean el valor nuevo.$env:TF_VAR_mongo_password = "<nueva>"
terraform -chdir=infra applyQuiero probar algo rápido
docker compose up --buildkubectl -n chanchito port-forward svc/monguito 27017:27017kubectl -n chanchito exec -it deploy/chanchito -- shSiguiente nivel
Lo mismo, más cerca de producción.
Imagen en un registry
docker tag chanchito:1.0.0 ghcr.io/<usuario>/chanchito:1.0.0
docker push ghcr.io/<usuario>/chanchito:1.0.0El cluster también con Terraform
En la nube, un módulo crea el cluster y el provider kubernetes se conecta a él con sus outputs:
module "eks" {
source = "terraform-aws-modules/eks/aws"
# versión, VPC, nodos…
}
provider "kubernetes" {
host = module.eks.cluster_endpoint
# certificado y token del cluster…
}
Un cluster gestionado cuesta plata por hora. Hacé destroy cuando termines de practicar.
Despliegue automático (CI)
# .github/workflows/deploy.yml (esquema)
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: ./app
push: true
tags: ghcr.io/<usuario>/chanchito:${{ github.sha }}
# luego: kubectl set image … con credenciales del cluster