Skip to content

Development Commands

These commands support local development. Build a sample project, import a dbt project or package, or scaffold an adapter. Run the HTTP server with its browser UI, or the language server. Check the project’s health, and list what it holds.


Create a self-contained sample project that runs on DuckDB in process. It needs no warehouse credentials and no external service. Use it to learn Rocky, to try model logic, and to prototype.

Terminal window
rocky playground [path]
Argument Type Default Description
path string rocky-playground Directory name for the playground project.
Flag Type Default Description
--template <TEMPLATE> string quickstart Sample project template. quickstart is the only one. The removed ecommerce and showcase templates fail with a message that lists the remaining templates.

Create a playground with the default name:

Terminal window
rocky playground
Rocky Playground
Created sample project at ./rocky-playground/
Template: Quickstart (3 models)
Using DuckDB (local, no warehouse needed)
Try:
cd rocky-playground
rocky compile # type-check the models
rocky test # run models on an in-memory DuckDB
rocky run # materialize the model DAG
rocky preview rows --model customer_orders # peek at materialized rows

Create a playground with a custom name:

Terminal window
rocky playground my-experiment
Rocky Playground
Created sample project at ./my-experiment/
Template: Quickstart (3 models)
Using DuckDB (local, no warehouse needed)
Try:
cd my-experiment
rocky compile
rocky test
rocky run

Import an existing dbt project and emit a Rocky project you can run. The importer reads dbt_project.yml and profiles.yml, and translates each .sql model body. It writes a self-contained directory that rocky compile and rocky plan plus rocky apply accept as is.

Translation expands {{ ref(...) }} and {{ source(...) }} into plain identifiers. A model with a Jinja statement tag ({% if %}, {% for %}, {% set %}) is refused on a no-manifest import and listed under “Failed models” with the reason. Run dbt compile --full-refresh and import with the manifest. A {{ ... }} expression the importer cannot lower is left as a /* TODO: unsupported Jinja expression */ comment, so nothing is dropped silently.

Terminal window
rocky import-dbt --dbt-project <PATH> [flags]
Flag Type Default Description
--dbt-project <PATH> PathBuf (required) Path to the dbt project directory (containing dbt_project.yml).
--output-dir <PATH> PathBuf rocky-out Output directory for the emitted Rocky repo (rocky.toml + models/ + seeds/ + MIGRATION-NOTES.md). Refuses to write into a non-empty directory unless --overwrite is set.
--target-adapter <KIND> string profile-derived Override the Rocky adapter type. Accepts duckdb, databricks, snowflake, bigquery. Defaults to whatever <dbt_project>/profiles.yml declares — or duckdb when the profile can’t be parsed or maps to an unsupported warehouse.
--overwrite bool false Replace contents of --output-dir if it already exists and is non-empty. Without this flag, the importer refuses to write into a non-empty directory so it never silently clobbers existing work.
--manifest <PATH> PathBuf auto-detect Path to target/manifest.json. When present, the importer uses dbt’s compiled manifest (Jinja already resolved). Auto-detected from <dbt_project>/target/ if omitted.
--no-manifest bool false Force regex-based import (skip manifest.json even if available). Useful when the manifest is stale or you want to verify the regex path.
--microbatch-as <KIND> string merge How to map dbt materialized='microbatch' models. merge reuses the dbt unique_key for an idempotent key-upsert; time_interval maps the batch onto Rocky’s partition-window strategy.
--skip-unit-tests bool false Don’t convert dbt manifest.unit_tests (1.8+) into Rocky [[test]] blocks. The unit-test counters still report what was found and skipped. Useful when you want to port unit tests by hand.
<output-dir>/
├── rocky.toml derived from dbt_project.yml + profiles.yml
├── models/
│ ├── _defaults.toml catalog + schema defaults from dbt_project.yml
│ ├── <name>.sql translated model body (Jinja stripped or kept
│ │ as a comment)
│ └── <name>.toml [strategy] + [target] sidecar
├── seeds/ verbatim copy of <dbt_project>/seeds/
└── MIGRATION-NOTES.md counts, known limitations, required env vars

The importer never inlines a connection secret. Passwords, API tokens, and service-account JSON come out as ${VAR} placeholders in rocky.toml. MIGRATION-NOTES.md lists every variable you must export before rocky plan plus rocky apply.

dbt materialized Rocky strategy
view view
table full_refresh
incremental with a unique_key merge
incremental without a unique_key full_refresh, with a warning
microbatch merge, or time_interval when you pass --microbatch-as time_interval. Without a unique_key, full_refresh with a warning
anything else full_refresh, plus a TODO line in MIGRATION-NOTES.md

An incremental model whose SQL uses dbt’s standard watermark filter imports as a Rocky incremental model. The filter must be one {% if is_incremental() %} block of this shape, with no {% else %}:

{% if is_incremental() %}
WHERE updated_at > (SELECT MAX(updated_at) FROM {{ this }})
{% endif %}

