Saltar al contenido principal

SPA → GitLab Registry → Dokploy (build local)

Objetivo: desplegar frontends (Vite o CRA) en Dokploy sin que el servidor compile: la imagen se construye en tu máquina, se sube al GitLab Container Registry y el droplet solo hace pull. Requiere: Docker en tu máquina y acceso a GitLab. Contexto y justificación en Registry + Dockerfile (GitLab). Resultado: npm run deploy:dev = build + push + redeploy en una línea.

La configuración base es Vite, que es el estándar de los proyectos. Lo específico de CRA está marcado como variante en la sección 4.


0. Resumen del flujo

tu máquina                         GitLab                  droplet (Dokploy)
────────── ────── ─────────────────
docker build ──push──► Container Registry ──pull──► contenedor nginx

curl al webhook ────┘

La imagen se construye en tu máquina, no en el servidor. El servidor solo baja la imagen (~80 MB) y la levanta.

Por qué así:

  • El build de un frontend consume 1–2 GB de RAM. En un droplet de 4 GB con otros contenedores corriendo, el proceso muere por OOM (exit code 137). Ver El problema de los builds.
  • No usa runners de GitLab CI, que consumen minutos con costo. El registry como almacén es gratis.
  • El deploy pasa de ~3 min con riesgo de fallo, a segundos.

Contra: hay que acordarse de correr el comando. No se despliega solo al hacer push.


1. Setup inicial (una sola vez para TODOS los proyectos)

Esto se configura una vez y sirve para todos los repos. No se repite por proyecto.

1.1 Token para subir imágenes (tu máquina)

Los tokens fine-grained NO funcionan para push al registry. En Packages and Registry solo ofrecen Container Repository (leer y borrar) — no existe permiso de escritura. Hay que usar un Legacy token.

  1. GitLab → foto de perfil → PreferencesAccess Tokens
  2. Generate tokenLegacy token
  3. Llenar:
    • Name: docker-registry
    • Expiration date: máximo permitido (1 año)
    • Scopes: solo ✅ read_registry y ✅ write_registry
  4. Create → copiar el token (solo se muestra una vez)

Aunque el legacy se describe como "broad permissions", los permisos reales son los scopes marcados. Con esos dos, el token no puede leer código ni tocar la API — solo push/pull de imágenes.

1.2 Login en Docker (tu máquina)

echo "<token>" | docker login registry.gitlab.com -u <tu-username> --password-stdin
  • El -u es tu username de GitLab (el @handle del perfil), no el email.
  • Usa --password-stdin en vez de -p <token>: con -p el token queda en el historial del shell.
  • docker login sin argumento apunta a Docker Hub. Con registry.gitlab.com autentica contra GitLab. El destino del push lo decide el nombre de la imagen, no la sesión.

Es permanente. Se guarda en ~/.docker/config.json (en Windows, en el credential manager de Docker Desktop) y sobrevive reinicios. Solo se repite si expira el token, haces docker logout, o reinstalas Docker.

Este login sirve para todos tus proyectos de GitLab. No se hace uno por repo.

Verificar sesión activa:

# Windows (Docker Desktop)
docker-credential-desktop list
# {"registry.gitlab.com":"tu-username"}

# Linux / Mac
cat ~/.docker/config.json # buscar "registry.gitlab.com" dentro de "auths"

El botón "Sign in" de Docker Desktop es la cuenta de Docker Hub — no tiene ninguna relación con esto. Puedes seguir sin iniciar sesión ahí.

1.3 Token para que Dokploy baje imágenes (deploy token)

GitLab permite deploy tokens en dos niveles y solo dos:

NivelAlcanceRequisito
Grupotodos los proyectos del gruporol Owner en el grupo
Proyectosolo ese repo (uno por proyecto)rol Maintainer u Owner

⚠️ Un namespace personal NO es un grupo. Si tus repos cuelgan de tu usuario (ej. gitlab.com/inmersys/mi-proyecto donde inmersys es tu cuenta, no un grupo), la opción de grupo no existe — solo verás Deploy tokens dentro de Settings del proyecto. Para saber cuál tienes: en tu perfil, pestaña Groups, lista los grupos reales a los que perteneces. Si el namespace de tus repos no aparece ahí, es tu usuario.

