Files
apf_portal/docs/setup/04-angular-nx-monorepo.md
T
julien 8a04540410
CI / check (push) Successful in 3m7s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m3s
CI / a11y (push) Successful in 3m41s
CI / perf (push) Successful in 5m24s
Docs site / build (push) Successful in 5m8s
docs(setup): add Debian 13 dev-VM setup procedure + scripts + devcontainer (#220)
## Summary

Adds a full Debian 13 dev-VM setup procedure ([docs/setup/01-dev-debian-vm-setup.md](docs/setup/01-dev-debian-vm-setup.md)) + 10 modular idempotent setup scripts + a systemd template + a `.devcontainer/` spec, in preparation for the new dev VM (`10.100.201.21`) replacing the WSL-based workflow. Both IDE flows (VSCode Remote-SSH + Devcontainer) and both Node toolchains (nvm on host + devcontainer image) are available — devs pick per task.

Adjacent context (does not ship here, planned follow-up):

- **Preview infra on the GitLab VM (`10.100.201.10`)** — same `dev.compose.yml`, deployed by CI on `main`. Doc placeholder in §8.6.
- **GitLab Runner migration** (act_runner Gitea → GitLab Runner Docker executor) — bundled with the Gitea → GitLab cutover.
- **Private dotfiles repo** (`apf/dotfiles`) — `~/.zshrc`, `~/.p10k.zsh`, `~/.tmux.conf` versioned. `80-dotfiles.sh` is already structured to fall back to a `~/.dotfiles/` clone when present.

No application-code changes. No CI gate impact (doc + scripts + devcontainer spec only).

## What lands

| Path | Change |
| --- | --- |
| `docs/setup/01-dev-debian-vm-setup.md` | **New.** Step-by-step doc: workstation prep (SSH agent + VSCode Remote-SSH + fonts), bootstrap orchestrator, per-script effects, project clone, infra boot, IDE flow A / B / C, apf-ai-service .NET appendix, troubleshooting. |
| `docs/setup/02-wsl-terminal-setup.md` | Renamed from `01-wsl-terminal-setup.md`. No content change. |
| `docs/setup/03-dev-web-stack.md` | Renamed from `02-dev-web-stack.md`. No content change. |
| `docs/setup/04-angular-nx-monorepo.md` | Renamed from `03-angular-nx-monorepo.md`. No content change. |
| `docs/setup/README.md` | **New.** Index of the `docs/setup/` folder. |
| `docs/setup/scripts/lib.sh` | **New.** Shared helpers — colour-coded log/ok/warn/err/skip, `apt_install` skipping already-installed, `ensure_line` idempotent append, `confirm` prompt. |
| `docs/setup/scripts/bootstrap.sh` | **New.** Orchestrator running scripts 10..80 in order with confirmation prompts. |
| `docs/setup/scripts/10-base-packages.sh` | **New.** apt update + base packages (curl, wget, git, build-essential, …). |
| `docs/setup/scripts/20-zsh.sh` | **New.** zsh + Oh My Zsh (RUNZSH=no, no shell hijack) + Powerlevel10k + `zsh-autosuggestions` + `zsh-syntax-highlighting`. Patches `~/.zshrc` (theme, plugins, fzf hook). |
| `docs/setup/scripts/30-cli-tools.sh` | **New.** `bat eza fd-find ripgrep fzf zoxide ncdu keychain` + `jq yq httpie make tree htop tmux direnv dnsutils unzip rsync`. Symlinks Debian-renamed binaries (`batcat`→`bat`, `fdfind`→`fd`) into `~/.local/bin` so notes/aliases.zsh works as-is. |
| `docs/setup/scripts/40-node.sh` | **New.** nvm v0.40.1 + Node from `.nvmrc` (currently 24) + corepack enable + pnpm warmed from `package.json#packageManager`. |
| `docs/setup/scripts/50-docker.sh` | **New.** Docker CE + compose plugin from docker.com apt repo, user added to `docker` group, `docker.service` enabled at boot. Pinned GPG key + repo line for Debian Trixie. |
| `docs/setup/scripts/60-tuning.sh` | **New.** `fs.inotify.max_user_watches=524288` (Vite/Nx watch ceiling), optional 4 GB swapfile, optional hostname rename (only prompts on generic hostnames like `debian13`). |
| `docs/setup/scripts/70-hardening.sh` | **New.** Best-effort UFW (`allow OpenSSH` only) + unattended-upgrades on security channel + fail2ban + sshd drop-in (`PermitRootLogin no`, `PasswordAuthentication no`, `AllowAgentForwarding yes`). Probes before applying, validates `sshd -t` before reloading, skips cleanly if infra already locked the box down. |
| `docs/setup/scripts/80-dotfiles.sh` | **New.** Symlinks `notes/aliases.zsh` → `~/.oh-my-zsh/custom/aliases.zsh` (backs up an existing target). Copies `notes/gitconfig.txt` to `~/.gitconfig`, prompts for identity, applies via `git config --global user.name/email`. |
| `docs/setup/systemd/apf-portal-infra@.service` | **New.** Template systemd unit auto-starting `./infra/local/dev.sh up` at boot. Install: `enable apf-portal-infra@$USER.service`. |
| `.devcontainer/devcontainer.json` | **New.** VSCode Dev Container spec on `mcr.microsoft.com/devcontainers/typescript-node:1-24-bookworm`. `docker-outside-of-docker` feature + `--network=apf-portal-dev` so DNS to `postgres`/`redis`/`otel-collector` works. `initializeCommand` fails fast if `dev.sh up` hasn't been run yet. Forwarded ports labelled. Six dev extensions pre-installed. |
| `.devcontainer/post-create.sh` | **New.** `corepack enable && pnpm install --frozen-lockfile`. |
| `CLAUDE.md` | "Environment conventions" rewritten: documents the two envs (`local` / `development`) + hybrid sub-mode + the two IDE flows (Remote-SSH / Devcontainer), points at the new VM setup doc and the legacy WSL doc. |

## Key choices

- **Idempotent scripts, individually runnable.** Each script probes before doing anything (apt package already installed? plugin already cloned? UFW already active? sshd drop-in already present?). Bootstrap is the orchestrator; each script also runs standalone. Re-running after a partial setup is safe and reports `↪ skip` for the no-op cases.
- **No private key on the VM — SSH agent forwarding instead.** `~/.ssh/config` on the workstation carries `ForwardAgent yes`; the VM never holds long-lived secrets. Same future pattern for GPG signing (covered in §8.5 as appendix). `keychain` is installed by `30-cli-tools.sh` as a fallback for scenarios where agent forwarding is not available (CI runners, scripts).
- **Hardening is "best-effort, probe-first".** Some infra teams ship pre-hardened VMs; this script doesn't fight that. UFW already active? Print rules and skip. unattended-upgrades already on? Skip. SSH already locked down? Skip. Each section validates before reloading so a misconfig can't take SSH offline.
- **Devcontainer assumes infra-on-host.** The container runs on the VM but talks to postgres / redis / otel **on the same VM's host docker daemon** through the shared `apf-portal-dev` Compose network. `initializeCommand` fails fast with a clear message if `./infra/local/dev.sh up` hasn't been run yet — better than puzzling `ECONNREFUSED` errors at runtime.
- **`60-tuning.sh` raises inotify to 524288.** Default Debian limit is 8K; Vite/Nx in this monorepo blow past that. The setting is persisted in `/etc/sysctl.d/99-apf-portal.conf` so it survives reboot.
- **Hybrid mode (workstation IDE + VM infra) is a documented sub-mode.** SSH `LocalForward` directives on 5432/6379/4317/4318 expose the VM's infra services as `localhost:*` on the workstation. Latency cost: 5-15 ms per query, fine for daily work; for long-running flows, wrap in `tmux` or use `autossh`.

## Notes for the reviewer

- **Renaming the existing setup docs (01→02, 02→03, 03→04) is the only "destructive" change.** `git log --follow` still works because of `git mv`. Diff shows up as renames, not delete-and-add.
- **The brief asked for `bat eza fd-find ripgrep fzf zoxide docker keychain ncdu git`.** `git` is installed by `10-base-packages.sh` (every other script needs it). Everything else lives in `30-cli-tools.sh` + `50-docker.sh`. The "fullstack-dev extras" (`jq yq httpie make tree htop tmux direnv dnsutils unzip rsync`) are additions I proposed in the lock-in question and that you greenlit (full scope) — easy to trim if any of them turn out to be unwanted.
- **Both Node toolchains in parallel** — nvm on the VM (via `40-node.sh`) **and** devcontainer in the repo. Devs can use either; both read the same `.nvmrc` + `packageManager` pin so the version stays consistent.
- **The systemd unit is a TEMPLATE** (`apf-portal-infra@.service`) — install once, enable per-user (`enable apf-portal-infra@$USER.service`). This is the right shape for a shared VM with multiple devs eventually, even if today only one user uses it.
- **No PR-body Co-Authored-By trailer, no Generated-with-Claude footer**, per the project rule.

## Test plan

Manual (no automated test exists for this kind of setup work):

- [ ] On a fresh Debian 13 VM: `git clone …`, `./docs/setup/scripts/bootstrap.sh`, answer prompts, end up with zsh + Powerlevel10k + all the requested CLI tools + Node 24 + pnpm 10.33.4 + Docker on PATH.
- [ ] `./infra/local/dev.sh up` boots successfully against the VM's local docker daemon.
- [ ] `pnpm install` + `pnpm exec nx run-many -t lint test --parallel=3` passes on the VM.
- [ ] VSCode Remote-SSH from a workstation: connect, open `~/Works/apf_portal`, run `pnpm exec nx serve portal-bff`, confirm reaches `postgres:5432`.
- [ ] VSCode Dev Containers from the same workstation: `Reopen in Container`, image builds, `postCreateCommand` runs `pnpm install`, dev server reaches postgres through the `apf-portal-dev` network.
- [ ] Hybrid mode: SSH tunnel from workstation, `pnpm exec nx serve portal-bff` locally, confirm postgres reachable via `localhost:5432`.
- [x] `pnpm exec prettier --check` clean on the touched markdown files.
- [x] Scripts pass `bash -n` (syntax check) — verified during writing.

## What's next

- Validate by walking through this doc on the actual VM `10.100.201.21`. Any friction surfaced becomes a follow-up PR (`docs(setup): ...`).
- Once the dev VM is operational, return to **ADR-0027 Implementation PR 1** (Region / Delegation / Structure Prisma schema + inline reference-data migration) — paused since the start of this PR.
- Set up the **private dotfiles repo** (`apf/dotfiles`) as a small follow-up, then teach `80-dotfiles.sh` to prefer the dotfiles repo over `notes/`.
- When migrating to GitLab: PR pair — (a) `git remote set-url` doc updates here, (b) `infra/gitlab-runners/` replacing `infra/ci-runners.compose.yml`.
- Future: deploy the **shared preview infra** on `vm-gitlab` (10.100.201.10) — CI-driven, separate PR.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #220
2026-05-24 18:40:07 +02:00

10 KiB

🏗️ Bootstrap the Nx monorepo for apf-portal

Status. Aligned with phase-1 ADRs (0002, 0003, 0004, 0005, 0006). Replaces the original placeholder-based draft.

Goal

Scaffold the apf-portal Nx monorepo containing:

  • apps/portal-shell — Angular SPA frontend (CSR, standalone, zoneless, Signals, Vitest, SCSS, strict TS).
  • apps/portal-bff — NestJS BFF backend (Express adapter, global ValidationPipe, Jest test runner).
  • libs/feature/<name> and libs/shared/<scope> — feature and shared libraries.

Out of scope here (covered by later phases): authentication (Entra ID), sessions (Redis), observability (Pino + OpenTelemetry), audit trail, accessibility / performance / security baselines in CI, downstream API integration, on-prem infrastructure stack.


Prerequisites

  • WSL + Zsh — see 01-wsl-terminal-setup.md.
  • Node.js latest LTS + pnpm via corepack — see 02-dev-web-stack.md.
  • Working tree under the WSL filesystem (~/dev/, ~/Works/), never /mnt/c/.
  • The repository apf-portal already exists locally with the ADRs and docs committed (you are reading one of them).

1) Initialize the Nx workspace inside the existing repository

