Skip to content

CLI

Install the nestrs binary, scaffold projects, generate features, and verify your toolchain.

The nestrs CLI scaffolds one layout: a workspace — crates/features/ for your domain, apps/* for the binaries that serve it. nestrs new reads the directory tree to know whether it is creating that workspace or adding an app to one you already have; there are no mode flags and no config file. Generated code follows the same decorators as the framework repo (users/ for features, api/ for composition apps).

Terminal window
cargo install --locked nest-rs-cli
nestrs version

A binary, so it is the one thing that does not come through the nest-rs umbrella — there is no --features cli.

Requires Rust 1.97+ (edition 2024). That single install is all you need: the first time you call nestrs run, the CLI installs the dev toolchain it drives — just, bacon, cargo-nextest, cargo-llvm-cov — once. Run nestrs doctor after install to check the Rust toolchain and list optional NESTRS_* environment variables.

nestrs new infers where you are and what to create. There is no nestrs-cli.yaml — the workspace shape is already declared in the root Cargo.toml (members = ["crates/*", "apps/*"]). The CLI walks up from the current directory (or -o path) and reads that manifest.

Where you runCommandResult
Outside a nestrs workspacenestrs new helloMonorepo at ./hello/ + apps/hello/ on port 3000
Inside a nestrs workspacenestrs new blogThin app at apps/blog/ — next free port in module.rs

If apps/blog/ already exists, the CLI stops with app blog already exists — it does not overwrite.

Terminal window
nestrs new hello
cd hello
nestrs run dev hello

Open http://localhost:3000/ — Hello World. Logic in crates/features/src/hello/; apps/hello/ composes modules only.

  • Directoryhello/
    • Cargo.toml
    • Justfile
    • test.just
    • db.just
    • compose.yml
    • .env
    • .env.development
    • .env.example
    • .gitignore
    • rust-toolchain.toml
    • README.md
    • AGENTS.md the conventions every coding agent reads
    • CLAUDE.md imports AGENTS.md
    • Directorycrates/
      • Directoryfeatures/
        • Cargo.toml
        • Directorysrc/
          • Directoryhello/
            • http/…
            • service.rs
          • lib.rs
      • Directorymigrations/ SeaORM migrations + the migrate binary
        • …
      • Directoryseed/ the seed binary
        • …
    • Directoryapps/
      • Directoryhello/
        • Directorysrc/
          • lib.rs
          • main.rs
          • module.rs
        • Directorytests/
          • integration/main.rs
          • e2e/main.rs

The e2e suite is scaffolded empty. It has to exist: nestrs run test unit filters on not binary(e2e) and nestrs run test e2e on binary(e2e), and nextest rejects a filterset naming a binary the workspace does not have.

Grow the workspace (from the workspace root or any sub-folder):

Terminal window
nestrs new blog
nestrs g resource posts

rust-toolchain.toml is what makes every recipe run on a project minutes old. It pins Rust 1.97 as the toolchain channel, plus three components, each because a recipe shells out to it:

  • clippy and rustfmt are what nestrs run lint runs. Undeclared, they are present only because rustup’s default profile happens to install them — so the recipe works on your machine and fails on a minimal one.
  • llvm-tools-preview carries the llvm-cov and llvm-profdata behind nestrs run test cov. Those two read a .profraw only when they come from the same LLVM as the rustc that wrote it, so they are pinned here, beside channel, rather than installed once per machine: following the channel is what keeps the two versions married.

Rustup is what reads that file. On a toolchain installed any other way it is inert — point LLVM_COV and LLVM_PROFDATA at your own binaries instead.

There is nothing to choose. Every path out of nestrs new writes the same hello module — a service with a greeting and one #[public] GET / that returns it:

What you runWhat you getGET /
nestrs new acmemonorepo + hello feature + apps/hello/ on 3000200 Hello World
nestrs new blog (inside one)blog feature + apps/blog/ on the next free port200 Hello World

A freshly created project has to prove it started, and a 404 proves nothing to someone looking at a browser — so there is no routeless variant to pick. Delete the feature once you have a real one; nestrs new blog refuses if a blog feature already exists, rather than overwriting it.

Ports: the CLI scans existing apps/*/src/module.rs and pins the next free one in code (same pattern as auth → 3001, api → 3002, …). Workspace .env files do not set NESTRS_HTTP__PORT — ports live in each app’s module.rs:

apps/api/src/module.rs
HttpModule::for_root(HttpConfig { port: 3002, ..Default::default() })

Optional: --check runs cargo check after generation.

From the workspace root (or apps/, crates/features/, …):

Terminal window
nestrs new blog
nestrs run dev blog

Creates a thin app under apps/blog/ — composition root only, no service.rs / controller.rs in the app crate — plus the blog feature it serves under crates/features/src/blog/. That split is why the greeting is a feature rather than a controller in the app: the layout keeps no business logic in an app crate, so the same rule that governs your code governs the starter.

Every project task runs through nestrs run <recipe> — the single front door. It forwards the recipe and its arguments verbatim to just, which reads the Justfile the scaffolder wrote:

Terminal window
nestrs run dev # watch mode (rebuild + restart on save)
nestrs run test unit # unit + integration
nestrs run db up # apply migrations
nestrs run # list the available recipes

The recipes are yours to edit. Add your own to the Justfile and they run the same way — nestrs run deploy, nestrs run docker-up, whatever you write. The framework owns the recipes it ships; you own the rest.

The first nestrs run installs the toolchain it drives (just, bacon, cargo-nextest, cargo-llvm-cov) once — all of it, whichever recipe you asked for, so no recipe is ever a step behind. On CI, where you provision tools yourself, set NESTRS_NO_BOOTSTRAP=1 or pass --no-bootstrap — a missing tool then fails with the manual install command instead of fetching anything.

Release binaries land in target/release/. nestrs run start compiles and starts the server; use nestrs run build when you only need the artifact (Docker, CI, or copying the binary elsewhere).

Terminal window
nestrs run build # default app (hello)
nestrs run build blog # after nestrs new blog
nestrs run build --all # every app in the workspace
nestrs run start hello # build + run in release

Generators scaffold under crates/features/src/<name>/, register pub mod … in crates/features/src/lib.rs, and — when you run them from inside an app — wire the edge module into that app’s module.rs. Every command takes --dry-run (print what would change, write nothing) and a -p path override (default: auto-discover from the current directory). Re-running a generator is safe: it refuses to overwrite an existing feature or adapter, and the wiring edits are idempotent.

A port is the transport-agnostic core (service + module). An adapter bolts one transport onto a port. A resource is a DB-backed CRUD port plus its HTTP adapter in one shot.

Terminal window
# Port only (service + module, no transport)
nestrs g feature posts
# DB-backed CRUD: #[expose] entity + CrudService + guarded #[crud] controller.
# Bootstraps the auth adapter below when the workspace has none — its routes
# name AuthnGuard/AuthzGuard, so they would not compile without it.
nestrs g resource posts
# One #[expose] entity in an existing feature — no controller, no auth
# bootstrap. The name after the slash is the entity's own when the feature's
# singular is not it.
nestrs g entity posts # → Post, in posts/entity.rs
nestrs g entity posts/comment # → Comment
# The app's authn/authz adapter — Claims, AuthnGuard, AuthzAbility, AuthzGuard.
# Run it *before* the first `g resource`, or not at all: it refuses when
# `crates/features/src/authz/` already exists.
nestrs g auth
# A SeaORM migration, registered in lib.rs and migrator.rs
nestrs g migration create_posts
# Bolt a transport onto an existing port
nestrs g http posts # controller
nestrs g graphql posts # resolver
nestrs g ws posts # gateway
nestrs g queue posts # processor
nestrs g schedule posts # scheduled tasks
nestrs g mcp posts # MCP tool

g graphql, g ws and g mcp also write that transport’s authz bridge under crates/features/src/authz/ when the workspace has a policy to enforce, and wire it into the app. It is not optional wiring: /graphql and /mcp gate in band, per operation, so without the bridge /mcp denies every call and /graphql runs operations with no ability installed. A gateway’s bridge carries the socket data context that scopes its rows.

Each adapter delegates to the port’s service, so a freshly-generated port plus any adapter compiles immediately — the handler is the seam you fill in. The generator prints the import line and the app-level module each transport needs (HttpModule, GraphqlModule, ScheduleModule, …).

When the cursor isn’t inside an app, wire the edge module by hand:

apps/api/src/module.rs
use features::posts::PostsHttpModule;
use nest_rs::core::module;
#[module(imports = [
// …
PostsHttpModule,
])]
pub struct ApiModule;

g resource emits the #[crud] + #[use_guards(AuthnGuard, AuthzGuard)] form, and scaffolds the g auth adapter when the workspace has none. That is not hardening you can defer: Repo filters every read by the caller’s ambient Ability, which only the ability guard installs, so an unguarded DB-backed controller mounts its routes and then serves nothing.

A generated resource therefore answers 401 without a token and 403 until AuthzAbility grants a rule for its entity — the two lines the generator prints. A resource meant to be readable without a login is the same slice with one route marked #[public] and a rule in define_visitor; see Public reads. Before inventing a second layout, open crates/features/src/orgs/ side by side with what the generator emitted.

A module keeps its entities in one of two shapes, and the generator reads which one you are in rather than asking. A feature with no entity yet gets the lone posts/entity.rs. A feature already keeping several in posts/entities/ gets one more file there, named for the singular — comment.rs beside post.rs.

Growing from the first shape into the second is the one thing it refuses. Moving entity.rs into entities/ deepens every super:: path the file carries — and SeaORM’s relation form spells those inside string literals, where a rewrite would either miss them or damage them silently. So the generator prints the four mechanical steps and leaves the move to you; re-run it afterwards.

The entity it writes names no service. #[expose(service = …)] points at the one CrudService whose Entity is this entity, g entity writes no service, and a plain g feature port’s service is not one — so the link is yours to add, and the printed next steps spell the line. Reach for g resource instead when you want the entity, the service and the guarded controller together.

g graphql over a resource carries the same guarantee

Section titled “g graphql over a resource carries the same guarantee”

The GraphQL adapter for a resource port is the #[crud] resolver behind the same two guards, and it comes with the per-operation bridge it is enforced through: crates/features/src/authz/graphql/ plus the AuthzGraphqlModule import in the adapter’s module.rs. That folder is not optional decoration — /graphql runs no guard at the HTTP edge, so the bridge is the only thing that authenticates the caller and installs the ability the resolvers read. Over a plain g feature port there are no rows to guard, so the resolver stays the #[public] stand-in and nothing else is generated.

Terminal window
nestrs doctor

Checks:

  • rustc ≥ 1.97 and cargo on PATH
  • whether the current directory sits inside a nestrs workspace
  • optional env vars (NESTRS_SEAORM__URL, NESTRS_REDIS__URL, …)

Use it after install and before running DB- or Redis-backed apps.

Terminal window
nestrs lint

Doctor asks whether the machine is right; lint asks whether the code is. It runs the one naming rule of your project’s AGENTS.md that a path cannot derive: a file’s stem against the types it declares.

One shape is refused, and only one — a stem that reaches nothing the file declares:

nestrs lint
crates/features/src/desks/principal.rs
`principal` reaches none of `DeskOperator`
name the file from the type, or split it — a stem that reaches nothing
is a slot, and a slot fills

That file was named for a slot — “who acts”, “what we pass around” — rather than for a subject. A slot has no admission test, so the next type about that slot lands there too; it is a shared/ folder at the scale of a file. And it is invisible from outside, because both names read perfectly well alone and only the pair is wrong.

Everything short of that passes. The word may come from the folder rather than the file — throttler/store.rs holding RedisThrottler. An inflection is the same word (scope.rs holding Scoped), and so is an abbreviation (context.rs holding Ctx). And a file whose principal export is a function is a namespace that owes no pairing at all: queue/consume.rs exporting consume::attempt. Files a table already names — service.rs, controller.rs, registry.rs — are named by that table and are not this rule’s business.

It exits non-zero on a finding, so it belongs in CI: the scaffolded lint recipe already runs it, and nestrs run lint is the front door.

The framework holds itself to it through the same code, from its own conformance suite — a rule shipped and not met is the failure that matters.

Terminal window
nestrs version
# → NestRS x.y.z
nestrs about
# → version, tagline, docs, repository, license, author
nestrs info
# → layout, name, root, framework pin, apps, features, env prefix, toolchain
nestrs update
# → checks crates.io; skips when already on the latest version
nestrs update --force
# → cargo install --force nest-rs-cli (reinstall even at the same version)