Con namespace personal, el token de proyecto es la vía correcta, no un error. Con pocos repos cuesta menos crear uno por proyecto que migrar.

Migrar a grupo: qué se conserva y qué se rompe

Si hoy estás en namespace personal y quieres el token de grupo, hay que transferir los proyectos. Requisitos: rol Owner en el proyecto y Maintainer u Owner en el grupo destino.

Se conservaCambia o se pierde
Código, historial, branches, tagsImágenes del container registry (ver abajo)
Issues, MRs, wikiMiembros heredados del namespace viejo (si no están en el grupo destino)
Variables de CI/CD, webhooks, deploy keysLa ruta del registry — y no redirige
Miembros directos del proyectoSecurity policies (se desasignan)
La URL vieja redirige a la nueva (los git remote siguen sirviendo)Suscripciones de pipeline, package registry si cambia el root namespace

A cambio, los miembros del grupo destino ganan acceso automático al proyecto — que es justo la ventaja de usar grupos cuando el equipo crece.

🚫 El bloqueador: el registry. En GitLab.com un proyecto no se puede transferir a otro top-level namespace si tiene imágenes en el container registry. Las imágenes viven en una ruta que espeja el path del proyecto, y esa ruta ni se mueve ni redirige.

Procedimiento completo:

  1. Borrar todas las imágenes del Container Registry del proyecto (DeployContainer Registry)
  2. New group — el nombre de tu usuario ya está ocupado, elige otro (inmersys-dev)
  3. Por proyecto: Settings → General → Advanced → Transfer project → el grupo
  4. Ya en el grupo: Settings → Repository → Deploy tokens
  5. Reconstruir y volver a subir las imágenes con el nombre nuevo (registry.gitlab.com/<grupo>/proyecto)
  6. Actualizar el campo Docker Image y las credenciales de cada app en Dokploy

¿Vale la pena? Si ya tienes imágenes subidas y funcionando, no: el único ahorro es no crear un deploy token por repo (30 segundos cada uno) a cambio de vaciar el registry y reconstruir todo. El grupo conviene cuando arrancas repos nuevos desde cero, o cuando entra más gente al equipo y quieres administrar accesos en un solo lugar.

Crear el token (mismos pasos en grupo o proyecto)

  1. SettingsRepositoryDeploy tokens (del grupo, o del proyecto si estás en namespace personal)
  2. Add token:
    • Name: dokploy — es solo una etiqueta
    • Expiration date: vacío (no expira). Si le pones fecha, el día que expire los deploys empiezan a fallar sin razón aparente.
    • Username: dokploy — si lo dejas vacío GitLab genera gitlab+deploy-token-{n}
    • Scopes: soloread_registry
  3. Create → copiar username y token (gldt-...), no se vuelven a mostrar

⚠️ Los scopes no se pueden editar después. Si te equivocas, hay que revocar y rehacer. Error común: marcar read_repository (acceso al código) en vez de read_registry (acceso a las imágenes). Produce denied: access forbidden al desplegar.

Si es de grupo, se registra una vez en Dokploy y sirve para todas las apps del grupo. Si es de proyecto, sirve solo para las apps que usen imágenes de ese repo — los demás proyectos necesitan el suyo.

1.4 Resumen de credenciales

CredencialDónde se creaPara quéScopes¿Reutilizable?
PAT legacyPerfil → Access Tokenstu docker pushread_registry + write_registrySí, todos tus proyectos
Deploy tokenGrupo → Settings → Repository (o Proyecto, si el namespace es personal)docker pull de Dokployread_registryGrupo: todos sus proyectos · Proyecto: solo ese repo

2. Archivos base del proyecto (Vite)

Cuatro archivos en la raíz. Son los mismos para cualquier proyecto Vite.

Dockerfile

# ---- Build stage ----
FROM node:22-alpine AS build

WORKDIR /app

# Instala dependencias con cache eficiente
COPY package.json package-lock.json ./
RUN npm ci

# Copia el resto y compila
COPY . .
RUN npm run build

# ---- Serve stage ----
FROM nginx:1.27-alpine AS serve