The repository already carries documentation, ADRs, .vscode/, and .gitignore. create-nx-workspace always generates into a new directory, so we generate next to the repo and copy the workspace files in.

# 1. Generate a clean Nx workspace next to the repo
cd ~/dev   # or any scratch location outside the repo
pnpm dlx create-nx-workspace@latest apf-portal-bootstrap \
  --preset=apps \
  --pm=pnpm \
  --ci=skip \
  --useGitHub=false

# 2. Copy the workspace files into the existing repo
cd apf-portal-bootstrap
cp -v package.json pnpm-workspace.yaml nx.json tsconfig.base.json \
       .prettierrc .prettierignore .editorconfig \
       eslint.config.mjs \
       ~/Works/apf_portal/

# 3. Drop the scratch workspace
cd ~ && rm -rf ~/dev/apf-portal-bootstrap

The apps preset is generic — it does not privilege a runtime (ADR-0002). Plugins are added in step 2.

Verify:

cd ~/Works/apf_portal
pnpm install
ls nx.json package.json tsconfig.base.json

2) Install Angular and Node plugins

pnpm add -D @nx/angular @nx/nest @nx/vite @nx/eslint @nx/js

3) Generate the frontend app — portal-shell

pnpm nx g @nx/angular:application portal-shell \
  --directory=apps/portal-shell \
  --routing=true \
  --standalone=true \
  --style=scss \
  --bundler=esbuild \
  --unitTestRunner=vitest \
  --e2eTestRunner=playwright \
  --strict=true \
  --tags="scope:portal-shell,type:app"

