Pular para o conteúdo

Buscar na documentação

Buscar na documentação do Skiffly

Monorepo com configuração como código

Describe a pnpm/Turborepo monorepo — a Next.js web app, a Node API, a worker and Postgres — in one `.skiffly/skiffly.ts`, apply it per environment, review changes with `skiffly config plan` in pull requests and deploy from GitHub Actions.

What you build: the repository acme/platform with apps/web (Next.js), apps/api (Fastify) and apps/worker (BullMQ) sharing packages/*, deployed as three services plus Postgres and Redis. One file defines everything; production and staging differ only in size. About 30 minutes.

You need: the monorepo on GitHub with a pnpm-workspace.yaml, the skiffly CLI, and @skiffly/config as a dev dependency for editor types (pnpm add -D -w @skiffly/config — optional, the CLI bundles its own copy).

1. How Railpack sees a monorepo#

Railpack installs from the repository root when it finds pnpm-workspace.yaml (or workspaces in package.json), so a service whose root directory is / can build any app with a filter. Two choices per service:

ApproachRoot directoryBuild / startWhen
Root + filter/pnpm --filter @acme/api build / pnpm --filter @acme/api startapps import packages/* (the common case)
App as rootapps/apiRailpack's defaults for that package.jsonthe app is self-contained, no workspace imports

This tutorial uses the first. Every service builds the whole workspace's dependencies (cached between builds), then its own app. Push filters (turbo run build --filter=api...) work the same way with Turborepo.

2. Make each app deployable#

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" } }

Each server listens on PORT (next start does; Fastify: app.listen({ port: Number(process.env.PORT ?? 3000), host: "0.0.0.0" })), and each has a /healthz route — the worker too, because every long-running service is probed on its port (a five-line http.createServer in worker.ts).

3. Write the config#

.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] });
});

What the file says:

  • ctx.isEnvironment("production") picks the branch, sizes, replicas and custom domains per environment; staging gets develop, one replica and a sleeping web app.
  • generate("hex64") creates JWT_SECRET on the first apply and preserves it afterwards; the worker references the API's copy instead of getting a second random value.
  • NEXT_PUBLIC_API_URL is inlined into the Next.js build — Skiffly passes service variables to Railpack, so the reference resolves before next build runs.
  • Server-to-server calls use the private hostname (api:3000) and never leave the environment.

Check it without touching Skiffly:

skiffly config validate

4. First apply#

skiffly init --name platform                          # or `skiffly link` to an existing project
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

Expected: five services, the databases SUCCESS first, then api, web, worker after their builds (3–5 minutes for the first one; later builds reuse the pnpm store cache). skiffly domain status api.example.com prints the CNAME/TXT records to create.

Staging is the same file:

skiffly environment new staging
skiffly config apply -e staging

5. Deploying on push, previews per PR#

Repository services follow their branch: a push to main rebuilds the production services from acme/platform; a push to develop rebuilds staging. Turn on PR previews in the project settings to get a pr-<n> environment per pull request (variables copied, an empty Postgres of its own; the config file is not applied there — the environment is a copy of production).

Because all three apps live in one repository, a push rebuilds all three services, whatever changed. Watch patterns are accepted by the API but not enforced yet — expect three builds per push.

6. Plans in pull requests, applies from 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 is a workspace token with the write scope (Settings → Developer). The paths filter keeps the workflow to config changes; code changes deploy through the GitHub connection anyway. plan --json is what you would post as a PR comment with a small script.

7. Everyday changes#

ChangeEditResult of plan
Add a variableenv: { ..., FEATURE_X: "1" }~ service api: + env.FEATURE_X — redeploys api
Rotate a secretreplace generate() with process.env.JWT_SECRET ?? preserve() and set it once with skiffly variables setpreserve() keeps whatever Skiffly has; plan fails if it is missing
Scalereplicas: prod ? 3 : 1~ replicas 2 → 3, no rebuild
New app apps/adminanother service("admin", { ...src("@acme/admin") })+ service admin
Remove a servicedelete it from the file and run apply --pruneasks before deleting; without --prune the service stays

Keep in mind: what the file leaves out is not managed. A service block without env leaves variables alone, so it is safe to manage only some settings from the file while the dashboard owns the rest. There is no state file — Skiffly is the source of truth and apply twice is a no-op.

Troubleshooting#

SymptomFix
ERR_PNPM_NO_MATCHING_VERSION / --frozen-lockfile failure in every servicethe lockfile is stale; pnpm install locally and commit
Cannot find module '@acme/shared' at runtimethe package is built by pnpm --filter @acme/api build only if it is a dependency and has a build script run via ... filters: use pnpm --filter @acme/api... build (with the dots) to build dependencies first
next build fails: NEXT_PUBLIC_API_URL empty in stagingapi had no domain yet when web built; skiffly redeploy -s web after the first apply
plan shows ~ env.JWT_SECRET on every runa plain random value in the file: use generate() or preserve()
Three builds per push is too slowsplit the repository per app or set a per-app root directory for self-contained apps; watch patterns are on the roadmap

Next: Config as code · Node.js · Next.js · Projects & environments