# Config de nginx con fallback SPA
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Copia el build de Vite (carpeta dist)
COPY --from=build /app/dist /usr/share/nginx/html

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

Por qué multi-stage: node solo existe durante la compilación. La imagen final es nginx + archivos estáticos (~80 MB) en vez de arrastrar node_modules (~1 GB).

Si el proyecto usa husky, agrega ENV HUSKY=0 antes del npm ci. Si no, npm ci dispara el script preparehusky install, que falla o avisa dentro de la imagen porque ahí no hay .git.

nginx.conf

server {
listen 80;
server_name _;

root /usr/share/nginx/html;
index index.html;

# Fallback SPA: cualquier ruta que no sea un archivo real
# sirve index.html para que React Router maneje el enrutado.
# Equivalente al "_redirects /* /index.html 200" de Netlify.
location / {
try_files $uri $uri/ /index.html;
}

# Cache agresivo para los assets con hash de Vite (immutable)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}

# No cachear el index.html para que siempre tome la versión nueva
location = /index.html {
add_header Cache-Control "no-cache";
}

# Compresión
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
gzip_min_length 256;
}

Sin el bloque try_files del location /, recargar con F5 en cualquier ruta interna devuelve 404: nginx busca un archivo que no existe porque el enrutado es del lado del cliente.

.dockerignore

node_modules
dist
.git
.gitignore
Dockerfile
.dockerignore
*.log
.env.local
.vscode
README.md

package.json — scripts

Sustituye <namespace> y <proyecto> por los tuyos:

"img:dev":  "docker build --build-arg ENV_FILE=env/.env.dev  -t registry.gitlab.com/<namespace>/<proyecto>:dev  . && docker push registry.gitlab.com/<namespace>/<proyecto>:dev",
"img:prod": "docker build --build-arg ENV_FILE=env/.env.prod -t registry.gitlab.com/<namespace>/<proyecto>:prod . && docker push registry.gitlab.com/<namespace>/<proyecto>:prod",
"img:test": "docker run --rm -p 8080:80 registry.gitlab.com/<namespace>/<proyecto>:dev",
"deploy:dev": "npm run img:dev && curl -X POST https://panel.<tu-dominio>/api/deploy/<WEBHOOK_TOKEN>"

El nombre de la imagen no se puede acortar: GitLab obliga a registry.gitlab.com/<namespace>/<proyecto>, y renombrar solo el registry no es posible (la ruta la fija el path del proyecto). Por eso vive en los scripts y no se teclea a mano.

Uso diario:

npm run img:test      # probar la imagen local en http://localhost:8080
npm run deploy:dev # build + push + redeploy, una sola línea
npm run img:prod # producción

3. Variables de entorno — el punto crítico

Las variables se hornean en el bundle durante el build. No se leen en runtime.

Implicaciones que causan la mayoría de las confusiones:

  1. Las Environment Settings de Dokploy no sirven para un frontend estático: son variables de runtime del contenedor, y el bundle ya está compilado. Nadie lee process.env en el navegador.
  2. Una imagen = un backend. No puedes reusar el mismo artefacto en dev y prod. Por eso hay dos tags.
  3. Cambiar una URL exige reconstruir la imagen. No basta reiniciar el contenedor.
FrameworkPrefijo obligatorioSe lee con
ViteVITE_import.meta.env.VITE_X
CRAREACT_APP_process.env.REACT_APP_X

Estrategia: un archivo por entorno

En vez de pasar N build-args (inmanejable cuando crecen las variables), se pasa un archivo:

env/.env.dev    → variables de la imagen :dev     (versionado)
env/.env.prod → variables de la imagen :prod (versionado)
.env → SOLO desarrollo local (gitignored)

En el Dockerfile, antes de RUN npm run build:

ARG ENV_FILE=env/.env.dev
COPY ${ENV_FILE} .env

Y agrega .env al .dockerignore para que el archivo local nunca se cuele en la imagen.

Ventaja: agregar variables nuevas solo requiere editarlas en env/.env.*. Ni el Dockerfile ni los scripts se tocan. Si el archivo no existe, el COPY falla y el build se detiene — error explícito en vez de un bundle con la URL vacía.