The block becomes WHERE @incremental_filter (or AND @incremental_filter), and the sidecar gets timestamp_column = "updated_at". A qualified or renamed left side (o._synced_at > (SELECT MAX(updated_at) ...)) also sets filter_column. Only a strict > is recognised. A >= compares differently, so it is not converted. The unique_key carries over when incremental_strategy is unset or 'merge'; 'append' drops it. Other strategies, 'delete+insert' included, keep their previous mapping: a keyed MERGE does not delete every target row of a non-unique key. on_schema_change maps fail and append_new_columns directly, sync_all_columns to append_new_columns, and ignore to fail, each change with a warning.

On the raw path, the other Jinja in the file must convert too. From a manifest, the importer rebuilds the SQL from raw_code when the block is its only statement tag. Otherwise it keeps the full-refresh compiled_code with no placeholder, and Rocky filters the model’s output column. rocky compile then refuses the model with E046 unless that column is a direct passthrough.

On the raw path, an incremental model that uses is_incremental() in any other way is still imported. The block is commented out under a -- TODO: dbt is_incremental() block not translated: line, and the sidecar has no watermark. rocky compile refuses the model with E037 until you add timestamp_column and the placeholder.

Any other append-style dbt model falls back to full_refresh, which cannot duplicate rows. The fallback covers four cases:

  • incremental with no unique_key
  • incremental_strategy = 'append', even with a unique_key
  • incremental_strategy = 'merge' with no unique_key
  • an incremental_strategy the importer does not recognise, even with a unique_key

A unique_key maps a model to merge only when incremental_strategy is unset or 'merge'. An explicit 'append' keeps the fallback.

Where the fallback is reported depends on the import path. From a manifest, each model is listed in MIGRATION-NOTES.md under “Items to translate manually”, and under “Warnings”. With --no-manifest, the importer keeps only the warning text, so the model appears under “Warnings” alone. That includes a model with no config() block that inherits materialized = 'incremental' from dbt_project.yml.

Some fallbacks are refused instead. A manifest import keeps dbt’s compiled SQL, and dbt compiles is_incremental() as true against an existing table. So the compiled SQL can keep an incremental filter, and as full_refresh each run would replace the table with only the recent rows. When an incremental or microbatch model would fall back to full_refresh and its raw SQL uses is_incremental(), the importer does not import it. The model is listed under failed_details. Rewrite it by hand: remove the is_incremental() filter, then use merge with a unique_key, or a time_interval model.

Apart from the incremental conversion above, Rocky refuses a model whose raw Jinja calls is_incremental() on either raw-SQL path: --no-manifest, or a manifest node with no compiled_code. Without compiled SQL, Rocky cannot preserve dbt’s first-run versus later-run distinction. Each refused model is listed under failed_details.

A profile type Rocky does not support natively stubs a DuckDB [adapter], so the emitted project still compiles. MIGRATION-NOTES.md records the original type under “Not Translated”.

When it reads profiles.yml, the importer resolves YAML anchors and aliases (&anchor, *alias) plus {{ env_var('VAR', 'default') }} expressions in the adapter type field. A profile that templates its warehouse type therefore detects the right adapter instead of stubbing DuckDB. --target-adapter overrides the detection entirely.

Listed in every emitted MIGRATION-NOTES.md:

  • Singular dbt tests (custom SQL files in tests/): not translated.
  • Macros and dbt_packages/: skipped. The hybrid-dbt-packages POC is the documented escape hatch.
  • dbt model contracts (contract: {enforced}): written to <model>.contract.toml next to the model. Column types carry over, read for the manifest’s dbt adapter where a name such as float or timestamp differs by warehouse. A column type Rocky has no name for (or one it reads differently on that warehouse), the not_null and primary_key constraints (Rocky cannot prove NOT NULL from the sources), and the unique, check, foreign_key and custom constraints do not. A model with such a part is reported with a warning and counted in contracts_dropped (JSON output and MIGRATION-NOTES.md).

The four built-in dbt generic tests (unique, not_null, accepted_values, relationships) translate to native Rocky [[tests]] on the matching per-model sidecar, as do several common dbt_utils / dbt_expectations tests. Tests with no native equivalent, or that reference columns Rocky didn’t translate, are surfaced as UnsupportedTest warnings and listed in MIGRATION-NOTES.md rather than silently dropped. See the full generic test mapping.

Import a dbt project end-to-end. The default output dir is ./rocky-out/:

Terminal window
rocky import-dbt --dbt-project ~/projects/acme-dbt
{
"version": "1.30.0",
"command": "import-dbt",
"import_method": "regex",
"project_name": "demo_project",
"imported": 1,
"failed": 0,
"warnings": 0,
"imported_models": ["orders"],
"warning_details": [],
"failed_details": [],
"emission": {
"out_dir": "rocky-out",
"rocky_toml_path": "rocky-out/rocky.toml",
"migration_notes_path": "rocky-out/MIGRATION-NOTES.md",
"models_translated_count": 1,
"models_skipped_count": 0,
"seeds_copied_count": 0,
"adapter_type": "duckdb",
"original_dbt_adapter_type": "duckdb",
"required_env_vars": []
}
}

import_method is "manifest" when a compiled target/manifest.json was found (pre-resolved Jinja), or "regex" when Rocky parsed the raw .sql files directly. The emission block is populated whenever --output-dir writes succeeded. Model-level import failures use the command’s partial-import contract and do not make the process exit non-zero, so automation must check failed and failed_details before consuming the emitted repository.

