Dockerizar una API en Go: binarios pequeños, imágenes limpias y despliegue simple
Dockerfile multi-stage para Go, binarios estáticos, variables de entorno, healthcheck y despliegue. Imágenes mínimas y sin sorpresas.

Una API en Go compila a un binario único. Una imagen Docker de ese binario puede pesar menos de 10 MB. Compara eso con la imagen típica de una aplicación Java (300-500 MB con la JVM), Python (200-400 MB con dependencias) o Node (200-300 MB con node_modules). Esa diferencia no es cosmética: afecta al tiempo de despliegue, al coste de almacenamiento de imágenes, al arranque en frío y a la superficie de ataque de tu contenedor.
Vengo de desplegar servicios en Kotlin con Spring Boot. Cada imagen Docker partía de 400 MB como mínimo. El Dockerfile era un artefacto de ingeniería: multi-stage con Maven, cacheo de dependencias, layer caching, y aun así el resultado era pesado y lento de arrancar. Cuando construí mi primera imagen Docker para Go, el Dockerfile tenía 12 líneas y la imagen final pesaba 8 MB. No porque yo fuera más listo, sino porque Go elimina la mayor parte de la complejidad que Docker tiene que gestionar en otros lenguajes.
Por qué Go y Docker encajan tan bien
Docker resuelve un problema fundamental: empaquetar una aplicación con todas sus dependencias para que funcione igual en cualquier entorno. Cuantas más dependencias tiene tu aplicación, más trabajo tiene Docker. Y más cosas pueden romperse.
Go minimiza ese problema por diseño:
- Binario estático: sin runtime, sin intérprete, sin máquina virtual. El binario es la aplicación completa.
- Sin dependencias del sistema: con
CGO_ENABLED=0, el binario no enlaza contra libc ni ninguna librería compartida. Funciona en cualquier Linux. - Cross-compilation trivial: compilar para
linux/amd64desde macOS es una variable de entorno, no un pipeline. - Arranque instantáneo: sin warm-up de JVM, sin carga de módulos, sin JIT. El proceso empieza a servir peticiones en milisegundos.
- Consumo de memoria predecible: sin garbage collector pesado ni overhead de runtime. Un servicio HTTP básico arranca con 5-10 MB de RAM.
Todo esto significa que la imagen Docker de tu API en Go puede ser extremadamente pequeña, rápida de construir y rápida de desplegar. Y no necesitas trucos para conseguirlo.
Dockerfile básico: una sola etapa
Empecemos por lo más simple. Un Dockerfile de una sola etapa que compila y ejecuta tu API:
FROM golang:1.23-alpine
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o server ./cmd/api
EXPOSE 8080
CMD ["./server"]Esto funciona. Tu API compila y se ejecuta dentro del contenedor. Pero hay un problema evidente: la imagen final incluye todo el toolchain de Go (compilador, herramientas, fuentes), todas las dependencias descargadas y tu código fuente. El resultado es una imagen de 300-400 MB.
docker build -t mi-api:single .
docker images mi-api:single
# REPOSITORY TAG SIZE
# mi-api single 387MBEsto es inaceptable para producción. Estás desplegando el compilador junto con tu aplicación. Es como enviar el taller mecánico junto con el coche.
Multi-stage build: el enfoque estándar
La solución en Docker es usar builds multi-stage. Compilas en una etapa con todas las herramientas necesarias y copias solo el binario resultante a una imagen final mínima.
# === Etapa 1: compilación ===
FROM golang:1.23-alpine AS builder
WORKDIR /app
# Cachear dependencias
COPY go.mod go.sum ./
RUN go mod download
# Compilar
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o server ./cmd/api
# === Etapa 2: imagen final ===
FROM alpine:3.20
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
USER nobody:nobody
CMD ["./server"]Analicemos las decisiones clave:
CGO_ENABLED=0: genera un binario estático sin dependencias de C. Imprescindible para que funcione en imágenes mínimas.GOOS=linux GOARCH=amd64: compilación cruzada explícita. Aunque estés compilando en Linux, es buena práctica declararlo.-ldflags="-s -w": elimina la tabla de símbolos y la información de depuración. Reduce el tamaño del binario un 20-30%.ca-certificates: necesario si tu API hace llamadas HTTPS a servicios externos. Sin esto, los certificados TLS no se validan.tzdata: necesario si trabajas con zonas horarias. Sin esto,time.LoadLocation("Europe/Madrid")falla.USER nobody:nobody: el contenedor no se ejecuta como root. Seguridad básica que demasiada gente olvida.
El resultado:
docker build -t mi-api:multi .
docker images mi-api:multi
# REPOSITORY TAG SIZE
# mi-api multi 18MBDe 387 MB a 18 MB. Y la imagen final solo contiene tu binario, los certificados y los datos de zona horaria. Nada más.
Scratch vs distroless vs Alpine
La imagen base de la etapa final es una decisión importante. Hay tres opciones habituales, cada una con trade-offs diferentes.
scratch
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD ["/server"]scratch es una imagen vacía. Literalmente no tiene nada: ni shell, ni herramientas, ni libc, ni /tmp, ni /etc. Tu binario es lo único que existe dentro del contenedor.
Ventajas: imagen mínima (solo tu binario, ~8-12 MB), superficie de ataque cero.
Desventajas: no puedes hacer docker exec -it contenedor sh para depurar. No hay /etc/passwd, así que USER no funciona de la forma habitual. Si tu aplicación necesita ficheros temporales, tienes que crear /tmp explícitamente.
gcr.io/distroless/static-debian12
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
CMD ["/server"]Distroless de Google incluye certificados CA, tzdata y un usuario nonroot configurado, pero sin shell ni package manager. Es un buen punto intermedio entre scratch y Alpine.
Ventajas: certificados y zonas horarias incluidos, usuario non-root preconfigurado, ~2 MB de base.
Desventajas: sin shell para depuración, dependencia de las imágenes de Google.
alpine
FROM alpine:3.20
RUN apk --no-cache add ca-certificates tzdata
COPY --from=builder /app/server /server
CMD ["/server"]Alpine es una distribución Linux mínima (~5 MB) con shell, package manager y herramientas básicas.
Ventajas: puedes entrar al contenedor y depurar, instalar herramientas si las necesitas, imagen pequeña (~8 MB de base).
Desventajas: ligeramente más grande que scratch/distroless, usa musl libc en vez de glibc (irrelevante si compilas con CGO_ENABLED=0).
Mi recomendación
Para la mayoría de equipos y proyectos, Alpine es la opción pragmática. La diferencia de tamaño con scratch es de 5-8 MB, y a cambio tienes un shell para cuando algo falla en producción a las 3 de la mañana. Si tu organización tiene requisitos de seguridad estrictos y no necesitas depuración interactiva, distroless es mejor opción que scratch porque incluye lo mínimo necesario sin que tengas que copiarlo manualmente.
Binarios estáticos: CGO_ENABLED=0 y lo que implica
CGO_ENABLED=0 es la pieza clave para que todo esto funcione. Por defecto, Go puede enlazar dinámicamente contra libc para ciertos paquetes de la librería estándar (net, os/user). Con CGO_ENABLED=0, Go usa implementaciones puras en Go para todo.
# Con CGO habilitado (por defecto en algunos casos)
CGO_ENABLED=1 go build -o server ./cmd/api
ldd server
# linux-vdso.so.1 => ...
# libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
# Con CGO deshabilitado
CGO_ENABLED=0 go build -o server ./cmd/api
ldd server
# not a dynamic executableCon CGO_ENABLED=0, el binario es completamente autocontenido. Puedes copiarlo a cualquier sistema Linux con la arquitectura correcta y funcionará. Sin librerías compartidas, sin dependencias.
Cuándo NO puedes usar CGO_ENABLED=0:
- Si usas SQLite a través de
mattn/go-sqlite3(requiere CGO). - Si dependes de librerías C nativas que no tienen equivalente puro en Go.
- Si usas paquetes que wrappean código C (ciertos drivers de bases de datos, librerías de criptografía especializadas).
En esos casos necesitas una imagen base que incluya libc (Alpine con musl o Debian), y tu Dockerfile se complica. Si puedes evitar CGO, evítalo. Para una API REST con Go estándar con PostgreSQL (usando pgx, que es puro Go), no necesitas CGO.
Las flags de ldflags también importan:
# Sin ldflags
go build -o server ./cmd/api
ls -lh server # 12.4 MB
# Con -s -w (sin símbolos ni debug info)
go build -ldflags="-s -w" -o server ./cmd/api
ls -lh server # 8.7 MB
# Inyectando versión en tiempo de compilación
go build -ldflags="-s -w -X main.version=1.2.3 -X main.commitHash=$(git rev-parse --short HEAD)" \
-o server ./cmd/apiInyectar la versión y el hash del commit en el binario es práctica estándar. Te permite saber exactamente qué versión está corriendo en producción sin depender de etiquetas Docker.
package main
var (
version = "dev"
commitHash = "unknown"
)
func main() {
log.Printf("Starting server version=%s commit=%s", version, commitHash)
// ...
}Variables de entorno y configuración
Una API en un contenedor Docker recibe su configuración por variables de entorno. Es el estándar de los 12-Factor Apps y es lo que esperan todos los orquestadores (Kubernetes, ECS, Docker Compose).
En Go, leer variables de entorno es trivial con la librería estándar:
package config
import (
"fmt"
"os"
"strconv"
)
type Config struct {
Port int
DatabaseURL string
LogLevel string
Environment string
}
func Load() (*Config, error) {
port, err := strconv.Atoi(getEnv("PORT", "8080"))
if err != nil {
return nil, fmt.Errorf("invalid PORT: %w", err)
}
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}
return &Config{
Port: port,
DatabaseURL: dbURL,
LogLevel: getEnv("LOG_LEVEL", "info"),
Environment: getEnv("ENVIRONMENT", "development"),
}, nil
}
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}En el Dockerfile, puedes declarar valores por defecto con ENV:
FROM alpine:3.20
ENV PORT=8080
ENV LOG_LEVEL=info
ENV ENVIRONMENT=production
COPY --from=builder /app/server /app/server
CMD ["/app/server"]Pero no pongas secretos en el Dockerfile. Nunca. Ni la URL de la base de datos, ni API keys, ni tokens. Esos valores se pasan en tiempo de ejecución:
docker run -d \
-e DATABASE_URL="postgres://user:pass@db:5432/mydb?sslmode=disable" \
-e PORT=8080 \
-p 8080:8080 \
mi-api:latestSi tu configuración se vuelve más compleja, hay librerías como caarlos0/env, kelseyhightower/envconfig o koanf que parsean variables de entorno en Go directamente a structs con validación incluida. Pero para la mayoría de servicios, os.Getenv con una función helper cubre de sobra.
Health checks
Un contenedor que arranca no es un contenedor que funciona. Docker y los orquestadores necesitan saber si tu aplicación está viva y lista para recibir tráfico.
Primero, expón un endpoint /health en tu API:
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, `{"status":"ok"}`)
})
// Si quieres un check más completo que verifique la DB
mux.HandleFunc("GET /ready", func(w http.ResponseWriter, r *http.Request) {
if err := db.Ping(r.Context()); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprintf(w, `{"status":"error","detail":"%s"}`, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, `{"status":"ok"}`)
})
// ... resto de rutas
}La distinción entre liveness (/health) y readiness (/ready) importa:
- Liveness: “El proceso está vivo y no colgado.” Si falla, Docker/Kubernetes reinicia el contenedor.
- Readiness: “La aplicación está lista para recibir tráfico.” Si falla, el orquestador deja de enviarle peticiones pero no la reinicia.
En el Dockerfile, configura el health check:
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
CMD ["/app/server", "-health"]Una alternativa más simple que no requiere modificar tu binario:
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1Si usas Alpine, wget está disponible. Si usas scratch o distroless, necesitas compilar un health check binario o usar la primera opción (un flag en tu propio binario):
func main() {
if len(os.Args) > 1 && os.Args[1] == "-health" {
resp, err := http.Get("http://localhost:8080/health")
if err != nil || resp.StatusCode != 200 {
os.Exit(1)
}
os.Exit(0)
}
// Arranque normal del servidor...
}Este patrón es elegante: tu binario puede funcionar como servidor y como health checker. Sin dependencias externas.
Docker Compose para desarrollo local
En desarrollo necesitas más que tu API: base de datos, quizá Redis, quizá un servicio de mensajería. Docker Compose es el estándar para orquestar esto localmente.
services:
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/myapp?sslmode=disable
- PORT=8080
- LOG_LEVEL=debug
- ENVIRONMENT=development
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myapp
volumes:
- pgdata:/var/lib/postgresql/data
- ./migrations/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:Puntos clave:
depends_onconcondition: service_healthy: la API no arranca hasta que PostgreSQL esté listo. Sin esto, tu API intentará conectar a la base de datos antes de que exista y fallará.- Volume con nombre (
pgdata): los datos de PostgreSQL persisten entre reinicios de Docker Compose. Sin esto, pierdes los datos cada vez que hacesdocker compose down. - Montaje de
init.sql: PostgreSQL ejecuta automáticamente los scripts en/docker-entrypoint-initdb.d/la primera vez que arranca. Ideal para crear tablas y datos iniciales.
Para desarrollo con hot-reload, puedes montar tu código fuente y usar Air:
services:
api:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "8080:8080"
volumes:
- .:/app
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/myapp?sslmode=disableCon un Dockerfile.dev que instale Air:
FROM golang:1.23-alpine
RUN go install github.com/air-verse/air@latest
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
CMD ["air", "-c", ".air.toml"]Así cada cambio en tu código recompila y reinicia el servidor automáticamente dentro del contenedor.
Comparación de tamaño de imagen: Go vs Java vs Python vs Node
Los números hablan por sí solos. Todos los ejemplos son APIs HTTP mínimas con un endpoint /health:
| Stack | Imagen base | Tamaño final |
|---|---|---|
| Go (multi-stage + scratch) | scratch | ~8 MB |
| Go (multi-stage + alpine) | alpine:3.20 | ~15 MB |
| Go (multi-stage + distroless) | distroless/static | ~10 MB |
| Java (Spring Boot + JRE) | eclipse-temurin:21-jre-alpine | ~250 MB |
| Python (FastAPI + uvicorn) | python:3.12-slim | ~180 MB |
| Node (Express) | node:20-alpine | ~180 MB |
| Rust (actix-web + scratch) | scratch | ~6 MB |
Go no es la más pequeña (Rust gana ahí), pero la diferencia con los ecosistemas de JVM, Python y Node es de un orden de magnitud. Esto tiene consecuencias reales:
- Tiempo de pull: descargar 10 MB tarda <1 segundo. Descargar 250 MB tarda 15-30 segundos en una red típica de CI.
- Almacenamiento de registry: si mantienes 50 imágenes con 20 tags cada una, la diferencia entre 10 MB y 250 MB es 10 GB vs 250 GB.
- Cold start en serverless: Google Cloud Run, AWS Lambda con containers. Arrancar un contenedor Go tarda 100-200 ms. Arrancar un contenedor Java puede tardar 5-15 segundos.
- Superficie de ataque: menos software en la imagen = menos CVEs posibles. Un scan de Trivy sobre una imagen Go+scratch devuelve cero vulnerabilidades. Una imagen basada en Debian-slim puede devolver docenas.
Para servicios cloud-native, donde puedes tener decenas o cientos de contenedores, estas diferencias se acumulan rápidamente.
Consideraciones para CI/CD
Un Dockerfile multi-stage bien configurado se integra directamente en cualquier pipeline de CI/CD. Pero hay detalles que marcan la diferencia entre un build de 30 segundos y uno de 5 minutos.
Cacheo de capas
El orden de las instrucciones en tu Dockerfile importa. Las capas se cachean de arriba a abajo, y cualquier cambio invalida todas las capas posteriores.
# BIEN: las dependencias cambian menos que el código
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o server ./cmd/api
# MAL: cualquier cambio en el código invalida la caché de dependencias
COPY . .
RUN go mod download
RUN go build -o server ./cmd/apiLa primera versión solo re-descarga dependencias cuando go.mod o go.sum cambian. La segunda re-descarga en cada cambio de código.
Build cache en CI
En GitHub Actions, puedes cachear las capas de Docker y los módulos de Go:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/mi-org/mi-api:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=maxtype=gha usa el cache nativo de GitHub Actions. Es la forma más sencilla de tener builds incrementales en CI sin montar infraestructura adicional.
Escaneo de vulnerabilidades
Incluir un paso de escaneo de imagen en tu pipeline es práctica estándar:
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: ghcr.io/mi-org/mi-api:${{ github.sha }}
format: table
exit-code: 1
severity: CRITICAL,HIGHCon imágenes Go basadas en Alpine o scratch, los resultados de Trivy suelen ser limpios. Si usas una imagen base más pesada, prepárate para gestionar CVEs que no tienen nada que ver con tu código.
Multi-arquitectura
Si despliegas en ARM (AWS Graviton, Apple Silicon), necesitas builds multi-arquitectura:
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/mi-org/mi-api:latest \
--push .Go hace esto trivial porque la cross-compilation es nativa. No necesitas emuladores ni builders específicos para cada arquitectura. Solo GOARCH=arm64 y listo.
Dockerfile final de referencia
Con todo lo anterior, este es el Dockerfile que uso como punto de partida para cualquier API en Go:
# === Build ===
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
ARG VERSION=dev
ARG COMMIT=unknown
RUN CGO_ENABLED=0 GOOS=linux \
go build \
-ldflags="-s -w -X main.version=${VERSION} -X main.commitHash=${COMMIT}" \
-o server ./cmd/api
# === Runtime ===
FROM alpine:3.20
RUN apk --no-cache add ca-certificates tzdata \
&& addgroup -S appgroup \
&& adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
USER appuser:appgroup
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
CMD ["./server"]Se construye con:
docker build \
--build-arg VERSION=1.0.0 \
--build-arg COMMIT=$(git rev-parse --short HEAD) \
-t mi-api:1.0.0 .El resultado: una imagen de ~15 MB con health check, usuario non-root, certificados TLS, zona horaria, versión inyectada y nada más. Sin sorpresas, sin dependencias ocultas, sin runtime que mantener.
Un binario, una imagen, cero sorpresas
Go y Docker encajan de forma natural. Donde otros lenguajes necesitan multi-stage builds elaborados, cacheo agresivo de dependencias y optimización de capas solo para llegar a imágenes de 200 MB, Go te da imágenes de 10-15 MB con un Dockerfile directo. CGO_ENABLED=0 para binarios estáticos, multi-stage builds para separar compilación de runtime, Alpine o distroless como base, -ldflags="-s -w" para recortar el binario, health checks integrados y cacheo de capas con go.mod/go.sum separados del código. Todo encaja sin forzar nada.
No necesitas arquitecturas de despliegue sofisticadas para empezar. Un Dockerfile multi-stage, un docker compose up y tu API está corriendo con PostgreSQL en local. Cuando llegue el momento de CI/CD, el mismo Dockerfile funciona sin cambios.
La simplicidad de despliegue es una de las razones más pragmáticas para elegir Go en backend. No es el lenguaje más expresivo ni el que tiene más features. Pero cuando toca llevar código a producción, menos complejidad es exactamente lo que quieres.