¿Versionar env/.env.*? Sí, si solo contienen URLs públicas de APIs. Si alguna vez metes una clave real, sácala de ahí — un bundle de frontend es público por definición, así que nada secreto debería estar en una variable VITE_/REACT_APP_ en primer lugar.


4. Variante CRA (Create React App)

Si el proyecto usa react-scripts en vez de Vite, cambian cuatro cosas:

Vite (base)CRA
Carpeta de salidadist/build/
Carpeta de assets con hash/assets//static/
Prefijo de variablesVITE_REACT_APP_
Versión de Nodenode:22-alpinenode:20-alpine

Dockerfile — cambia la línea del COPY final:

COPY --from=build /app/build /usr/share/nginx/html

nginx.conf — cambia el bloque de cache:

location /static/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}

.dockerignore — cambia dist por build.

Node 20, no 22: react-scripts 5.0.1 es viejo (webpack 5, dependencias sin mantener) y no está probado en Node 22. Node 20 es el punto seguro. En Vite usa 22 sin problema.

Dos ENV que a veces hacen falta solo en CRA, si te topas con el problema:

  • ENV CI=false — si el builder exporta CI=true, los warnings de ESLint tumban el build. Vite no hace esto.
  • ENV GENERATE_SOURCEMAP=false — si el build se queda sin RAM. Los source maps son el mayor consumo de memoria. En Vite ya están desactivados por default.

5. Estrategia de tags

Opción A — tags fijos :dev y :prod (la que usamos)

Simple. Dokploy apunta a un nombre estable y nunca hay que reconfigurarlo.

Contra: cada push sobrescribe el tag. La imagen anterior queda huérfana (sin nombre) y ya no es alcanzable. Es decir, no hay rollback: si subes un :prod roto, hay que recompilar desde el commit anterior.

Opción B — tags versionados :4.20.0

Te queda historial completo y rollback inmediato: cambias el tag en Dokploy a la versión anterior y listo.

Contra: hay que actualizar el campo Image en Dokploy en cada release.

Opción C — las dos (recomendado para producción)

Subir la misma imagen con dos tags:

docker build --build-arg ENV_FILE=env/.env.prod -t registry.gitlab.com/<ns>/<proy>:prod .
docker tag registry.gitlab.com/<ns>/<proy>:prod registry.gitlab.com/<ns>/<proy>:4.20.0
docker push registry.gitlab.com/<ns>/<proy>:prod
docker push registry.gitlab.com/<ns>/<proy>:4.20.0

Dokploy consume :prod (estable, nunca lo tocas) y :4.20.0 queda como respaldo. Si algo truena, apuntas Dokploy al tag de la versión buena.

Para desarrollo, la opción A basta: cambias mil veces al día y no te importa el histórico.


6. Configuración en Dokploy

6.1 Registrar el registry (una vez por instancia)

Dokploy → SettingsRegistry → agregar:

  • Registry URL: registry.gitlab.com
  • Username / Password: los del deploy token (sección 1.3)

6.2 Crear la aplicación

General → Provider → pestaña Docker (ver Providers)

CampoValor
Docker Imageregistry.gitlab.com/<namespace>/<proyecto>:dev
Registry URLregistry.gitlab.com
Usernameel del deploy token
Passwordel gldt-...

Al elegir provider Docker, Dokploy no clona el repositorio: no ve tu Dockerfile, no compila nada, solo hace pull. La sección Build Type queda ignorada por completo.

Domains → Container Port: 80

nginx escucha en 80 (listen 80 + EXPOSE 80). El 3000 sería el dev server de Vite/CRA, que aquí no corre. Si quisieras otro puerto, tendrías que cambiar las tres cosas a la vez.

Da Deploy manual la primera vez para confirmar que el pull funciona.

6.3 Webhook de redeploy

Deployments → Webhook URL: https://panel.<tu-dominio>/api/deploy/<token>

No se registra en GitLab. Con provider Docker, un push al repo no cambia la imagen, así que un webhook de GitLab no serviría de nada. Lo dispara tu máquina con el curl del script deploy:dev. Comparar con el flujo normal en Auto-deploy y webhooks.