Import into a custom directory and override the target adapter (e.g. migrating a Postgres dbt project to Rocky-on-Snowflake):

Terminal window
rocky import-dbt --dbt-project ~/projects/acme-dbt --output-dir ./acme-rocky --target-adapter snowflake

Re-run after fixing the dbt source, replacing the previous emit:

Terminal window
rocky import-dbt --dbt-project ~/projects/acme-dbt --output-dir ./acme-rocky --overwrite

End-to-end migration in one shot (emit, then compile to verify the result is loadable):

Terminal window
rocky import-dbt --dbt-project ~/projects/acme-dbt --output-dir ./acme-rocky --overwrite \
&& rocky compile --models ./acme-rocky/models/

Vendor a dbt Hub package, such as a Fivetran connector package, as Rocky models. dbt runs once per add or update to compile the package. After that, Rocky owns the models. See Vendor dbt Packages for the walkthrough.

Terminal window
rocky package add <namespace>/<name>[@<version-spec>] [flags]
rocky package update [<name>] [flags]
rocky package list
rocky package remove <name> [--force]

add and update run dbt deps and dbt compile --full-refresh in a temporary dbt project, with a cleared environment plus an allowlist. Credentials reach dbt as DBT_ENV_SECRET_ROCKY_* variables. With --build-empty, dbt run --empty --full-refresh runs between them. The profile comes from the rocky.toml adapter (duckdb, snowflake, databricks, bigquery or postgres). Rocky writes the package’s models to models/packages/<package>/ and records them in rocky-packages.lock at the project root.

Flag Type Default Description
--vars <KEY=VALUE> string (repeatable) lockfile vars on update A dbt var for the package. The value is read as YAML. Recorded in the lockfile and reused by update.
--adapter <NAME> string the only warehouse adapter The rocky.toml adapter dbt compiles against.
--target-schema <NAME> string the warehouse default schema (main on DuckDB, public on Postgres, PUBLIC on Snowflake, default on Databricks) Schema the vendored models build into. Required on BigQuery.
--dbt <PATH> PathBuf dbt on PATH The dbt executable. A relative path is resolved against the current directory.
--dbt-timeout <SECONDS> u64 1800 Stop a dbt step (deps, run --empty, compile) that runs longer.
--allow-secret-var bool false Accept --vars names that look like credentials (*_key, *_token, *_password, …). Vars are stored in clear text in the lockfile.
--compiled <DIR> PathBuf Import an already-compiled dbt project (<DIR>/target/manifest.json + <DIR>/package-lock.yml) instead of running dbt.
--build-empty[=BOOL] bool false on add; the lockfile’s mode on update Run dbt run --empty --full-refresh before compiling, so macros that read upstream columns see them. Writes empty rocky_package_build* schemas to the warehouse and runs the package’s hooks. Without it, a package whose compile came out with all-NULL columns or a placeholder * is refused with E055. Conflicts with --compiled.

remove --force also deletes vendored files you edited.

The lockfile records the build mode: compile-only, build-empty or compiled. update reuses it. A compiled package needs --compiled <dir> or --build-empty on update.

update never overwrites a file you edited. When the package also changed that file, the new version goes to <file>.incoming and W055 names it. Unedited files are written, deleted or restored to match the package. See Update a package.

  • E055: refused. A bad spec, dbt not found, an adapter with no profile mapping, a failed dbt deps or dbt compile, a compile that came out wrong because upstream relations did not exist (all-NULL columns or a placeholder *, without --build-empty), a model name or [target] table the project or another package already owns (ignoring case), a vendored model that reads a model that was not vendored or a dbt seed, vendored SQL that does not parse, a previously vendored model that no longer imports (on update), a dbt project name already vendored from another Hub package, Jinja in a var, a credential-like var name without --allow-secret-var, a dbt step past --dbt-timeout, a symlink under models/packages/, or remove of edited files without --force. Nothing is written.
  • W055: vendored, with something to review. An .incoming file, an edited file upstream removed, a model that could not be vendored, an incremental model that fell back to full refresh, dropped dbt tests, or a dbt run --empty failure.

--output json prints PackageAddOutput (command: "package_add"), PackageUpdateOutput, PackageListOutput or PackageRemoveOutput. The schemas are in schemas/package_*.schema.json.

{
"version": "1.76.0",
"command": "package_add",
"lockfile": "./rocky-packages.lock",
"package": {
"name": "stripe",
"hub": "fivetran/stripe",
"version": "1.10.1",
"version_spec": ">=1.0.0,<2.0.0",
"dbt_version": "1.12.5",
"adapter": "duckdb",
"target_schema": "main",
"vars_hash": "blake3:af13…",
"mode": "build-empty",
"includes": [],
"models": ["int_stripe__account_daily", "..."],
"models_added": ["int_stripe__account_daily", "..."],
"models_removed": [],
"sources": [{ "name": "stripe.charge", "catalog": "dev", "schema": "stripe", "table": "charge" }],
"tests_mapped": 15,
"tests_dropped": [],
"incremental_fallbacks": [],
"failed_models": [],
"files_written": ["models/packages/stripe/int_stripe__account_daily.sql", "..."],
"files_incoming": [],
"files_deleted": [],
"files_kept_edited": [],
"files_unchanged": 0
},
"diagnostics": []
}

