crabster

Developer guide

{% raw %} How to build an application with Crabster, from installing it to deploying what it made. You need no other page to get all the way through; the ones referenced go deeper, they do not fill gaps.

What Crabster does, and does not. It writes a complete Rust project from a domain model and hands it to you: the generated code is yours, there is no support library to keep up to date, and you can stop using Crabster without losing anything. What it does not do is live inside your project.


1. Install🔗

cargo install --path path/to/crabster/crates/crabster-cli --locked
crabster --version

Three commands, and that is all there is:

CommandWhat it does
crabster new <name>A project with no model: server, configuration, health, metrics, Dockerfile, CI. Run it with no arguments on a terminal and it asks — including whether you want one application or a system of several
crabster import-cdl <file>A full project from a domain model
crabster record <Name>Adds a record to an existing project
crabster introspect <url>Writes a .cdl from a database that already exists

2. Five minutes🔗

Nothing else to install: the default target is SQLite, which is a file.

crabster new blog --database sqlite --port 8123
cd blog

crabster record Author \
  --field "name: Text @length(2..80)" \
  --field "email: Text @unique"

crabster record Post --filterable \
  --field "title: Text @length(2..200)" \
  --field "body: LongText?" \
  --field "publishedAt: Timestamp" \
  --field "author: ref Author"

cp .env.example .env
cargo run

In another terminal:

curl -X POST localhost:8123/api/authors -H 'content-type: application/json' \
  -d '{"name":"Ada","email":"ada@example.com"}'

curl -X POST localhost:8123/api/posts -H 'content-type: application/json' \
  -d '{"title":"First post","body":null,
       "publishedAt":"2026-01-01T10:00:00Z","authorId":1}'

curl 'localhost:8123/api/posts?title=First%20post'

And http://localhost:8123/swagger-ui in a browser.

crabster record wrote your model into .crabster/model.cdl. From here you can carry on with the command or edit that file — both lead to the same place.

Or a system, if one application is not the shape🔗

Run crabster new with no arguments on a terminal and the second question is whether you want one application or several. Answer several and it asks for a gateway, the services, whether they register with a catalogue and whether they export spans — then writes the model and generates one project per service, plus a root that starts all of them:

crabster new shop --architecture microservices \
  --gateway edge:9400 --service orders:sqlite:9401 --service billing:sqlite:9402 \
  --discovery consul --telemetry otlp

The flags and the questions are the same thing: a system you answered for is the system those flags make, and both are the system crabster import-cdl makes from the .cdl left in the root. There is one generator, and the wizard is a way of writing its input.

What comes out has no records — a system is created before it has any. Add them with crabster record inside a service, exactly as above, then run crabster upgrade at the root: it assembles the model again from what the services now hold and rebuilds the gateway's routing table from it. Without that step the gateway routes nothing, because what it routes is what the model says a service owns and it was generated when nothing owned anything. The command says so when it finishes.


3. Modelling🔗

A model is a .cdl file. It has three shapes: record, enum, and an optional service block.

service blog {
    database postgres
    port     8080
    auth     jwt        // optional: adds accounts and sessions
}

enum Status { DRAFT, PUBLISHED, ARCHIVED }

record Author {
    name:  Text @length(2..80)
    email: Text @unique @matches("^.+@.+$")
}

@filterable
record Post {
    title:       Text @length(2..200)
    body:        LongText?
    status:      Status
    publishedAt: Timestamp
    author:      ref Author
}
crabster import-cdl blog.cdl --path ~/blog

The rules that matter🔗

A field is mandatory unless it ends in ?. The common case is the one you should not have to write. The distinction reaches the API: on a PATCH, an absent key keeps its value and an explicit null clears it — and null is only accepted on a field the model allows to be empty.

id belongs to Crabster. You never declare it, and a field by that name is refused.

A reference is written where the foreign key physically is. author: ref Author on Post gives the table an author_id column, a constraint, and the relations on both sides. Nothing to declare on Author.

WrittenMeans
author: ref AuthorMany posts per author, reference required
author: ref Author?The same, but a post may have none
author: ref Author @uniqueOne-to-one: at most one post per author

