Saltar al contenido principal
Versión: main (sin publicar)

Levantar la infra de un proyecto platform-core en Coolify

Qué es esto

Guía genérica para poner en pie la infraestructura de cualquier proyecto que consuma platform-core: Postgres, OpenFGA, almacenamiento S3, correo, backend y frontend, todo en Coolify.

No está atada a ningún dominio de negocio. Cada trampa documentada acá se pisó de verdad levantando un proyecto real de punta a punta — no son advertencias teóricas. Si algo dice "esto falla", es porque falló.

Antes de empezar​

NecesitásDónde
Token de API de CoolifyPanel de Coolify → Keys & Tokens. Ojo: el token trae un |, así que nunca hagas source de un archivo de credenciales — leelo con grep/cut
Usuario Nexus de solo lecturaPara resolver dev.echotechs.core:* y @echotechs/*. El acceso anónimo está apagado en toda la instancia: leer también pide credenciales
UUID del server en CoolifyGET /api/v1/servers
Dominio wildcardCoolify genera hostnames bajo el wildcard configurado en el server

Convención usada en los ejemplos:

COOLIFY=https://coolify.tu-dominio.net
TOKEN=<tu token de Coolify>
PROJECT=<uuid del proyecto>
SERVER=<uuid del server>

Modelo mental: los tres tipos de recurso​

Esta es la decisión más importante y la que más tiempo cuesta si se elige mal.

ApplicationServiceManaged database
Qué esUn contenedor, desde repo Git o imagen DockerUn docker-compose completoPostgres/MySQL/Redis gestionado
Multi-contenedorNoSíNo
Dominio propio por APISí (PATCH /applications/{uuid} con domains)No — solo desde la UIN/A
Varios dominiosSí (https://a.com:9000,https://b.com:9001)Solo con plantillas reconocidasN/A
RedEstá en la red coolifyRed aislada propiaEstá en coolify
Montar archivosSí (POST /applications/{uuid}/storages)Vía composeNo

Cómo elegir:

  • ¿Un solo contenedor y querés el dominio por API? → application
  • ¿Necesitás varios contenedores (init containers, sidecars)? → service
  • ¿Base de datos? → managed database, siempre
No conviertas un service a application solo por el dominio

Es tentador (las applications sí permiten fijar el dominio por API). Pero al pasar MinIO y OpenFGA a applications, los contenedores no arrancaron: start_command reemplaza el CMD y muchas imágenes esperan que su entrypoint reciba argumentos específicos. Si necesitás multi-contenedor, quedate en service y fijá el dominio desde la UI — son 10 segundos.

Las cinco reglas de oro​

  1. Nunca escribas labels de Traefik a mano. Coolify las genera. Si las escribís vos, Coolify no registra el dominio y no funciona.
  2. La pestaña "Links" es el detector. ¿No aparece en un recurso? Su dominio no está registrado: el ruteo está mal cableado. Punto.
  3. Un contenedor en más de una red necesita traefik.docker.network. Sin eso el ruteo falla en silencio, sin errores en ningún lado.
  4. Verificá las variables de entorno dentro del contenedor, no en la API. La API miente (ver más abajo).
  5. Los secretos van en las env vars de Coolify, no hardcodeados en el compose que commiteás. Usá ${VAR}.

Paso a paso​

0. Crear el proyecto​

curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"$COOLIFY/api/v1/projects" \
-d '{"name":"MiProyecto","description":"Infra de mi-proyecto"}'
precaución

La descripción solo acepta letras, números, espacios y - _ . , ! ? ( ) ' " + = * / @ &. Un + entre palabras pasa; otros símbolos hacen fallar la validación con un mensaje poco claro.

Guardá el uuid que devuelve. El environment_name por defecto es production.

1. Postgres​

curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"$COOLIFY/api/v1/databases/postgresql" \
-d "{
\"project_uuid\":\"$PROJECT\", \"server_uuid\":\"$SERVER\",
\"environment_name\":\"production\", \"name\":\"postgres-miproyecto\",
\"image\":\"postgres:16\", \"postgres_db\":\"miproyecto\",
\"postgres_user\":\"miproyecto_user\", \"postgres_password\":\"<generada>\",
\"instant_deploy\": true
}"

Te devuelve internal_db_url con el hostname interno, que es el UUID del recurso:

postgres://miproyecto_user:...@ggxj12b7hyzk3vhe8sr24ed0:5432/miproyecto

Ese hostname es el que usan el backend y todo lo que corra dentro de Coolify.

Exponerlo públicamente (opcional, para DataGrip/psql desde tu máquina)​

# 1) Elegí un puerto LIBRE en TODA la instancia (ver la trampa abajo)
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"$COOLIFY/api/v1/databases/$DB_UUID" -d '{"public_port":5434,"is_public":true}'
# 2) Reiniciar para que se publique el proxy
curl -s -X POST -H "Authorization: Bearer $TOKEN" "$COOLIFY/api/v1/databases/$DB_UUID/restart"
El puerto público es global a toda la instancia, no a tu proyecto

Coolify puede asignarte un puerto que ya está en uso por otro proyecto y no impedírtelo. El síntoma es brutal de diagnosticar: el TCP conecta bien, pero el cliente se queda colgado hasta el timeout, porque estás hablando con el proxy de otra base (que no habla protocolo Postgres). Parece un firewall cerrado y no lo es.

Verificá siempre qué puertos están tomados antes de elegir:

curl -s -H "Authorization: Bearer $TOKEN" "$COOLIFY/api/v1/databases" | \
python3 -c "import json,sys; [print(d['name'], d.get('public_port')) for d in json.load(sys.stdin) if d.get('public_port')]"

Y confirmalo en el host: ss -lntp | grep <puerto> te dice qué contenedor lo tiene.

SSL

Las Postgres gestionadas por Coolify tienen SSL apagado. Usá sslmode=disable o prefer. Con require la conexión cuelga hasta timeout en vez de fallar rápido — otra causa de diagnóstico perdido.

2. OpenFGA​

Necesita multi-contenedor (una migración que corre y termina), así que va como service.

# infra/openfga.compose.yml
services:
migrate:
image: openfga/openfga:v1.18.1
command: migrate --datastore-engine postgres --datastore-uri ${OPENFGA_DATASTORE_URI}
restart: "no"
networks: [coolify]

openfga:
image: openfga/openfga:v1.18.1
depends_on:
migrate:
condition: service_completed_successfully
command: run --datastore-engine postgres --datastore-uri ${OPENFGA_DATASTORE_URI} --authn-method preshared --authn-preshared-keys ${OPENFGA_PRESHARED_KEY}
environment:
SERVICE_FQDN_OPENFGA_8080: openfga.miproyecto.tu-dominio.net
restart: unless-stopped
networks: [coolify]
labels:
- traefik.docker.network=coolify

networks:
coolify:
external: true

Variables a cargar en el service (no en el compose):

  • OPENFGA_DATASTORE_URI = postgres://usuario:pass@<uuid-de-la-db>:5432/midb?sslmode=disable
  • OPENFGA_PRESHARED_KEY = token generado
Nunca uses el datastore en memoria

Es el default. Con él, cada reinicio borra el store y el modelo de autorización, lo que invalida el OPENFGA_STORE_ID del backend y rompe todo sin aviso. Usá siempre Postgres.

Reutilizá la Postgres de la app

OpenFGA crea sus tablas en el schema public; Liquibase de platform-core usa app y core. No chocan. Levantar una segunda Postgres solo para un puñado de tuplas es CPU, RAM y otro volumen que respaldar. Para producción, donde quieras restaurar la app sin revertir permisos, ahí sí separalas.

Sin healthcheck

La imagen de OpenFGA es FROM scratch: no tiene shell, ni curl, ni wget. Cualquier probe CMD-SHELL deja el contenedor en unhealthy para siempre y Traefik le responde 503. Mejor sin healthcheck.

El Playground no se puede desplegar​

Tres razones, todas verificadas:

  1. --playground-enabled junto a --authn-method=preshared hace panic al arrancar: the playground only supports authn method 'none'.
  2. Se bindea a 127.0.0.1 por defecto — inalcanzable desde un reverse proxy.
  3. El propio OpenFGA lo marca como deprecado: "the built-in Playground is deprecated and will be removed in a future release".

Una instancia aparte con authn=none tampoco sirve: el Playground es una SPA que llama a la API desde el navegador, así que exponerlo implica exponer una API de autorización sin autenticar. Usá el CLI:

brew install openfga/tap/fga
export FGA_API_URL=https://openfga.miproyecto.tu-dominio.net
export FGA_API_TOKEN=<OPENFGA_PRESHARED_KEY>

fga store create --name mi-proyecto
fga model write --store-id <id> --file src/main/resources/openfga/authorization-model.fga
fga tuple write --store-id <id> user:alice member organization:acme
fga query check --store-id <id> user:alice can_view resource:doc1

Anotá el store id y el authorization_model_id: van al backend.

3. Almacenamiento S3 (MinIO)​

Usá la plantilla oficial de Coolify tal cual. Es el único camino que da dos dominios propios en un solo service, y además expone las credenciales en la pestaña Configuration de la UI.

# infra/minio.compose.yml
services:
minio:
image: 'ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z'
command: 'server /data --console-address ":9001"'
environment:
- MINIO_SERVER_URL=$MINIO_SERVER_URL
- MINIO_BROWSER_REDIRECT_URL=$MINIO_BROWSER_REDIRECT_URL
- MINIO_ROOT_USER=$SERVICE_USER_MINIO
- MINIO_ROOT_PASSWORD=$SERVICE_PASSWORD_MINIO
volumes:
- 'minio-data:/data'
healthcheck:
test: [CMD, mc, ready, local]
interval: 5s
timeout: 20s
retries: 10

Después seteá como env vars del service:

  • MINIO_SERVER_URL = https://s3.miproyecto.tu-dominio.net (API S3 → puerto 9000)
  • MINIO_BROWSER_REDIRECT_URL = https://minio.miproyecto.tu-dominio.net (consola → 9001)

SERVICE_USER_MINIO y SERVICE_PASSWORD_MINIO los genera Coolify solo y quedan visibles en la UI. Leelos después con GET /api/v1/services/{uuid}/envs.

No declares SERVICE_FQDN_* dos veces en un mismo servicio

Si ponés SERVICE_FQDN_MINIO_9000 y SERVICE_FQDN_MINIO_9001 juntos, Coolify genera cero labels y el servicio queda sin ruteo, sin ningún error visible. Ese es exactamente el problema que la plantilla de arriba evita usando MINIO_SERVER_URL/MINIO_BROWSER_REDIRECT_URL.

El bucket lo crea el backend o un init container con mc mb --ignore-existing local/mi-bucket.

4. Correo (Mailpit)​

Captura todo lo que manda el backend (reset de contraseña, OTP, notificaciones) sin enviar nada afuera.

# infra/mailpit.compose.yml
services:
mailpit:
image: axllent/mailpit:latest
environment:
SERVICE_FQDN_MAILPIT_8025: mailpit.miproyecto.tu-dominio.net
MP_MAX_MESSAGES: 2000
MP_DATABASE: /data/mailpit.db
volumes:
- mailpit-data:/data
networks: [coolify]
labels:
- traefik.docker.network=coolify
restart: unless-stopped

volumes:
mailpit-data:

networks:
coolify:
external: true

El SMTP (1025) queda solo interno a propósito. El backend desplegado lo alcanza por mailpit-<uuid-del-service>:1025; un backend corriendo en tu máquina no — ahí usá tu propio Mailpit en localhost.

5. Backend​

Application desde el repo Git, con build_pack: dockerfile.

curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"$COOLIFY/api/v1/applications/private-github-app" \
-d "{
\"project_uuid\":\"$PROJECT\", \"server_uuid\":\"$SERVER\",
\"environment_name\":\"production\", \"github_app_uuid\":\"<uuid>\",
\"git_repository\":\"org/mi-repo\", \"git_branch\":\"main\",
\"build_pack\":\"dockerfile\", \"name\":\"mi-backend\",
\"ports_exposes\":\"8080\", \"instant_deploy\": false
}"

# el dominio SÍ se puede fijar por API en applications
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"$COOLIFY/api/v1/applications/$APP" -d '{"domains":"https://api.miproyecto.tu-dominio.net"}'

Los github_app_uuid disponibles salen de GET /api/v1/github-apps (endpoint no documentado, pero existe).

Variables que el backend necesita​

Fáciles de olvidar, y todas fallan en silencio:

VariablePor qué
OPENFGA_AUTHN_METHOD=API_TOKEN + OPENFGA_PRESHARED_KEYSin esto core-authz arma un cliente sin autenticar y toda escritura a OpenFGA muere con 401 missing bearer token, que llega al usuario como un 500 genérico
EMAIL_HOST / EMAIL_PORT / EMAIL_USERNAME / EMAIL_PASSWORD / EMAIL_FROMOjo: son EMAIL_*, no SMTP_*
ECHOTECHS_WEB_CORS_ALLOWED_ORIGINSNi el perfil dev (solo permite localhost:3000) ni prod (CORS apagado) habilitan el origen del frontend desplegado
NEXUS_READER_USERNAME / NEXUS_READER_PASSWORDMarcadas como build-time (is_buildtime: true) — se usan durante el build de Gradle

Chequeo rápido de que no falta ninguna:

grep -rhoE '\$\{[A-Z_0-9]+(:[^}]*)?\}' src/main/resources/application*.yml \
| sed 's/[:}].*//;s/\${//' | sort -u
ECHOTECHS_WEB_CORS_ALLOWED_ORIGINS no alcanza si el YAML del perfil no la referencia

Setear la env var en Coolify no activa el CORS de core-web por sí sola — CorsAutoConfiguration usa @ConditionalOnProperty(prefix="echotechs.web.cors", name="allowed-origins"), que exige que la clave echotechs.web.cors.allowed-origins exista en el Environment, no sólo que la env var tenga un valor. Si tu application-dev.yml ya la referencia (allowed-origins: ${ECHOTECHS_WEB_CORS_ALLOWED_ORIGINS:...}) pero copiaste application-prod.yml de otro proyecto sin esa línea, el perfil prod nunca crea el bean de CORS — el navegador ve el preflight OPTIONS responder sin ningún header Access-Control-*, sin ningún error en los logs del backend. Verificá que cada application-<perfil>.yml que vayas a correr en Coolify declare la clave (con default vacío está bien: allowed-origins: ${ECHOTECHS_WEB_CORS_ALLOWED_ORIGINS:}), no sólo el que usás en dev local.

6. Frontend​

Igual que el backend, pero con base_directory: /frontend y ports_exposes: 3000.

7. pgAdmin (opcional)​

Va como application (necesita dominio por API), y el servers.json se inyecta con el file-mount:

curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"$COOLIFY/api/v1/applications/$APP/storages" \
-d '{"type":"file","mount_path":"/pgadmin4/servers.json","content":"{...}"}'

Usá el hostname interno de la base (<uuid>:5432) y SSLMode: prefer.

Dos trampas encadenadas con pgAdmin
  1. PATCH .../storages actualiza el registro de Coolify pero NO el archivo en disco. El contenedor sigue sirviendo el contenido viejo mientras la API te devuelve el nuevo, tan campante. Verificá con docker exec <pgadmin> cat /pgadmin4/servers.json, o escribí el archivo del host directo en /data/coolify/applications/<app-uuid>/pgadmin4/servers.json.
  2. pgAdmin importa servers.json solo en el primer arranque con su base interna vacía. Si el volumen ya tiene un pgadmin4.db inicializado, todo import posterior se salta en silencio. Para cambiar la entrada hay que apuntar a un volumen nuevo.

8. Verificación end-to-end​

for u in miproyecto.tu-dominio.net/ api.miproyecto.tu-dominio.net/actuator/health \
s3.miproyecto.tu-dominio.net/minio/health/live \
openfga.miproyecto.tu-dominio.net/healthz; do
printf " %-50s -> " "$u"; curl -sk -o /dev/null -w "%{http_code}\n" -m 12 "https://$u"
done

Un 401 del backend es correcto (Spring Security pidiendo auth) — significa que arrancó bien.

Seguir un deploy: GET /api/v1/deployments no siempre es una lista

Para saber si un POST .../start ya terminó, GET /api/v1/deployments/{deployment_uuid} (con el deployment_uuid que te devolvió el start) es confiable y siempre da un objeto — usalo para el polling. El endpoint de colección GET /api/v1/deployments (sin uuid, para listar los últimos deploys de todas las apps) a veces devuelve un dict con claves numéricas tipo {"0": {...}, "1": {...}} en vez de un list plano — iterar con for x in d asumiendo lista rompe (unhashable type: 'slice' si además indexás con d[:3]). Si necesitás la colección, manejá ambas formas: items = d if isinstance(d, list) else list(d.values()). Dos deploys en curso a la vez (front + back, mismo repo, mismo push) es normal si un único webhook de GitHub dispara ambas apps — no asumas que un deployment_uuid corresponde a un solo recurso sin chequear application_name en la respuesta.


Ruteo: cómo Coolify asigna dominios​

Hay dos mecanismos y sirven para cosas distintas.

Mecanismo 1 — SERVICE_FQDN_<SERVICIO>_<PUERTO>​

Genérico, sirve para cualquier imagen. Va dentro del bloque environment: del compose:

services:
miservicio:
environment:
SERVICE_FQDN_MISERVICIO_8080: miservicio.tu-dominio.net
  • Clave: SERVICE_FQDN_<NOMBRE_DEL_SERVICIO_EN_MAYÚSCULAS>_<PUERTO>
  • Valor: el hostname pelado, sin esquema ni puerto
  • Un solo puerto por servicio de compose
El hostname lo regenera Coolify en cada deploy

En esta versión, el hostname es autogenerado (<nombre>-<uuid>.<wildcard>) y se regenera en cada deploy. Hacerle PATCH a la env var por API no persiste — lo probé por PATCH y forzando re-parse del compose, y revierte siempre.

Para fijar un hostname propio: UI → el service → sub-servicio → campo Domains, y después redeploy para que Traefik regenere las labels. No hay camino por API.

Mecanismo 2 — env vars de URL reconocidas por plantilla​

Lo que usa la plantilla oficial de MinIO: MINIO_SERVER_URL (→9000) y MINIO_BROWSER_REDIRECT_URL (→9001) son env vars normales con la URL completa, y Coolify arma los dos pares de routers a partir de ellas.

Es la única forma de tener dos dominios propios en un solo service, y los hostnames sí persisten entre deploys.

Cómo saber si quedó bien​

# ¿Coolify generó las labels?
curl -s -H "Authorization: Bearer $TOKEN" "$COOLIFY/api/v1/services/$UUID" | \
python3 -c "import json,sys,re; print(len(re.findall(r'traefik', json.load(sys.stdin).get('docker_compose',''))))"

Si da 0, el ruteo no existe. Si el recurso no muestra la pestaña Links en la UI, lo mismo.


Redes: la trampa que más cuesta encontrar​

Un service vive en su propia red Docker aislada: no resuelve el hostname interno de una base gestionada. Se arregla declarando la red compartida de Coolify como externa:

services:
miservicio:
networks: [coolify]

networks:
coolify:
external: true
Si hacés eso, tenés que fijar Traefik a esa red
labels:
- traefik.docker.network=coolify

Un contenedor en más de una red deja que Traefik elija una arbitrariamente. Si elige una a la que Traefik no está conectado, las peticiones se van a timeout total, sin ningún error en ningún log. El contenedor se ve Up, sano, y responde perfecto internamente — parece que el servicio está caído y no lo está.

No listes default junto a coolify: hace que Coolify cree una tercera red <uuid>_default y amplía la ambigüedad.

El campo connect_to_docker_network de la API no alcanza: se pone en true pero el compose generado sigue teniendo solo la red del servicio.

Diagnóstico rápido:

docker inspect <container> --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}'
docker inspect <container> --format '{{index .Config.Labels "traefik.docker.network"}}'