Start an HTTP server for the project. It answers under /api/v1 with typed, schema-backed payloads: models, lineage and the DAG, runs and schedules, products, the review queue, and the governor’s brief, audit and custody reads. A route that matches a CLI command returns what that command’s --output json prints. The routes with no CLI counterpart, such as /models and /dag/layers, have their own shapes. It also runs run, plan and apply as background jobs. --ui adds the browser UI, and --scheduler adds the resident scheduler. The Embedding guide covers the routes, and the OpenAPI document lists every one.

Terminal window
rocky serve [flags]

rocky serve binds 127.0.0.1 (loopback) by default. Whether a request needs a Bearer token depends on one thing: whether a token is configured.

  • No token configured (the loopback default) — nothing checks a Bearer token: the middleware passes every request to its handler. Any process on the machine can read model SQL, run history and the governor ledger, and can call the mutating routes. A handler’s own checks still apply — a body it cannot parse is 400, a second run while one is in flight is 409, and the webhook route still verifies its HMAC when ROCKY_WEBHOOK_SECRET is set. This is the development posture. It is refused on a non-loopback bind, so a misconfigured network cannot expose the server this way. A web page in a browser on the machine cannot use it either: the host check below refuses its requests.
  • A token configured — --token <secret>, or the ROCKY_SERVE_TOKEN env var. The Bearer check runs on every request whose path is not exempt. Two paths are exempt, whatever the method: /api/v1/health, and /api/v1/hooks/trigger/{pipeline} (exactly one path segment after the prefix), where the handler verifies an X-Rocky-Signature HMAC instead — and accepts an unsigned POST only on a loopback bind with --scheduler and no ROCKY_WEBHOOK_SECRET (the edge below). Two more things carry no token by design: a CORS preflight (OPTIONS) is answered before the check runs, and the browser UI’s own files under /ui/ are public — they carry no data.
  • Non-loopback bind — --host 0.0.0.0 (or any non-loopback address) requires a token; rocky serve refuses to start otherwise. --ui requires a read-only token on a non-loopback bind. On a loopback bind with no token configured, --ui generates one for the process (see the browser UI).
  • The host check — on every loopback bind, with or without --ui, and on every --ui bind, a request whose Host is not a loopback name (localhost, 127.0.0.1, [::1], any port), the bind host, or an --allowed-host entry is refused 421 host_not_allowed before routing. A present Origin that is neither this server’s own nor an --allowed-origin entry is refused 403 origin_not_allowed. This stops DNS rebinding: a web page cannot point its own domain at 127.0.0.1 and then call POST /api/v1/jobs/run on a server with no token. GET /api/v1/health skips the check. A non-loopback bind without --ui runs no host check: it is reached by names it cannot know, such as a container’s service name or a pod IP, and it must carry a token. To reach a loopback server through a reverse proxy that sends its own name as Host, pass that name with --allowed-host.

A token is full-scope by default: it reaches every route. --token-scope read-only narrows it. A read-only token authenticates the same way, then gets 403 on any non-exempt request that is not GET, HEAD, or OPTIONS. Give that token to a browser UI on a shared server, so a leaked token cannot start a run:

read-only token
├─ GET /api/v1/dag → 200
├─ GET /api/v1/compile → 200
├─ POST /api/v1/jobs/run → 403 forbidden_read_only_token
└─ POST /api/v1/compile → 403 forbidden_read_only_token

The check reads the HTTP method, not a list of paths, so a route added later with a mutating method is refused the moment it exists. Two limits are worth knowing: the check classifies the METHOD, not the handler, so a GET that mutates would pass; and it only covers routes registered inside the authenticated router, not one added after that layer. Two things it does not touch: GET /api/v1/health stays open, and the webhook route POST /api/v1/hooks/trigger/{pipeline} keeps its own HMAC check.

The webhook exemption has three more clauses, and they are stated here rather than left to be assembled from three sections.

On a loopback bind with --scheduler and no ROCKY_WEBHOOK_SECRET, the route accepts an unsigned POST and queues work for the scheduler. No token is involved, so a read-only token does not stop it. This is the dev-convenience path.

On a non-loopback bind with no secret, the route is refused outright — 404 webhook_disabled, not accepted-unsigned. So the unsigned path cannot be reached from the network by binding wider; widening the bind closes it rather than exposing it.

rocky serve --ui --scheduler refuses to start without ROCKY_WEBHOOK_SECRET. A browser can reach the webhook route, so the combination that would let page script queue unsigned work is rejected at startup rather than served.

--scheduler, no ROCKY_WEBHOOK_SECRET:
loopback bind → unsigned POST ACCEPTED (dev convenience)
non-loopback bind → 404 webhook_disabled (refused, not unsigned)
loopback bind + --ui → refuses to START

Set ROCKY_WEBHOOK_SECRET whenever a browser or an untrusted process can reach the server.

CORS is empty-by-default. Browser apps must declare every allowed origin via --allowed-origin <ORIGIN>. Permitted methods: GET, POST, OPTIONS. Permitted headers: Authorization, Content-Type. CORS authenticates nothing: it decides what a browser may read from a response, and it answers OPTIONS before the token check runs.

