Contenedores Low Cost: Builds Mínimos y Deploy Barato

Contenedores Low Cost: Builds Mínimos y Deploy Barato

Reduce al máximo el costo de desplegar contenedores: imágenes ultra-comprimidas, builds optimizados, registries baratos y servidores mínimos con rendimiento real.

Por Omar Flores

Un piloto de avión comercial tiene dos palancas que determinan el costo de cada vuelo: el peso del avión y el consumo de combustible. Todo lo demás — el destino, la ruta, el número de pasajeros — es secundario si esos dos números están fuera de control. Un avión que pesa el doble de lo necesario consume el doble de combustible para ir al mismo lugar.

Los contenedores funcionan igual. Cada megabyte de imagen es combustible que pagas tres veces: cuando la construyes, cuando la subes al registry, y cuando la descargas al servidor. Cada segundo de build es tiempo de CI que alguien está facturando. Cada gigabyte de RAM que el contenedor retiene sin necesidad es dinero que pagas al proveedor de cloud al final del mes.

La mayoría de los equipos no miden estos costos porque están dispersos en cuatro facturas distintas. Este post los centraliza y te da las herramientas para reducirlos al mínimo.

Dónde se va el dinero realmente

Antes de optimizar, hay que saber qué estás pagando. Los costos de contenedores en producción tienen cuatro fuentes que rara vez se analizan juntas:

Storage del registry. Docker Hub cobra por storage y por pulls en el plan gratuito. GitHub Container Registry y AWS ECR cobran por GB almacenado. Una imagen de 800MB que tienes en 20 tags distintos ocupa 16GB — y eso es solo una aplicación.

Tiempo de CI/CD. GitHub Actions cobra por minutos de ejecución. Un build de 8 minutos que corre 30 veces al día son 240 minutos — $1.92 USD diarios, $58 al mes, solo en ese repositorio. Con 10 repositorios, son $580 al mes en builds.

Ancho de banda del servidor. Cada deploy descarga la imagen al servidor. Una imagen de 800MB descargada 30 veces al mes son 24GB de transferencia entrante. En servidores cloud con ancho de banda medido, eso tiene un costo directo.

RAM del servidor. El tamaño de la imagen no determina la RAM en runtime, pero las decisiones que inflan la imagen (dependencias innecesarias, procesos extra dentro del contenedor) sí afectan el consumo en runtime.

El primer paso es medir tu situación actual:

# Ver tamaño de todas tus imágenes
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" | sort -k3 -h

# Ver cuánto espacio ocupa Docker en total
docker system df

# Detalle por tipo
docker system df -v

Con esos números en pantalla, el resto del post tiene contexto concreto.

El fundamento: imágenes base mínimas

La imagen base es el piso sobre el que todo lo demás se construye. Elegir mal aquí multiplica el problema en cada capa posterior.

La jerarquía de tamaño de imágenes base, de mayor a menor:

ubuntu:22.04       ~77MB   — sistema operativo completo, innecesario para producción
debian:bookworm    ~48MB   — más pequeño, pero sigue siendo demasiado para la mayoría
node:20            ~1.1GB  — Node + Debian completo
node:20-slim       ~220MB  — Node + Debian mínimo
node:20-alpine     ~180MB  — Node + Alpine Linux
alpine:3.19        ~7MB    — solo el sistema operativo base
distroless/static  ~2MB    — sin shell, solo certificados TLS
scratch            ~0MB    — absolutamente nada

La regla práctica: usa la imagen más pequeña que tenga todo lo que tu runtime necesita en producción. Para Go: scratch o distroless. Para Node.js: node:20-alpine. Para Python: python:3.12-alpine. Para aplicaciones que necesitan glibc en lugar de musl (la libc de Alpine): distroless/base.