Variables de entorno: la API duplica en vez de actualizar​

Esto arruina despliegues de forma silenciosa

POST /applications/{uuid}/envs crea una segunda fila para una clave que ya existe, y un PATCH posterior puede actualizar la copia que el contenedor no lee.

En un proyecto real esto dejó al backend corriendo con un OPENFGA_STORE_ID que apuntaba a una instancia de OpenFGA que ya no existía, mientras la API reportaba alegremente el valor nuevo. Terminaron siendo 48 filas para 24 claves.

Verificá siempre dentro del contenedor:

docker exec <container> printenv | sort

Detectar duplicados:

curl -s -H "Authorization: Bearer $TOKEN" "$COOLIFY/api/v1/applications/$APP/envs" | python3 -c "
import json,sys
from collections import Counter
c=Counter(e['key'] for e in json.load(sys.stdin))
print({k:v for k,v in c.items() if v>1})
"
No confundas esto con el par is_preview — ese es normal

El conteo de arriba SIEMPRE va a mostrar cada clave dos veces si el proyecto tiene preview deployments habilitados: Coolify crea automáticamente una fila is_preview: false (el deploy real) y otra is_preview: true (para PRs/branches de preview) por cada POST .../envs. Eso no es el bug de esta sección — es "1 fila normal + 1 fila de preview" por diseño, no "N copias de la misma fila". Antes de correr el arreglo de abajo, filtrá por is_preview para confirmar cuál de los dos casos tenés:

curl -s -H "Authorization: Bearer $TOKEN" "$COOLIFY/api/v1/applications/$APP/envs" | python3 -c "
import json,sys
d=json.load(sys.stdin)
for e in d:
print(e['key'], '| is_preview=', e.get('is_preview'), '| uuid=', e['uuid'])
"

Si ves más de una fila con el mismo is_preview, ahí sí es el bug real — seguí con el arreglo.

Arreglo: borrar todas y recrear cada clave exactamente una vez. Parchear en el lugar no es confiable una vez que hay duplicados.

curl -s -H "Authorization: Bearer $TOKEN" "$COOLIFY/api/v1/applications/$APP/envs" \
| python3 -c "import json,sys; [print(e['uuid']) for e in json.load(sys.stdin)]" \
| while read u; do
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" "$COOLIFY/api/v1/applications/$APP/envs/$u" >/dev/null
done

Dockerfiles​

Backend (Gradle + Java 25)​

# Gradle 8.14 NO puede lanzarse sobre un JVM 25 — necesita un JDK que soporte (21),
# y el JDK 25 se instala aparte solo como toolchain de compilación.
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/* && mkdir -p /opt/jdk25 \
&& curl -sL "https://api.adoptium.net/v3/binary/latest/25/ga/linux/$(dpkg --print-architecture | sed 's/amd64/x64/;s/arm64/aarch64/')/jdk/hotspot/normal/eclipse" -o /tmp/jdk25.tar.gz \
&& tar xzf /tmp/jdk25.tar.gz -C /opt/jdk25 --strip-components=1 && rm /tmp/jdk25.tar.gz

COPY gradlew settings.gradle.kts build.gradle.kts gradle.properties ./
COPY gradle ./gradle
COPY src ./src

ARG NEXUS_READER_USERNAME
ARG NEXUS_READER_PASSWORD
ENV NEXUS_READER_USERNAME=$NEXUS_READER_USERNAME
ENV NEXUS_READER_PASSWORD=$NEXUS_READER_PASSWORD

RUN ./gradlew bootJar --no-daemon \
-Porg.gradle.java.installations.paths=/opt/jdk25 \
-Porg.gradle.java.installations.auto-detect=false

FROM eclipse-temurin:25-jre AS runtime
RUN useradd --system --uid 1001 appuser
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-jar", "app.jar"]

Ojo: el arco del JDK 25 se detecta con dpkg --print-architecture — hardcodear x64 rompe en los runners ARM64.

Frontend (Next.js standalone)​

FROM node:22-slim AS build
WORKDIR /app
RUN corepack enable

ARG NEXUS_NPM_AUTH
# pnpm se NIEGA a expandir ${VAR} de credenciales en un .npmrc de proyecto (protección contra
# secretos commiteados). El valor real va en un ~/.npmrc a nivel de usuario.
RUN { \
echo "@echotechs:registry=https://nexus.tu-dominio.net/repository/npm-hosted/"; \
echo "//nexus.tu-dominio.net/repository/npm-hosted/:_auth=${NEXUS_NPM_AUTH}"; \
echo "//nexus.tu-dominio.net/repository/npm-hosted/:always-auth=true"; \
} > /root/.npmrc

COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM node:22-slim AS runtime
RUN useradd --system --uid 1001 appuser
WORKDIR /app
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
# CRÍTICO: output:"standalone" sólo copia lo que server.js necesita para arrancar — NO copia public/.
# Sin esta línea, cada asset estático (logo, favicon, imágenes) 404 en el contenedor desplegado aunque el
# archivo esté commiteado y el build local (pnpm dev/pnpm start fuera de Docker) los sirva bien, lo que
# hace que el síntoma parezca un problema del navegador o del archivo, no del Dockerfile.
COPY --from=build /app/public ./public
USER appuser
EXPOSE 3000
ENV PORT=3000
# CRÍTICO: server.js se bindea a lo que diga HOSTNAME, y Docker lo sobreescribe con el id del contenedor.
# Sin esto escucha en una sola interfaz y el proxy recibe connection refused → 502, aunque el log diga "Ready".
ENV HOSTNAME=0.0.0.0
ENTRYPOINT ["node", "server.js"]

Y un .dockerignore obligatorio:

node_modules
.next
.env.local
.npmrc
*.tsbuildinfo

Sin él, COPY . . arrastra el node_modules local encima del que armó el build, y el chequeo de dependencias de pnpm 11 aborta con ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY. Solo afecta builds locales (Coolify clona limpio de git), lo que lo hace más confuso todavía.

next.config.mjs necesita output: "standalone".


Errores comunes: síntoma → causa → arreglo​

SíntomaCausa realArreglo
Timeout total, sin logs. El contenedor está Up y sanoContenedor en varias redes sin traefik.docker.networkAgregar el label y dejar solo la red coolify
502 en el frontend, el log dice ✓ ReadyNext.js standalone bindeado al hostname del contenedorENV HOSTNAME=0.0.0.0
503 persistenteContenedor unhealthy por un healthcheck imposible (imagen scratch, sin shell)Sacar el healthcheck
"no available server" / no aparece LinksLabels de Traefik escritas a mano, o SERVICE_FQDN_* seteada por la API de envs en vez del composePoner SERVICE_FQDN_* dentro del environment: del compose y borrar las labels manuales
psql/DataGrip cuelga hasta timeoutEl puerto público lo tiene otro proyecto, o sslmode=require contra una base sin SSLElegir un puerto libre en toda la instancia; usar sslmode=disable
500 en endpoints que tocan permisos, log dice 401 missing bearer tokenFalta OPENFGA_AUTHN_METHOD=API_TOKEN / OPENFGA_PRESHARED_KEYSetearlas y redeployar
Variables correctas en la API, la app se comporta como si noEnv vars duplicadasdocker exec <c> printenv; borrar todas y recrear una vez
OPENFGA_STORE_ID inválido tras un reinicioOpenFGA con datastore en memoriaMigrar a Postgres
pgAdmin no ve el servidor precargadoservers.json solo se importa en el primer arranque con volumen vacíoVolumen nuevo
Cambiaste servers.json por API y no tomaPATCH /storages no reescribe el archivo en discoEscribir el archivo del host directo
CORS bloquea al frontend desplegadoNi dev ni prod permiten su origenECHOTECHS_WEB_CORS_ALLOWED_ORIGINS
Seteaste ECHOTECHS_WEB_CORS_ALLOWED_ORIGINS y el preflight sigue sin headers Access-Control-*application-<perfil>.yml nunca referencia echotechs.web.cors.allowed-origins — la env var sola no crea el beanAgregar la clave al YAML del perfil activo, ver el aviso arriba en "Variables que el backend necesita"
Logo/imagen 404 en el frontend desplegado, pero funciona en pnpm devoutput:"standalone" no copia public/ — falta esa línea en el DockerfileCOPY --from=build /app/public ./public en el stage runtime
El contenedor sale apenas arranca (imagen Docker como application)start_command reemplaza el CMD y el entrypoint espera otros argumentosIncluir el binario: minio server /data ..., no server /data ...
Build de Gradle falla en el runnerGradle 8.14 no corre sobre JDK 25JDK 21 como launcher, JDK 25 solo como toolchain
El deploy falla sin razón clara y el log se cortaContención de recursos por muchos deploys en paraleloReintentar de a uno

Lo que solo se puede hacer desde la UI​

No hay API para esto:

  • Fijar un dominio propio en un sub-servicio de un service. UI → service → sub-servicio → Domains.
  • Borrar el registro de un sub-recurso obsoleto. Si sacaste un contenedor del compose, Coolify conserva el registro y lo cuenta como caído, dejando el service en Degraded para siempre. DELETE .../services/{uuid}/databases/{sub} da 404. La única salida es recrear el service (y volver a poner el dominio a mano). Es cosmético.

Checklist final​

  • Cada recurso muestra la pestaña Links
  • Ningún compose tiene labels de Traefik escritas a mano
  • Todo contenedor en varias redes tiene traefik.docker.network
  • docker exec <backend> printenv | sort coincide con lo esperado, sin duplicados
  • OpenFGA persiste en Postgres, y el store/modelo sobreviven un reinicio
  • El puerto público de la base no choca con otro proyecto
  • Los secretos están en env vars de Coolify, no en compose commiteados
  • Existe un .env gitignoreado con endpoints y credenciales para la próxima sesión
  • El smoke test end-to-end pasa (login → un endpoint que toque OpenFGA → uno que toque S3)