Módulo 17: Despliegue, Contenedores & Producción
De tu máquina a un servidor real con HTTPS automático.
Fase 4: Rendimiento, observabilidad, escritorio y producción · Lección 20 de 20
🎯 Objetivos
- Compilar binarios estáticos ultraligeros sin dependencias.
- Crear imágenes Docker multi-stage y orquestarlas con Docker Compose.
- Publicar con Caddy como proxy inverso con HTTPS automático.
- Automatizar el despliegue y operar la aplicación con seguridad.
1. Binarios estáticos con Go
Go compila a un único ejecutable sin necesidad de intérprete ni máquina virtual. Con CGO desactivado, además es estático: no depende de librerías del sistema, de modo que corre en cualquier Linux, incluso en una imagen vacía.
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags "-s -w -X main.version=$(git describe --tags --always)" \
-o bin/tareas ./cmd/tareas
ls -lh bin/tareas # típicamente 10–25 MB
file bin/tareas # ELF 64-bit ... statically linked
| Opción | Efecto |
|---|---|
CGO_ENABLED=0 | Sin dependencias de C: binario estático y compilación cruzada trivial |
GOOS / GOARCH | Sistema y arquitectura de destino (linux/arm64 para servidores ARM, windows/amd64…) |
-trimpath | Quita rutas locales del binario (reproducibilidad y privacidad) |
-ldflags "-s -w" | Elimina tablas de símbolos y depuración (−25–30 % de tamaño) |
-X main.version=… | Inyecta valores en tiempo de compilación |
Como todo (plantillas, estáticos, Swagger UI) va incrustado con go:embed, copiar ese único archivo es desplegar. Mira la lista de plataformas con go tool dist list.
Si una dependencia requiere CGO (algunos drivers de SQLite), usa alternativas puras en Go (
modernc.org/sqlite) o compila en una imagen congccy ejecuta en una conlibc.
2. Dockerfile multi-stage
Una imagen “ingenua” con el SDK de Go pesa más de 800 MB y contiene herramientas de compilación que no deben estar en producción. Con multi-stage se compila en una etapa y se copia solo el binario a una imagen mínima.
Dockerfile:
# syntax=docker/dockerfile:1
# ---------- Etapa 1: compilación ----------
FROM golang:1.25 AS build
WORKDIR /src
# Dependencias primero: esta capa se cachea mientras go.mod/go.sum no cambien
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
ARG VERSION=dev
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux \
go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/tareas ./cmd/tareas
# ---------- Etapa 2: ejecución ----------
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/tareas /tareas
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/tareas"]
Por qué así:
distroless/static: sin shell ni gestor de paquetes (≈2 MB), con certificados CA y datos de zona horaria. Menor superficie de ataque. (Alternativa mínima:FROM scratch, copiando tú los certificados.)nonroot: la aplicación no corre como root.- Copiar
go.mod/go.sumantes que el código aprovecha la caché de capas: reconstruir tras cambiar solo código es rapidísimo. - La imagen final suele pesar 15–30 MB.
.dockerignore (imprescindible: evita enviar basura al contexto y filtrar secretos):
.git
.env
bin/
*.log
node_modules/
docker build -t tareas:dev --build-arg VERSION=$(git describe --tags --always) .
docker run --rm -p 8080:8080 --env-file .env tareas:dev
docker images tareas # comprueba el tamaño
Por defecto distroless no tiene shell: para depurar usa docker logs (de ahí que escribir logs a stdout sea la regla) o la variante :debug.
3. Docker Compose: toda la pila
Compose describe y arranca varios servicios (aplicación, base de datos, caché, migraciones, proxy) con un solo archivo.
compose.yaml:
services:
app:
build:
context: .
args: { VERSION: "${VERSION:-dev}" }
restart: unless-stopped
environment:
APP_ENV: produccion
PORT: "8080"
DATABASE_URL: postgres://tareas:${DB_PASSWORD}@db:5432/tareas?sslmode=disable
REDIS_ADDR: redis:6379
depends_on:
db: { condition: service_healthy }
redis: { condition: service_healthy }
migrate: { condition: service_completed_successfully }
healthcheck: # distroless no tiene curl: el binario implementa su propio chequeo (ver abajo)
test: ["CMD", "/tareas", "-healthcheck"]
interval: 15s
timeout: 3s
retries: 3
networks: [interna]
worker: # el mismo binario con otro comando (cmd/worker) o argumento
build: { context: ., dockerfile: Dockerfile.worker }
restart: unless-stopped
environment:
DATABASE_URL: postgres://tareas:${DB_PASSWORD}@db:5432/tareas?sslmode=disable
REDIS_ADDR: redis:6379
depends_on: [db, redis]
networks: [interna]
migrate:
image: migrate/migrate:v4.18.1
command: ["-path=/migraciones", "-database=postgres://tareas:${DB_PASSWORD}@db:5432/tareas?sslmode=disable", "up"]
volumes: ["./db/migrations:/migraciones:ro"]
depends_on:
db: { condition: service_healthy }
networks: [interna]
db:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_USER: tareas
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: tareas
volumes: ["pgdata:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tareas -d tareas"]
interval: 5s
timeout: 3s
retries: 10
networks: [interna] # NO se publica ningún puerto al exterior
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes: ["redisdata:/data"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
networks: [interna]
caddy:
image: caddy:2
restart: unless-stopped
ports: ["80:80", "443:443", "443:443/udp"] # único punto de entrada público
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_datos:/data # guarda los certificados: ¡no lo pierdas!
- caddy_config:/config
depends_on: [app]
networks: [interna]
volumes: { pgdata: {}, redisdata: {}, caddy_datos: {}, caddy_config: {} }
networks: { interna: {} }
Detalles importantes:
- Los secretos (
DB_PASSWORD) salen de un.envjunto alcompose.yaml(¡fuera de Git!) o de los secrets de Docker. Nunca de la imagen. - Solo Caddy expone puertos (80/443). La base de datos y Redis viven en una red interna: inaccesibles desde internet.
depends_onconconditiongarantiza el orden: base de datos sana → migraciones → aplicación.- Volúmenes con nombre conservan los datos al recrear contenedores.
- El healthcheck de la app: como no hay
curlendistroless, añade al binario un modo-healthcheckque haga una petición ahttp://127.0.0.1:8080/healthzy devuelva código de salida 0/1.
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
c := http.Client{Timeout: 2 * time.Second}
resp, err := c.Get("http://127.0.0.1:" + os.Getenv("PORT") + "/healthz")
if err != nil || resp.StatusCode != http.StatusOK {
os.Exit(1)
}
os.Exit(0)
}
Comandos habituales:
docker compose up -d --build # construir y arrancar en segundo plano
docker compose ps # estado y salud
docker compose logs -f app # seguir los logs de un servicio
docker compose exec db psql -U tareas -d tareas
docker compose down # detener (los volúmenes se conservan)
docker compose down -v # ¡también borra los datos!
4. Caddy: proxy inverso con HTTPS automático
Caddy es un servidor web/proxy inverso escrito en Go. Su gran característica: obtiene y renueva certificados TLS de Let’s Encrypt automáticamente. Sin certbot, sin crons, sin configuración de certificados.
Caddyfile:
{
email tu@correo.com # contacto para Let's Encrypt (avisos de caducidad)
}
tareas.midominio.com {
encode zstd gzip # compresión (Módulo 12)
# Cabeceras de seguridad (refuerzan las que ya envía la app)
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
-Server # oculta la cabecera Server
}
# Los estáticos con hash en el nombre se cachean un año
@estaticos path /static/*
header @estaticos Cache-Control "public, max-age=31536000, immutable"
# El panel de métricas no se expone
@privado path /metrics /debug/*
respond @privado 404
# Limita el tamaño de las peticiones
request_body {
max_size 5MB
}
reverse_proxy app:8080 {
health_uri /healthz
health_interval 10s
header_up X-Real-IP {remote_host}
}
log {
output stdout
format json
}
}
# Redirección del dominio "desnudo" o de www
www.midominio.com {
redir https://tareas.midominio.com{uri} permanent
}
Para que funcione:
- Un servidor (VPS) con IP pública y Docker instalado.
- Un registro DNS tipo A (y AAAA si tienes IPv6) de
tareas.midominio.comhacia la IP del servidor. - Los puertos 80 y 443 abiertos (Caddy usa el 80 para el desafío ACME y para redirigir a HTTPS).
Al arrancar, Caddy detecta el dominio, solicita el certificado, lo instala y redirige HTTP → HTTPS. Los certificados se renuevan solos. Para pruebas locales usa localhost (Caddy genera un certificado local de confianza) o el staging de Let’s Encrypt para no agotar límites de emisión.
En la aplicación Go: confía en
X-Forwarded-*/X-Real-IPsolo si la petición viene del proxy (red interna), y marcaSecureen las cookies (el navegador ve HTTPS, aunque el tráfico interno sea HTTP).
5. Lista de verificación de producción
Seguridad
- HTTPS en todo;
Strict-Transport-Securityactivado. - Contenedores sin root, imágenes mínimas, sin herramientas de compilación.
- Solo el proxy expone puertos; base de datos y Redis en red interna.
- Secretos en variables de entorno/secrets, nunca en Git ni en la imagen.
- Cortafuegos del servidor (
ufw allow 22,80,443/tcp), SSH solo con claves, sin login de root. -
govulnchecky escaneo de imágenes (docker scout,trivy) en la CI. - Actualizaciones de seguridad del sistema operativo y de las imágenes base.
- Limitación de tasa en login y registro.
Fiabilidad
-
restart: unless-stoppedy healthchecks. - Apagado ordenado (
Shutdown) ystop_grace_periodcoherente. - Migraciones automáticas y reversibles; despliegues compatibles hacia atrás (la versión nueva del código debe funcionar un momento con el esquema anterior).
- Límites de recursos (
mem_limit,cpus) yGOMEMLIMITajustado a ~90 % del límite de memoria. - Zona horaria y reloj correctos (UTC en servidores, convierte en la interfaz).
Datos
- Copias de seguridad automáticas de PostgreSQL, almacenadas fuera del servidor.
- Prueba de restauración periódica: una copia que nunca has restaurado no es una copia.
- Volúmenes persistentes para PostgreSQL y para los certificados de Caddy.
Ejemplo de copia diaria (con cron del host):
#!/usr/bin/env bash
# /opt/tareas/backup.sh
set -euo pipefail
fecha=$(date +%F)
docker compose -f /opt/tareas/compose.yaml exec -T db \
pg_dump -U tareas -Fc tareas > "/var/backups/tareas/tareas-$fecha.dump"
find /var/backups/tareas -name '*.dump' -mtime +14 -delete # retención de 14 días
# y sincroniza a almacenamiento externo (rclone, rsync, S3…)
# Restaurar
docker compose exec -T db pg_restore -U tareas -d tareas --clean --if-exists < tareas-2026-10-21.dump
Observabilidad
- Logs JSON a stdout con rotación (
logging: driver: json-file, options: { max-size: "10m", max-file: "5" }). -
/metricsaccesible solo desde la red interna (Prometheus en la misma red). - Alertas sobre disponibilidad, tasa de errores y latencia (Módulo 13).
6. Despliegue automatizado
Un flujo sencillo y sólido: GitHub Actions construye y publica la imagen; el servidor la descarga.
.github/workflows/deploy.yml:
name: Desplegar
on:
push:
tags: ["v*"]
jobs:
imagen:
runs-on: ubuntu-latest
permissions: { contents: read, packages: write }
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
build-args: VERSION=${{ github.ref_name }}
tags: |
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
desplegar:
needs: imagen
runs-on: ubuntu-latest
steps:
- name: Actualizar el servidor por SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVIDOR_HOST }}
username: ${{ secrets.SERVIDOR_USUARIO }}
key: ${{ secrets.SERVIDOR_SSH_KEY }}
script: |
cd /opt/tareas
docker compose pull app worker
docker compose up -d
docker image prune -f
(En el compose.yaml del servidor, app usaría image: ghcr.io/TU_USUARIO/tareas:latest en lugar de build:.)
Estrategias de versión y reversión: etiqueta cada imagen con la versión (v1.4.2), no solo latest; así, para volver atrás basta cambiar la etiqueta y docker compose up -d. Para despliegues sin interrupción, el patrón blue/green con Caddy (dos instancias y cambio de reverse_proxy) o un orquestador como Kubernetes; para empezar, Compose en un único servidor es más que suficiente para la mayoría de proyectos.
Alternativas de alojamiento
- VPS (Hetzner, DigitalOcean, OVH…) + Docker Compose: control total y barato (lo que hemos hecho).
- PaaS (Fly.io, Railway, Render): subes el
Dockerfiley ellos gestionan la infraestructura. - Binario con
systemdsin Docker: perfectamente válido para Go. Copias el ejecutable y creas una unidad:
# /etc/systemd/system/tareas.service
[Unit]
Description=Tareas
After=network-online.target postgresql.service
[Service]
User=tareas
EnvironmentFile=/etc/tareas/entorno
ExecStart=/opt/tareas/tareas
Restart=on-failure
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.target
🧪 Práctica
Escribe un Dockerfile multi-stage, un compose.yaml con la app, PostgreSQL y Redis, y un Caddyfile que sirva tu dominio con HTTPS.
Ver solución
- Añade
.dockerignore,Dockerfile,compose.yaml,Caddyfiley un.env(fuera de Git) conDB_PASSWORD=$(openssl rand -base64 24). - Implementa el modo
-healthcheckenmain.go. - Local con HTTPS: pon
localhostcomo dirección en elCaddyfile, ejecutadocker compose up -d --buildy abrehttps://localhost(acepta el certificado local de Caddy). - Verifica:
docker compose ps # todos "healthy"
curl -I https://localhost -k # 200, con cabeceras de seguridad y compresión
docker images | grep tareas # tamaño final de la imagen (¡decenas de MB!)
docker compose exec db psql -U tareas -c '\dt' # tablas creadas por la migración
docker compose down && docker compose up -d # los datos persisten
- En un VPS real: apunta el registro DNS A a su IP, cambia
localhostpor tu dominio, abre 80/443 y ejecutadocker compose up -d. Observa endocker compose logs caddycómo se obtiene el certificado. - Comprueba el resultado final:
https://tu-dominiocon candado válido;/metricsdevuelve 404 desde fuera;docker compose psmuestra que ni PostgreSQL ni Redis publican puertos. - Programa la copia de seguridad con
crony prueba a restaurarla en una base nueva. - (Bonus) Añade el workflow de GitHub Actions y haz un despliegue con una etiqueta
v1.0.0.
⚠️ Errores comunes
- Imagen final basada en el SDK de Go (cientos de MB y superficie de ataque enorme).
- Contenedores corriendo como root.
- Publicar los puertos de PostgreSQL o Redis a internet.
- Meter secretos en la imagen, en el repositorio o en
docker-composeversionado. - No persistir el volumen de Caddy (se agotan los límites de emisión de Let’s Encrypt).
- Tener copias de seguridad pero nunca haber probado a restaurarlas.
- Usar solo la etiqueta
latest: imposible volver atrás con seguridad. - No configurar rotación de logs y llenar el disco.
📌 Resumen
CGO_ENABLED=0+go:embed= un binario estático autosuficiente.- Dockerfile multi-stage con
distrolessy usuario no-root: imágenes pequeñas y seguras. - Compose orquesta aplicación, worker, migraciones, PostgreSQL, Redis y proxy; solo Caddy es público.
- Caddy da HTTPS automático, compresión y cabeceras con unas pocas líneas.
- Producción es seguridad + fiabilidad + datos + observabilidad: copias probadas, healthchecks, logs, métricas y despliegues reproducibles.
✅ Autoevaluación
- ¿Por qué
CGO_ENABLED=0facilita el despliegue? - ¿Qué ventaja tiene una construcción multi-stage?
- ¿Qué servicios deben tener un puerto publicado y cuáles no? ¿Por qué?
- ¿Qué hace Caddy por ti respecto a los certificados?
- ¿Por qué hay que probar la restauración de las copias de seguridad?
- ¿Cómo vuelves a una versión anterior si el despliegue sale mal?
- Puedo explicar con mis palabras cada objetivo de esta lección.
- Mi aplicación corre con
docker compose up -dy responde por HTTPS. - Sé qué parte me costó más y la repasaré.
🎓 ¡Has completado el curso!
Recorriste todo el camino: terminal y HTTP, el lenguaje Go, una arquitectura limpia, web hipermedia con HTMX, PostgreSQL con SQL tipado, concurrencia, seguridad, APIs con OpenAPI, tiempo real y colas, rendimiento, observabilidad, pruebas, apps de escritorio y móviles, y un despliegue profesional. Lo importante: un dominio, muchos adaptadores, y cada pieza medible, probada y explícita.
Próximos pasos sugeridos:
- Publica tu proyecto Tareas completo en GitHub, con README, CI y despliegue.
- Elige un problema real propio y constrúyelo con este stack.
- Profundiza: Effective Go, el blog oficial de Go, el código de la biblioteca estándar y los proyectos de la comunidad.
- Contribuye: abre issues o propuestas de mejora a este curso.