Alpine no es siempre la respuesta correcta. Usa musl en lugar de glibc, y algunas bibliotecas C no funcionan correctamente con musl. Si tu aplicación Node.js usa módulos nativos con bindings de C, node:20-alpine puede dar errores en producción que no aparecen en desarrollo. En ese caso, node:20-slim (~220MB) es más seguro.

Multistage builds: separar construcción de ejecución

El error más caro en producción es incluir las herramientas de build en la imagen de runtime. Un proyecto TypeScript compilado no necesita TypeScript en producción. Un proyecto Go no necesita el compilador. Una app React no necesita Webpack ni Vite.

El multistage build es la técnica que más impacto tiene en el tamaño final, y la mayoría de los equipos no la usa correctamente.

Node.js / TypeScript

# ── Builder: todo lo necesario para compilar ────────────────────────────────
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# ci instala exactamente lo del lockfile — más rápido y reproducible que install
RUN npm ci --frozen-lockfile

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# ── Producción: solo lo que corre ────────────────────────────────────────────
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production

COPY package.json package-lock.json ./
# --omit=dev elimina todas las devDependencies — puede reducir node_modules 60-80%
RUN npm ci --frozen-lockfile --omit=dev

COPY --from=builder /app/dist ./dist

RUN addgroup -S app && adduser -S appuser -G app
USER appuser

EXPOSE 3000
CMD ["node", "dist/index.js"]

El truco de las tres etapas en lugar de dos: la etapa deps instala todas las dependencias (incluyendo dev). La etapa builder las usa para compilar. La etapa production instala solo las de producción desde cero, sin pasar por builder. Esto evita que archivos de node_modules de desarrollo contaminen el caché de producción.

Resultado típico para una API NestJS o Express con TypeScript: de ~1.1GB a ~120MB.

Go: el caso extremo

Go es el lenguaje que mejor se presta a contenedores mínimos porque compila a un binario estático.

FROM golang:1.23-alpine AS builder
WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download && go mod verify

COPY . .

# -s -w: elimina símbolos de debug (~30% menos de tamaño)
# CGO_ENABLED=0: binario completamente estático — funciona en scratch
# -trimpath: elimina rutas del filesystem del builder del binario
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build \
    -ldflags="-s -w" \
    -trimpath \
    -o /bin/server \
    ./cmd/server

# ── Producción: imagen vacía ─────────────────────────────────────────────────
FROM scratch
# Certificados TLS — necesarios para hacer llamadas HTTPS desde la app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# El binario
COPY --from=builder /bin/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

Resultado: imagen de 8-15MB dependiendo del tamaño de la aplicación. Comparado con golang:1.23 (~800MB) que algunos equipos usan en producción, el ahorro es de ~98%.

Comprimir el binario de Go con UPX

Si esos 12MB todavía te parecen mucho — por ejemplo, en un edge environment donde cada KB de descarga tiene costo — UPX comprime el binario ejecutable.

FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -trimpath -o /bin/server ./cmd/server

# Comprimir el binario — puede reducir 50-70% el tamaño
FROM alpine:3.19 AS compressor
RUN apk add --no-cache upx
COPY --from=builder /bin/server /bin/server
RUN upx --best --lzma /bin/server

FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=compressor /bin/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

UPX reduce el binario de Go de ~12MB a ~4-6MB. El trade-off: el arranque del proceso tarda ~50-100ms más porque UPX descomprime el binario en memoria al iniciarse. Para servicios de larga ejecución ese costo es irrelevante — se paga una sola vez. Para funciones serverless que arrancan en cada petición, puede no valer la pena.

Frontend estático: Nginx Alpine

Para Astro, React, Vue o cualquier SPA que genera archivos estáticos en build:

FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile
COPY . .
RUN npm run build

FROM nginx:alpine AS production
# Imagen base: ~25MB
# nginx:alpine-slim: ~12MB si no necesitas módulos adicionales
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Resultado: ~25MB sirviendo miles de usuarios simultáneos. Si necesitas aún menos, nginx:alpine-slim es ~12MB pero sin algunos módulos como gzip_static.