Cycles are refused. A → B → A has no migration order, and a foreign key is declared inside CREATE TABLE.

An attribute qualifies what precedes it, on the same line. Written above a field it would qualify the previous one: that is refused, and the message names which. Only a record's attribute (@filterable) goes above.

Types🔗

CDLRustNotes
TextStringVARCHAR. With no upper bound: 255
LongTextStringTEXT, unbounded
Int Longi32 i64
Float Doublef32 f64
DecimalDecimal(19, 4). Not exact on SQLite — see below
Boolbool
Date TimestampDate DateTimeUtcTimestamp becomes DATETIME on MySQL
UuidUuid
BytesVec<u8>BLOB; an array of integers in JSON

Decimal is not exact on SQLite. The value comes back through an f64: sqlx deliberately does not support rust_decimal there, and no column type changes that. A model storing money in production targets PostgreSQL or MySQL.

Attributes🔗

AttributeOnEffect
@uniquea fieldUniqueness in the database; on a ref, one-to-one
@length(2..120)textEither end may stay open: @length(..200)
@range(0..1000)numbersSame shape
@matches("regex")textCompiled while parsing: an invalid regex is refused here
@filterablea recordLists accept exact filters from the query string

An attribute that could not mean anything is refused rather than ignored: @length on an Int, @range on Text, @range on a Decimal.

The service block🔗

SettingValuesDefault
databasepostgres, mysql, sqlitepostgres
port1–655358080
authjwtabsent

Command-line flags win. One database per project: there is no separate setting for development and production, because the schema is not the same from one database to the next.

The language in full: CDL language.


4. What you get🔗

A project made from a model. crabster new gives you the top third of it: no src/domain, src/dto, src/api or src/migration, which come from records.

Cargo.toml            Dockerfile           .dockerignore
README.md             .env.example         .gitignore
.spectral.yaml        .github/workflows/ci.yml
docker-compose.yml    (PostgreSQL and MySQL only — SQLite is a file)
config/               default.toml, dev.toml, prod.toml
.crabster/            what Crabster recorded — commit it
src/
  main.rs             startup, router, HTTP layers
  config.rs           layered configuration
  health.rs           /health, /health/live, /health/ready
  http.rs             timeout, concurrency, body size, CORS
  observability.rs    logs, metrics, correlation id
  openapi.rs          the OpenAPI document
  error.rs            the error contract
  state.rs            what every handler is given
  testing.rs          where the tests get a database
  domain/             one SeaORM entity per record
  dto/                what the API accepts and returns
  api/                one endpoint module per record
  migration/          one migration per record, in order

The endpoints🔗

Five per record:

MethodPathAnswers
GET/api/<records>?page=0&size=20One page, with totalItems and totalPages
GET/api/<records>/{id}One record, or 404
POST/api/<records>201 and the created record, with Location
PATCH/api/<records>/{id}The updated record
DELETE/api/<records>/{id}204, or 409 if something still references it

PATCH rather than PUT: the body is a set of changes, and a key you leave out keeps its value.

Ordering on every list, with nothing to declare: ?sort=<field>&order=asc|desc, over a per-record whitelist. A field that is not accepted answers 400 and lists the ones that are.

Filters with @filterable. The bare name is still an exact match — ?title=First — and the rest are asked for as ?<field>.<operator>=:

OperatorOnExample
(the bare name), equalsany filterable field?status=PAID
notEqualsthe same?status.notEquals=CANCELLED
containsshort text?label.contains=table
greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqualnumbers, decimals, dates, timestamps?price.lessThan=100
specifiedany optional field?summary.specified=false

Several at once are combined with AND: a row has to satisfy every one. Long text, binary and references have no value filter — only specified, and only when they are optional. No comparison on text: string order depends on the collation, and the three databases do not agree on it.

And a parameter the endpoint does not read is refused, with a 400 listing the ones it takes. A misspelt filter does not apply: without that refusal it answered the whole table, which looks like a result.

Related objects: a reference is an identifier, and ?expand= replaces it with the record itself.

curl "localhost:9000/api/orders?expand=customer,product"
{"id":1,"customerId":1,"productId":1,
 "customer":{"id":1,"email":"a@b.com","fullName":"Ada L"},
 "product":{"id":1,"label":"Chair","price":"49.90"}}

