Un monorepo con configuración como código
Describe un monorepo pnpm/Turborepo — una app web Next.js, una API Node, un worker y Postgres — en un solo `.skiffly/skiffly.ts`, aplícalo por entorno, revisa los cambios con `skiffly config plan` en los pull requests y despliega desde GitHub Actions.
Qué vas a construir: el repositorio acme/platform con apps/web (Next.js), apps/api (Fastify) y apps/worker (BullMQ), que comparten packages/*, desplegado como tres servicios más Postgres y Redis. Un solo archivo define todo; production y staging solo difieren en tamaño. Unos 30 minutos.
Necesitas: el monorepo en GitHub con un pnpm-workspace.yaml, la CLI skiffly y @skiffly/config como dependencia de desarrollo para los tipos en el editor (pnpm add -D -w @skiffly/config; es opcional, la CLI incluye su propia copia).
1. Cómo ve Railpack un monorepo#
Railpack instala desde la raíz del repositorio cuando encuentra pnpm-workspace.yaml (o workspaces en package.json), así que un servicio cuyo directorio raíz es / puede compilar cualquier app con un filtro. Dos opciones por servicio:
| Enfoque | Directorio raíz | Build / inicio | Cuándo |
|---|---|---|---|
| Raíz + filtro | / | pnpm --filter @acme/api build / pnpm --filter @acme/api start | las apps importan packages/* (el caso habitual) |
| La app como raíz | apps/api | los valores predeterminados de Railpack para ese package.json | la app es autónoma, sin imports del workspace |
Este tutorial usa el primero. Cada servicio compila las dependencias de todo el workspace (con caché entre builds) y luego su propia app. Los filtros de push (turbo run build --filter=api...) funcionan igual con Turborepo.
2. Hacer que cada app sea desplegable#
{ "name": "@acme/api", "scripts": { "build": "tsc -p tsconfig.build.json", "start": "node dist/server.js" } }{ "name": "@acme/web", "scripts": { "build": "next build", "start": "next start" } }{ "name": "@acme/worker", "scripts": { "build": "tsc -p tsconfig.build.json", "start": "node dist/worker.js" } }Cada servidor escucha en PORT (next start ya lo hace; Fastify: app.listen({ port: Number(process.env.PORT ?? 3000), host: "0.0.0.0" })) y cada uno tiene una ruta /healthz; el worker también, porque todo servicio de larga duración se sondea en su puerto (un http.createServer de cinco líneas en worker.ts).
3. Escribir la configuración#
import { defineSkiffly, generate, github, postgres, project, redis, service } from "@skiffly/config";
export default defineSkiffly((ctx) => {
const prod = ctx.isEnvironment("production");
const src = (filter: string) => ({
source: github("acme/platform", { branch: prod ? "main" : "develop" }),
build: `pnpm install --frozen-lockfile && pnpm --filter ${filter} build`,
start: `pnpm --filter ${filter} start`,
healthcheck: "/healthz",
});
const db = postgres("db", { sizeGb: prod ? 50 : 10 });
const cache = redis("cache");
const api = service("api", {
...src("@acme/api"),
port: 3000,
env: { NODE_ENV: "production", DATABASE_URL: db.env.DATABASE_URL, REDIS_URL: cache.env.REDIS_URL, JWT_SECRET: generate("hex64") },
resources: { cpu: prod ? "1000m" : "500m", memory: prod ? "1GB" : "512MB" },
replicas: prod ? 2 : 1,
domain: true,
domains: prod ? ["api.example.com"] : [],
});
const web = service("web", {
...src("@acme/web"),
port: 3000,
env: { NODE_ENV: "production", NEXT_PUBLIC_API_URL: prod ? "https://api.example.com" : "https://${{api.SKIFFLY_PUBLIC_DOMAIN}}", API_INTERNAL_URL: "http://${{api.SKIFFLY_PRIVATE_DOMAIN}}:3000" },
resources: { memory: prod ? "1GB" : "512MB" },
domain: true,
domains: prod ? ["www.example.com"] : [],
sleep: !prod,
});
const worker = service("worker", {
...src("@acme/worker"),
env: { NODE_ENV: "production", DATABASE_URL: db.env.DATABASE_URL, REDIS_URL: cache.env.REDIS_URL, JWT_SECRET: api.env.JWT_SECRET },
});
return project("platform", { resources: [db, cache, api, web, worker] });
});Qué dice el archivo:
ctx.isEnvironment("production")elige la rama, los tamaños, las réplicas y los dominios propios por entorno;stagingrecibedevelop, una réplica y una app web en reposo.generate("hex64")creaJWT_SECRETen el primer apply y lo conserva después; el worker referencia la copia de la API en lugar de recibir un segundo valor aleatorio.NEXT_PUBLIC_API_URLse incrusta en el build de Next.js: Skiffly pasa las variables del servicio a Railpack, así que la referencia se resuelve antes de que se ejecutenext build.- Las llamadas entre servidores usan el hostname privado (
api:3000) y nunca salen del entorno.
Compruébalo sin tocar Skiffly:
skiffly config validate4. Primer apply#
skiffly init --name platform # o `skiffly link` a un proyecto existente
skiffly config plan -e productionplatform / production
+ service db # will deploy
+ service cache # will deploy
+ service api # will deploy
+ source.repo = "acme/platform" (main)
+ build = "pnpm install --frozen-lockfile && pnpm --filter @acme/api build"
+ env.JWT_SECRET = <generated>
+ domain (generated), + domain api.example.com
+ service web # will deploy
+ service worker # will deployskiffly config apply -e production
skiffly status -e productionResultado esperado: cinco servicios, primero las bases de datos en SUCCESS y luego api, web y worker tras sus builds (3–5 minutos el primero; los siguientes reutilizan la caché del store de pnpm). skiffly domain status api.example.com imprime los registros CNAME/TXT que hay que crear.
Staging es el mismo archivo:
skiffly environment new staging
skiffly config apply -e staging5. Despliegue en cada push, vistas previas por PR#
Los servicios de repositorio siguen su rama: un push a main recompila los servicios de producción desde acme/platform; un push a develop recompila staging. Activa las vistas previas de PR en la configuración del proyecto para obtener un entorno pr-<n> por cada pull request (variables copiadas, un Postgres vacío propio; el archivo de configuración no se aplica ahí: el entorno es una copia de producción).
Como las tres apps viven en un mismo repositorio, un push recompila los tres servicios, cambie lo que cambie. La API acepta patrones de observación (watch patterns), pero todavía no los aplica: cuenta con tres builds por push.
6. Planes en los pull requests, applies desde CI#
name: skiffly
on:
pull_request:
paths: [".skiffly/**"]
push:
branches: [main]
paths: [".skiffly/**"]
jobs:
plan:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npx skiffly config plan -p platform -e production --json > plan.json
env: { SKIFFLY_TOKEN: ${{ secrets.SKIFFLY_TOKEN }} }
- run: npx skiffly config plan -p platform -e production
env: { SKIFFLY_TOKEN: ${{ secrets.SKIFFLY_TOKEN }} }
apply:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npx skiffly config apply --yes -p platform -e production
env: { SKIFFLY_TOKEN: ${{ secrets.SKIFFLY_TOKEN }} }SKIFFLY_TOKEN es un token del espacio de trabajo con el alcance write (Configuración → Desarrollador). El filtro paths limita el workflow a cambios de configuración; los cambios de código se despliegan de todos modos a través de la conexión con GitHub. plan --json es lo que publicarías como comentario en el PR con un pequeño script.
7. Cambios del día a día#
| Cambio | Edición | Resultado de plan |
|---|---|---|
| Agregar una variable | env: { ..., FEATURE_X: "1" } | ~ service api: + env.FEATURE_X; vuelve a desplegar api |
| Rotar un secreto | reemplaza generate() por process.env.JWT_SECRET ?? preserve() y configúralo una vez con skiffly variables set | preserve() conserva lo que Skiffly ya tiene; plan falla si no existe |
| Escalar | replicas: prod ? 3 : 1 | ~ replicas 2 → 3, sin recompilar |
Nueva app apps/admin | otro service("admin", { ...src("@acme/admin") }) | + service admin |
| Eliminar un servicio | bórralo del archivo y ejecuta apply --prune | pregunta antes de eliminar; sin --prune el servicio se queda |
Ten en cuenta: lo que el archivo omite no se gestiona. Un bloque de servicio sin env deja las variables como están, así que es seguro gestionar solo algunos ajustes desde el archivo mientras el panel se encarga del resto. No hay archivo de estado: Skiffly es la fuente de verdad y ejecutar apply dos veces no hace nada.
Solución de problemas#
| Síntoma | Solución |
|---|---|
ERR_PNPM_NO_MATCHING_VERSION / fallo de --frozen-lockfile en todos los servicios | el lockfile está desactualizado; ejecuta pnpm install en local y haz commit |
Cannot find module '@acme/shared' en tiempo de ejecución | pnpm --filter @acme/api build compila el paquete solo si es una dependencia y tiene un script build ejecutado mediante filtros ...: usa pnpm --filter @acme/api... build (con los puntos) para compilar primero las dependencias |
next build falla: NEXT_PUBLIC_API_URL vacía en staging | api todavía no tenía dominio cuando se compiló web; ejecuta skiffly redeploy -s web después del primer apply |
plan muestra ~ env.JWT_SECRET en cada ejecución | hay un valor aleatorio plano en el archivo: usa generate() o preserve() |
| Tres builds por push es demasiado lento | divide el repositorio por app o define un directorio raíz por app para las apps autónomas; los patrones de observación están en la hoja de ruta |
Siguiente: Configuración como código · Node.js · Next.js · Proyectos y entornos