Ir al contenido

Buscar en la documentación

Busca en la documentación de Skiffly

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:

EnfoqueDirectorio raízBuild / inicioCuándo
Raíz + filtro/pnpm --filter @acme/api build / pnpm --filter @acme/api startlas apps importan packages/* (el caso habitual)
La app como raízapps/apilos valores predeterminados de Railpack para ese package.jsonla 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#

apps/api/package.json
{ "name": "@acme/api", "scripts": { "build": "tsc -p tsconfig.build.json", "start": "node dist/server.js" } }
apps/web/package.json
{ "name": "@acme/web", "scripts": { "build": "next build", "start": "next start" } }
apps/worker/package.json
{ "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#

.skiffly/skiffly.ts
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; staging recibe develop, una réplica y una app web en reposo.
  • generate("hex64") crea JWT_SECRET en 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_URL se 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 ejecute next build.
  • Las llamadas entre servidores usan el hostname privado (api:3000) y nunca salen del entorno.

Compruébalo sin tocar Skiffly:

skiffly config validate

4. Primer apply#

skiffly init --name platform                          # o `skiffly link` a un proyecto existente
skiffly config plan -e production
platform / 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 deploy
skiffly config apply -e production
skiffly status -e production

Resultado 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 staging

5. 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#

.github/workflows/skiffly.yml
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#

CambioEdiciónResultado de plan
Agregar una variableenv: { ..., FEATURE_X: "1" }~ service api: + env.FEATURE_X; vuelve a desplegar api
Rotar un secretoreemplaza generate() por process.env.JWT_SECRET ?? preserve() y configúralo una vez con skiffly variables setpreserve() conserva lo que Skiffly ya tiene; plan falla si no existe
Escalarreplicas: prod ? 3 : 1~ replicas 2 → 3, sin recompilar
Nueva app apps/adminotro service("admin", { ...src("@acme/admin") })+ service admin
Eliminar un serviciobórralo del archivo y ejecuta apply --prunepregunta 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íntomaSolución
ERR_PNPM_NO_MATCHING_VERSION / fallo de --frozen-lockfile en todos los serviciosel lockfile está desactualizado; ejecuta pnpm install en local y haz commit
Cannot find module '@acme/shared' en tiempo de ejecuciónpnpm --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 stagingapi 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ónhay un valor aleatorio plano en el archivo: usa generate() o preserve()
Tres builds por push es demasiado lentodivide 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