One query per reference for the whole page, not one per row. The key is left out when there is nothing to put in it — nobody asked, or the reference is empty; customerId beside it says which. A name the record does not reference is refused, listing the ones it has.

Plus, on every project: /health, /health/live, /health/ready, /metrics, /api-docs/openapi.json, /swagger-ui, /problems.

Writing without overwriting somebody🔗

A record marked @versioned turns down a write that does not say what it read:

curl -i localhost:9000/api/orders/1            # ETag: "3"
curl -X PATCH localhost:9000/api/orders/1 \
     -H 'If-Match: "3"' -H 'content-type: application/json' \
     -d '{"status":"PAID"}'                     # 200, ETag: "4"
What the client sendsAnswer
If-Match with the current version200, and a new ETag
If-Match with a version since passed412, /problems/stale-version
Nothing at all428, /problems/version-required

PATCH and DELETE are both covered. The check and the write are one SQL statement (WHERE id = ? AND version = ?), so nothing can slip between them.

Without @versioned, two clients changing the same row overwrite one another in silence: the last one wins and the first never finds out. It is the one place where generated code was wrong rather than merely incomplete.

@audited adds createdAt and updatedAt, written by the code. Both attributes can be added later: crabster apply turns them into migrations.

When it refuses🔗

Every failure answers RFC 9457 problem details, as application/problem+json:

{
  "type": "/problems/validation",
  "title": "The submitted data is invalid",
  "status": 422,
  "detail": "…",
  "errors": {"email": [{"code": "email"}]}
}

type is the field to branch on, and the one the status cannot replace: two different 409s carry two different types. It is a URI the project serves — GET /problems lists every kind, GET /problems/unique-conflict explains one.

StatusWhen
400Unreadable body, or an invalid query parameter
404No such record, or no such endpoint
405That path exists under another method
409A unique value is taken, or a reference does not hold
413 415Body too large, or not sent as JSON
422The JSON was read and the model refuses it

422 and not 400 for a refused body: 400 says "I could not read this", 422 says "I read it and will not have it". Sending someone to check their serialisation when the real complaint is one field is the kind of help that costs an afternoon.


5. Growing the project🔗

crabster record Comment \
  --field "text: LongText" \
  --field "post: ref Post"

The command merges nothing. Every generated file was stamped as it was written:

That is a message to read, not an obstacle to get past with --force — which really does overwrite.

Migrations already applied keep their numbers. A record can only be added: to remove one, edit .crabster/model.cdl and generate elsewhere, or write the migration yourself.

To change a record that already exists — one field more, one field less — use crabster apply, just below. Do not edit .crabster/model.cdl: it is the copy of what Crabster last generated, and so the only record of what your database already holds.

Growing the model🔗

Edit the model the project records, and run crabster apply:

crabster apply --path my-api --dry-run   # reads .crabster/model.cdl
crabster apply --path my-api

If you keep your .cdl elsewhere in the repository, name it: crabster apply shop.cdl --path my-api.

The command compares that model with .crabster/applied.cdl — the model the migrations have actually built — and the difference becomes new migrations, never a rewrite of the old ones. The two files do not move together: model.cdl changes the moment you edit it, applied.cdl only when a migration is written for the difference. That gap is what answers the question no single file can: does this model declare anything the database has never been told about?

While anything is owed, crabster record and crabster upgrade refuse, naming the field:

this project's model declares things its database has never been told about:
  `Customer.tier`, which no migration creates

That is not stiffness: regenerating the code from a model the database has never seen gives a project that compiles and fails on its first request. The rest of the code is regenerated exactly as on crabster upgrade: rewritten where you touched nothing, merged where you wrote.

Migrations written, to be applied next time the project starts:
  src/migration/n0001_customer_add_nickname.rs

New migrations live in the n band; the m band is table creations. They are applied on the next start, like every other.