Optimizar el caché de capas: builds rápidos sin pagar más

El caché de capas de Docker es la herramienta más importante para reducir tiempos de build — y la más mal usada.

La regla es simple: lo que cambia menos frecuentemente va primero. Lo que cambia en cada commit va al final.

# ANTIPATRÓN: invalida el caché de dependencias en cada cambio de código
FROM node:20-alpine
WORKDIR /app
COPY . .                          # Todo el código — cambia en cada commit
RUN npm ci                        # Se reinstala en cada commit
RUN npm run build

# CORRECTO: dependencias cacheadas hasta que package.json cambie
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./   # Solo cambia cuando añades dependencias
RUN npm ci --frozen-lockfile             # Cacheado — rara vez se ejecuta
COPY . .                                 # El código — invalida desde aquí
RUN npm run build                        # Solo se reconstruye cuando el código cambia

Con el orden correcto, un commit que solo modifica código fuente reutiliza la capa de npm install del build anterior. En un proyecto con 200 dependencias, eso ahorra 2-4 minutos de build por commit.

.dockerignore: no enviar basura al builder

El contexto de build es todo lo que Docker envía al daemon antes de empezar. Sin .dockerignore, envías node_modules, .git con todo el historial, archivos de logs, y cualquier otro archivo del directorio.

# .dockerignore — debería estar en todos los repositorios
node_modules
.git
.gitignore
*.md
.env
.env.*
*.log
dist
build
coverage
.nyc_output
.DS_Store
Thumbs.db
*.test.ts
*.spec.ts
__tests__
.github
.vscode
docker-compose*.yml
Dockerfile*

Un contexto de build de 50MB versus 500MB significa que el primer paso de cada build (transferencia del contexto al daemon) tarda 0.5 segundos en lugar de 5. En builds frecuentes, ese segundo se acumula.

BuildKit: paralelismo y caché externo

BuildKit es el motor moderno de Docker. En builds con múltiples etapas, ejecuta etapas independientes en paralelo — si deps y test no dependen entre sí, corren al mismo tiempo.

# Activar BuildKit (default en Docker 23+, pero por si acaso)
export DOCKER_BUILDKIT=1

# Build con caché externo — reutilizar capas de builds anteriores en CI
docker build \
  --cache-from type=registry,ref=ghcr.io/usuario/mi-app:cache \
  --cache-to type=registry,ref=ghcr.io/usuario/mi-app:cache,mode=max \
  -t ghcr.io/usuario/mi-app:latest \
  .

mode=max cachea todas las capas intermedias, no solo la final. En un repositorio donde los cambios de código son frecuentes pero las dependencias son estables, esto puede reducir el tiempo de build de 6 minutos a 45 segundos.

En GitHub Actions:

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: ghcr.io/usuario/mi-app:${{ github.sha }}
    # caché por scope — builds independientes no se contaminan entre sí
    cache-from: type=gha,scope=mi-app
    cache-to: type=gha,scope=mi-app,mode=max

El caché de GitHub Actions (type=gha) persiste entre runs del mismo repositorio y es gratuito dentro del límite de storage de Actions (10GB por defecto).

Elegir el registry correcto

El registry donde guardas tus imágenes tiene costo directo. La comparación honesta:

RegistryStoragePullsPrivado gratuito
Docker Hub$0 (1 repo privado)Limitado en free1 repo
GitHub Container Registry$0 hasta 500MB/mesIlimitado internoIncluido con GitHub
AWS ECR$0.10/GB/mes$0.09/GB transferencia
DigitalOcean Registry$5/mes (5GB)Gratis desde sus droplets
Fly.io RegistryIncluido en el planGratis desde fly

Para la mayoría de proyectos independientes y startups: GitHub Container Registry es la opción de menor costo si ya usas GitHub. El storage dentro de GitHub Actions es gratuito hasta 500MB, los pulls desde GitHub Actions son ilimitados y gratuitos, y la autenticación usa GITHUB_TOKEN sin configuración adicional.

