← Blog
Frontend 29 de mayo de 2026 8 min de lectura

De 175 assets dispersos a 76 en CDN: consolidación, optimización y versionado de marca

Teníamos 175 assets de marca esparcidos en 4 repositorios. Un cambio de branding requería 4 deploys. Lo resolvimos con un repositorio dedicado, S3 + CloudFront, y una sola variable de entorno.

De 175 assets dispersos a 76 en CDN: consolidación, optimización y versionado de marca

Antes de este trabajo, cambiar el logo de la plataforma significaba abrir cuatro repositorios, crear cuatro pull requests, y coordinar cuatro deploys. A veces el logo nuevo aparecía en producción en un repo antes de que los otros hubieran mergeado. Durante horas, la misma plataforma mostraba el logo viejo en la pantalla de login y el nuevo en el dashboard.

El objetivo era simple de enunciar: que un cambio de marca sea un cambio de una línea. Una variable de entorno. Cero deploys de aplicación.

Este post documenta cómo llegamos ahí.


El problema: assets acoplados al ciclo de deploy

Los assets de marca —logos, banners, estados de error, ilustraciones de onboarding— vivían dentro de cada repositorio que los necesitaba. En frontend-platform/src/assets/images/. En client-account-app/src/assets/. En product-ecosystem/public/. En ui-libraries/packages/components/src/.

El resultado práctico era predecible:

Drift de versiones. Cada repo tenía su propia copia, y esas copias divergían. Un fix de accesibilidad en un SVG requería aplicarlo en cada repo por separado. La mayoría de las veces, solo se aplicaba en uno.

Coupling con el ciclo de deploy. Los assets eran parte del bundle. Si el diseño actualizaba un logo, ese logo no llegaba a producción hasta el próximo deploy de la app. Si había un feature freeze, el logo esperaba.

Invisible para el equipo de diseño. No había una fuente de verdad. Para saber qué versión de un asset estaba en producción, había que buscar en cuatro repos distintos.

Contando archivos antes de la migración llegué a ~175 assets de marca distribuidos entre esos repositorios. No todos duplicados exactamente —algunos repos tenían variantes que otros no—, lo que hacía la situación peor: la inconsistencia no era uniforme.


La decisión: un repositorio dedicado como fuente de verdad

La solución obvia sería "subir los assets a S3". Pero la decisión más importante no fue la infraestructura —fue el modelo de gestión.

Un bucket de S3 sin estructura es un cajón. Cualquiera puede subir cualquier cosa, sin convención de nombres, sin historia de cambios, sin revisión. En seis meses tienes logo-final-v3-REAL.svg y logo-nuevo-2.png conviviendo.

La decisión fue: el repositorio de Git es la fuente de verdad. S3 es un espejo del repo. La estructura del repo determina la estructura del bucket. El historial de commits es el historial de cambios de los assets. Un PR es la forma de agregar o modificar un asset.

assets-repo/
├── README.md
├── MANIFEST.md
├── CONVENTIONS.md
├── .svgo.config.mjs
├── .github/
│   └── workflows/
│       └── deploy.yml
└── v2/
    ├── platform/
    │   ├── logos/
    │   ├── banners/
    │   ├── icons/
    │   ├── states/
    │   ├── onboarding/
    │   ├── password-reset/
    │   ├── timer/
    │   └── emails/
    └── partners/
        ├── partner-a.svg
        └── partner-b.svg

La carpeta v2/ mapea 1:1 al prefijo en el bucket de S3. Lo que está en el repo, está en el CDN. Nada más.


De 175 a 76: qué eliminamos y qué optimizamos

La reducción más grande no vino de optimizar —vino de eliminar.

Entre los assets originales había un SVG de 2.8 MB. Una ilustración compleja, con gradientes, filtros y centenares de paths. Técnicamente era un SVG, pero en la práctica se comportaba como una imagen rasterizada: no escalaba visualmente mejor que un WebP, tardaba más en parsear, y bloqueaba el render. La decisión fue no migrarlo. Eliminado, no reemplazado.

Esa decisión sola fue el mayor impacto en peso total del conjunto.

Para el resto, la estrategia fue dual-format:

SVG para logos e iconos. Son vectoriales por naturaleza, escalan sin pérdida, y una vez comprimidos con gzip en CloudFront son muy pequeños. sidebar.svg pesa 0.5 KB. Su equivalente sidebar.webp pesa 1.6 KB —el SVG es más liviano aquí. Y para emails y casos donde SVG no renderiza, tenemos el WebP como fallback.

WebP para fotos e imágenes complejas. login.webp, el banner de pantalla completa de la página de login, pesa 2.1 MB. Es el asset más pesado del repositorio —intencionalmente, porque es una imagen fotográfica a pantalla completa y WebP es el formato correcto para ese caso.

El resultado final: 76 archivos — 37 SVG + 38 WebP + 1 PNG (el PNG es fragment-bg.png, mantenido como original en el repo pero excluido del deploy al CDN, que usa su versión WebP).