Enable zoneless change detection (ADR-0004)

Edit apps/portal-shell/src/app/app.config.ts:

import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [provideZonelessChangeDetection(), provideRouter(routes)],
};

Remove every reference to zone.js:

  • delete any import 'zone.js' from apps/portal-shell/src/main.ts and apps/portal-shell/src/test-setup.ts;
  • drop zone.js from any polyfills array in apps/portal-shell/project.json;
  • check the lockfile no longer pulls zone.js.

Verify:

grep -RIn "zone.js" apps/portal-shell || echo "no zone.js references — OK"

Strict TypeScript (workspace-wide)

In tsconfig.base.json:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
  },
}

4) Generate the backend app — portal-bff

pnpm nx g @nx/nest:application portal-bff \
  --directory=apps/portal-bff \
  --unitTestRunner=jest \
  --tags="scope:portal-bff,type:app"

Test runner note. NestJS ships with Jest. Vitest unification across the monorepo is possible (via @nx/vite:configuration) and remains a future option — no ADR yet.

Wire the global ValidationPipe (ADR-0005)

Edit apps/portal-bff/src/main.ts:

import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app/app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );

  // Phase-2 security ADRs will add: helmet, CORS allowlist, cookie-session,
  // CSRF protection, rate limiting, auth guards, structured error filter.

  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