Para proyectos en AWS: ECR tiene sentido porque las transferencias entre ECR y servicios AWS en la misma región no cuestan nada. Descargar una imagen de 100MB desde ECR a un ECS en la misma región tiene costo cero de transferencia.

Limpiar imágenes antiguas automáticamente

El storage se acumula silenciosamente. Sin una política de limpieza, cada tag que construyes permanece para siempre.

En GitHub Container Registry, una GitHub Action que corre semanalmente:

# .github/workflows/cleanup-registry.yml
name: Cleanup old images

on:
  schedule:
    - cron: '0 3 * * 0'   # Domingos a las 3am

jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/delete-package-versions@v4
        with:
          package-name: mi-app
          package-type: container
          min-versions-to-keep: 10          # Mantener los últimos 10 tags
          delete-only-untagged-versions: false

En AWS ECR, una lifecycle policy hace lo mismo sin necesidad de Actions:

{
  "rules": [
    {
      "rulePriority": 1,
      "description": "Keep last 10 tagged images",
      "selection": {
        "tagStatus": "tagged",
        "tagPrefixList": ["v"],
        "countType": "imageCountMoreThan",
        "countNumber": 10
      },
      "action": { "type": "expire" }
    },
    {
      "rulePriority": 2,
      "description": "Delete untagged images after 1 day",
      "selection": {
        "tagStatus": "untagged",
        "countType": "sinceImagePushed",
        "countUnit": "days",
        "countNumber": 1
      },
      "action": { "type": "expire" }
    }
  ]
}

En proyectos activos que hacen varios deploys al día, esta política puede ahorrar 5-20GB de storage al mes.

El servidor más barato que puede correr tu stack

La imagen optimizada reduce el tiempo de descarga y el overhead de storage. El servidor donde corre determina el costo mensual fijo. La pregunta correcta no es “¿qué servidor necesito?” sino “¿cuál es el servidor más pequeño que puede manejar mi carga?”

Para una API de Go con tráfico moderado (hasta ~500 req/s), los números reales son sorprendentes:

API Go optimizada en estado estable:
- RAM: 30-80MB dependiendo de la carga de trabajo
- CPU: <5% en idle, picos bajo carga

Servidores que pueden manejarlo con margen:
- Hetzner CX11: 2GB RAM, 1 vCPU — €3.29/mes
- DigitalOcean Droplet Basic: 1GB RAM, 1 vCPU — $6/mes
- Fly.io shared-cpu-1x: 256MB RAM — $1.94/mes (+ $0.02/GB transfer)
- Oracle Cloud Always Free: 1GB RAM, 1 OCPU — $0/mes

Para un frontend estático en Nginx sirviendo hasta 10,000 usuarios simultáneos:

Nginx con assets estáticos:
- RAM: 15-30MB
- CPU: <2% bajo carga normal

Opciones de costo cero o casi cero:
- Netlify: deploy gratuito, CDN global incluida
- Cloudflare Pages: deploy gratuito, CDN, sin límite de bandwidth
- GitHub Pages: gratuito para repos públicos
- Fly.io: plan gratuito para apps pequeñas

La lección: un frontend estático no necesita un servidor dedicado. Plataformas como Netlify y Cloudflare Pages distribuyen los assets en CDN global, manejan HTTPS automáticamente, y tienen un plan gratuito que cubre la mayoría de proyectos personales y startups en etapa inicial.

Dimensionar correctamente con límites de recursos

Poner límites de recursos en Docker Compose no es solo una práctica de seguridad — es una herramienta de cost management. Cuando defines los límites, puedes empacar más contenedores en el mismo servidor.