What you changeWhat happens
One more optional fieldADD COLUMN
One more mandatory fieldyou have to say what goes in the rows that exist: --default "Customer.nickname=unknown"
@unique addeda unique index
One fewer fieldDROP COLUMN, and only with --force: the column goes with its values
One fewer recordDROP TABLE, --force too
One more recordits creation migration, as crabster record writes
Making a field optional or mandatoryPostgreSQL and MySQL alter the column; SQLite rebuilds the table
A wider @length boundon PostgreSQL and MySQL, the column is widened to match; on SQLite nothing is written
A narrower @length boundthe same, and only with --force: a value already longer than the new bound cannot be kept
One more optional referencePostgreSQL and MySQL add the column and its foreign key; SQLite rebuilds the table, a foreign key being part of its definition there
@unique taken awaySQLite only, by rebuilding the table: the other two wrote the constraint under a name they invented, and neither the model's
Int → Long, Float → Double, Text → LongTextthe column is restated at the wider type; on SQLite nothing is written, all three pairs sharing one affinity there

The bound is the column's own width, not only a request check — something writing to the database directly is held to it too. Except on SQLite, which records a width and enforces none of it: there the bound is the request check's alone, and regeneration rewrites that on its own.

On SQLite, a change to a table becomes a rebuild. Its ALTER TABLE adds a column, drops one, and renames — whether a column may be empty, whether it is unique, and the foreign keys a table declares are all settled by CREATE TABLE and cannot be altered after. So Crabster does what SQLite documents: it builds the table your model describes beside the old one, copies the rows across, drops the old one and takes its name. One migration for the record, whatever else changed about it. Every row keeps its id.

That is also why a generated SQLite project runs its migrations on a connection of its own, with foreign keys off (see main.rs). With them on, SQLite rewrites the REFERENCES clauses of every table pointing at the one being replaced, so they would follow it out of existence — and PRAGMA foreign_keys is a no-op inside a transaction, which is where a migration runs. The connection is closed as soon as the migrations are done; the pool that serves requests has foreign keys on, as it always did.

And what is refused, by name: changing a field's type to anything but the three widenings above, removing a @unique on PostgreSQL or MySQL, removing a reference, and adding a mandatory one — the rows already in the table point at nothing, and no --default rescues that one, because the value would have to name a row of the target that exists and the model cannot promise it does. Add it optional, fill it in, then make it mandatory. For those Crabster generates nothing: write the migration yourself, in a file of its own, for the database you actually run.

That is deliberate. An ALTER that is right on two databases out of three is worse than no ALTER at all: the third only finds out in production.

What is already yours🔗

Some places belong to you and survive regeneration byte for byte — they are not even run through rustfmt:

// Yours: kept as-is when Crabster regenerates this file.
// crabster:begin dependencies
// crabster:end dependencies

There are regions in Cargo.toml ([dependencies]), at the end of src/main.rs, in src/api/mod.rs (routes that come from no record) and in config/default.toml. Writing elsewhere works too — it will simply make crabster record stop and name the file, which is the behaviour you want.

Commit .crabster/🔗

project.toml records what the command line decided (name, database, port, modules), model.cdl the model verbatim, applied.cdl the model the migrations built, files.toml the stamps, and snapshot/ a copy of every file as it was written. Without them, crabster record can no longer tell your work from its own, and crabster upgrade can no longer merge.

The snapshot doubles the file count of the repository — 42 files and 201 KB for examples/shop.cdl, as much again as the project itself. That is what merging costs, and it is visible: every record and every upgrade produces a diff on the project and a diff on its copy.

Starting from a database you already have🔗

crabster introspect "postgres://user:pass@localhost/shop" --service shop_api --out shop.cdl
crabster import-cdl shop.cdl --path shop

This is the one command that connects to a database. Everything else computes from the model, and the generated project is what runs a migration — here the schema is the input, and nothing but the database has it. PostgreSQL, MySQL and SQLite.

What comes out is a starting point and says so, in a comment at the top of the file rather than in a document you might not read. A schema holds what a database enforces; a model holds more:

What does not come backWhy
enum and its variantsAn enumeration is stored as text, so the variants are in the rows
@filterableWhich fields a list may be narrowed by is an API decision, and a table holds no trace of it
@matches, @rangeChecked on the way in, before anything is stored
The service blockA port, an authentication module and a database name describe a deployment
The lower half of @lengthA column bounds how long a value may be and never how short

