enterprise-stack

star 0

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.

RonanCodes By RonanCodes schedule Updated 6/8/2026

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):

  1. 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.
  2. 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.
  3. 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 (*.csproj with Microsoft.NET.Sdk.Web, Program.cs, a multi-stage Dockerfile: dotnet/sdk:8.0 build to dotnet/aspnet:8.0 runtime). Add a minimal project.json with a name so the Nx graph does not choke (see Learnings).
  • infra/keycloak/<realm>-realm.json — a realm export (realm, a public web-app client with PKCE + localhost redirect, the roles, a test user). Mount it for auto-import.
  • docker-compose.ymlmongo, keycloak (start-dev --import-realm, realm dir mounted to /opt/keycloak/data/import), and the .NET connector (build: its dir). The web + bff run via nx serve in 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 login or a FLY_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 needs Zone.DNS:Edit to 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, assembled MONGODB_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 content glob 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. Use content: [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_URI is 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. Use fly deploy --remote-only so no local Docker daemon is needed (Fly builds the image).
  • Nx node build externalises deps via generatePackageJson. The Docker runtime stage copies dist/apps/bff/ (which includes a generated package.json) and runs npm 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:application generator refuses ("The Angular framework doesn't support a TypeScript setup with project references"). Use --preset=angular-monorepo. (You can override with NX_IGNORE_UNSUPPORTED_TS_SETUP=true, but that is at-your-own-risk; do not.)
  • The angular-monorepo preset maps to a fixed demo template. In current Nx it clones nrwl/angular-template (a "shop" demo) and ignores --appName/--style. Accept it, then generate your own web app and remove the demo projects via nx g @nx/workspace:remove <name> --forceRemove. The demo's project names are not the folder names; list them with nx show projects.
  • Bleeding-edge Angular triggers npm peer-dep conflicts on nx g @nx/angular:application (the generator's internal npm install hits ERESOLVE). Add legacy-peer-deps=true to .npmrc BEFORE generating, and use npm 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/docker infers 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 minimal project.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/import with start-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.
Install via CLI
npx skills add https://github.com/RonanCodes/ronan-skills --skill enterprise-stack
Repository Details
star Stars 0
call_split Forks 1
navigation Branch main
article Path SKILL.md
More from Creator