services:
  api:
    image: mi-api-go:latest
    deploy:
      resources:
        limits:
          cpus: '0.5'      # Máximo medio núcleo
          memory: 128M     # Máximo 128MB RAM
        reservations:
          cpus: '0.1'      # Mínimo garantizado
          memory: 32M      # Mínimo garantizado

  frontend:
    image: mi-frontend:latest
    deploy:
      resources:
        limits:
          cpus: '0.25'
          memory: 64M
        reservations:
          memory: 16M

  db:
    image: postgres:16-alpine
    deploy:
      resources:
        limits:
          memory: 256M
        reservations:
          memory: 128M
    command: >
      postgres
      -c shared_buffers=64MB
      -c max_connections=20
      -c work_mem=2MB

Con estos límites, el stack completo (API + frontend + base de datos) corre en menos de 512MB de RAM. Un servidor de 1GB de RAM en Hetzner por €3.29/mes tiene capacidad suficiente con margen para el sistema operativo y los procesos del host.

Los parámetros de PostgreSQL también importan. max_connections=20 en lugar del default 100 reduce el consumo de RAM de Postgres de ~150MB a ~40MB para la mayoría de cargas de trabajo pequeñas. Cada conexión en Postgres consume ~5-10MB. Si tu API tiene un pool de conexiones de 5-10, no necesitas 100 conexiones disponibles.

Builds en ARM: reducir costos de CI

Los runners de GitHub Actions en arquitectura ARM son considerablemente más baratos que los x86:

jobs:
  build:
    # ARM runner — mismo precio que x86 pero más eficiente en algunos builds
    runs-on: ubuntu-latest-arm

    steps:
      - uses: actions/checkout@v4

      - name: Set up Buildx para multi-arch
        uses: docker/setup-buildx-action@v3

      - name: Build para ARM y AMD64
        uses: docker/build-push-action@v5
        with:
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ghcr.io/usuario/mi-app:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

Una imagen multi-arch (linux/amd64,linux/arm64) funciona tanto en servidores x86 (los típicos VPS) como en ARM (AWS Graviton, Ampere en Oracle Cloud, los Mac con Apple Silicon). Oracle Cloud tiene instancias ARM Ampere en su tier Always Free — 4 OCPUs y 24GB de RAM de forma gratuita. Con una imagen multi-arch, tu stack corre ahí sin modificaciones.

Análisis de capas: encontrar el peso escondido

Antes de optimizar ciegamente, dive muestra exactamente qué hay en cada capa y cuánto pesa.

# Instalar dive
wget -qO- https://github.com/wagoodman/dive/releases/download/v0.12.0/dive_0.12.0_linux_amd64.tar.gz | tar xz
sudo mv dive /usr/local/bin/

# Analizar una imagen
dive mi-app:latest

dive muestra un árbol interactivo de capas con el tamaño de cada una y los archivos que agrega o modifica. Las capas más comunes que esconden peso innecesario:

  • Caché de npm/pip/go después de npm install: el gestor de paquetes descarga archivos a un directorio de caché que permanece en la capa. Limpiar en la misma instrucción RUN.
# ANTIPATRÓN: el caché de npm queda en la capa
RUN npm ci --frozen-lockfile

# CORRECTO: limpiar en la misma instrucción RUN
RUN npm ci --frozen-lockfile && npm cache clean --force
# Go — limpiar caché del módulo después de descargar
RUN go mod download && go clean -modcache
# Alpine — limpiar caché de apk
RUN apk add --no-cache curl
# --no-cache ya evita guardar el índice de paquetes — equivalente a apk update && apk add && rm -rf /var/cache/apk/*
  • Archivos de test y documentación dentro de node_modules: algunas dependencias incluyen sus propios tests, documentación, y ejemplos. node_modules puede reducirse 20-30% eliminándolos después de instalar:
RUN npm ci --frozen-lockfile --omit=dev \
    && find node_modules -name "*.test.js" -delete \
    && find node_modules -name "*.spec.js" -delete \
    && find node_modules -type d -name "__tests__" -exec rm -rf {} + 2>/dev/null || true

