Files
apf_portal/docs/setup/01-dev-debian-vm-setup.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

432 lines
22 KiB
Markdown

# 🚀 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](../decisions/0006-persistence-postgresql-prisma.md) / [ADR-0010](../decisions/0010-session-management-redis.md) / [ADR-0012](../decisions/0012-observability-pino-opentelemetry.md)) 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](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).
```bash
# 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):
```powershell
# 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:
```bash
# 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
```bash
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:
```bash
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:
```bash
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.
```bash
./docs/setup/scripts/bootstrap.sh
```
What each script does:
| Script | Effect | Idempotent? |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| [`10-base-packages.sh`](scripts/10-base-packages.sh) | `apt update` + install base packages (curl wget git build-essential ca-certificates gnupg software-properties-common) | ✅ |
| [`20-zsh.sh`](scripts/20-zsh.sh) | Install zsh, set as default shell. Install Oh My Zsh, Powerlevel10k, plugins (autosuggestions, syntax-highlighting). Patch `~/.zshrc`. | ✅ |
| [`30-cli-tools.sh`](scripts/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 (`batcat``bat`, `fdfind``fd`). | ✅ |
| [`40-node.sh`](scripts/40-node.sh) | Install nvm. Install Node from the repo's `.nvmrc` (currently `24`). Enable corepack, pin pnpm to `package.json`'s `packageManager`. | ✅ |
| [`50-docker.sh`](scripts/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`](scripts/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`](scripts/70-hardening.sh) | Best-effort UFW (allow SSH only by default), `unattended-upgrades` on security channel, `fail2ban`, sshd hardening (no root, no password). **Probes before applying** — skips what's already done. | ✅ |
| [`80-dotfiles.sh`](scripts/80-dotfiles.sh) | Symlink `notes/aliases.zsh``~/.oh-my-zsh/custom/aliases.zsh`. Generate `~/.gitconfig` from `notes/gitconfig.txt`, prompt for identity (name + email). | ✅ |
After bootstrap, reload your shell to pick up the new PATH + zsh:
```bash
exec zsh -l
p10k configure # only if you skipped during 20-zsh.sh
```
## Step 4 — Project secrets + first run
```bash
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](../../infra/README.md)).
Then install the workspace dependencies:
```bash
pnpm install
pnpm exec nx run-many -t lint test --parallel=3 # smoke test
```
## Step 5 — Choose your IDE flow
### Option A — VSCode Remote-SSH (default)
From your workstation:
1. `F1``Remote-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:
```bash
# 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
```bash
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:
```bash
# 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:
```bash
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:
```bash
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:
```bash
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:
```bash
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 is already structured to fall back to either (a) the in-repo files (`notes/aliases.zsh`, `notes/gitconfig.txt`) or (b) a `~/.dotfiles/` checkout if one exists. A subsequent PR creates the dotfiles repo and reuses these inputs as the 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](https://wiki.gnupg.org/AgentForwarding); 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:
```bash
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.