Levantar la infra de un proyecto platform-core en Coolify
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ás | Dónde |
|---|---|
| Token de API de Coolify | Panel 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 lectura | Para 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 Coolify | GET /api/v1/servers |
| Dominio wildcard | Coolify 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.
| Application | Service | Managed database | |
|---|---|---|---|
| Qué es | Un contenedor, desde repo Git o imagen Docker | Un docker-compose completo | Postgres/MySQL/Redis gestionado |
| Multi-contenedor | No | Sí | No |
| Dominio propio por API | Sí (PATCH /applications/{uuid} con domains) | No — solo desde la UI | N/A |
| Varios dominios | Sí (https://a.com:9000,https://b.com:9001) | Solo con plantillas reconocidas | N/A |
| Red | Está en la red coolify | Red aislada propia | Está en coolify |
| Montar archivos | Sí (POST /applications/{uuid}/storages) | Vía compose | No |
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
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
- Nunca escribas labels de Traefik a mano. Coolify las genera. Si las escribís vos, Coolify no registra el dominio y no funciona.
- La pestaña "Links" es el detector. ¿No aparece en un recurso? Su dominio no está registrado: el ruteo está mal cableado. Punto.
- 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. - Verificá las variables de entorno dentro del contenedor, no en la API. La API miente (ver más abajo).
- 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"}'
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"
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.
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=disableOPENFGA_PRESHARED_KEY= token generado
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.
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.
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:
--playground-enabledjunto a--authn-method=presharedhace panic al arrancar:the playground only supports authn method 'none'.- Se bindea a
127.0.0.1por defecto — inalcanzable desde un reverse proxy. - 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.
SERVICE_FQDN_* dos veces en un mismo servicioSi 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:
| Variable | Por qué |
|---|---|
OPENFGA_AUTHN_METHOD=API_TOKEN + OPENFGA_PRESHARED_KEY | Sin 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_FROM | Ojo: son EMAIL_*, no SMTP_* |
ECHOTECHS_WEB_CORS_ALLOWED_ORIGINS | Ni el perfil dev (solo permite localhost:3000) ni prod (CORS apagado) habilitan el origen del frontend desplegado |
NEXUS_READER_USERNAME / NEXUS_READER_PASSWORD | Marcadas 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 referenciaSetear 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.
PATCH .../storagesactualiza 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á condocker exec <pgadmin> cat /pgadmin4/servers.json, o escribí el archivo del host directo en/data/coolify/applications/<app-uuid>/pgadmin4/servers.json.- pgAdmin importa
servers.jsonsolo en el primer arranque con su base interna vacía. Si el volumen ya tiene unpgadmin4.dbinicializado, 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.
GET /api/v1/deployments no siempre es una listaPara 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
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
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
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})
"
is_preview — ese es normalEl 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íntoma | Causa real | Arreglo |
|---|---|---|
Timeout total, sin logs. El contenedor está Up y sano | Contenedor en varias redes sin traefik.docker.network | Agregar el label y dejar solo la red coolify |
502 en el frontend, el log dice ✓ Ready | Next.js standalone bindeado al hostname del contenedor | ENV HOSTNAME=0.0.0.0 |
| 503 persistente | Contenedor unhealthy por un healthcheck imposible (imagen scratch, sin shell) | Sacar el healthcheck |
| "no available server" / no aparece Links | Labels de Traefik escritas a mano, o SERVICE_FQDN_* seteada por la API de envs en vez del compose | Poner SERVICE_FQDN_* dentro del environment: del compose y borrar las labels manuales |
| psql/DataGrip cuelga hasta timeout | El puerto público lo tiene otro proyecto, o sslmode=require contra una base sin SSL | Elegir un puerto libre en toda la instancia; usar sslmode=disable |
500 en endpoints que tocan permisos, log dice 401 missing bearer token | Falta OPENFGA_AUTHN_METHOD=API_TOKEN / OPENFGA_PRESHARED_KEY | Setearlas y redeployar |
| Variables correctas en la API, la app se comporta como si no | Env vars duplicadas | docker exec <c> printenv; borrar todas y recrear una vez |
OPENFGA_STORE_ID inválido tras un reinicio | OpenFGA con datastore en memoria | Migrar a Postgres |
| pgAdmin no ve el servidor precargado | servers.json solo se importa en el primer arranque con volumen vacío | Volumen nuevo |
Cambiaste servers.json por API y no toma | PATCH /storages no reescribe el archivo en disco | Escribir el archivo del host directo |
| CORS bloquea al frontend desplegado | Ni dev ni prod permiten su origen | ECHOTECHS_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 bean | Agregar 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 dev | output:"standalone" no copia public/ — falta esa línea en el Dockerfile | COPY --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 argumentos | Incluir el binario: minio server /data ..., no server /data ... |
| Build de Gradle falla en el runner | Gradle 8.14 no corre sobre JDK 25 | JDK 21 como launcher, JDK 25 solo como toolchain |
| El deploy falla sin razón clara y el log se corta | Contención de recursos por muchos deploys en paralelo | Reintentar 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 | sortcoincide 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
.envgitignoreado 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)