Optimización de SVG

El config de svgo que usamos:

// .svgo.config.mjs
export default {
  multipass: true,
  plugins: [
    {
      name: 'preset-default',
      params: {
        overrides: {
          removeViewBox: false,  // crítico: sin viewBox el SVG no escala en <img>
          cleanupIds: false,     // preservar IDs que otros elementos referencian internamente
        },
      },
    },
  ],
};

Dos overrides que importan:

removeViewBox: false — la optimización por defecto de svgo elimina el atributo viewBox cuando puede derivarlo de width/height. El problema es que un <img src="logo.svg"> con dimensiones width: 100% necesita viewBox para saber el aspect ratio y escalar correctamente. Sin él, el SVG colapsa o se renderiza con dimensiones incorrectas.

cleanupIds: false — algunos SVGs usan IDs internamente para referencias entre elementos (<use href="#icon-path"/>). Limpiar esos IDs rompe el SVG silenciosamente.

Para optimizar antes de commit:

npx svgo --config=.svgo.config.mjs "v2/platform/**/*.svg"

Arquitectura CDN: S3, CloudFront y versionado por folder

La estrategia de caché

Cache-Control: public, max-age=31536000, immutable

Un año de caché, immutable. El browser no vuelve a pedir el asset mientras esté en caché. CloudFront lo sirve desde el edge más cercano al usuario.

La implicación: los assets son inmutables una vez publicados. Si el contenido de sidebar.svg cambia, la URL tiene que cambiar. La forma de manejar esto en este sistema es la misma convención de versionado que usamos para todo.

Versionado por folder, no por filename

La alternativa clásica al versionado de assets es el hash en el filename: sidebar.a3f8c2.svg. Funciona bien cuando los assets los genera un build tool que actualiza las referencias automáticamente. No funciona bien cuando hay cuatro repos distintos que necesitan saber la nueva URL.

La decisión fue versionar por folder: /v2/ es la versión actual. Cuando llegue un rebranding mayor, vivirá en /v3/ —sin tocar /v2/. Los repos que aún no migraron siguen apuntando a /v2/ sin romperse. Los que sí migraron usan /v3/. La transición es incremental.

cdn.platform.com/v2/platform/logos/sidebar.svg  ← versión actual
cdn.platform.com/v3/platform/logos/sidebar.svg  ← futura versión, cuando exista

Esto hace que la URL sea predecible y human-readable. Un desarrollador puede construir la URL de memoria. No necesita consultar un manifest para saber dónde está login.webp.

Tres ambientes, un repo

                         ┌─────────────────────────────────────────┐
                         │           assets-repo (repo Git)        │
                         │   branch: develop / staging / main      │
                         └────────────┬────────────────────────────┘
                                      │ push → GitHub Actions
                    ┌─────────────────┼──────────────────────┐
                    ▼                 ▼                       ▼
          S3 (dev)           S3 (stg)                 S3 (prd)
          │                  │                         │
          ▼                  ▼                         ▼
   CloudFront (dev)   CloudFront (stg)          CloudFront (prd)
          │                  │                         │
          ▼                  ▼                         ▼
cdn.dev.platform.com  cdn.stg.platform.com   cdn.platform.com

Cada merge a la rama correspondiente dispara el deploy a ese ambiente. El flujo de promoción es branch-driven: developstaging → release tag → prd.


El pipeline de CI/CD

El workflow de GitHub Actions delega en un template reutilizable (manejado por DevOps). Lo que controla este repo es qué se sube y a dónde:

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches:
      - main
    paths:
      - 'v2/**'           # solo se dispara si cambia algo en v2/
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: false
        type: choice
        default: dev
        options:
          - dev
          - stg
          - prd