5) Add PostgreSQL + Prisma (ADR-0006)

pnpm add -D prisma
pnpm add @prisma/client nestjs-prisma

mkdir -p apps/portal-bff/prisma
pnpm exec prisma init \
  --datasource-provider postgresql \
  --schema apps/portal-bff/prisma/schema.prisma

Wire PrismaModule in apps/portal-bff/src/app/app.module.ts:

import { Module } from '@nestjs/common';
import { PrismaModule } from 'nestjs-prisma';

@Module({
  imports: [PrismaModule.forRoot({ isGlobal: true })],
})
export class AppModule {}

Commit a .env.example (the actual .env is git-ignored):

DATABASE_URL="postgresql://portal:portal@localhost:5432/portal_dev?schema=public"

Out of scope. PostgreSQL provisioning (HA, backups, RLS policies for the dual-audience model) and Prisma migration deployment will be handled by infrastructure ADRs (phase 3).


6) Generate the first libraries

Convention (ADR-0003):

  • libs/feature/<name> for vertical features;
  • libs/shared/<scope> for cross-cutting concerns.

Examples:

# Frontend feature lib
pnpm nx g @nx/angular:library feature-auth \
  --directory=libs/feature/auth \
  --standalone=true \
  --tags="scope:portal-shell,type:feature"