El orden del script importa y es el correcto: primero sube la imagen, y solo si eso sale bien (&&) llama al webhook.

⚠️ Esa URL es una credencial: cualquiera que la tenga puede disparar un deploy. Si el repo es privado y del equipo, versionarla en package.json es tolerable. Si no, sácala a un archivo ignorado.

Nota: los deploys por webhook aparecen como "NEW CHANGES", sin mensaje de commit. Es inherente al provider Docker: Dokploy nunca clona el repo, así que no sabe qué commit generó la imagen. Si necesitas trazabilidad, usa tags versionados (sección 5) o agrega un label OCI a la imagen.


7. Almacenamiento y costos

El container registry es gratis y no consume la cuota que puede bloquear tus proyectos.

El límite de 10 GiB por proyecto del plan Free aplica solo a repositorio y LFS. El container registry, el package registry y los artefactos de CI no cuentan para ese límite — así que acumular imágenes nunca va a dejar un proyecto en read-only.

Los deploy tokens también son Tier: Free, sin costo.

Dónde ver cuánto estás guardando

Por namespace/grupo: GrupoSettingsUsage quotas → pestaña Storage

Ahí ves el Namespace storage used y el desglose por proyecto. La columna Containers es específicamente lo que ocupan tus imágenes. Se actualiza cada 90 minutos.

Por proyecto: ProyectoDeployContainer Registry — lista de imágenes, tags, fecha de publicación y tamaño de cada uno.

Referencia de tamaños

  • Imagen de un frontend (nginx + estáticos): ~80 MB
  • Imagen con node_modules incluido: ~1 GB (por eso el multi-stage)

Con dos tags fijos por proyecto, el registry nunca crece más allá de un par de entradas.

Limpieza (opcional)

ProyectoDeployContainer RegistrySet up cleanup

Solo tiene sentido si usas tags versionados y acumulas decenas. Ojo: borrar un tag no libera espacio de inmediato — los blobs se eliminan cuando corre el garbage collector.


8. Errores frecuentes y sus causas reales

SíntomaCausaSolución
Bad Gateway (502)El contenedor no escucha en el puerto configurado en Domains, o la app apunta al repo equivocadoContainer Port = 80; verificar Repository/Image
Build cancelado en Creating an optimized production build... / exit code 137OOM. Sin RAM suficiente en el dropletConstruir en local (esta guía). Parche: GENERATE_SOURCEMAP=false + swap
denied: access forbidden al hacer pullEl deploy token tiene read_repository en vez de read_registryRevocar y recrear con el scope correcto
denied: requested access to the resource is denied al hacer pushFalta write_registry, o no hay sesiónVerificar scopes; docker-credential-desktop list
Dockerfile not foundLos archivos existen solo en localCommit + push antes de desplegar
An image does not exist locally with the tagdocker push sin tag empuja :latest, que no existeIncluir el tag: ...:dev
404 al recargar (F5) en una ruta internaFalta el fallback SPABloque try_files $uri $uri/ /index.html
La app despliega pero pega a la API equivocadaLas variables se hornean en buildReconstruir la imagen, no reiniciar
Imagen vieja tras el deployEl tag no cambió de nombre y se reusó la cacheadaForzar pull, o usar tags versionados

Verificar que se está usando el Dockerfile correcto

En el log de build deben aparecer dos load metadata (node y nginx) y pasos [build n/n] / [serve n/n]. Si solo aparece uno, o ves comandos que no reconoces, estás compilando el Dockerfile de otro proyecto.

Diagnóstico desde el servidor

pgrep -fa "vite|react-scripts|webpack"   # ¿sigue compilando?
docker images | grep <proyecto> # ¿se creó la imagen? (~80 MB = correcta)
docker service ls | grep <proyecto> # Dokploy usa Docker Swarm
docker service ps <servicio> --no-trunc # Running / Failed / reintentando
free -h
dmesg -T | grep -i "killed process" # confirmar OOM

Si necesitas compilar en un droplet con poca RAM, agrégale swap:

fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab

Referencias

GitLab

Frameworks

Infra


📖 Relacionado: Vite (con build en servidor) · Providers · Auto-deploy · Registry + Dockerfile (GitLab) · El problema de los builds