jobs:
  deploy:
    uses: org/static-files-pipeline-template/.github/workflows/deploy.yml@main
    with:
      aws_dev_account_id: ${{ vars.AWS_DEV_ACCOUNT_ID }}
      aws_stg_account_id: ${{ vars.AWS_STG_ACCOUNT_ID }}
      aws_prd_account_id: ${{ vars.AWS_PRD_ACCOUNT_ID }}
      aws_iam_role_name: ${{ vars.AWS_IAM_ROLE_NAME_FOR_S3_CLOUDFRONT_DEPLOYER }}
      environment: ${{ inputs.environment || 'dev' }}
      s3_bucket_dev: assets-dev
      s3_bucket_stg: assets-stg
      s3_bucket_prd: assets-prd
      s3_prefix: v2
      cloudfront_invalidation_paths: /v2/*
      skip_build: true
      build_output_dir: v2

Puntos clave:

paths: - 'v2/**' — el workflow no se dispara si solo cambia un README. Solo los cambios en assets reales activan el deploy.

skip_build: true — no hay paso de build. Lo que está en el repo es lo que va al CDN. El "build" es la optimización que se hace antes de commitear.

cloudfront_invalidation_paths: /v2/* — invalida todo el prefijo /v2/ en CloudFront después del sync. Necesario porque el header es immutable y CloudFront no invalida automáticamente al actualizar S3.

El template reutilizable ejecuta internamente el equivalente de:

aws s3 sync ./v2/ s3://assets-<env>/v2/ \
  --cache-control "public, max-age=31536000, immutable" \
  --delete

aws cloudfront create-invalidation \
  --distribution-id <dist-id> \
  --paths "/v2/*"

Lo que ve el consumidor: una variable de entorno

El contrato con los repos consumidores es minimalista. Una variable de entorno. Eso es todo.

# .env.development
VITE_CDN_URL=https://cdn.dev.platform.com

# .env.staging
VITE_CDN_URL=https://cdn.stg.platform.com

# .env.production
VITE_CDN_URL=https://cdn.platform.com

El consumer no sabe nada de S3, CloudFront, ni de la estructura interna del repo de assets. Construye URLs a partir de esa variable.

Acceso directo (sin librería)

const CDN = import.meta.env.VITE_CDN_URL;

function LoginPage() {
  return (
    <img
      src={`${CDN}/v2/platform/banners/login.webp`}
      alt="Login illustration"
    />
  );
}

Acceso tipado con @org-scope/assets

Para los consumers dentro del ecosistema, publicamos un paquete que encapsula el manifest y resuelve todas las URLs con tipado completo:

// src/configs/assets.ts
import { createAssets } from '@org-scope/assets';

export const assets = createAssets({
  cdnUrl: import.meta.env.VITE_CDN_URL,
});

export type AppAssets = typeof assets;
// Cualquier componente
import { assets } from '@configs/assets';

function ErrorState() {
  return (
    <img
      src={assets.states.error.svg}
      alt="Error"
    />
  );
}

function OnboardingBanner() {
  return (
    <img
      src={assets.onboarding.hand.webp}
      alt="Onboarding"
    />
  );
}

createAssets valida en runtime que cdnUrl no esté vacío. TypeScript da autocompletado sobre el manifest entero. No hay strings mágicos.

El cambio de CDN

Cuando el CDN de producción cambia de dominio, o cuando llega una versión /v3/ con nuevos assets, el cambio es:

# Cambiar en CI/CD (o en .env.production):
VITE_CDN_URL=https://cdn-nuevo.platform.com

Sin redeploy de la aplicación. Sin tocar código. Solo la variable de entorno, y en el próximo build (o en el próximo request si es un env var dinámica), todos los assets apuntan al nuevo origen.


Lo que aprendí

El repositorio como fuente de verdad, no como paso intermedio. La decisión de tratar el repo de Git como el artefacto definitivo —no como un lugar donde se guarda código que después genera el artefacto— cambió cómo todo el equipo razona sobre los assets. PR = revisión de un cambio. Merge = deploy (mediado por CI). El historial de Git es el historial de cambios del CDN.

Eliminar antes de optimizar. El SVG de 2.8 MB que eliminamos valió más que todas las optimizaciones de svgo sobre el resto. Antes de mejorar algo, preguntar si debe existir. El inventario de 175 assets incluía assets que nadie usaba, duplicados entre repos, y variantes que el diseño había descartado hace meses. La consolidación fue también un proceso de limpieza.

El versionado de folder es un contrato de comunicación, no solo técnico. /v2/ le dice a cualquiera que lo lea: "estos assets son para la versión 2 del design system". Cuando llegue /v3/, la coexistencia de ambas versiones en el mismo CDN hace la migración incremental sin coordinación forzada entre repos. No es solo una solución técnica —es una convención que reduce la coordinación necesaria entre equipos.

immutable + invalidación explícita es el contrato correcto. La caché de un año con immutable es agresiva. El browser ni siquiera verifica si cambió. Eso es exactamente lo que queremos para assets estáticos. La condición es que las URLs sean efectivamente inmutables: si el contenido cambia, la URL debe cambiar. La disciplina de versionado hace que eso sea sostenible.


Conclusión

La arquitectura que quedó es deliberadamente simple: un repo Git como fuente de verdad, un pipeline que lo sincroniza con S3, CloudFront como capa de distribución, y una variable de entorno como contrato con los consumidores.

La reducción de 175 archivos a 76 no fue el objetivo principal —fue la consecuencia de tomar decisiones explícitas sobre qué debía existir y en qué formato. El número que importa no es cuántos assets hay, sino desde cuántos lugares los gestiona el equipo. La respuesta pasó de cuatro a uno.

El siguiente post de la serie da un paso atrás de la infraestructura para ver la coordinación: cómo se orquestó el release de breaking changes a través de 4 repositorios y 4 paquetes simultáneamente, qué secuencia de versiones se siguió para que ningún consumer quedara roto durante la transición, y qué decisiones técnicas en el versionado semántico hacen esa coordinación manejable.