name: enterprise-stack
description: Scaffold, build, and deploy an app on the enterprise reference stack (Nx monorepo with an Angular + Spartan + Tailwind frontend and a NestJS Backend-for-Frontend, plus .NET microservices, MongoDB, and Keycloak, in Docker, deployed to Fly.io). This is the heavier, team-shaped, polyglot alternative to the TanStack canonical stack (/ro:new-tanstack-app). Use when the user wants to build on the enterprise stack, mirror an enterprise architecture, scaffold an Nx + Angular + NestJS + .NET app, or learn this stack hands-on. Accumulates build learnings in the Learnings section as the stack is exercised.
category: project-setup
argument-hint: [--no-dotnet] [--no-keycloak] [--deploy]
allowed-tools: Bash(npx *) Bash(npm *) Bash(nx *) Bash(git *) Bash(docker *) Bash(fly *) Bash(mkdir *) Bash(cp *) Bash(mv *) Bash(rm *) Read Write Edit
Enterprise stack
The heavier, team-shaped sibling of the TanStack canonical stack. Pick this when the goal is a polyglot, multi-service architecture (the kind a team runs), not the lightest stack that ships. It is deliberately more work than /ro:new-tanstack-app, and that is the point.
The stack
| Layer | Choice | Notes |
|---|---|---|
| Monorepo | Nx | Holds the frontend and the BFF, with shared TypeScript libs. The .NET services live OUTSIDE the Nx workspace (own toolchain). |
| Frontend | Angular + Spartan (spartan-ng) + Tailwind | Spartan is shadcn-style unstyled primitives on Tailwind, chosen over Angular Material. Bleeding-edge Angular where it helps (signals, zoneless, standalone, new control flow). |
| BFF | NestJS | Backend-for-Frontend: aggregates the .NET services into frontend-shaped DTOs, validates the Keycloak token, serves the built SPA. No heavy domain logic here. |
| Services | .NET microservices | The heavy domain logic and the external connectors. Each is its own container with its own database where it needs one. |
| Data | MongoDB | Database-per-service. Local: a container. Deploy: a Fly app with a volume, or Mongo Atlas free tier. |
| Auth | Keycloak | Realms, roles, clients, OIDC + PKCE. The single auth authority; the BFF and the services validate its tokens. |
| Runtime | Docker locally via docker-compose | Kubernetes is the production-faithful option; Fly.io is the simplest real deploy (see Deploy). |
| Deploy | Fly.io | Runs every container natively (Node, .NET, Mongo, Keycloak), private networking, volumes, secrets. The one thing you trade away vs k8s is the Kubernetes control plane itself. |
This is NOT for small solo SaaS where speed-to-market wins. For that, use /ro:new-tanstack-app. Use this when learning the enterprise stack, or when a real team architecture is the requirement.
Platform shape: many apps, one shared backend
The payoff of this stack is reuse across products. Build the expensive parts once and share them:
- Each app = a thin frontend + its own BFF (one per product). Apps are cheap.
- One shared core data service (the "data hub"): owns the canonical models; every BFF reads/writes through it.
- One shared set of connector services (each its own container) that wrap an external API (a registry, e-signing, a payment network, a CRM). A connector hides the upstream's auth and quirks behind a clean internal API; the BFFs and the core call it, the browser never does.
A new product is then a new frontend + BFF that plugs into the existing core + connectors, you don't rebuild the integrations. (Optional but fun: name the tiers with a consistent metaphor so the system is memorable, e.g. a nautical "fleet": ships = apps, bridges = BFFs, a harbour = the core, tenders = connectors.)
The load-bearing rules that make this work (hard constraints):
- The shared services are generic. No app-specific logic in the core or the connectors, ever. A connector normalises one external API; the core stores/caches generic entities. They serve every app identically. If a product rule is creeping into a .NET service, it belongs in that product's BFF.
- The BFF is where each app lives. Wiring (composing the core + connectors) plus all of that app's business rules and response shaping live in the per-app BFF, and only there.
- Databases live only in core services, one DB each (DB-per-service). Apps, BFFs, and connectors own no DB, connectors are stateless proxies (caching their results is a core-service job). Persistence lives in the generic core microservices, and each owns its own database (the data hub is one core; add others, e.g. interactions/documents/identity, as generic needs arise, each with its own DB, never app-specific tables). An app fetches data live from a connector or reads/writes a core service. A dedicated per-app store is a high-bar escape hatch.
In one line: dumb-but-generic shared services, smart-but-thin per-app BFFs.
Scaffold recipe
The exact sequence that works (the gotchas are baked in; see Learnings for why each step is shaped this way).
# 1. Create an Angular-COMPATIBLE Nx workspace. Do NOT use --preset=apps (it sets up
# TypeScript project references, which Angular does not support). The angular-monorepo
# preset configures the correct TS setup.
npx --yes create-nx-workspace@latest <app> \
--preset=angular-monorepo --appName=web --style=scss --ssr=false \
--e2eTestRunner=none --unitTestRunner=jest --bundler=esbuild \
--nxCloud=skip --no-interactive --packageManager=npm
cd <app>
# 2. The angular-monorepo preset now maps to a fixed demo template (nrwl/angular-template)
# that ships a "shop" app and demo libs, ignoring --appName/--style. Bleeding-edge
# Angular also triggers peer-dep conflicts on the next generators. Set legacy-peer-deps:
echo "legacy-peer-deps=true" >> .npmrc
# 3. Generate your own clean apps + a shared lib.
npx nx g @nx/angular:application apps/web --style=scss --routing=true \
--e2eTestRunner=none --unitTestRunner=jest --ssr=false --no-interactive
npx nx add @nx/nest
npx nx g @nx/nest:application apps/bff --unitTestRunner=jest --no-interactive
npx nx g @nx/js:library libs/shared-types --unitTestRunner=jest --no-interactive
npm install --legacy-peer-deps
# 4. Strip the demo projects the template shipped. Find their real names first:
npx nx show projects
# then remove each (names are like feature-products, shared-ui, models, products, data, shop, api):
npx nx g @nx/workspace:remove <demo-project> --forceRemove --no-interactive
Then add the non-Nx pieces by hand (deterministic, no install friction):
services/<name>-connector/— a .NET minimal-API project (*.csprojwithMicrosoft.NET.Sdk.Web,Program.cs, a multi-stageDockerfile:dotnet/sdk:8.0build todotnet/aspnet:8.0runtime). Add a minimalproject.jsonwith anameso the Nx graph does not choke (see Learnings).infra/keycloak/<realm>-realm.json— a realm export (realm, a publicweb-appclient with PKCE + localhost redirect, the roles, a test user). Mount it for auto-import.docker-compose.yml—mongo,keycloak(start-dev --import-realm, realm dir mounted to/opt/keycloak/data/import), and the.NETconnector (build:its dir). The web + bff run vianx servein dev; add them as containers when you want the full mesh.
Commit in layers: workspace + shared lib first, then the docker-compose + Keycloak + .NET skeleton.
Deploy (Fly.io)
Fly is the deployment platform for this stack. Defer to /ro:fly-deploy for the mechanics; this is the stack-specific shape:
- web + bff ship as one Fly app (the BFF serves the built Angular bundle and exposes
/api). - Each .NET service is its own Fly app, reached over Fly private networking (
<app>.internal). - MongoDB: a Fly app with a volume (self-hosted, no extra vendor) or Mongo Atlas free tier (paste the SRV URI as a Fly secret).
- Keycloak: a Fly app with a volume, backed by a small Postgres (Fly Postgres). Set the public hostname +
KC_HOSTNAME. - Secrets via
fly secrets set. Auth:fly auth loginor aFLY_API_TOKEN.
Proven web+bff deploy (works first-try)
# Build stage: install everything, build the Angular app + the NestJS BFF
FROM node:22-slim AS build
WORKDIR /src
ENV CI=true NX_DAEMON=false
COPY package.json package-lock.json .npmrc ./
RUN npm install --legacy-peer-deps
COPY . .
RUN npx nx build bff && npx nx build web
# Runtime: just the BFF, which serves the Angular static + the API
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production PORT=8080 STATIC_DIR=/app/web
COPY --from=build /src/dist/apps/bff/ ./
RUN npm install --omit=dev --legacy-peer-deps && npm cache clean --force
COPY --from=build /src/dist/apps/web/browser ./web
EXPOSE 8080
CMD ["node", "main.js"]
The BFF's webpack uses generatePackageJson: true, so dist/apps/bff/ ships a package.json listing the externalised runtime deps (mongodb, @nestjs/*). The runtime stage just npm installs that, no native-dep bundling. The Angular browser build lands in dist/apps/web/browser; the BFF serves it via app.useStaticAssets(STATIC_DIR) plus an SPA fallback. Deploy:
fly apps create <app> --org personal # app name is GLOBAL; pick an unused one
fly secrets set MONGODB_URI="..." -a <app> --stage
fly deploy -a <app> --remote-only # builds on Fly's builder, no local Docker needed
Mongo Atlas via the Admin API (no console clicking): create an org API key, then POST /api/atlas/v2/groups/{gid}/clusters with providerName: TENANT, backingProviderName: AWS, instanceSize: M0, a databaseUsers POST, an accessList POST (0.0.0.0/0), then GET .../clusters/<name> for connectionStrings.standardSrv. Assemble mongodb+srv://<user>:<pass>@<host>/<db>.
Custom domain (Fly + Cloudflare): fly certs add <sub>.<domain> -a <app> prints the A/AAAA records to add. Add them to Cloudflare as DNS only / unproxied (grey cloud), a proxied (orange) record breaks Fly's TLS handshake. Create them via the API with a token that has Zone.DNS:Edit on that zone (a zone-read-only token is not enough):
curl -X POST -H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
--data '{"type":"A","name":"<sub>","content":"<fly-ipv4>","ttl":300,"proxied":false}'
Repeat for the AAAA record (Fly's dedicated IPv6). Then fly certs check <sub>.<domain> until the status is Issued (a minute or two). The CF token and zone id live in ~/.claude/.env per the operator's per-domain convention.
Credentials: check ~/.claude/.env FIRST
Before asking the user for any credential (Fly, Atlas, Cloudflare, etc.), grep ~/.claude/.env first and only ask if it is genuinely absent. The file is the single source of truth for tokens; the user keeps it current. Names follow a per-target convention so the right one is unambiguous:
- Cloudflare DNS for a domain →
CLOUDFLARE_API_TOKEN_<DOMAIN>+CLOUDFLARE_ZONE_ID_<DOMAIN>(e.g._RONANCONNOLLY). Pick the var whose suffix matches the domain; do not guess a generic one. A token needsZone.DNS:Editto add records, a read-only token will 401 on writes. - Fly →
FLY_API_TOKEN(quoted, contains a space). - Atlas →
MONGODB_ATLAS_PUBLIC_KEY/MONGODB_ATLAS_PRIVATE_KEY, assembledMONGODB_URI.
If a token in .env fails (401 / invalid), say so and check for a sibling var before falling back to asking, the working one is often already there under a clearer name.
Environment (in ~/.claude/.env)
FLY_API_TOKEN="FlyV1 ..." # org deploy token (QUOTE it: the value contains a space)
MONGODB_ATLAS_PUBLIC_KEY=... # Atlas Admin API (HTTP digest auth)
MONGODB_ATLAS_PRIVATE_KEY=...
MONGODB_URI=mongodb+srv://... # assembled after the cluster is IDLE
CLOUDFLARE_API_TOKEN_<DOMAIN>=... # Zone.DNS:Edit on that zone, for custom domains
CLOUDFLARE_ZONE_ID_<DOMAIN>=...
Quote FLY_API_TOKEN in .env: its value contains a space, and an unquoted value breaks source/.-ing the file (it silently breaks every script that sources .env). When reading it back with grep ... | cut -d= -f2-, strip the quotes with tr -d '"'.
The k8s trade-off: Fly gives you the polyglot multi-container experience (images, private networking, volumes, secrets) but not the Kubernetes control plane (manifests, kubectl, ingress controllers). For k8s reps specifically, do a separate k3s-on-a-VPS pass; it does not need to block the Fly deploy.
Learnings (append as the stack is exercised)
Real gotchas, newest first. Keep these generic and reusable.
- Tailwind
contentglob must be absolute in an Nx workspace. A relative./src/**resolves against the workspace root (not the app), so Tailwind scans nothing and purges every utility, you get a base-only ~5KB stylesheet and an unstyled page. Usecontent: [join(__dirname, 'src/**/*.{html,ts}')]. A correctly-scanning build produces a visibly larger stylesheet. - Make the BFF resilient to a missing datastore. Have the data service try Mongo on boot and fall back to in-memory seed data if
MONGODB_URIis unset or unreachable. The live endpoint then works on the very first deploy, before the database is wired, which decouples "is it deployed" from "is the DB connected". - Fly app names are global.
fly apps create <name>fails if taken; pick an unused one. Usefly deploy --remote-onlyso no local Docker daemon is needed (Fly builds the image). - Nx node build externalises deps via
generatePackageJson. The Docker runtime stage copiesdist/apps/bff/(which includes a generatedpackage.json) and runsnpm install --omit=dev, so the native-ish driver (mongodb) is installed at runtime rather than bundled by webpack. - Angular + Nx workspace setup: avoid the
apps/empty preset. It configures TypeScript project references, and the@nx/angular:applicationgenerator refuses ("The Angular framework doesn't support a TypeScript setup with project references"). Use--preset=angular-monorepo. (You can override withNX_IGNORE_UNSUPPORTED_TS_SETUP=true, but that is at-your-own-risk; do not.) - The
angular-monorepopreset maps to a fixed demo template. In current Nx it clonesnrwl/angular-template(a "shop" demo) and ignores--appName/--style. Accept it, then generate your ownwebapp and remove the demo projects vianx g @nx/workspace:remove <name> --forceRemove. The demo's project names are not the folder names; list them withnx show projects. - Bleeding-edge Angular triggers npm peer-dep conflicts on
nx g @nx/angular:application(the generator's internalnpm installhits ERESOLVE). Addlegacy-peer-deps=trueto.npmrcBEFORE generating, and usenpm install --legacy-peer-deps. Note: the generator writes the project files before the failed install, so on retry it reports "project already exists"; just run the install. - A non-Nx folder with a Dockerfile breaks the Nx project graph.
@nx/dockerinfers it as a project but it has no name, so the whole graph fails with "projects in the following directories have no name provided". Fix: drop a minimalproject.json({"name": "...", "projectType": "application", "tags": [...]}) into that folder. - No local .NET SDK is needed. Build the .NET service in a multi-stage Dockerfile and run it via docker-compose. This keeps the Node workspace and the .NET toolchain decoupled, and the connector builds the same way locally and in CI.
- Keycloak realm-as-code. Commit a realm export JSON and mount it to
/opt/keycloak/data/importwithstart-dev --import-realm, so the realm, client, role, and a test user come up ready on first boot. No manual console clicking, and the stack is reproducible from a clean clone. - The split is load-bearing. Angular + NestJS live in the Nx monorepo (sharing a types lib). The .NET services live outside it, wired by network (docker-compose / Fly private networking), not by Nx imports. The BFF talks to them over HTTP; the browser never does.
Related
- /ro:new-tanstack-app — the lighter canonical stack (TanStack + Workers); use that for small solo SaaS.
- /ro:fly-deploy — the deploy mechanics this skill defers to.
- /ro:better-auth, /ro:clerk — auth for the TanStack stack; this stack uses Keycloak instead.