# Shared UI lib (frontend-only)
pnpm nx g @nx/angular:library shared-ui \
  --directory=libs/shared/ui \
  --standalone=true \
  --tags="scope:portal-shell,type:shared"

# Shared utility lib (consumed by both apps)
pnpm nx g @nx/js:library shared-util \
  --directory=libs/shared/util \
  --tags="scope:shared,type:shared"

7) Enforce module boundaries

In eslint.config.mjs, configure @nx/enforce-module-boundaries with the project tags above:

{
  '@nx/enforce-module-boundaries': [
    'error',
    {
      depConstraints: [
        { sourceTag: 'scope:portal-shell', onlyDependOnLibsWithTags: ['scope:portal-shell', 'scope:shared'] },
        { sourceTag: 'scope:portal-bff',   onlyDependOnLibsWithTags: ['scope:portal-bff',   'scope:shared'] },
        { sourceTag: 'type:app',     onlyDependOnLibsWithTags: ['type:feature', 'type:shared'] },
        { sourceTag: 'type:feature', onlyDependOnLibsWithTags: ['type:feature', 'type:shared'] },
        { sourceTag: 'type:shared',  onlyDependOnLibsWithTags: ['type:shared'] },
      ],
    },
  ],
}

This prevents portal-shell from importing portal-bff code (and vice versa), and keeps shared-* libraries free of feature-level dependencies.


8) Prettier, git hooks, and Conventional Commits

See ADR-0007 for the rationale (Husky + lint-staged + commitlint with Conventional Commits).

.prettierrc (project-wide):

{
  "singleQuote": true,
  "semi": true,
  "printWidth": 100
}

Install Husky, lint-staged, commitlint:

pnpm add -D husky lint-staged @commitlint/cli @commitlint/config-conventional
pnpm exec husky init

.husky/pre-commit:

pnpm exec lint-staged

.husky/commit-msg:

pnpm exec commitlint --edit "$1"

commitlint.config.cjs (workspace root):

module.exports = { extends: ['@commitlint/config-conventional'] };

package.json excerpt:

{
  "scripts": { "prepare": "husky" },
  "lint-staged": {
    "*.{ts,html,scss,md,json}": ["pnpm nx format:write --uncommitted"]
  }
}

9) CI/CD

The pipeline targets the Gitea repository (gitea@git.unespace.com:julien/apf_portal.git). Pipeline shape (Gitea Actions vs. third-party runner) and branch protection are deferred to phase-3 ADRs (planned: ADR-0017). Locally, the equivalent of the future CI check is:

pnpm nx affected -t lint test build

10) Daily workflow

# Dev servers
pnpm nx serve portal-shell        # http://localhost:4200
pnpm nx serve portal-bff          # http://localhost:3000

# Tests
pnpm nx test portal-shell         # Vitest
pnpm nx test portal-bff           # Jest

# Quality sweeps
pnpm nx run-many -t lint test     # full
pnpm nx affected -t lint test build   # only what changed since main

# Generators
pnpm nx g @nx/angular:component <name> --project=portal-shell --standalone
pnpm nx g @nx/nest:resource <name> --project=portal-bff

What this guide does not cover (yet)

Concern Phase
Authentication (Entra ID workforce + External ID) 2
Sessions / cache (Redis self-hosted) 2
Downstream API access (existing apps) 2
Observability (Pino + OpenTelemetry + W3C) 2
Audit trail 2
Accessibility baseline (WCAG 2.2 AA + axe-core in CI) 3
Performance budgets (Lighthouse CI) 3
Security baseline (CSP, dep / secret scanning, OWASP ASVS) 3
CI/CD on Gitea 3
On-prem infrastructure stack (orchestration, Postgres HA, Redis HA, observability backend) 3