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)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]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)planandapplytake-p/--project,-e/--environment,-f/--file,--json,--show-secrets.- What the file leaves out is not managed. A service without
envkeeps 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;applyasks before deleting (--yesin CI). - Changed services are deployed at the end of
apply;--no-deployskips that.
Services#
| Option | Example | Maps to |
|---|---|---|
source | github("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 |
port | 8080 | port |
domain | true | a 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 |
replicas | 2 | replicas |
sleep | true | App 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#
- 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#
defineRailway→defineSkiffly; platform variables areSKIFFLY_*.domainsare applied, not import-only;domain: truemanages the generated domain.volumesare created and pruned by mount path; resizing is a warning.generate()creates first-apply secrets;resources,sleep,restartPolicyare Skiffly settings.- Not available:
bucket(),group(),template(), multi-region replica maps, TCP proxies (useskiffly proxy).
Full reference: the @skiffly/config README on npm.