Flag Type Default Description
--models <PATH> PathBuf models Directory containing model files. When omitted and rocky.toml declares a transformation pipeline, the server compiles every transformation pipeline’s own models set, the same set GET /api/v1/dag and rocky dag read. If those models cannot be loaded (for example two files with one model name in two pipelines, or a malformed sidecar), the server compiles models/ alone and reports the load error as a W014 warning that names the pipeline and file; /models and /dag then show only models/ until the error is fixed. If models/ is absent or has no model, the server answers engine_not_ready with the load error instead. When given, only this directory is compiled.
--contracts <PATH> PathBuf Directory containing data contract definitions.
--host <HOST> String 127.0.0.1 Bind host. Non-loopback (0.0.0.0, etc.) requires --token.
--port <PORT> u16 8080 Port to listen on.
--token <SECRET> String Bearer token. When set, the check runs on every request whose path is not exempt (/api/v1/health, and the HMAC-checked webhook route); the UI’s own files are public. When unset, no request is asked for a token — see Security defaults. Falls back to ROCKY_SERVE_TOKEN env var when omitted. Required when --host is non-loopback, including with --ui. With --ui on loopback and no token, the server generates a per-process token: full scope (operator mode), or read-only with --read-only or when --allowed-host/--allowed-origin is given.
--token-scope <SCOPE> full | read-only full What --token may do. read-only allows GET, HEAD, and OPTIONS only; anything else gets 403 forbidden_read_only_token. Falls back to ROCKY_SERVE_TOKEN_SCOPE. Setting a scope without a token is an error. The one exception is --ui on loopback, which generates a token with that scope. With --ui on loopback and --allowed-host or --allowed-origin, full is refused.
--read-only bool false Alias for --token-scope read-only. Conflicts with --token-scope full. Use it for a view-only UI.
--allowed-origin <ORIGIN> String (repeatable) [] Add an origin to the CORS allowlist. Repeat for multiple origins (e.g. --allowed-origin http://localhost:5173 --allowed-origin https://dashboard.example.com).
--ui bool false Serve the browser UI at /ui/. Release binaries carry it; from source, build with --features ui. On loopback with no token, the server generates one for the process: full scope (operator mode), or read-only with --read-only. On any other host, pass --token with --read-only. A server with --allowed-host or --allowed-origin is shared, so it stays read-only. Requires ROCKY_WEBHOOK_SECRET with --scheduler. Prints the address to open, token included.
--allowed-host <HOST> String (repeatable) [] An extra Host header value to accept, for a reverse proxy in front of the server. Loopback names and the bind host are always accepted; any other Host is refused 421 on a loopback bind or a --ui bind (see Security defaults). A non-loopback bind without --ui checks no Host. With --ui, makes the server count as shared, so the UI token stays read-only.
--watch bool false Watch the models directory, functions/ beside it and the bound rocky.toml, and recompile on a change. Without --models, also watch each transformation pipeline’s models directory that exists at startup. A pipeline root that does not exist at startup, or a pipeline added to rocky.toml later, is compiled but watched only after a restart. A missing default models/ is skipped when pipeline roots exist.
--scheduler bool false Also run the resident scheduler: a timer loop that evaluates every pipeline’s [schedule] and runs what is due, in-process. On SIGTERM or Ctrl-C the server drains a running scheduled child before it exits. Run one instance per project directory. Experimental.
--poll-interval-seconds <SECONDS> u64 15 Seconds between scheduler ticks. Must be at least 1. Only meaningful with --scheduler.
--drain-timeout-seconds <SECONDS> u64 60 Seconds a running scheduled child may keep going after a shutdown signal before Rocky terminates it. Only meaningful with --scheduler.
--open bool false With --ui: open the printed address in the default browser once the listener is bound — after the startup sweep, never before. The address, token included, is handed to the system opener (open, xdg-open, rundll32) as an argument, so it is visible in the process list while the opener runs. At full scope, another local OS user could act with it. On a shared machine, pass --token and --read-only. A missing opener, or one that exits non-zero, is a warning; the server still starts and still prints the address. Refused without --ui.

rocky serve --ui serves a browser UI at /ui/, from files built into the release binaries (cargo feature ui; a plain cargo build has no UI and refuses the flag with the build command to run).

On your own machine, --ui alone is enough:

Terminal window
rocky serve --ui
# Rocky UI: http://127.0.0.1:8080/login?t=<64 hex characters>

With no token configured on a loopback bind (127.0.0.1, ::1, localhost), the server generates a per-process token. It works for every request until the server stops, and each start makes a new one. Once the listener is bound, the server prints the Rocky UI: address with the token on stdout, and a note on stderr.

The generated token has full scope. This is operator mode: the UI can run, plan, approve and apply as the OS user who started the server, like the VS Code extension. The stderr note says so. For a view-only UI, add --read-only. A server with --allowed-host or --allowed-origin is shared, so it generates a read-only token and refuses --token-scope full.

An SSH -L tunnel or kubectl port-forward to a loopback port cannot be detected: the server still looks local. Use --read-only whenever someone else can reach your port. With --open, the opener’s command line holds the address and its token, and other local users can read it with ps. The address is also printed on stdout, so it lands in anything that captures stdout (a terminal log, docker logs, a service journal) and in browser history. On a shared host, choose the token yourself. Do the same to keep one token across restarts, or to serve on any other host:

Terminal window
rocky serve --ui --token s3cret --read-only
# Rocky UI: http://127.0.0.1:8080/login?t=s3cret
--ui, no --token and no ROCKY_SERVE_TOKEN:
loopback, not fronted → generates a full token (operator mode)
loopback, not fronted, --read-only → generates a read-only token
loopback, fronted → generates a read-only token
loopback, fronted, scope full → refuses to start
non-loopback bind → refuses to start
("fronted" = --allowed-host or --allowed-origin given)

The rules, each refused at start with its fix:

  • --ui needs a token. On a non-loopback bind, the token must be read-only and you must pass it. On loopback, a server with a token you gave accepts full (or no scope, which means full) unless it is fronted. A fronted server refuses a full-scope token, because writes from the UI need a token for each person. One token can plan, approve and apply, as the CLI user can: approving in the UI is not a second person’s sign-off.
  • The printed address is /login?t=<token>. The server checks the token and answers 303 to /ui/ with a session cookie, rocky_ui_<tag> (the tag differs per server; HttpOnly, SameSite=Strict, a session cookie; Secure behind a TLS proxy that sends X-Forwarded-Proto: https). The cookie holds a keyed hash of the token, so a restart ends every session. The server logs no request URIs, and the answers send no-store and no-referrer. A reverse proxy may log the query of /login: configure it not to. A bad token gets a 401 page with a token field that posts to POST /login, which needs an allowed Origin.
  • The page and its files are public: they carry no data. Every API call the page makes carries the cookie. A route accepts Authorization: Bearer <token> or the cookie. A write by cookie must also carry an Origin that names this server exactly (or an --allowed-origin entry) and X-Rocky-UI: 1, or the answer is 403 ui_write_not_from_ui. The read-only scope applies to cookie sessions too.
  • With --ui, a request whose Host is not a loopback name, the bind host, or an --allowed-host entry is refused 421 host_not_allowed before routing. A present Origin that is neither this server’s own nor an --allowed-origin entry is refused 403 origin_not_allowed. Both refusals carry the error envelope. The same checks run on a loopback bind without --ui; only a non-loopback bind without --ui skips them (see Security defaults). GET /api/v1/health skips both checks, so a load balancer or a Kubernetes probe that sends the pod IP as Host still gets 200. The route carries no data.
  • Every UI response carries a Content Security Policy that allows scripts, styles, images, fonts and connections from this server only and forbids framing. The page loads nothing from any other host.
  • --ui --scheduler refuses to start without ROCKY_WEBHOOK_SECRET: a browser can reach the webhook route.

A reverse proxy in front of the UI names itself with --allowed-host proxy.internal; a page served from another origin lists it with --allowed-origin https://app.example.

The browser UI guide shows what each screen displays.

GET /api/v1/settings reports the posture of the running server. Read it when you want to check what a server is actually enforcing, rather than what its start command looked like.

Terminal window
curl -H "Authorization: Bearer $ROCKY_SERVE_TOKEN" \
http://127.0.0.1:8080/api/v1/settings

It reports the bind host, the CORS allowlist, the Host values the host check accepts, whether the scheduler and the UI are on, the token’s scope, and the state backend.

No secret is in the response. The token appears as its name and scope. The webhook secret appears only as whether it can sign a webhook:

webhook_secret what it means
------------------ --------------------------------------------------
present webhook requests must carry a signature
absent loopback accepts UNSIGNED; non-loopback answers 404
set_but_unusable blank or not valid UTF-8 -- --scheduler will REFUSE
to start

That field is reported even when the scheduler is off. It tells you what will happen when you turn the scheduler on, which is the point.

Two fields have a different freshness from the rest. state_backend and concurrency_control come from rocky.toml, and are read once on the first request to this route, then fixed for the life of the process. They are not read at startup on purpose. On a plain rocky serve, nothing on the path to binding the listener reads a file, and reading rocky.toml eagerly would put one there — letting a rocky.toml on a stalled mount stop the server binding at all. (--scheduler without an explicit --poll-interval already reads the config before binding; that is unchanged.) When concurrency_control is unset, the field reports the backend default (cas on s3, gcs, tiered; off on local, valkey). It is the requested mode: the writers’ startup probe does not run here, so a store that falls back to off shows up in rocky doctor instead.

That read runs one at a time under a 5 second deadline, so a stuck file cannot starve the server either:

503 engine_busy another settings read is in flight -- retry
504 settings_config_timeout the read blew its deadline; usually a file
that will not return, so retrying will not help

Every other field on the route is unaffected by either.

So a config edited after that first request is not reflected here, while the scheduler — which re-reads the file every tick — acts on the new one. They are null when there was no readable config, and config_status says which:

loaded read and parsed
absent no rocky.toml -- an ordinary fact, not a failure
unreadable present but would not parse; the scheduler will skip every
tick until it does

allowed_hosts and allowed_origins both report what is enforced, not what was typed. --allowed-host only becomes a check on a loopback or --ui bind, so that list is empty on a non-loopback bind without --ui. --allowed-origin values that are not valid header values are dropped when the CORS layer is built, and an origin that could not be installed grants nothing, so they are absent here too.

Start the server with defaults (loopback, token via env var):

Terminal window
export ROCKY_SERVE_TOKEN=$(openssl rand -hex 32)
rocky serve
Compiled 14 models in 42ms
Listening on http://127.0.0.1:8080
Endpoints:
GET /api/v1/models - List all compiled models
GET /api/v1/models/:name - Get model details
GET /api/v1/models/:name/lineage - Column-level lineage
GET /api/v1/dag - Full dependency graph

Call an authenticated endpoint:

Terminal window
curl -H "Authorization: Bearer $ROCKY_SERVE_TOKEN" http://127.0.0.1:8080/api/v1/models

Start with file watching on a custom port:

Terminal window
rocky serve --port 3000 --watch --token "$ROCKY_SERVE_TOKEN"
Compiled 14 models in 42ms
Watching models/ for changes...
Listening on http://127.0.0.1:3000

Expose to the LAN with explicit token + origin allowlist:

Terminal window
rocky serve \
--host 0.0.0.0 \
--token "$ROCKY_SERVE_TOKEN" \
--allowed-origin https://dashboard.internal \
--port 9090

Start with contracts:

Terminal window
rocky serve --models src/models --contracts src/contracts --port 9090 --watch
  • rocky lsp – IDE integration via Language Server Protocol
  • rocky compile – one-shot compilation without the server
  • rocky lineage – CLI lineage (the server exposes the same data via HTTP)

Start the language server for editors. It gives diagnostics, completions, hover information and go-to-definition for Rocky models. It talks over stdin and stdout. An editor starts it; you rarely run it by hand.

Terminal window
rocky lsp

No command-specific flags. A hidden --stdio flag is accepted for editors that pass it. Stdio is always the transport.

In VS Code, the Rocky extension starts rocky lsp for you. rocky.server.path sets the binary (default rocky), and rocky.server.extraArgs adds arguments.

In Neovim, with lspconfig:

require('lspconfig').rocky.setup({
cmd = { "rocky", "lsp" },
filetypes = { "sql" },
root_dir = function(fname)
return require('lspconfig.util').root_pattern('rocky.toml')(fname)
end,
})

Scaffold a new warehouse adapter crate under crates/rocky-<name>/. Run it from the engine workspace root. It refuses when the directory already exists.

Terminal window
rocky init-adapter <name>
Argument Type Default Description
name string (required) Adapter name, for example redshift. The crate is rocky-<name>.
Terminal window
rocky init-adapter redshift
Created adapter scaffold at crates/rocky-redshift/
crates/rocky-redshift/
├── Cargo.toml
├── src/
│ ├── lib.rs
│ ├── dialect.rs ← SqlDialect trait (TODO: implement methods)
│ ├── adapter.rs ← WarehouseAdapter trait (TODO: implement)
│ └── types.rs ← TypeMapper trait
└── tests/
└── integration.rs ← Live tests (#[ignore])

The command then prints the next steps: add the crate to the workspace, choose literal_escape in dialect.rs (the crate does not compile until you do), implement the traits, and register the adapter in registry.rs.


List the lifecycle hooks configured in rocky.toml, or fire a test event at them.

Terminal window
rocky hooks list
rocky hooks test <event>
Argument Type Description
event string Event name for hooks test, for example on_pipeline_start or on_materialize_error.

hooks test builds a synthetic context for the event, prints it, and runs every hook configured for that event.

Terminal window
$ rocky hooks test on_pipeline_start
Firing test event: on_pipeline_start
Context JSON:
{ ... }
All hooks passed.

See Hooks for the events and the config.


Compare imported dbt model names with a Rocky project. The command also reports dbt test conversion. It does not compile Rocky models or compare warehouse data.

Terminal window
rocky validate-migration --dbt-project <PATH> [flags]
Flag Type Default Description
--dbt-project <PATH> PathBuf (required) Path to the dbt project directory.
--rocky-project <PATH> PathBuf Path to the Rocky project directory. Optional, for a side-by-side comparison.
--sample-size <N> usize Accepted but ignored. This command does not sample rows or compare warehouse data.
Terminal window
$ rocky validate-migration --dbt-project ~/dbt-project
Migration Validation Report
===========================
Models: 12 imported, 0 failed
Results:
12 models validated successfully

Discover and inspect the adapters available on your system. Rocky follows the cargo-subcommand convention: any executable on your PATH whose name starts with rocky- is treated as a process adapter named after the suffix, so a rocky-snowplow binary registers as the adapter snowplow.

Walks PATH, runs each rocky-* candidate, and prints one row per discovered adapter.

Terminal window
rocky adapter list [--output json]

The table shows NAME, VERSION, DIALECT, and PATH. An adapter that fails to initialize still appears in the listing with its error, so a broken install is visible rather than silently skipped. The bundled rocky-lsp language server is filtered out. Passing --output json returns each adapter’s full manifest.

Resolves rocky-<name> on PATH, runs it, and prints its manifest (name, version, SQL dialect, and the path it resolved to).

Terminal window
rocky adapter info snowplow

Run the conformance suite against an adapter. Pass --adapter or --command. With neither, the command fails.

Terminal window
rocky test-adapter --adapter <NAME> [--adapter-config <JSON>]
rocky test-adapter --command <PATH> [--adapter-config <JSON>]
Flag Type Default Description
--adapter <NAME> string Adapter name. Built-in names are databricks, snowflake, duckdb, postgres, redshift, clickhouse, sqlserver and spark. Any other name resolves to a rocky-<name> executable on your PATH.
--command <PATH> string Path to a process adapter binary.
--adapter-config <JSON> string Adapter config as a JSON string.
Terminal window
$ rocky test-adapter --adapter duckdb
Adapter Conformance: duckdb (SDK 1.x)
==================================================
...

Most specs report skipped today. The suite declares 26 specs. Three of them (format_table_ref, watermark_where and row_hash) run real dialect calls, and only against a process adapter. On a built-in adapter, those three are skipped too. A skipped spec never counts as a pass. A run that verified nothing prints a warning. It still exits 0, because the exit status keys on failures only.


Run health checks on the project and report each result. It exits 3 when any check is critical, and 0 otherwise.

Terminal window
rocky doctor [--check <NAME>] [--verbose]
Flag Type Default Description
--check <NAME> string (all) Run only the check with this name, from the table below. A name that matches no check runs nothing.
--verbose bool false Add context under each check. In JSON, each check gains a details array of [key, value] pairs. Without the flag, details is left out.
Check What it checks
config rocky.toml parses, and its adapters and pipelines are valid.
state The local state store is readable and not corrupted.
state_schema The state store’s schema version. critical when the store was written by a newer engine than this binary.
adapters Each adapter’s configuration, without connecting.
pipelines Schema patterns, templates and governance config.
state_sync The configured remote state backend (type only).
state_rw A put, get and delete of a marker object on the state backend, so access and reachability problems show at start-up. No-op for local; tiered probes both legs.
state_concurrency Lost-update exposure of a remote [state] backend. See below. Silent for local.
auth, auth/<adapter> Pings each warehouse and discovery adapter to check credentials and connectivity.
scheduler critical when the scheduler’s .rocky/tick.lock is held with a stale heartbeat. A warning when no tick ran for twice the shortest cron interval. Silent when no pipeline has a [schedule].
spool Webhook demands that a tick quarantined as unreadable. Silent when the spool does not exist. A spool that exists but cannot be read is a warning.

state_concurrency. Rocky resolves the concurrency mode as a writer does at start-up. An unset concurrency_control takes the backend default, and "cas" (compare-and-swap) runs a conditional-write probe.

  • Healthy: the mode resolves to "cas" and the store supports it. The end-of-run upload and every ledger write (rocky policy, rocky gc, rocky restore, rocky apply) commit by compare-and-swap.
  • Critical: "cas" is explicit, and the probe shows the store ignores conditional writes.
  • Warning, each with its own message: the mode is "off" but the cas-required marker exists, so uploads will be refused; an unset mode fell back to "off"; the probe was inconclusive; "off" is explicit; or "cas" is set on a backend with no compare-and-swap write.
  • --verbose adds resolved, cas_probe and cas_required_marker.

With --verbose, the other checks add the config path, the state file size, each adapter’s type and credential signal, each pipeline’s kind, and the state backend. The credential signal is one of token, oauth_client, oauth_token, key_pair, password, service_account, adc, env or none.

Terminal window
rocky doctor --check auth
{
"command": "doctor",
"overall": "warning",
"checks": [
{ "name": "config", "status": "healthy", "message": "rocky.toml valid", "duration_ms": 4 },
{ "name": "state", "status": "healthy", "message": "state store readable", "duration_ms": 2 },
{ "name": "adapters", "status": "warning", "message": "adapter.fivetran: API key not set", "duration_ms": 120 }
],
"suggestions": [
"Set FIVETRAN_API_KEY to enable the Fivetran discovery adapter."
]
}

overall is critical when any check is critical, warning when any check warns, and healthy otherwise.


List what the project holds, without running anything.

Terminal window
rocky list pipelines # pipeline definitions
rocky list adapters # adapter configurations
rocky list models # transformation models
rocky list sources # replication source configurations
rocky list deps <model> # what a model depends on
rocky list consumers <model> # what depends on a model
Flag Type Default Description
--models <PATH> PathBuf models Models directory. Accepted by bare rocky list, models, deps and consumers.
--select, --exclude, --state-ref, --state-working-tree string (all) Node selection. Accepted by bare rocky list and rocky list models.

Bare rocky list lists models, so rocky list --select +fct_orders works like dbt ls. Its JSON is the same as rocky list models. Rocky finds models in the models directory and its direct subdirectories, which covers the common models/{layer}/ layout.

$ rocky -o table list pipelines
NAME TYPE TARGET SOURCE DEPENDS ON
playground replication default default -
{
"version": "1.80.0",
"command": "list_pipelines",
"pipelines": [
{
"name": "playground",
"pipeline_type": "replication",
"target_adapter": "default",
"source_adapter": "default",
"depends_on": [],
"concurrency": "16"
}
]
}
  • rocky dag – the same dependencies as a graph
  • rocky catalog – export the compiled graph as a lineage snapshot