Template fullstack listo para producción: React 19 + Vite 7 + Tailwind v4 + shadcn/ui en el frontend, NestJS 11 + TypeORM + PostgreSQL 16 en el backend, todo en un monorepo pnpm con schemas Zod compartidos y deploy pensado para Coolify.
Regla de oro:
synchronize: falsesiempre. El esquema de la base de datos cambia solo con migraciones.
# 1. Crea tu repo desde el template
gh repo create mi-proyecto --template HeyArnoldo/template-fullstack --private --clone
cd mi-proyecto
# 2. Inicialízalo (renombra todo y se autoelimina)
node scripts/init.mjs mi-proyecto # mantiene el CRUD demo de notas
node scripts/init.mjs mi-proyecto --clean # sin demo: solo auth + estructura
# 3. Configura el entorno
cp .env.example .env # ajusta JWT_SECRET y ADMIN_*
# 4. Levanta todo
pnpm install
pnpm --filter @app/contracts build # la API importa @app/contracts: sin esto fallan las migraciones
pnpm db:up # postgres en docker
pnpm migration:run # aplica las migraciones
pnpm seed # crea el admin inicial (idempotente)
pnpm dev # api en :3000, web en :5173Abre http://localhost:5173 y entra con el ADMIN_EMAIL / ADMIN_PASSWORD de tu .env.
├── apps/
│ ├── api/ # NestJS 11 · TypeORM · auth JWT en cookie httpOnly
│ └── web/ # React 19 · Vite 7 · Tailwind v4 · shadcn · TanStack Query
├── packages/
│ ├── contracts/ # Schemas Zod compartidos (única fuente de verdad api ↔ web)
│ └── tsconfig/ # Config TypeScript base
├── docker-compose.yml # Postgres para desarrollo (+ adminer con --profile tools)
├── docker-compose.prod.yml # Stack completo para probar los Dockerfiles en local
└── scripts/init.mjs # Inicializador (se autoelimina)
¿Por qué packages/contracts? El mismo schema Zod valida el body en la API (ZodValidationPipe) y el formulario en el frontend (zodResolver). Cambias el contrato en un solo lugar y ambos lados se actualizan — con build dual CJS+ESM para que la API (CommonJS) y Vite (ESM) lo consuman sin fricción.
| Comando | Qué hace |
|---|---|
pnpm dev |
API + web + contracts en watch, en paralelo |
pnpm dev:api / pnpm dev:web |
Solo una app |
pnpm build |
Build de todo (contracts → api → web, en orden) |
pnpm lint / pnpm typecheck / pnpm test |
Calidad en todos los workspaces |
pnpm db:up / pnpm db:down |
Postgres en Docker |
pnpm migration:generate src/database/migrations/Nombre |
Genera migración desde los cambios en entities |
pnpm migration:run / pnpm migration:revert |
Aplica / revierte migraciones |
pnpm seed |
Admin inicial (idempotente, seguro en cada arranque) |
La auth se controla 100% con variables de entorno. El frontend consulta GET /api/auth/config y renderiza el login según lo que esté activo.
AUTH_LOCAL_ENABLED=true
ADMIN_EMAIL=admin@miapp.com
ADMIN_PASSWORD=UnPasswordFuerte!El seed crea el admin local. Los usuarios se registran en /register.
AUTH_LOCAL_ENABLED=false
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=xxx
GOOGLE_CALLBACK_URL=https://api.midominio.com/api/auth/google/callback
ADMIN_EMAIL=admin@miapp.com # whitelist: recibe rol admin en su primer loginSin ADMIN_PASSWORD, el seed no crea usuario: ADMIN_EMAIL actúa como whitelist y ese correo se vuelve admin al entrar con Google. Las rutas de login/register local devuelven 404.
Define todo lo anterior con AUTH_LOCAL_ENABLED=true. El login muestra formulario y botón de Google. Si un usuario local entra luego con Google (mismo email), las cuentas se vinculan solas.
Detalles de implementación: el JWT viaja en una cookie httpOnly (app_session) — inmune a XSS, el frontend nunca toca el token. GoogleStrategy solo se registra en Nest si hay credenciales (instanciarla vacía rompe passport). CORS exige orígenes exactos porque credentials: true.
# 1. Edita o crea un *.entity.ts
# 2. Genera la migración (compara entities vs DB real)
pnpm migration:generate src/database/migrations/AgregaCampoX
# 3. Revisa el SQL generado (¡siempre!)
# 4. Aplícala
pnpm migration:runsynchronize: falseestá fijo enapps/api/src/config/typeorm.config.ts. No lo cambies: pierde datos en producción y rompe el flujo de migraciones.- En producción,
docker-entrypoint.shcorremigration:run+ seed antes de arrancar la API: un deploy nuevo siempre tiene el esquema al día. - Adminer (UI de la DB):
docker compose --profile tools up -d→ http://localhost:8081
El CRUD demo de notas es la referencia copiable end-to-end:
- Contrato —
packages/contracts/src/mi-feature.ts: schemas Zod + tipos, exporta enindex.ts. - API —
apps/api/src/mi-feature/:*.entity.ts,*.service.ts,*.controller.ts(conZodValidationPipe+JwtAuthGuard),*.module.tsregistrado enapp.module.ts. Genera y corre la migración. - Web —
apps/web/src/: service enservices/, hooks TanStack Query enhooks/, página enpages/, ruta enrouter.tsx.
Componentes shadcn nuevos: cd apps/web && pnpm dlx shadcn@latest add <componente>.
Cada app se despliega como recurso independiente usando su Dockerfile (build context = raíz del repo):
| API | Web | |
|---|---|---|
| Dockerfile | apps/api/Dockerfile |
apps/web/Dockerfile |
| Puerto | 3000 | 80 |
| Healthcheck | /health (ya configurado) |
/ (ya configurado) |
API — variables de entorno en Coolify (ejemplo completo, ajusta dominios y secretos):
NODE_ENV=production
# Base de datos — si Coolify te da una URL postgres://USER:PASSWORD@HOST:PORT/DBNAME,
# mapea cada parte a su variable:
DB_HOST=mi-postgres-interno
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=el-password-de-la-url
DB_NAME=postgres
CORS_ORIGIN=https://app.midominio.com
FRONTEND_URL=https://app.midominio.com
AUTH_LOCAL_ENABLED=true
JWT_SECRET=<openssl rand -base64 32>
JWT_EXPIRES_IN=7d
COOKIE_SECURE=true
COOKIE_SAMESITE=lax
COOKIE_DOMAIN=.midominio.com # solo si api y web comparten dominio raíz
ADMIN_EMAIL=admin@midominio.com
ADMIN_PASSWORD=UnPasswordFuerte!
ADMIN_NAME=Admin
API_PORTno se define: el default es 3000 y el healthcheck del Dockerfile está fijado a ese puerto — cambiarlo haría fallar el healthcheck.
Web — build arg (no es variable de runtime: queda horneada en el bundle al compilar):
VITE_API_URL=https://api.midominio.comCookies entre dominios:
| Caso | Config |
|---|---|
app.midominio.com + api.midominio.com |
COOKIE_DOMAIN=.midominio.com y COOKIE_SAMESITE=lax |
| Dominios distintos | COOKIE_SAMESITE=none + COOKIE_SECURE=true obligatorio |
La API ya setea trust proxy (necesario para cookies secure detrás de Traefik).
Probar los Dockerfiles en local antes de subir:
docker compose -f docker-compose.prod.yml up -d --build
# api → http://localhost:3000/health · web → http://localhost:8090
docker compose -f docker-compose.prod.yml down- Husky + lint-staged: prettier automático en cada commit.
- commitlint: mensajes en formato conventional commits (
feat: ...,fix: ...) — un commit mal formado no entra. - GitHub Actions (
.github/workflows/ci.yml): lint → typecheck → build → test en cada push/PR. - TypeScript estricto en todo el monorepo (base compartida en
packages/tsconfig).
Un solo .env en la raíz alimenta docker-compose, la API y el frontend (Vite lee VITE_* vía envDir). Todas documentadas en .env.example. La API valida el entorno con Zod al arrancar: si falta algo requerido, no levanta (mejor fallar temprano que en runtime).
- pnpm 11 pineado (
packageManager+ corepack).pnpm-workspace.yamldeclaraallowBuilds— sin eso pnpm bloquea los postinstall nativos (esbuild, tailwind oxide). - API en CommonJS (no NodeNext): imports sin sufijo
.jsy la CLItypeorm-ts-node-commonjsfunciona sin hacks. - bcryptjs (JS puro): cero bindings nativos en Alpine/CI.
- Cliente axios con guarda anti-HTML: si la API no responde y vuelve el index.html del dev server, lo convierte en error visible en vez de datos corruptos.
- Código e identificadores en inglés, comentarios y docs en español.