El pipeline de CI mínimo y barato

Un pipeline de CI que construye, testea y despliega sin desperdiciar minutos de ejecución:

# .github/workflows/ci.yml
name: CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Caché de dependencias — evita reinstalar en cada run
      - name: Cache node_modules
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: npm-${{ hashFiles('package-lock.json') }}
          restore-keys: npm-

      - run: npm ci --frozen-lockfile
      - run: npm test

  build-deploy:
    needs: test
    if: github.ref == 'refs/heads/main'   # Solo en push a main, no en PRs
    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 }}   # Sin secrets adicionales — GITHUB_TOKEN es automático

      - uses: docker/setup-buildx-action@v3

      - uses: docker/build-push-action@v5
        with:
          context: .
          target: production
          push: true
          # SHA corto como tag — 7 caracteres identifican el commit sin desperdiciar storage
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_IP }}
          username: deploy
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            docker pull ghcr.io/${{ github.repository }}:${{ github.sha }}
            docker stop mi-app 2>/dev/null || true
            docker rm mi-app 2>/dev/null || true
            docker run -d \
              --name mi-app \
              --restart unless-stopped \
              -p 8080:8080 \
              --memory 128m \
              --cpus 0.5 \
              -e DATABASE_URL="${{ secrets.DATABASE_URL }}" \
              ghcr.io/${{ github.repository }}:${{ github.sha }}
            docker image prune -f

Este pipeline no usa Docker Compose en el servidor — usa docker run directamente para mayor control sobre los recursos asignados. docker image prune -f después de cada deploy elimina imágenes sin tag (las versiones anteriores que ya no tienen referencia), liberando espacio automáticamente.

Los números reales

Para que esto no sean solo consejos abstractos, estos son los números de un stack real: API en Go + frontend en React + PostgreSQL.

Sin optimizar:
- Imagen API Go:      823MB
- Imagen Frontend:    742MB
- Build time API:     4m 32s
- Build time Frontend: 3m 15s
- RAM API en runtime: 340MB
- RAM Frontend (Nginx): 180MB
- Servidor necesario: 4GB RAM mínimo

Después de optimizar:
- Imagen API Go:       11MB   (-98.7%)
- Imagen Frontend:     26MB   (-96.5%)
- Build time API:      48s    (-82.4%, con caché)
- Build time Frontend: 35s    (-82.1%, con caché)
- RAM API en runtime:  45MB   (-86.8%)
- RAM Frontend (Nginx): 18MB  (-90%)
- Servidor necesario:  512MB RAM

El servidor de 4GB a $40/mes se convierte en uno de 1GB a €3.29/mes. El costo de CI con 30 builds al día pasa de $58/mes a $9/mes. El storage del registry pasa de 48GB (si guardas 30 tags) a 1.1GB.

El esfuerzo de implementar estas optimizaciones: medio día de trabajo una sola vez.

Lo que esto significa en la práctica

Optimizar el tamaño de imágenes y la eficiencia de builds no es trabajo de infraestructura avanzada. Es disciplina básica que cualquier equipo puede aplicar desde el primer Dockerfile.

Los equipos que no lo hacen no es porque no sepan que existe — es porque el costo es invisible. La factura de AWS o DigitalOcean llega consolidada: “computación”, “storage”, “transferencia”. Nadie la atribuye a la imagen de 800MB que lleva dos años en producción.

He visto el mismo patrón varias veces: un equipo paga $300/mes en infraestructura para una aplicación que hace 1,000 usuarios. Después de dos días de optimización, el mismo stack corre en $15/mes con mejor rendimiento. El costo no disminuyó porque la aplicación cambió — disminuyó porque se eliminó el peso que nadie había cuantificado.

El código que nadie lee no hace daño. La imagen de 800MB que se descarga treinta veces al día sí. La diferencia es que el código está visible y la imagen no.