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.

Cover for Dockerizar una API en Go: binarios pequeños, imágenes limpias y despliegue simple

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/amd64 desde 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    387MB

Esto 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     18MB

De 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 executable

Con 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/api

Inyectar 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:latest

Si 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 1

Si 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_on con condition: 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 haces docker 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=disable

Con 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:

StackImagen baseTamañ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/api

La 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=max

type=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,HIGH

Con 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.

OshyTech

Ingeniería backend y de datos orientada a sistemas escalables, automatización e IA.

Navegación

Copyright 2026 OshyTech. Todos los derechos reservados