And one thing comes back that you may not have written: a Text field with no bound is given one when the column is built, so @length(..255) may appear where the model said nothing. A schema cannot tell a default from a decision.

What does come back is everything the schema carries: every record, every field with its type and whether it may be absent, every reference — read from the foreign keys, so customer_id becomes customer: ref Customer — every single-column uniqueness, and @versioned and @audited, recognised by the columns they add.

Read it, add what is missing, and keep it: from there on it is the model, and crabster record, apply and upgrade all work on it.

Crossing a version🔗

When a new Crabster moves the templates, a project already generated is not stuck on the old ones:

crabster upgrade --path my-api --dry-run   # says what would move
crabster upgrade --path my-api             # moves it

The command regenerates the project from what .crabster/ recorded — same model, same database, same modules — and decides file by file, from the stamps:

What the stamp saysWhat happens
The file is exactly what Crabster wroteit is rewritten with the new version
You edited it, and the two changes do not overlapthey are merged: your work and the templates' change are both in the file
You edited the very line the templates changeit is not touched; the merge, conflict markers and all, is left in .crabster/incoming/<the same path>
The file is no longer produced at allit is deleted — unless you had edited it, in which case it stays
It is a migration already writtenit is never touched, whatever happens, and what the templates would have written goes to .crabster/incoming/

The merge is a real three-way merge, the same kind git does. The common ancestor is .crabster/snapshot/: a copy of every file as Crabster wrote it. Without it no merge is possible — what the generator produced on a given day cannot be recovered afterwards, its templates being compiled into the binary that wrote it. Commit it, like the rest of .crabster/.

Protected regions are re-injected as on crabster record: what you wrote in [dependencies] crosses the version without counting as an edit of the file.

On a conflict, the file left beside yours carries the usual markers: ours is your version, original is what Crabster had written there, theirs is what the new templates write. Settle them, copy the file back, then delete .crabster/incoming/ — it is emptied on every upgrade anyway.

Nothing you wrote is ever overwritten: on a conflict Crabster leaves your file exactly as it is rather than planting markers in it.

--force rewrites your files with the templates' version, without merging. It is not the way to use this.

A clean merge can still produce code that does not compile — that is true of git too. Read it, and run cargo test.

Migrations never move. SeaORM decides applied-or-pending by the migration's name alone: rewriting the contents of src/migration/m0002_customer.rs without changing its name would change what your project expects without changing your database, and nothing would say so — the failure would arrive at the first request. Crabster therefore does not regenerate them at all: a file written once is never rewritten, deleted, compared or reported, and --force does not open it. A module declares its own in its module.toml (write_once = ["src/migration/*_*.rs"]), and a blueprint can declare more.

One consequence worth knowing: changing a migration's template reaches only the projects that do not have it yet. That is deliberate — for the others, the one correct way to change the schema is another migration.

Two points of method: do it on a clean git tree, so git diff shows you exactly what moved; and if the project was generated through a blueprint, pass --blueprint again — .crabster/project.toml does not record its path, which belongs to one machine.

A project generated before .crabster/snapshot/ existed has no ancestor to merge from. It still crosses the version: its edited files are simply left alone, with the new version beside them. The first upgrade writes the copy, so the next one merges.


6. Configuration🔗

Layered, each source overriding the previous one:

  1. config/default.toml — committed defaults
  2. config/{profile}.toml — APP_PROFILE picks one
  3. APP__* environment variables — APP__SERVER__PORT=9000

An unset APP_PROFILE means prod, deliberately: a container deployed without the variable must not come up bound to the loopback address. .env.example selects dev, and cp .env.example .env is the first line of the generated README.

KeyWhat it is
server.host server.portWhere it listens
logging.levelOverridden by RUST_LOG
http.timeout_secondsPast it, the request answers 408
http.max_concurrent_requestsHow many may run at once
http.max_body_bytesWhat one request can make the process allocate
http.cors_allowed_originsEmpty means none. There is no wildcard
auth.*Token lifetimes (see §8)

Secrets go in the environment, never in the TOML files. DATABASE_URL is read from the environment or .env.

A key nothing declares stops the server🔗

APP__SERVERR__PORT=9000 used to leave the server on the port it already had, and say nothing. Now it refuses to start:

Error: failed to load configuration

Caused by:
    unknown field `serverr`, expected one of `profile`, `server`, `logging`, `http`

Ignoring is the wrong answer for configuration, for the reason an unknown query-string filter is a 400 rather than a shrug: a setting silently dropped surfaces later as a service nobody can reach, far from the typo that caused it. It holds for every source — the files, the environment, and the key-value store if config consul is on — and for the sections modules add as well as the core ones.

Settings of your own🔗

A key you add to config/default.toml has to be declared in src/config.rs too, and that takes both regions:

pub struct Settings {
    // …
    // crabster:begin settings
    pub mine: MineSettings,          // the field
    // crabster:end settings
}

// crabster:begin types
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MineSettings {            // the type it names
    pub retries: u32,
}
// crabster:end types

with the matching section inside the region at the end of config/default.toml:

[mine]
retries = 3

Both regions survive regeneration — crabster record and apply keep what is inside them. Written anywhere else, the same code marks the file as edited and stops the next crabster record instead.

And your section is checked like the rest: retrys = 3 answers unknown field `retrys`, expected `retries` for key `mine` .

The editor knows the keys too🔗

Each config/*.toml opens with a line your editor reads:

#:schema ./schema.json

config/schema.json is generated beside them, derived from Settings in src/config.rs rather than written next to it — one statement of the shape, so the two cannot part company. Taplo reads the pointer, and VS Code and the JetBrains IDEs both ship Taplo: keys are completed as you type them, their doc comments show in the tooltip, and a misspelt one is underlined before anything runs.

Two deliberate gaps, both of which the server still catches at startup:


7. Tests🔗

cargo test

Three tests per record: a full lifecycle, a paged and ordered list, and what the endpoint refuses. They go through the whole router, not the handlers.

They run against the database the project targets. For SQLite, an in-memory database per test: nothing to install. For PostgreSQL and MySQL, a container the suite starts itself — Docker required — with a database created per test. If you already have a server:

TEST_DATABASE_URL=postgres://user:pass@localhost:5432/postgres cargo test

Coverage, with no configuration:

cargo install cargo-llvm-cov
cargo llvm-cov --html

8. Accounts and sessions🔗

Add auth jwt to the service block, or try it without touching the model:

crabster import-cdl blog.cdl --with auth-jwt --path ~/try

Five endpoints appear, under src/auth/:

MethodPathAnswers
POST/auth/register201 and the account. Password ≥ 12 characters
POST/auth/loginAn access token, a refresh token, and how long the first lasts
POST/auth/refreshA new session, roles read again from the account
GET/auth/meThe account this session belongs to
GET/auth/usersEvery account. Requires the ADMIN role

Protecting a route of your own🔗

Name the extractor in the handler. That is what protects the route — there is no second place where it is declared:

use crate::auth::extract::Authenticated;

async fn mine(Authenticated(claims): Authenticated) -> AppResult<Json<Thing>> {
    claims.require_role("ADMIN")?;
    // ...
}

The generated CRUD endpoints are not protected by default: protecting them all would change the meaning of every endpoint in your model.

The signing key🔗

.env.example ships a placeholder so a working copy runs with no setup. That exact value is refused outside the dev profile, so a deployment cannot run with the key that is in your repository:

openssl rand -base64 48    # then APP__AUTH__SECRET=…

A key shorter than 32 bytes is refused everywhere. Both refusals happen at startup, not at the first login.

Stateless, therefore irrevocable. Nothing is stored on the server side: an access token stays valid until it expires whatever happens to the account. Hence fifteen minutes for access and thirty days for refresh — with roles read from the account on every refresh.

Roles🔗

A comma-separated list on the account, USER on creation. There is no endpoint that grants a role, on purpose: change the column.


9. Observability and the API contract🔗

PathAnswers
/health/live200 as long as the process is serving. Reaches nothing
/health/ready200 when everything it depends on answers, 503 otherwise — naming which
/metricsCounts, latencies, in-flight requests, in Prometheus format
/api-docs/openapi.jsonThe OpenAPI 3.1 document
/swagger-uiA console over it

The health split matters: a restart policy reads liveness (restarting a healthy process because a database went down turns one outage into two), a load balancer reads readiness.

Metrics are labelled by the matched route — /api/posts/{id}, never /api/posts/1 — so the number of series is bounded by the size of your project rather than by its traffic.

Every request carries an x-request-id, minted if absent, written into every log line of that request and returned on the response.

The document is generated from the handlers themselves; it cannot drift from the code. To check it:

curl -s localhost:8123/api-docs/openapi.json > openapi.json
npx @stoplight/spectral-cli lint openapi.json    # uses .spectral.yaml

One rule is off there, info-contact: who answers for your API is the one thing Crabster cannot know. Put a contact in the info(...) block of src/openapi.rs and turn it back on.


10. Deploying🔗

docker build -t blog .
docker run --rm -p 8123:8123 -e DATABASE_URL=… blog

Four stages, two of them for dependency caching. The final image holds the binary and its config/ and nothing else — no shell, no package manager — and runs as nonroot.

APP_PROFILE is unset in it, so prod: the server binds every interface, which is what a container has to do to be reachable.

With the database, locally — PostgreSQL and MySQL only: a SQLite project has no docker-compose.yml, there being no server to start.

docker compose up -d --wait          # the database alone
docker compose --profile app up      # both

The profile is what keeps the first command useful: while you are working you want the server from cargo run.

For a SQLite project, give the container somewhere to keep its file:

mkdir -p data
docker run --rm -p 8123:8123 -v "$PWD/data:/data" --user "$(id -u):$(id -g)" \
  -e DATABASE_URL='sqlite:///data/blog.db?mode=rwc' blog

.github/workflows/ci.yml runs the formatter, Clippy at -D warnings, the tests against the database you target, a dependency audit and a build of the image. No secret, no repository setting. The image is built and not pushed — the workflow says in a comment what to add. The generated README carries the GitLab equivalent.


11. Extending🔗

A blueprint changes what is generated without touching Crabster's core:

crabster import-cdl blog.cdl --blueprint ./my-blueprint

Four things are possible — replace a template, add one, add a module, write into a file another module owns. That is the subject of Writing a blueprint, and there is a complete example in examples/blueprints/gitlab.


12. When it goes wrong🔗

What you seeWhat it is
failed to bind …: Address already in useThe port from the service block is taken. APP__SERVER__PORT=8123 cargo run
The server starts and nothing answersNo .env → prod profile, listening on 0.0.0.0. With .env → dev, on 127.0.0.1
DATABASE_URL is not setcp .env.example .env
configuration file "config/default" not foundThe binary reads config/ relative to the working directory. Run it from the project, or put config/ beside it — which is what the image does
crabster record names a file and stopsYou edited it. Read the diff before reaching for --force
… was generated by another versionThe project came from other templates. crabster upgrade crosses the version for it, leaving the files you edited alone
The tests want DockerA PostgreSQL or MySQL project. Start Docker, or set TEST_DATABASE_URL
`X` is not a setting any installed module answers toAn unknown service setting. The message lists the ones that exist
A Decimal comes back wrongSQLite. See §3

13. Quick reference🔗

crabster new [<name>] [--database postgres|mysql|sqlite] [--port N] [--no-input]
                    [--path DIR] [--blueprint DIR] [--with M] [--without M] [--check]

crabster new [<name>] --architecture microservices
                    [--gateway NAME[:PORT]] [--service NAME[:DB[:PORT]]]…
                    [--discovery consul] [--telemetry otlp] [--path DIR]

crabster import-cdl <file.cdl> [--database …] [--port N]
                    [--path DIR] [--blueprint DIR] [--with M] [--without M] [--check]

crabster record <Name> --field "name: Type" [--field …] [--filterable]
                    [--path DIR] [--blueprint DIR] [--force] [--check]

crabster introspect <URL> [--out FILE] [--service NAME]

--check runs cargo check before reporting success: safe, and a minute slower the first time.

Further🔗

PageWhat it adds
CDL languageThe language in full, and what it refuses
Writing a blueprintExtending generation
Technical architectureWhy each choice was made
Vision and goalsWhat Crabster is trying to be
V2 scopeWhat comes next
{% endraw %}