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).
Install
Section titled “Install”cargo install --locked nest-rs-clinestrs versionA 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.
Layout detection
Section titled “Layout detection”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 run | Command | Result |
|---|---|---|
| Outside a nestrs workspace | nestrs new hello | Monorepo at ./hello/ + apps/hello/ on port 3000 |
| Inside a nestrs workspace | nestrs new blog | Thin 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.
Create a project
Section titled “Create a project”nestrs new hellocd hellonestrs run dev helloOpen 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
migratebinary- …
Directoryseed/ the
seedbinary- …
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):
nestrs new blognestrs g resource postsrust-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:
clippyandrustfmtare whatnestrs run lintruns. Undeclared, they are present only because rustup’s default profile happens to install them — so the recipe works on your machine and fails on aminimalone.llvm-tools-previewcarries thellvm-covandllvm-profdatabehindnestrs run test cov. Those two read a.profrawonly when they come from the same LLVM as the rustc that wrote it, so they are pinned here, besidechannel, 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.
One starter, no template flag
Section titled “One starter, no template flag”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 run | What you get | GET / |
|---|---|---|
nestrs new acme | monorepo + hello feature + apps/hello/ on 3000 | 200 Hello World |
nestrs new blog (inside one) | blog feature + apps/blog/ on the next free port | 200 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:
HttpModule::for_root(HttpConfig { port: 3002, ..Default::default() })Optional: --check runs cargo check after generation.
Add an app to an existing workspace
Section titled “Add an app to an existing workspace”From the workspace root (or apps/, crates/features/, …):
nestrs new blognestrs run dev blogCreates 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.
Running tasks
Section titled “Running tasks”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:
nestrs run dev # watch mode (rebuild + restart on save)nestrs run test unit # unit + integrationnestrs run db up # apply migrationsnestrs run # list the available recipesThe 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).
nestrs run build # default app (hello)nestrs run build blog # after nestrs new blognestrs run build --all # every app in the workspacenestrs run start hello # build + run in releaseGenerate features and adapters
Section titled “Generate features and adapters”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.
# 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.rsnestrs 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.rsnestrs g migration create_posts
# Bolt a transport onto an existing portnestrs g http posts # controllernestrs g graphql posts # resolvernestrs g ws posts # gatewaynestrs g queue posts # processornestrs g schedule posts # scheduled tasksnestrs g mcp posts # MCP toolg 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:
use features::posts::PostsHttpModule;use nest_rs::core::module;
#[module(imports = [ // … PostsHttpModule,])]pub struct ApiModule;Why g resource is guarded
Section titled “Why g resource is guarded”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.
Where g entity puts the file
Section titled “Where g entity puts the file”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.
Doctor
Section titled “Doctor”nestrs doctorChecks:
rustc≥ 1.97 andcargoonPATH- 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.
nestrs lintDoctor 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 fillsThat 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.
Version, about, info, and update
Section titled “Version, about, info, and update”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:
nestrs update --from-path# → cargo install --locked --path crates/nest-rs-cli --forceCommand reference
Section titled “Command reference”| Command | Description |
|---|---|
nestrs new <name> | Monorepo at ./<name>/, or app at apps/<name>/ when already inside one |
nestrs new <name> --check | Run cargo check after scaffolding |
nestrs doctor | Toolchain and env sanity check |
nestrs lint | Every file named for what it declares |
nestrs version | Print the CLI version |
nestrs about | Print NestRS metadata (tagline, docs, license, author) |
nestrs info | Report the project the current directory sits in |
nestrs update | Install latest CLI from crates.io when a newer version exists |
nestrs update --force | Reinstall from crates.io even when already on the latest version |
nestrs update --from-path | Reinstall 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 auth | The 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.
Move an existing crate into a workspace
Section titled “Move an existing crate into a workspace”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.
# 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:
crates/features/src/lib.rs— addpub mod posts;.crates/features/src/posts/mod.rs(new) — the folder index:mod service; mod module; pub mod http;plus thepub uselines. A single crate needs none, becausemain.rsdeclares its modules.crates/features/src/posts/http/mod.rs(new) —mod controller; mod module;plus itspub uselines, and ahttp/module.rsholding#[module(imports = [PostsModule], providers = [PostsController])].crates/features/src/posts/module.rs— dropHttpModule::for_root(…)and the controller fromproviders. In a workspace the transport is the app’s decision, and the port module provides only the service:#[module(providers = [PostsService])].- Imports —
crate::service::PostsServicebecomescrate::posts::PostsService; the app’smodule.rsaddsfeatures::posts::PostsHttpModuleto 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:
Error: duplicate controller prefix "/": HelloController and PostsControllerboth mount there — a controller prefix is its exclusive namespace; give eachone a distinct pathGive 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.
Going further
Section titled “Going further”- Getting started — first run after
nestrs new. - Tutorial — build
postsinapps/blog/, HTTP only. - Fundamentals / Modules — how root modules compose features.