Монорепозиторий с конфигурацией как код
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:
| Approach | Root directory | Build / start | When |
|---|---|---|---|
| Root + filter | / | pnpm --filter @acme/api build / pnpm --filter @acme/api start | apps import packages/* (the common case) |
| App as root | apps/api | Railpack's defaults for that package.json | the 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#
{ "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" } }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#
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;staginggetsdevelop, one replica and a sleeping web app.generate("hex64")createsJWT_SECRETon the first apply and preserves it afterwards; the worker references the API's copy instead of getting a second random value.NEXT_PUBLIC_API_URLis inlined into the Next.js build — Skiffly passes service variables to Railpack, so the reference resolves beforenext buildruns.- Server-to-server calls use the private hostname (
api:3000) and never leave the environment.
Check it without touching Skiffly:
skiffly config validate4. First apply#
skiffly init --name platform # or `skiffly link` to an existing project
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 productionExpected: 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 staging5. 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#
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#
| Change | Edit | Result of plan |
|---|---|---|
| Add a variable | env: { ..., FEATURE_X: "1" } | ~ service api: + env.FEATURE_X — redeploys api |
| Rotate a secret | replace generate() with process.env.JWT_SECRET ?? preserve() and set it once with skiffly variables set | preserve() keeps whatever Skiffly has; plan fails if it is missing |
| Scale | replicas: prod ? 3 : 1 | ~ replicas 2 → 3, no rebuild |
New app apps/admin | another service("admin", { ...src("@acme/admin") }) | + service admin |
| Remove a service | delete it from the file and run apply --prune | asks 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#
| Symptom | Fix |
|---|---|
ERR_PNPM_NO_MATCHING_VERSION / --frozen-lockfile failure in every service | the lockfile is stale; pnpm install locally and commit |
Cannot find module '@acme/shared' at runtime | the 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 staging | api had no domain yet when web built; skiffly redeploy -s web after the first apply |
plan shows ~ env.JWT_SECRET on every run | a plain random value in the file: use generate() or preserve() |
| Three builds per push is too slow | split 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