Skip to content

Search docs

Search the Skiffly documentation

Config as code

Describe a project in `.skiffly/skiffly.ts` with `@skiffly/config`, preview the diff with `skiffly config plan` and reconcile it with `skiffly config apply`.

The file mirrors Railway's railway/iac SDK: the builders keep their names, defineRailway becomes defineSkiffly. The CLI diffs the file against one environment and applies the difference through the public API. There is no state file; Skiffly is the source of truth, so running apply twice is a no-op.

Setup#

npm i -g skiffly && skiffly login
pnpm add -D @skiffly/config        # optional: types in your editor (the CLI ships its own copy)
.skiffly/skiffly.ts
import { defineSkiffly, github, postgres, project, service } from "@skiffly/config";
 
export default defineSkiffly(() => {
  const db = postgres("db");
 
  const web = service("web", {
    source: github("acme/web"),
    build: "pnpm build",
    start: "pnpm start",
    healthcheck: "/health",
    env: {
      NODE_ENV: "production",
      DATABASE_URL: db.env.DATABASE_URL,
    },
    domain: true,
  });
 
  return project("my-app", { resources: [db, web] });
});

skiffly.config.ts in the project root, .js, .mjs and .json work too. TypeScript is transpiled in memory; no build step.

Commands#

skiffly config init                 # adopt an existing project: write the file from the linked environment
skiffly config validate             # evaluate the file, no API calls
skiffly config plan [--prune]       # + service db, ~ env web.DATABASE_URL, - volume /data
skiffly config apply [--yes] [--prune] [--no-deploy]
skiffly config plan
my-app / production
 
  + service db  # will deploy
      + source.image = "postgres:17"
      + port = 5432
      + env.POSTGRES_PASSWORD = <generated password>
      + volume /var/lib/postgresql/data = "10 GB"
  ~ service web  # will deploy
      ~ env.DATABASE_URL: "postgres://…" → "${{db.DATABASE_URL}}"
      + domain (generated)
  • plan and apply take -p/--project, -e/--environment, -f/--file, --json, --show-secrets.
  • What the file leaves out is not managed. A service without env keeps its variables untouched; env: {} means "no variables" and, with --prune, deletes them.
  • Resources on Skiffly that the file does not declare are kept unless --prune; apply asks before deleting (--yes in CI).
  • Changed services are deployed at the end of apply; --no-deploy skips that.

Services#

OptionExampleMaps to
sourcegithub("acme/web", { branch: "main", rootDir: "apps/web" }), image("nginx:1.27"), empty()source
build"pnpm build" or { builder: "DOCKERFILE", dockerfilePath: "Dockerfile.prod" }build settings
start"node server.js" (null clears)start command
healthcheck"/health" or { path, timeout }health check
env{ KEY: "v", URL: db.env.DATABASE_URL, SECRET: preserve(), TOKEN: generate("hex32") }service variables
port8080port
domaintruea generated domain must exist
domains["app.example.com"]custom domains
volumes["/data"] or [{ mountPath: "/data", sizeGb: 20 }]volumes
cron"0 3 * * *"cron schedule
resources{ cpu: "500m", memory: "1GB" }limits
replicas2replicas
sleeptrueApp Sleeping
restartPolicy"ON_FAILURE" or { type, maxRetries }restart policy

fn(name, config) is a service with kind: "function"; combine with cron for jobs. empty() means the source is managed elsewhere (dashboard) while the file manages settings, variables, domains and volumes.

Variables and secrets#

const api = service("api");
service("web", {
  env: {
    DATABASE_URL: db.env.DATABASE_URL,                    // ${{db.DATABASE_URL}}, typed
    API_HOST: api.env.SKIFFLY_PRIVATE_DOMAIN,             // built-in of another service
    SELF_URL: "https://${{self.SKIFFLY_PUBLIC_DOMAIN}}",  // self = this service
    REGION: shared("REGION"),                             // ${{shared.REGION}}
    STRIPE_KEY: preserve(),                               // keep the value stored on Skiffly
    JWT_SECRET: generate("hex64"),                        // random on first apply, then preserved
  },
});

skiffly config init writes existing secrets as process.env.NAME ?? preserve(), so the generated file is safe to commit. plan fails if a preserve() variable has no value on Skiffly yet.

Databases#

const db = postgres("db", { sizeGb: 20, env: { POSTGRES_DB: "shop" } });
const cache = redis("cache");
const sql = mysql("sql");
const docs = mongo("docs");

Each expands to the template of the same name: image, port, volume, generated password and the connection-string variable (db.env.DATABASE_URL, cache.env.REDIS_URL, sql.env.MYSQL_URL, docs.env.MONGO_URL). database(name, engine, options) is the generic form for another image of a known engine.

Environments in one file#

export default defineSkiffly((ctx) => {
  const prod = ctx.isEnvironment("production");
  const web = service("web", { replicas: prod ? 2 : 1, resources: { memory: prod ? "2GB" : "512MB" } });
  return project("my-app", { resources: [web] });
});

ctx carries command, projectId, environment, isEnvironment(), randomString() and shared (ctx.shared.NAME references a shared variable).

CI#

.github/workflows/deploy.yml
- run: npx skiffly config apply --yes -p my-app -e production
  env:
    SKIFFLY_TOKEN: ${{ secrets.SKIFFLY_TOKEN }}

Use a workspace token with the write scope. plan --json produces a machine-readable diff for pull-request comments.

Differences from Railway's SDK#

  • defineRailwaydefineSkiffly; platform variables are SKIFFLY_*.
  • domains are applied, not import-only; domain: true manages the generated domain.
  • volumes are created and pruned by mount path; resizing is a warning.
  • generate() creates first-apply secrets; resources, sleep, restartPolicy are Skiffly settings.
  • Not available: bucket(), group(), template(), multi-region replica maps, TCP proxies (use skiffly proxy).

Full reference: the @skiffly/config README on npm.