Files
apf_portal/docs/setup/01-dev-debian-vm-setup.md
T
julien c080d1ad89
CI / scan (push) Successful in 3m8s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m58s
CI / a11y (push) Successful in 4m38s
Docs site / build (push) Successful in 7m47s
CI / perf (push) Successful in 8m53s
feat(infra): dockerised full-stack dev mode — apps compose profile (ADR-0030) (#258)
## Summary

Implements [ADR-0030](../docs/decisions/0030-dockerised-dev-mode.md) (now `accepted`): a Docker Compose `apps` profile that runs the three Nx dev servers (`portal-bff`, `portal-shell`, `portal-admin`) from a shared `Dockerfile.dev`, so a developer can boot the whole stack with **no native Node/pnpm**:

```bash
./infra/local/dev.sh up apps   # infra + portal-bff:3000 + portal-shell:4200 + portal-admin:4300
```

Purely additive and profile-gated — the native `nx serve` flow and the devcontainer are untouched. Dev-only; no production images (those stay with the ADR-0028 Container Registry work).

## What lands

| File | Change |
| --- | --- |
| `docs/decisions/0030-dockerised-dev-mode.md` | Status `proposed` → `accepted`. |
| `docs/decisions/README.md` | Index status → `accepted`. |
| `infra/local/Dockerfile.dev` | **New.** `node:24-bookworm` + corepack (pnpm resolved from `packageManager` at runtime — no pinned version to drift). No COPY/install at build time. `NX_DAEMON=false`, `NODE_OPTIONS=--max-old-space-size=4096`. |
| `infra/local/dev-entrypoint.sh` | **New.** Shared entrypoint: BFF (`APF_ROLE=bff`) runs `prisma generate` + `prisma migrate deploy` then serves; SPA services go straight to `nx serve`. |
| `infra/local/dev.compose.yml` | **New `apps` profile.** A one-shot `apps-deps` service installs into a shared `node_modules` volume once (the 3 servers gate on its `service_completed_successfully`, avoiding a 3-way install race); `portal-bff` / `portal-shell` / `portal-admin` services from the shared image via a `x-app-base` anchor. Repo bind-mounted; `node_modules` + `.nx` in named volumes. |
| `infra/local/dev.sh` | `apps` added to `ALL_PROFILES` (so teardown / status / logs catch it) + usage / examples. |
| `infra/README.md` | New "Dockerised app dev mode" section + cheat-sheet / file-table rows. |
| `docs/setup/01-dev-debian-vm-setup.md` | "Three dev modes — which when" table at the top of Step 5. |
| `CLAUDE.md` | Architecture roll-up bullet + ADR-count line + environment-conventions note. |

## Key design decisions

- **One image, one install.** The monorepo means a single `Dockerfile.dev` + a single `pnpm install` serves all three apps.
- **`node_modules` + `.nx` in named volumes, not bind-mounted.** The container's install (native modules — `esbuild`, `@swc/core`, Prisma engines, `lmdb`, `@parcel/watcher` — built for this image) must never be shadowed by the host's `node_modules`. The repo source is bind-mounted for hot reload; these two directories are overlaid with named volumes.
- **`apps-deps` one-shot avoids the install race.** Three services sharing one `node_modules` volume can't all run `pnpm install` concurrently. A dedicated install service runs first; the three app services `depends_on` its completion.
- **`NX_DAEMON=false`** in the containers — three containers sharing one workspace would otherwise contend on the Nx daemon.
- **Env wiring.** The BFF reuses its own `apps/portal-bff/.env` (Entra / session / jwks secrets) via `env_file: { required: false }`; the host-specific URLs (`DATABASE_URL` / `REDIS_URL` / OTel endpoint) are overridden in `environment:` — rebuilt from `infra/local/.env` creds → Compose service names. Compose `environment` wins over `env_file`, so the localhost values in the BFF `.env` don't leak into the container.
- **BFF still needs its secrets.** "No native toolchain" ≠ "no config". `apps/portal-bff/.env` must exist (same as native dev); `required: false` lets SPA-only devs `up` without it (the BFF then fails its own boot validators with a clear message).

## Validation on the VM

- [x] `docker compose -f dev.compose.yml --profile apps config` validates (YAML, anchors / merge, env interpolation).
- [x] `bash -n` clean on `dev-entrypoint.sh` and `dev.sh`.
- [x] **Full boot on vm-dev** — `./infra/local/dev.sh up apps` brings up postgres / redis / otel + `apps-deps` (one-shot, exit 0) + portal-bff / portal-shell / portal-admin, all containers report healthy or running.
- [x] `apps-deps` populates the shared `node_modules` volume; the three servers reach their `nx serve` step without re-installing.
- [x] Ports published as expected: BFF :3000, portal-shell :4200, portal-admin :4300.
- [x] `./infra/local/dev.sh up` (no `apps`) unchanged for native devs.

## Follow-ups identified during VM validation

- **SPA → BFF reachability from a remote browser.** Opening `http://<vm-ip>:4200/` from the workstation surfaces a "Backend unreachable" message: the SPA's hardcoded `bffApiBaseUrl: 'http://localhost:3000/api'` (ADR-0018 build-time env) plus the BFF's `CORS_ALLOWED_ORIGINS=http://localhost:4200,…` both assume "browser on the same machine as the BFF", which doesn't hold here. Fixed in the **stacked follow-up PR `feat/spa-dev-proxy`** (proxy `/api` in the Angular dev-server + relative `bffApiBaseUrl`), which lands right after this PR.
- The OTel HTTP exporter URL (`environment.otlpEndpoint`) and the cross-SPA links (`adminAppUrl`, `shellAppUrl`) remain absolute and hit the same remote-browser limit; not blocking for v1, can be revisited if needed.

## Related

- [ADR-0030](docs/decisions/0030-dockerised-dev-mode.md) — the decision (accepted in this PR's chain).
- [ADR-0020](docs/decisions/0020-portal-admin-app.md) — the devcontainer this complements.
- [ADR-0028](docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md) — production images / Container Registry (deferred).
- Follow-up branch `feat/spa-dev-proxy` — the SPA-side proxy fix that makes the dockerised mode usable from a remote browser.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #258
2026-06-01 11:11:44 +02:00

24 KiB

🚀 Debian 13 dev-VM setup for apf-portal + apf-ai-service

Goal

A reproducible developer environment on a fresh Debian 13 VM, equivalent to the current Windows-WSL setup but running on a corp-network VM. After running this procedure:

  • a developer has zsh + Oh My Zsh + Powerlevel10k + the project alias / gitconfig set;
  • the apf-portal local infrastructure stack (Postgres + Redis + OTel Collector per ADR-0006 / ADR-0010 / ADR-0012) is running on the VM;
  • two IDE flows are available: VSCode Remote-SSH (the default — files + tooling on the VM, VSCode window on Windows) and Devcontainer (Node / pnpm / Nx pinned in a versioned image);
  • the doc + scripts are reusable to onboard the next dev in ~30 minutes.

This doc is idempotent — every script in docs/setup/scripts/ can be re-run without breaking what's already there. Each script reports what it changed.

Topology

Three machines are in play. Naming them once here so the rest of the doc can refer back:

Name Role Owner
workstation The developer's Windows machine. Runs VSCode, terminals, the SSH agent. Holds the SSH / GPG private keys. per-dev
vm-dev 10.100.201.21 — Debian 13 VM provided by the infra team. Runs the dev tooling (code, Node, Docker, per-dev infra). per-dev
vm-gitlab 10.100.201.10 — Debian VM hosting GitLab + the shared preview infra (db / redis / otel) + the GitLab Runners (post-migration). shared (all devs)

Two environments at the developer-machine level — the codebase has identical configuration in both, only the runtime location differs:

Env Code lives IDE-server runs Nx servers run Local infra stack runs Used for
local workstation workstation workstation workstation Legacy WSL flow — kept working; see 02-wsl-terminal-setup.md
development vm-dev vm-dev vm-dev vm-dev The default flow this doc sets up
Hybrid workstation OR vm-dev workstation workstation vm-dev via SSH tunnels Run Nx locally for IDE convenience while sharing the VM's infra services

The Hybrid row is a sub-mode of development, not a third environment — Angular environment.ts and BFF .env are the same. Only the network path to Postgres / Redis / OTel differs.

Prerequisites

  • A Debian 13 VM with your SSH public key already authorized (~/.ssh/authorized_keys populated by the infra team).
  • Corporate-network access to 10.100.201.21, 10.100.201.10, and git.unespace.com (Gitea) from your workstation.
  • A workstation with VSCode + the Remote-SSH and Dev Containers extensions installed.
  • A locally-installed Nerd Font on the workstation (MesloLGS NF recommended — used by Powerlevel10k).
  • Optional but recommended: an OpenSSH-compatible SSH agent on the workstation (Windows' built-in ssh-agent service, or pageant via WSL ssh-agent bridge, or 1Password's agent integration) so we can forward the agent and never copy a private key onto the VM.

TL;DR — quickstart

The fast path. ~30 minutes including the interactive prompts (Powerlevel10k configurator, gitconfig identity, hardening review).

# On your workstation — add the VM to ~/.ssh/config with agent forwarding:
cat >> ~/.ssh/config <<'EOF'
Host vm-dev
  HostName 10.100.201.21
  User <your-vm-username>
  ForwardAgent yes
  ServerAliveInterval 60
EOF

# Then on vm-dev:
ssh vm-dev
git clone git@git.unespace.com:julien/apf_portal.git ~/Works/apf_portal
cd ~/Works/apf_portal
./docs/setup/scripts/bootstrap.sh    # runs 10..80 in order, prompts where needed
exec zsh -l                          # reload to pick up zsh + the new PATH
./infra/local/dev.sh up              # boot the local infra stack

The detailed procedure below explains each script's effect — read it once on your first VM, skim it on subsequent ones.

Step 0 — Workstation preparation

Done on your Windows / macOS / Linux workstation, not on the VM.

0.1 — SSH agent + agent forwarding

The dev VM should never hold a long-lived private key. We rely on SSH agent forwarding: the agent on the workstation holds the key; when the VM needs to authenticate to Gitea, it borrows the workstation's key through the forwarded socket.

Windows native (PowerShell, admin once):

# Start the agent (idempotent — already running is fine)
Set-Service -Name ssh-agent -StartupType Automatic
Start-Service ssh-agent
# Add your key
ssh-add ~/.ssh/id_ed25519

~/.ssh/config block for the VM with ForwardAgent yes — see the TL;DR above. Verify after first SSH:

# On vm-dev:
ssh-add -l            # should list your workstation key, NOT empty
echo $SSH_AUTH_SOCK   # should be a path, not empty
ssh -T git@git.unespace.com   # should greet you by name

If ssh-add -l says "Could not open a connection to your authentication agent", agent forwarding isn't working — verify ForwardAgent yes is on the right Host block on the workstation.

0.2 — VSCode extensions

  • Remote - SSH (ms-vscode-remote.remote-ssh) — for IDE flow A.
  • Dev Containers (ms-vscode-remote.remote-containers) — for IDE flow B.

Both ship from Microsoft.

0.3 — Fonts (workstation side)

Powerlevel10k uses Nerd Font glyphs. The font must be installed on the workstation (Windows / macOS / Linux), not on the VM — terminals render glyphs at the workstation level.

https://github.com/romkatv/powerlevel10k-media/raw/master/MesloLGS%20NF%20Regular.ttf
https://github.com/romkatv/powerlevel10k-media/raw/master/MesloLGS%20NF%20Bold.ttf
https://github.com/romkatv/powerlevel10k-media/raw/master/MesloLGS%20NF%20Italic.ttf
https://github.com/romkatv/powerlevel10k-media/raw/master/MesloLGS%20NF%20Bold%20Italic.ttf

Download, install, then set the font in Windows Terminal / VSCode integrated terminal to MesloLGS NF.

0.4 — Optional: GPG agent forwarding for signed commits

If you sign commits with GPG, the same pattern as SSH: agent on workstation, forward the socket to the VM. Setup is more involved than SSH — covered in step 8.5 below as an optional appendix.

Step 1 — First SSH and sanity checks

ssh vm-dev
hostname               # what infra named the VM — note it
cat /etc/os-release    # should report Debian 13 (Trixie)
timedatectl            # check timezone is Europe/Paris and NTP is active
locale                 # check LANG / LC_* are sane (en_US.UTF-8 or fr_FR.UTF-8)
df -h /                # check disk headroom — node_modules + docker images burn 10-30 GB
free -h                # check available RAM

If the hostname is generic (e.g. debian13), set a meaningful one — the 60-tuning.sh script handles this interactively.

Step 2 — Clone the repo

The setup scripts live in docs/setup/scripts/. Clone the repo first to get them:

mkdir -p ~/Works
cd ~/Works
git clone git@git.unespace.com:julien/apf_portal.git
cd apf_portal

If git isn't installed yet (some Debian images are minimal), install it first:

sudo apt update && sudo apt install -y git

Use the SSH URL (git@git.unespace.com:…), not the HTTPS one — when we migrate to GitLab, swapping remote is git remote set-url, with no credential re-config.

Step 3 — Run the bootstrap orchestrator (or step through individually)

The bootstrap.sh script runs every numbered script (10-…80-…) in order, with a confirmation prompt at the head of each. You can also call any individual script directly.

./docs/setup/scripts/bootstrap.sh

What each script does:

Script Effect Idempotent?
10-base-packages.sh apt update + install the minimal set every other script depends on: curl wget git ca-certificates gnupg build-essential pkg-config.
20-zsh.sh Install zsh + Oh My Zsh + Powerlevel10k + plugins (autosuggestions, syntax-highlighting). Patch ~/.zshrc (theme + plugins + fzf hook) and ~/.bashrc (exec zsh on interactive shells — chsh is blocked on the corp VM).
30-cli-tools.sh Install bat eza fd-find ripgrep fzf zoxide ncdu keychain + jq yq httpie make tree htop tmux direnv dnsutils unzip. Aliases for the Debian-renamed binaries (batcatbat, fdfindfd).
40-node.sh Install nvm. Install Node from the repo's .nvmrc (currently 24). Enable corepack, pin pnpm to package.json's packageManager. Mirror the nvm init into ~/.zshrc (the installer only patches ~/.bashrc, which the zsh hand-off execs past — without this, zsh has no node/pnpm on PATH).
50-docker.sh Install Docker CE + compose plugin from the Docker apt repo. Add the current user to the docker group. Enable docker.service.
60-tuning.sh fs.inotify.max_user_watches=524288 (Vite / Nx watch). Prompt for hostname (only if generic). Add a 4 GB swapfile if none.
70-hardening.sh Best-effort UFW (allow SSH only, prompts before enabling), unattended-upgrades on security channel, fail2ban (opt-in — prompts before installing), sshd hardening (no root, no password — prompts before applying). Probes before applying — skips what's already done.
80-dotfiles.sh Symlink docs/setup/dotfiles/aliases.zsh~/.oh-my-zsh/custom/aliases.zsh. Generate ~/.gitconfig from docs/setup/dotfiles/gitconfig.txt, prompt for identity (name + email).

After bootstrap, reload your shell to pick up the new PATH + zsh:

exec zsh -l
p10k configure   # only if you skipped during 20-zsh.sh

Step 4 — Project secrets + first run

cd ~/Works/apf_portal
cp infra/local/.env.example infra/local/.env
${EDITOR:-nano} infra/local/.env
# Set POSTGRES_PASSWORD and REDIS_PASSWORD to strong dev values.
# Keep them URL-safe (no @ # : / ? % & = + ; in the password) —
# the BFF reads DATABASE_URL and Prisma demands the URL form.

./infra/local/dev.sh up
./infra/local/dev.sh status

You should see postgres, redis, and otel-collector healthy. The optional pgweb / jaeger / serve-static profiles can be activated when needed (./infra/local/dev.sh up dbtools, etc. — see infra/README.md).

Then install the workspace dependencies:

pnpm install
pnpm exec nx run-many -t lint test --parallel=3   # smoke test

Step 5 — Choose your IDE flow

Running the apps — three modes (which when)

There are three ways to run the apps locally. They coexist; pick by need.

Mode Toolchain on host Best for
Native (pnpm nx serve) Node + pnpm native Day-to-day dev — fastest iteration, simplest debugger attach.
Devcontainer (Option B below) none (Docker) IDE-integrated dev with no native toolchain; VSCode attaches, you run nx serve inside.
Compose apps profile none (Docker) "Run everything with one command" — onboarding, frontend-only work, demos. No IDE attach.

The Compose apps profile (per ADR-0030) is the lightest way to boot the full stack without a native toolchain:

./infra/local/dev.sh up apps   # infra + portal-bff:3000 + portal-shell:4200 + portal-admin:4300

Usage, prerequisites (the BFF still needs its apps/portal-bff/.env secrets) and the port caveat are documented in infra/README.md → "Dockerised app dev mode". The IDE-flow options below (Remote-SSH / Devcontainer / Hybrid) are orthogonal — they decide where your editor + terminals run, not how the apps boot.

Option A — VSCode Remote-SSH (default)

From your workstation:

  1. F1Remote-SSH: Connect to Host…vm-dev.
  2. File → Open Folder…~/Works/apf_portal.

VSCode installs a server on the VM the first time; subsequent connections reuse it. Your terminals inside VSCode are on the VM. The extension list you installed locally (Angular Language Service, ESLint, Prettier, Prisma, …) follows you to the remote profile — install them once in the "Remote-SSH: vm-dev" profile.

Claude Code runs natively in this mode — install the extension in the Remote-SSH profile and it operates against the VM's filesystem.

Option B — Devcontainer

A .devcontainer/devcontainer.json ships in the repo. When you open the repo in VSCode (Remote-SSH first, see Option A), VSCode detects it and offers Reopen in Container. The container:

  • Pins Node + pnpm + Nx to the image (no reliance on the VM's nvm).
  • Mounts the docker socket so it can still reach the host's apf-portal-dev Compose network (postgres / redis / otel).
  • Forwards the standard dev ports (4200, 3000, 8081, 16686).

The image builds on first open (~3 min); subsequent opens reuse the layer cache. The host VM's nvm + pnpm install (from 40-node.sh) is still useful as a fallback for command-line work outside the container.

Option C — Hybrid (local Nx + VM infra)

For when you want the IDE on the workstation but the infra services on the VM:

# On workstation:
ssh -L 5432:localhost:5432 \
    -L 6379:localhost:6379 \
    -L 4317:localhost:4317 \
    -L 4318:localhost:4318 \
    vm-dev

Add LocalForward directives to ~/.ssh/config to make this implicit:

Host vm-dev
  HostName 10.100.201.21
  User <your-vm-username>
  ForwardAgent yes
  LocalForward 5432 localhost:5432
  LocalForward 6379 localhost:6379
  LocalForward 4317 localhost:4317
  LocalForward 4318 localhost:4318
  ServerAliveInterval 60

Then on the workstation, point the BFF at localhost:5432 / localhost:6379 as today — the tunnels do the routing. Latency adds 5-15 ms per query, fine for daily work.

Caveat: the tunnels die when the SSH session does. For long-running workflows use autossh or wrap in tmux.

Step 6 — Optional: auto-start the local infra at boot

sudo cp docs/setup/systemd/apf-portal-infra.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now apf-portal-infra.service

The unit binds to docker.service, runs infra/local/dev.sh up at boot, and tears down cleanly on systemctl stop. Edit the unit's User= line if your VM username differs from WorkingDirectory= assumptions.

Step 7 — apf-ai-service (.NET, optional)

apf-ai-service is .NET 9 (Apf.Ai.slnx), not Node — it doesn't share the apf-portal Node devcontainer. If you'll work on it from this VM, install the .NET SDK:

# Microsoft official .NET repo for Debian 13:
wget https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb
sudo dpkg -i packages-microsoft-prod.deb
rm packages-microsoft-prod.deb
sudo apt update
sudo apt install -y dotnet-sdk-9.0

(At time of writing the Microsoft repo for Debian 13 / Trixie may not exist yet — fall back to the Debian 12 / bookworm package, which is binary-compatible.)

Clone the repo:

git clone git@git.unespace.com:julien/apf-ai-service.git ~/Works/apf-ai-service
cd ~/Works/apf-ai-service
dotnet build

A future ADR may codify a .NET devcontainer too; not in scope here.

Step 8 — Advanced topics

8.1 — Tmux for resilient sessions

tmux is installed by 30-cli-tools.sh. Workflow:

tmux new -s dev        # first time
# inside tmux:
nx serve portal-bff    # long-running
# detach with Ctrl-b d
# disconnect SSH — process keeps running

# next session:
ssh vm-dev
tmux attach -t dev     # right back where you were

Lifesaver when the corp network blips. A minimal ~/.tmux.conf lands as part of dotfiles (step 8.4); the bare tmux is already usable.

8.2 — Direnv for per-project env vars

direnv is installed by 30-cli-tools.sh and hooked into zsh by 20-zsh.sh (eval "$(direnv hook zsh)"). Per-project:

cd ~/Works/apf_portal
echo 'dotenv infra/local/.env' > .envrc
direnv allow .

infra/local/.env's variables are now in your shell whenever you cd into the project. The BFF + Nx pick them up automatically.

8.3 — Preparing for the Gitea → GitLab migration

This setup is intentionally GitLab-compatible already:

  • Remotes use SSH URLs (git@…:org/repo.git), so migration is git remote set-url origin git@vm-gitlab:org/repo.git — no credential dance.
  • SSH agent forwarding works against any Git host that speaks the SSH protocol.
  • The keychain package is installed by 30-cli-tools.sh as a fallback for non-agent-forwarded scenarios (CI tools, automation).

When GitLab is active:

  1. git remote set-url origin git@vm-gitlab:org/repo.git on each clone.
  2. Drop the agent against vm-gitlab once to register: ssh -T git@vm-gitlab.
  3. Migrate the runner stack (see step 8.6).

8.4 — Dotfiles repo

A private apf/dotfiles repo is the natural home for ~/.zshrc, ~/.p10k.zsh, ~/.tmux.conf, ~/.gitconfig (template), and any per-dev overrides. Onboarding the next dev becomes:

git clone git@git.unespace.com:apf/dotfiles.git ~/.dotfiles
~/.dotfiles/install.sh

The dotfiles repo is out of scope for this PR but the 80-dotfiles.sh script reads the project-wide templates from docs/setup/dotfiles/ (aliases.zsh, gitconfig.txt). A subsequent PR creates the per-dev ~/.dotfiles/ repo and reuses those templates as its seed.

8.5 — GPG agent forwarding (optional, for signed commits)

The pattern is identical to SSH: agent on workstation, forward the socket. Setup is more finicky because GnuPG sockets are not standardised. The full procedure lives in GnuPG's official doc; the gist on Windows + Debian VM:

  1. Workstation: gpg-connect-agent /bye (initialises the agent).
  2. Find the workstation's agent socket: gpgconf --list-dirs agent-extra-socket.
  3. SSH with -R /run/user/$(id -u)/gnupg/S.gpg-agent:<workstation-socket> (use RemoteForward in ~/.ssh/config for persistence).
  4. On the VM: import the public part of your signing key, set git config --global commit.gpgsign true.

Skip this if you don't sign commits. Many teams settle on signed commits only when the org's RSSI / compliance requirement lands.

8.6 — Shared preview infra + runners on vm-gitlab (10.100.201.10)

This is the shared half of the topology — handled in a follow-up. The intent, documented here so the dev VM's setup doesn't drift into it:

  • Preview env: a clone of infra/local/dev.compose.yml runs on vm-gitlab, deployed by CI on main (or on PR via a manual gate). All devs point at it for cross-dev demos; no isolation, expect schema state to churn.
  • GitLab Runner with the Docker executor on vm-gitlab, replacing the Gitea act_runner setup in infra/ci-runners.compose.yml. Migration steps land alongside the actual Gitea → GitLab cutover.
  • Tag convention: apf-portal, docker, on-prem — analogous to the current Gitea self-hosted + on-prem labels.

When this lands, it will live at infra/preview/ and infra/gitlab-runners/ (sibling to infra/local/ and infra/ci-runners.compose.yml). The dev VM doc here links to it without ownership.

Step 9 — Verification + smoke test

End-to-end check after step 4:

cd ~/Works/apf_portal

# 1. Tooling versions
node --version           # should match .nvmrc (24.x)
pnpm --version           # should match package.json packageManager (10.33.4)
docker --version
docker compose version

# 2. Infra reachability
./infra/local/dev.sh status
docker exec -it apf-portal-postgres pg_isready -U portal -d portal_dev

# 3. Workspace build
pnpm exec nx run-many -t lint test build --parallel=3

# 4. Boot the BFF + SPA + admin
pnpm exec nx run-many -t serve --parallel --projects=portal-bff,portal-shell,portal-admin

# 5. Reach them
curl -fs http://localhost:3000/health
curl -fs http://localhost:4200/
curl -fs http://localhost:4300/

If the curls succeed, the dev VM is operational.

Re-onboarding the next dev

Send them:

  1. Their VM SSH credentials.
  2. This doc.

Their procedure: clone the repo, run bootstrap.sh, fill in .env, run dev.sh up. ~30 minutes including the interactive prompts. The dotfiles repo (step 8.4) shaves another 5-10 min once it exists.

Troubleshooting

ssh-add -l says "Could not open a connection"

Agent forwarding broken. On workstation: check ForwardAgent yes is on the right Host block. Check the agent is running locally (ssh-add -l on workstation). Reconnect.

Docker permission denied after 50-docker.sh

The script adds you to the docker group, but existing shells don't pick up the new group membership until you re-log. exit and ssh vm-dev again.

nx serve watches break above N files

fs.inotify.max_user_watches too low. 60-tuning.sh sets it to 524288 — check sysctl fs.inotify.max_user_watches. If a reboot is needed for the value to stick, /etc/sysctl.d/99-apf-portal.conf already carries it.

Powerlevel10k shows boxes / wrong glyphs

Font misconfiguration on the workstation, not the VM. Re-verify MesloLGS NF is installed locally and selected in your terminal's profile (Windows Terminal / VSCode integrated terminal). Restart the terminal.

Hybrid mode (Option C) connections timeout intermittently

The SSH session is dying. Use autossh -M 0 -o "ServerAliveInterval 30" -o "ServerAliveCountMax 3" vm-dev instead of plain ssh, or run the tunnel inside tmux on the workstation.