about is the framework — the same lines on every machine. info is the tree in front of you: which layout you are standing in, the apps and features it holds, the nest-rs version its manifests pin, and which app a generator would wire into. It reports the root relative to where you ran it, and outside a project it says so rather than failing.

Contributors inside the monorepo clone:

Terminal window
nestrs update --from-path
# → cargo install --locked --path crates/nest-rs-cli --force
CommandDescription
nestrs new <name>Monorepo at ./<name>/, or app at apps/<name>/ when already inside one
nestrs new <name> --checkRun cargo check after scaffolding
nestrs doctorToolchain and env sanity check
nestrs lintEvery file named for what it declares
nestrs versionPrint the CLI version
nestrs aboutPrint NestRS metadata (tagline, docs, license, author)
nestrs infoReport the project the current directory sits in
nestrs updateInstall latest CLI from crates.io when a newer version exists
nestrs update --forceReinstall from crates.io even when already on the latest version
nestrs update --from-pathReinstall from monorepo crates/nest-rs-cli
nestrs g feature <name>Transport-agnostic port in crates/features/src/
nestrs g resource <name>DB-backed CRUD port + guarded HTTP adapter (deps auto-added)
nestrs g entity <feature>[/<name>]One #[expose] entity in an existing feature
nestrs g authThe app’s authn/authz adapter — one per workspace
nestrs g migration <name>SeaORM migration, registered in lib.rs and migrator.rs
nestrs g http|graphql|ws|queue|schedule|mcp <name>Add one transport adapter to a port

Every generator accepts --dry-run and -p <path>. Aliases: nestrs g = nestrs generate. All of them require a workspace, which is what nestrs new writes.

Bringing a crate you already have — a small service, a prototype — under crates/features/ is a handful of file relocations. There is no nestrs migrate for it: doing it by hand keeps you in control of the app boundary you are introducing.

The one thing to know before you start: nestrs new <workspace> already writes a hello feature and a hello app — the starter that proves the workspace runs. Move the incoming crate in under its own name. mv does not warn about overwriting, and nestrs new initialises no git repository, so a collision has nothing to recover from.

Terminal window
# 1. Scaffold the workspace beside the crate you are moving.
nestrs new acme && cd acme
# 2. Give the incoming crate its own feature folder, with the adapter
# sub-folder the workspace layout uses.
mkdir -p crates/features/src/posts/http
# 3. Move the port and the adapter into their conventional homes. `main.rs`
# stays behind: apps/hello/src/main.rs already exists and is the same six
# lines.
mv ../posts/src/service.rs crates/features/src/posts/
mv ../posts/src/module.rs crates/features/src/posts/
mv ../posts/src/controller.rs crates/features/src/posts/http/

Then the edits a move cannot do:

  1. crates/features/src/lib.rs — add pub mod posts;.
  2. crates/features/src/posts/mod.rs (new) — the folder index: mod service; mod module; pub mod http; plus the pub use lines. A single crate needs none, because main.rs declares its modules.
  3. crates/features/src/posts/http/mod.rs (new) — mod controller; mod module; plus its pub use lines, and a http/module.rs holding #[module(imports = [PostsModule], providers = [PostsController])].
  4. crates/features/src/posts/module.rs — drop HttpModule::for_root(…) and the controller from providers. In a workspace the transport is the app’s decision, and the port module provides only the service: #[module(providers = [PostsService])].
  5. Imports — crate::service::PostsService becomes crate::posts::PostsService; the app’s module.rs adds features::posts::PostsHttpModule to its imports.

Both halves of step 4 fail loudly if skipped. Leaving HttpModule::for_root(…) in the moved module attaches a second HTTP transport — a duplicated attached module-contributed transport line at boot. And a moved controller still declaring #[controller(path = "/")] collides with the starter one:

Terminal window
Error: duplicate controller prefix "/": HelloController and PostsController
both mount there — a controller prefix is its exclusive namespace; give each
one a distinct path

Give the moved controller its own prefix, or delete the starter hello feature and app once yours is serving.

crates/migrations/ and crates/seed/ come from nestrs new, so the database verbs and g migration work from the first nestrs run db up. From here every generator is available: nestrs g http <feature> and its siblings wire themselves into the app whose directory you are standing in.