Create a Project
bun x @venizia/vulcan init --preset ardor creates a monorepo with one ARDOR admin inside it. To add an admin to a monorepo that already exists, run bun x @venizia/vulcan add --preset ardor at its root.
You get an ARDOR admin on Vite and React, styled with Tailwind CSS and shadcn components, with the REST data provider and i18n, plus a Dockerfile for an nginx image that gzips, answers /health, sends security headers and reads its configuration when the container starts. bun x fetches the latest VULCAN release, so you have the current preset unless you pinned an older one.
Initialize a New Monorepo
Pass each answer as a flag, and init asks nothing: --project (the monorepo folder), --frontend-dir (holds every frontend app), --app (the app name) and --shared-dir (shared code; leave it out to skip). A name is lowercase letters, digits and -:
bun x @venizia/vulcan init --preset ardor --project demo --frontend-dir apps --app adminTo name the app's package with a scope, add --scope @acme: the package is @acme/admin and the folder stays apps/admin. Without the flag the package is named admin.
In a terminal, leave a flag out and init asks for it, with nothing pre-filled (the scope is an optional question, empty for none). Then vulcan sync asks which coding agents you use (Enter accepts). Without a terminal it writes the Claude files and prints vulcan sync --agents claude,omp (or none) for choosing later.
The command prints a created line for each file of the app, runs git init, bun install, vulcan sync, the app's four checks (bun run typecheck, bun run lint, bun run test, bun run compile), vulcan doctor and vulcan x okf check --strict, and ends with:
Done. Next:
cd demo && make dev
open http://localhost:5173/What it Created
The command generates the project folder demo/:
demo/
package.json workspaces, one catalog with every version
Makefile README.md AGENTS.md .gitignore .dockerignore bunfig.toml
.configs/vulcan.config.mts
.agents/rules.md .agents/knowledge/{index.md,packages/admin.md}
apps/admin/
package.json tsconfig.json .env.example
.configs/lints/{oxlint.config.mts,eslint.config.mjs,.ignore} .configs/formatters/.ignore
.configs/builders/{vite.config.mts,Dockerfile,nginx.conf.template}
.configs/builders/nginx-snippets/{security-headers.conf,api-proxy.conf}
.configs/builders/docker-entrypoint.d/{40-runtime-config.sh,45-api-proxy.sh}
src/index.html src/public/config.js src/main.tsx src/application.ts src/application.test.ts src/boot.test.ts
src/configurations/admin.configuration.ts
src/pages/home.tsx
src/components/shadcn/{button,card}.tsx
src/themes/index.css src/utilities/tw.utility.tsvulcan sync also writes .mcp.json, .agents/vulcan.manifest.json and your agent files (CLAUDE.md, .claude/); .gitignore keeps the per-machine ones out of git. The compile check leaves apps/admin/dist/. Where things live:
src/main.tsxrendersArdorApplicationwith the home page as itsdashboard.src/application.tsis the application, which lists the decorated classes the Vite plugin found (AutoArtifacts).src/configurations/admin.configuration.tsbinds the REST data provider and the i18n provider (the auth provider stays unbound, so every page is public).src/pages/home.tsxis the one page: the ARDOR logo, the app name and the message the backend answers toGET /api/hello. It shows a placeholder while it loads, and when the backend is down it says so and showsbun run server:dev, the command that starts it, with a button to try again. The call goes through the data provider'ssend, not a rawfetch. Replace it with your own page.src/components/shadcn/*are shadcn-style components, copied into the app as shadcn does, so you own them. There is nocomponents.json(the app root holds nothing else): to add more with the shadcn CLI, runbunx shadcn initfirst, which writes its own.src/themes/index.cssis the whole Tailwind CSS v4 setup:@import 'tailwindcss', the colour tokens of ARDOR's palette in a light and a dark set, and@theme. The theme follows the system's light or dark preference; there is notailwind.configfile.src/application.test.tshas two tests: the container binds the REST data provider options (the API is/apiby default), and the API url is the oneconfig.jswrote when the container started.src/boot.test.tscallsassertArdorApplicationBootson the realApplication: every binding resolves, the providers the first render needs are bound and the messages load, with no browser and no network. See Testing.package.jsonhas the script names every ARDOR and IGNIS app has:server:devandcompile(viteandvite build, with--config .configs/builders/vite.config.mts;compilewritesdist/),typecheck(bun check),lintandlint:fix(devz lint, thedevzbin of@venizia/dev-configs),test(bun test) andclean(devz clean, which removesdist/and the tool caches). There is nodevand nobuild..env.examplelists the variables the app reads:PORT(default 5173) andAPI_URL, the backend that the dev server forwards/apito, andVITE_API_URL, the API the app calls (see One image for every environment). An empty value keeps the default, which forAPI_URLis the backend chosen when the app was added and forVITE_API_URLis/api. Copy the file to.envto change them on your machine;.envstays out of git and out of the image.bunfig.tomlat the root of the monorepo holds the format of every file: its[fmt]section sets the print width, single quotes and arrow parentheses, anddevz lintformats withbun fmt, which reads it from the app's folder up. There is no formatter config in the app..configs/lints/.ignoreand.configs/formatters/.ignorehold only comments.devz lintskips the files whose patterns you add (one gitignore-style pattern per line, relative to the app):lints/.ignorefor oxlint and ESLint,formatters/.ignoreforbun fmt, which is where shadcn'ssrc/components/shadcn/**goes if it must keep its own format..configs/builders/vite.config.mtshas the dev port and the/apiproxy, from those variables. Vite's root issrc/(that is whereindex.htmlis), so the app root holds nothing but.configs/,src/,package.json,tsconfig.jsonand.env.example..configs/builders/Dockerfileandnginx.conf.templatebuild and serve the app: see An image of the app.
An image of the app
The Dockerfile builds the app and serves it with nginx. Build it from the monorepo root, so the workspace packages and the lockfile are in the context. init leaves the repository without a commit, and until you make one git rev-parse HEAD prints the word HEAD, which the build refuses, so commit first:
git add . && git commit -m "First commit"
docker build -f apps/admin/.configs/builders/Dockerfile -t admin \
--build-arg APP_BUILD_COMMIT="$(git rev-parse HEAD)" \
--build-arg APP_BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)" .
docker run --rm --network host -e API_UPSTREAM=http://127.0.0.1:3000 adminOpen http://localhost:8080. On Linux, --network host lets the container reach a backend listening on port 3000 of your machine (an IGNIS app added to the monorepo serves there; make dev of this project starts only the admin), so there is no -p. In compose or a cluster, API_UPSTREAM is the service name (http://api:3000) and the container needs no special flag. nginx resolves the name with DNS, not with /etc/hosts, so a name added with --add-host does not work; an IP address does. A host that does not resolve does not stop the container: the page answers, and /api answers 502 until the name resolves.
What the image does:
- Three stages.
depsinstalls withbun install --frozen-lockfile --ignore-scripts,builderrunsbun run compile, andproductionis nginx (unprivileged, port 8080) servingdist/. Every route that is not a file answers withindex.html. /apiproxy./apigoes toAPI_UPSTREAM(defaulthttp://api:3000): a compose service name, a cluster service or an address. The script45-api-proxy.shwrites the proxy fromnginx-snippets/api-proxy.confonly whenAPI_UPSTREAMis set and not empty, so an app served behind a gateway runs with-e API_UPSTREAM=(orENV API_UPSTREAM=in its Dockerfile) and has no/apiat all. A value that is not an address (a space, a;, a quote) stops the container./health. Answers200 okfrom nginx alone, never cached, for a load balancer or an orchestrator. It does not call the backend.- Compression. nginx gzips text, JavaScript, CSS, JSON and SVG.
- Security headers. Every response carries
X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-originandX-Frame-Options: SAMEORIGIN. They are innginx-snippets/security-headers.conf, which every location includes; aContent-Security-Policy-Report-Onlyis sent too: the browser reports in its console what the policy would block and blocks nothing, so you can adapt it to what your app loads (an API of another origin goes intoconnect-src) and then rename the header toContent-Security-Policyto enforce it. The server also sends no nginx version (server_tokens off). - No secrets. An environment file in the build context would stay in the image's layers, so the
depsstage stops when the context holds one. The root.dockerignorekeeps.envand.env.*out and.env.examplein. - The commit. The image has no
.git, so a build withoutAPP_BUILD_COMMITstops, and so does a value that is not a sha of at least seven lowercase hex digits (Pass --build-arg APP_BUILD_COMMIT=$(git rev-parse HEAD) after a first commit; got: HEAD). The commit becomes theorg.opencontainers.image.revisionlabel of the image. - A private registry.
BUN_CONFIG_REGISTRYis empty; pass it as a build argument to install from one.
The preset cannot know which workspace packages the app needs, so the build copies the whole context (the root .dockerignore filters it). To keep a change in a source file from redoing the install, list the packages by hand and copy each package.json first.
One image for every environment
A value like the API address is usually fixed when you run vite build, so each environment needs its own image. The image of the preset reads it when the container starts instead:
docker run --rm -p 8080:8080 \
-e API_UPSTREAM=http://api:3000 \
-e VITE_API_URL=https://api.example.com \
adminAt start the image writes /config.js from the container's VITE_ variables, as window.__APP_CONFIG__, and index.html loads it before the app. The app reads a value with RuntimeConfig.get: the value in config.js, else the build-time import.meta.env (this is what dev uses, from .env), else the default you give. Pass required: true for a value the app cannot run without: the result is then a string, and a missing value throws an error that names the key (never a value) and whose normalized.code is RUNTIME_CONFIG_REQUIRED_CODE, so code can tell it without reading the message. The preset calls it for VITE_API_URL, with the default /api, in src/configurations/admin.configuration.ts; required: true is for values you add.
| Rule | What it means |
|---|---|
Only VITE_ variables are published | The name must be VITE_ followed by capitals, digits and underscores. DATABASE_URL, API_UPSTREAM and every other variable stay in the container. |
| A published value is public | Every visitor can read /config.js. Never put a secret in a VITE_ variable. |
| Secret-looking names stop the container | A VITE_ name with SECRET, PASSWORD, PASSWD, PASS, PRIVATE, CREDENTIAL or TOKEN in it, or a value that is a URL with a user and a password in it, makes the container exit and name the variable (never the value), so a mistake shows at deploy. A name with KEY is fine: public keys are normal. |
| Values are escaped | Quotes, backslashes, newlines and < cannot end the string or the file. |
config.js is never cached | nginx answers it with Cache-Control: no-store. |
A mounted config.js wins | If you mount a read-only file over it, the container keeps your file. |
In dev and in vite build, config.js is the empty src/public/config.js, so the app falls back to .env.
Require variables
By default a container starts whatever its environment holds, and an app with a missing value falls back to its build-time one. To refuse to start instead, list the variables the app cannot run without in APP_REQUIRED_ENV, in the app's Dockerfile:
ENV APP_REQUIRED_ENV="VITE_API_URL,API_UPSTREAM"docker run --rm -e API_UPSTREAM=http://api:3000 admin
# [runtime-config] FATAL: required variable(s) missing or empty: VITE_API_URL| Rule | What it means |
|---|---|
| A comma-separated list | VITE_API_URL,API_UPSTREAM. Spaces around a name and empty entries are ignored. |
| Unset or empty is missing | The container exits with code 1 and names every missing variable, in the order listed, once each. No config.js is written. |
| Any variable name | The list is not limited to VITE_ names, so API_UPSTREAM can be required. A name outside VITE_ is still not published. |
| No value is printed | Only names reach the message, so a secret cannot leak through a failed start. |
| A bad entry stops the container | An entry that is not a variable name (letters, digits and _, not starting with a digit) exits with code 1 and gives its position in the list. |
| Nothing listed, nothing checked | An unset or empty APP_REQUIRED_ENV keeps the start as it was. -e APP_REQUIRED_ENV=... overrides the list for one environment. |
A mounted config.js is not checked | A read-only file mounted over config.js is kept as supplied, and the variables are not checked. |
Expected API
The app has no sign-in and expects one route of the backend: GET /api/hello answering { "message": "..." }, which an IGNIS app made from its preset has. To use your own resources, add them in src/main.tsx (resources). For sign-in, see Add sign-in.
Add an Admin to an Existing Monorepo
To add an admin to a monorepo that already exists (for example one created with bun x @venizia/vulcan init --preset ignis), run this at its root:
bun x @venizia/vulcan add --preset ardor --app web --frontend-dir apps --api api--api names the backend app to proxy /api to (here api, one of the monorepo's backend apps). For an admin that talks to some other backend, pass --no-api instead. Without --api <app> or --no-api, and without a terminal, the command stops with vulcan add: missing --api <app> or --no-api. In a terminal, add asks for what you leave out: the frontend folder (once chosen it is offered as the answer next time), the app name and the backend app. --scope @acme names the package @acme/web and leaves the folder apps/web. The dev server port is 5173, or the next free one when another app already uses it. The proxy target is http://localhost:<port of that backend app>, read from the backend's .env.example.
The monorepo's catalog keeps its versions of the IGNIS packages and of @venizia/dev-configs (the admin's lint config needs 0.4.0 or newer): a version already in the catalog wins, so every app shares one copy. If the catalog pins an IGNIS version outside the range ARDOR supports, add stops before writing anything and names both versions, for example the monorepo pins @venizia/ignis-kernel 0.1.0, but the preset needs <the range ARDOR supports> - move the catalog first.
Add a Feature Library
A feature is a package of routes, resources and messages that more than one app mounts. To add one to the monorepo, run this at its root:
bun x @venizia/vulcan add --preset ardor --lib merchant --dir libs --scope @acme--dir is the folder of the libraries (without it, VULCAN uses the folder of the libraries the monorepo already has), and --scope sets the package scope (without it the package is named merchant). Without --lib or --app, the command asks in a terminal and stops without one.
It creates libs/merchant as the package @acme/merchant:
libs/merchant/
package.json name, exports ".", imports #src/*, the scripts, catalog: versions
tsconfig.json
.configs/lints/{oxlint.config.mts,eslint.config.mjs} .configs/formatters/.ignore
src/index.ts src/feature.ts src/feature.test.tssrc/feature.tsisFeatures.define({ name: 'merchant' }), empty until you add routes, resources and messages to it.src/feature.test.tsmounts it in a small application and reads it back, so the test runs from the first day.- The scripts are
typecheck,lint,lint:fix,testandclean. There is no bundler and no dev server: an app that imports the package compiles its source. @venizia/ardor,react,react-dom,react-router-domandreflect-metadataare peer dependencies, so the app decides the versions.
An app mounts the feature by listing it in features(): import { feature } from '@acme/merchant'.
Next Steps
After init or add: cd <project>, then make dev at the root (or bun run server:dev in apps/admin) serves the admin on http://localhost:5173/. In a library, bun run test runs the feature's test.
- Already have a Vite project? See Set up your environment for the manual way.
- For a guided walk through the code, see the Quickstart.