docs(setup): add Debian 13 dev-VM setup procedure + scripts + devcontainer
CI / commits (pull_request) Successful in 2m52s
CI / check (pull_request) Successful in 3m3s
CI / scan (pull_request) Successful in 3m11s
CI / a11y (pull_request) Successful in 3m5s
Docs site / build (pull_request) Successful in 3m10s
CI / perf (pull_request) Successful in 6m53s
CI / commits (pull_request) Successful in 2m52s
CI / check (pull_request) Successful in 3m3s
CI / scan (pull_request) Successful in 3m11s
CI / a11y (pull_request) Successful in 3m5s
Docs site / build (pull_request) Successful in 3m10s
CI / perf (pull_request) Successful in 6m53s
New procedure in docs/setup/01-dev-debian-vm-setup.md targeting the new dev VM (10.100.201.21, Debian 13) that replaces the WSL workflow. The existing setup files are renumbered (01->02, 02->03, 03->04) to free prefix 01 for the default flow. 10 modular idempotent scripts under docs/setup/scripts/ install the stack: zsh + Oh My Zsh + Powerlevel10k + plugins, modern CLI tooling (bat, eza, fd-find, ripgrep, fzf, zoxide, ncdu, keychain + dev extras), nvm + Node from .nvmrc + corepack + pnpm, Docker CE + compose plugin, inotify watches + swap + hostname tuning, best-effort UFW + sshd hardening that probes-before-applying, and notes/aliases.zsh + notes/gitconfig.txt installation. A systemd template (apf-portal-infra@.service) auto-starts the local dev infra stack at boot per user. .devcontainer/ ships a VSCode Dev Container spec pinning Node 24 and attaching to the host's apf-portal-dev Compose network so postgres / redis / otel resolve by DNS from inside the container. initializeCommand fails fast if the infra hasn't been brought up yet. CLAUDE.md Environment conventions documents the two envs (local + development) plus the hybrid sub-mode (workstation IDE + VM infra via SSH LocalForward) and the two IDE flows (Remote-SSH + Devcontainer). Adjacent follow-ups (not in this PR): preview infra on the GitLab VM (10.100.201.10), GitLab Runner migration alongside the Gitea -> GitLab cutover, private dotfiles repo.
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
# 🚀 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.
|
||||
@@ -4,10 +4,10 @@
|
||||
|
||||
A terminal that is:
|
||||
|
||||
* readable
|
||||
* fast
|
||||
* equipped with intelligent autocomplete
|
||||
* suited for modern development
|
||||
- readable
|
||||
- fast
|
||||
- equipped with intelligent autocomplete
|
||||
- suited for modern development
|
||||
|
||||
---
|
||||
|
||||
@@ -70,8 +70,8 @@ Install a Nerd Font on Windows (MesloLGS NF recommended).
|
||||
|
||||
Configure it in Windows Terminal:
|
||||
|
||||
* Settings → Debian profile
|
||||
* Font → MesloLGS NF
|
||||
- Settings → Debian profile
|
||||
- Font → MesloLGS NF
|
||||
|
||||
---
|
||||
|
||||
@@ -129,6 +129,6 @@ Add to `~/.zshrc`:
|
||||
|
||||
## Result
|
||||
|
||||
* A modern terminal
|
||||
* Fast navigation
|
||||
* Higher productivity
|
||||
- A modern terminal
|
||||
- Fast navigation
|
||||
- Higher productivity
|
||||
@@ -48,9 +48,9 @@ cd ~/dev
|
||||
|
||||
## 4) Conventions
|
||||
|
||||
* ❌ No global Angular install
|
||||
* ✅ Use `pnpm dlx`
|
||||
* ✅ Versions pinned per project
|
||||
- ❌ No global Angular install
|
||||
- ✅ Use `pnpm dlx`
|
||||
- ✅ Versions pinned per project
|
||||
|
||||
---
|
||||
|
||||
@@ -69,11 +69,11 @@ pnpm run test
|
||||
|
||||
Recommended extensions:
|
||||
|
||||
* WSL
|
||||
* Angular Language Service
|
||||
* ESLint
|
||||
* Prettier
|
||||
* GitLens
|
||||
- WSL
|
||||
- Angular Language Service
|
||||
- ESLint
|
||||
- Prettier
|
||||
- GitLens
|
||||
|
||||
---
|
||||
|
||||
@@ -83,7 +83,7 @@ Install Docker Desktop (Windows).
|
||||
|
||||
Enable:
|
||||
|
||||
* Settings → WSL Integration → Debian
|
||||
- Settings → WSL Integration → Debian
|
||||
|
||||
Test:
|
||||
|
||||
@@ -95,6 +95,6 @@ docker ps
|
||||
|
||||
## Result
|
||||
|
||||
* Stable environment
|
||||
* Clean version management
|
||||
* Team-compatible
|
||||
- Stable environment
|
||||
- Clean version management
|
||||
- Team-compatible
|
||||
@@ -0,0 +1,33 @@
|
||||
# Setup guides
|
||||
|
||||
Step-by-step setup procedures for new contributors. Each file is self-contained and idempotent — they document the state a developer should reach, not the order in which it must be done.
|
||||
|
||||
| # | File | Audience |
|
||||
| --- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 01 | [Debian 13 dev-VM setup](01-dev-debian-vm-setup.md) | **Default** — a dev who has just received a fresh dev VM from the infra team. Covers the full path: zsh + Oh My Zsh + Powerlevel10k, modern CLI tooling, Node via nvm, Docker CE, system tuning, optional VM hardening, project clones, the two IDE flows (VSCode Remote-SSH and Devcontainer), the hybrid (local + VM) mode. |
|
||||
| 02 | [WSL terminal setup](02-wsl-terminal-setup.md) | Legacy — the original Windows-WSL flow. Kept for reference for devs still on that path until the org migrates to the VM-based flow. |
|
||||
| 03 | [Dev web stack](03-dev-web-stack.md) | Notes on the web-side tooling. |
|
||||
| 04 | [Angular + Nx monorepo](04-angular-nx-monorepo.md) | Reference notes on the Angular + Nx workspace layout. |
|
||||
|
||||
## `scripts/`
|
||||
|
||||
Modular setup scripts called from the doc above. Each is idempotent, each can be re-run safely. Run via the orchestrator (`bootstrap.sh`) or individually.
|
||||
|
||||
| Script | Purpose |
|
||||
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [`bootstrap.sh`](scripts/bootstrap.sh) | Run every numbered script in order with a confirmation prompt at each step. |
|
||||
| [`lib.sh`](scripts/lib.sh) | Common helpers — colour logging, idempotent `apt_install` / `ensure_line`. |
|
||||
| [`10-base-packages.sh`](scripts/10-base-packages.sh) | apt + base packages every other script assumes (`curl wget git build-essential …`). |
|
||||
| [`20-zsh.sh`](scripts/20-zsh.sh) | zsh, Oh My Zsh, Powerlevel10k, autosuggestions, syntax-highlighting. Patches `~/.zshrc`. |
|
||||
| [`30-cli-tools.sh`](scripts/30-cli-tools.sh) | `bat eza fd-find ripgrep fzf zoxide ncdu keychain` + dev extras (`jq yq httpie make tree htop tmux direnv`). |
|
||||
| [`40-node.sh`](scripts/40-node.sh) | nvm + Node from the repo's `.nvmrc` + corepack pinned to `package.json#packageManager`'s pnpm version. |
|
||||
| [`50-docker.sh`](scripts/50-docker.sh) | Docker CE + compose plugin from docker.com apt repo + user added to `docker` group + service enabled at boot. |
|
||||
| [`60-tuning.sh`](scripts/60-tuning.sh) | inotify watches → 524288, optional 4 GB swapfile, optional hostname rename (only prompts if generic). |
|
||||
| [`70-hardening.sh`](scripts/70-hardening.sh) | Best-effort UFW + unattended-upgrades + fail2ban + sshd hardening drop-in. Probes before applying — skips done items. |
|
||||
| [`80-dotfiles.sh`](scripts/80-dotfiles.sh) | Symlinks `notes/aliases.zsh` → `~/.oh-my-zsh/custom/aliases.zsh`. Copies `notes/gitconfig.txt` to `~/.gitconfig` with prompted identity. |
|
||||
|
||||
## `systemd/`
|
||||
|
||||
| File | Purpose |
|
||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| [`apf-portal-infra@.service`](systemd/apf-portal-infra@.service) | Template unit auto-starting `./infra/local/dev.sh up` at boot for a given user. |
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# apt update + install base packages every other script assumes.
|
||||
set -euo pipefail
|
||||
# shellcheck source=./lib.sh
|
||||
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
|
||||
require_debian
|
||||
require_not_root
|
||||
|
||||
log "apt update"
|
||||
sudo apt-get update -qq
|
||||
|
||||
apt_install \
|
||||
curl wget git ca-certificates gnupg lsb-release \
|
||||
software-properties-common apt-transport-https \
|
||||
build-essential pkg-config
|
||||
|
||||
ok "Base packages ready."
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# zsh + Oh My Zsh + Powerlevel10k + plugins (autosuggestions, syntax-highlighting).
|
||||
# Patches ~/.zshrc to set the theme, plugin list, fzf hook.
|
||||
set -euo pipefail
|
||||
# shellcheck source=./lib.sh
|
||||
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
|
||||
require_debian
|
||||
require_not_root
|
||||
|
||||
apt_install zsh
|
||||
|
||||
# Make zsh the default shell for $USER (idempotent).
|
||||
current_shell="$(getent passwd "$USER" | cut -d: -f7)"
|
||||
zsh_path="$(command -v zsh)"
|
||||
if [[ "$current_shell" != "$zsh_path" ]]; then
|
||||
log "Setting zsh as default shell for $USER (sudo will prompt)."
|
||||
sudo chsh -s "$zsh_path" "$USER"
|
||||
ok "Default shell set to $zsh_path. Takes effect on next login."
|
||||
else
|
||||
skip "zsh already default shell."
|
||||
fi
|
||||
|
||||
# Oh My Zsh — install with RUNZSH=no to avoid spawning zsh during the script.
|
||||
OMZ_DIR="$HOME/.oh-my-zsh"
|
||||
if [[ -d "$OMZ_DIR" ]]; then
|
||||
skip "Oh My Zsh already installed."
|
||||
else
|
||||
log "Installing Oh My Zsh."
|
||||
RUNZSH=no CHSH=no KEEP_ZSHRC=yes sh -c \
|
||||
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
|
||||
ok "Oh My Zsh installed."
|
||||
fi
|
||||
|
||||
ZSH_CUSTOM_DIR="${ZSH_CUSTOM:-$OMZ_DIR/custom}"
|
||||
|
||||
clone_or_skip() {
|
||||
local name="$1" url="$2" dir="$3"
|
||||
if [[ -d "$dir" ]]; then
|
||||
skip "$name already cloned at $dir."
|
||||
else
|
||||
log "Cloning $name."
|
||||
git clone --depth=1 "$url" "$dir"
|
||||
ok "$name installed."
|
||||
fi
|
||||
}
|
||||
|
||||
# Powerlevel10k theme.
|
||||
clone_or_skip "Powerlevel10k" \
|
||||
"https://github.com/romkatv/powerlevel10k.git" \
|
||||
"$ZSH_CUSTOM_DIR/themes/powerlevel10k"
|
||||
|
||||
# Plugins.
|
||||
clone_or_skip "zsh-autosuggestions" \
|
||||
"https://github.com/zsh-users/zsh-autosuggestions.git" \
|
||||
"$ZSH_CUSTOM_DIR/plugins/zsh-autosuggestions"
|
||||
|
||||
clone_or_skip "zsh-syntax-highlighting" \
|
||||
"https://github.com/zsh-users/zsh-syntax-highlighting.git" \
|
||||
"$ZSH_CUSTOM_DIR/plugins/zsh-syntax-highlighting"
|
||||
|
||||
# Patch ~/.zshrc — theme + plugin list + fzf hook.
|
||||
ZSHRC="$HOME/.zshrc"
|
||||
[[ -f "$ZSHRC" ]] || touch "$ZSHRC"
|
||||
|
||||
if grep -q '^ZSH_THEME=' "$ZSHRC"; then
|
||||
if grep -q '^ZSH_THEME="powerlevel10k/powerlevel10k"' "$ZSHRC"; then
|
||||
skip "ZSH_THEME already powerlevel10k."
|
||||
else
|
||||
sed -i 's#^ZSH_THEME=.*#ZSH_THEME="powerlevel10k/powerlevel10k"#' "$ZSHRC"
|
||||
ok "ZSH_THEME → powerlevel10k/powerlevel10k."
|
||||
fi
|
||||
else
|
||||
echo 'ZSH_THEME="powerlevel10k/powerlevel10k"' >> "$ZSHRC"
|
||||
ok 'Set ZSH_THEME=powerlevel10k/powerlevel10k.'
|
||||
fi
|
||||
|
||||
PLUGINS_LINE='plugins=(git fzf zoxide direnv zsh-autosuggestions zsh-syntax-highlighting)'
|
||||
if grep -q '^plugins=' "$ZSHRC"; then
|
||||
if grep -q "^$PLUGINS_LINE" "$ZSHRC"; then
|
||||
skip "plugins=(...) already configured."
|
||||
else
|
||||
sed -i "s#^plugins=.*#$PLUGINS_LINE#" "$ZSHRC"
|
||||
ok "plugins=(...) updated."
|
||||
fi
|
||||
else
|
||||
echo "$PLUGINS_LINE" >> "$ZSHRC"
|
||||
ok "plugins=(...) added."
|
||||
fi
|
||||
|
||||
ensure_line '[ -f ~/.fzf.zsh ] && source ~/.fzf.zsh' "$ZSHRC"
|
||||
|
||||
ok "zsh + Oh My Zsh + Powerlevel10k ready."
|
||||
log "Next: run 'exec zsh -l' then 'p10k configure' to pick a Powerlevel10k preset."
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Modern CLI tools — what notes/aliases.zsh assumes + a fullstack-dev kit.
|
||||
set -euo pipefail
|
||||
# shellcheck source=./lib.sh
|
||||
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
|
||||
require_debian
|
||||
require_not_root
|
||||
|
||||
# Requested in the project brief.
|
||||
apt_install bat eza fd-find ripgrep fzf zoxide ncdu keychain
|
||||
|
||||
# Useful extras for a fullstack dev workflow.
|
||||
apt_install jq yq httpie make tree htop tmux direnv dnsutils unzip rsync
|
||||
|
||||
# Debian renames `bat`→`batcat` and `fd`→`fdfind` to avoid binary
|
||||
# conflicts. notes/aliases.zsh aliases the Debian names back, but a
|
||||
# direct binary on PATH is more robust (works for non-interactive
|
||||
# scripts and other shells). Symlink in ~/.local/bin.
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
for pair in "batcat:bat" "fdfind:fd"; do
|
||||
src="${pair%%:*}"
|
||||
dst="${pair##*:}"
|
||||
src_path="$(command -v "$src" 2>/dev/null || true)"
|
||||
if [[ -z "$src_path" ]]; then
|
||||
warn "$src not found on PATH — skipping symlink."
|
||||
continue
|
||||
fi
|
||||
if [[ -L "$HOME/.local/bin/$dst" && "$(readlink "$HOME/.local/bin/$dst")" == "$src_path" ]]; then
|
||||
skip "$HOME/.local/bin/$dst already linked to $src."
|
||||
elif [[ -e "$HOME/.local/bin/$dst" ]]; then
|
||||
skip "$HOME/.local/bin/$dst exists (not a managed symlink) — leaving as-is."
|
||||
else
|
||||
ln -s "$src_path" "$HOME/.local/bin/$dst"
|
||||
ok "Linked $HOME/.local/bin/$dst → $src."
|
||||
fi
|
||||
done
|
||||
|
||||
# Make sure ~/.local/bin is on PATH for both bash and zsh.
|
||||
for rcfile in "$HOME/.bashrc" "$HOME/.zshrc"; do
|
||||
[[ -f "$rcfile" ]] || touch "$rcfile"
|
||||
ensure_line 'export PATH="$HOME/.local/bin:$PATH"' "$rcfile"
|
||||
done
|
||||
|
||||
ok "CLI tools installed."
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# nvm + Node from the repo's .nvmrc + corepack + pnpm at the version pinned
|
||||
# in package.json's packageManager field.
|
||||
set -euo pipefail
|
||||
# shellcheck source=./lib.sh
|
||||
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
|
||||
require_debian
|
||||
require_not_root
|
||||
|
||||
NVM_VERSION="v0.40.1"
|
||||
NVM_DIR="$HOME/.nvm"
|
||||
|
||||
if [[ -d "$NVM_DIR" ]]; then
|
||||
skip "nvm already installed at $NVM_DIR."
|
||||
else
|
||||
log "Installing nvm $NVM_VERSION."
|
||||
curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/$NVM_VERSION/install.sh" | bash
|
||||
ok "nvm installed."
|
||||
fi
|
||||
|
||||
# Source nvm so we can use it in this shell.
|
||||
export NVM_DIR
|
||||
# shellcheck disable=SC1091
|
||||
source "$NVM_DIR/nvm.sh"
|
||||
|
||||
# Read .nvmrc from the repo root.
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [[ -z "$REPO_ROOT" ]]; then
|
||||
err "Run from inside a git checkout of apf_portal (no repo root found)."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$REPO_ROOT/.nvmrc" ]]; then
|
||||
warn "$REPO_ROOT/.nvmrc not found — installing Node LTS as fallback."
|
||||
nvm install --lts
|
||||
nvm alias default 'lts/*'
|
||||
else
|
||||
NODE_VER="$(<"$REPO_ROOT/.nvmrc")"
|
||||
log "Installing Node $NODE_VER (from $REPO_ROOT/.nvmrc)."
|
||||
nvm install "$NODE_VER"
|
||||
nvm alias default "$NODE_VER"
|
||||
ok "Node $(node --version) active."
|
||||
fi
|
||||
|
||||
# corepack — provides pnpm + yarn shims, reads packageManager from package.json.
|
||||
corepack enable
|
||||
ok "corepack enabled."
|
||||
|
||||
# Warm pnpm to download the pinned version.
|
||||
if [[ -f "$REPO_ROOT/package.json" ]]; then
|
||||
pushd "$REPO_ROOT" >/dev/null
|
||||
pnpm --version >/dev/null
|
||||
ok "pnpm $(pnpm --version) ready (from package.json packageManager)."
|
||||
popd >/dev/null
|
||||
fi
|
||||
|
||||
log "Reload your shell (exec zsh -l) so nvm + node + pnpm are on PATH."
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# Docker CE + compose plugin from the docker.com apt repo. Adds the
|
||||
# current user to the `docker` group and enables the daemon at boot.
|
||||
set -euo pipefail
|
||||
# shellcheck source=./lib.sh
|
||||
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
|
||||
require_debian
|
||||
require_not_root
|
||||
|
||||
if command -v docker >/dev/null 2>&1 \
|
||||
&& docker compose version >/dev/null 2>&1; then
|
||||
skip "docker + compose plugin already present."
|
||||
else
|
||||
log "Installing Docker CE from docker.com apt repo."
|
||||
|
||||
# GPG key.
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg \
|
||||
| sudo tee /etc/apt/keyrings/docker.asc >/dev/null
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||
fi
|
||||
|
||||
# Repo definition.
|
||||
ARCH="$(dpkg --print-architecture)"
|
||||
# shellcheck disable=SC1091
|
||||
CODENAME="$(. /etc/os-release && echo "${VERSION_CODENAME}")"
|
||||
REPO_LINE="deb [arch=${ARCH} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${CODENAME} stable"
|
||||
|
||||
if [[ -f /etc/apt/sources.list.d/docker.list ]] \
|
||||
&& grep -qxF "$REPO_LINE" /etc/apt/sources.list.d/docker.list; then
|
||||
skip "docker.list already configured for $CODENAME."
|
||||
else
|
||||
echo "$REPO_LINE" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
|
||||
ok "Wrote /etc/apt/sources.list.d/docker.list."
|
||||
fi
|
||||
|
||||
sudo apt-get update -qq
|
||||
apt_install docker-ce docker-ce-cli containerd.io \
|
||||
docker-buildx-plugin docker-compose-plugin
|
||||
ok "Docker installed."
|
||||
fi
|
||||
|
||||
# Add user to docker group.
|
||||
if id -nG "$USER" | grep -qw docker; then
|
||||
skip "User $USER already in docker group."
|
||||
else
|
||||
sudo usermod -aG docker "$USER"
|
||||
warn "Added $USER to docker group — log out and back in for it to apply."
|
||||
fi
|
||||
|
||||
# Enable docker.service at boot.
|
||||
if systemctl is-enabled docker.service >/dev/null 2>&1; then
|
||||
skip "docker.service already enabled at boot."
|
||||
else
|
||||
sudo systemctl enable --now docker.service
|
||||
ok "docker.service enabled and started."
|
||||
fi
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# System-level tuning for Node / Vite / Nx development on a Debian VM:
|
||||
# - inotify watches limit (default 8K is too low for monorepos)
|
||||
# - hostname (prompt only if generic)
|
||||
# - swapfile (4 GB, only if none exists)
|
||||
set -euo pipefail
|
||||
# shellcheck source=./lib.sh
|
||||
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
|
||||
require_debian
|
||||
require_not_root
|
||||
|
||||
# 1) inotify watches.
|
||||
SYSCTL_FILE="/etc/sysctl.d/99-apf-portal.conf"
|
||||
INOTIFY_LINE="fs.inotify.max_user_watches=524288"
|
||||
if sudo grep -qxF "$INOTIFY_LINE" "$SYSCTL_FILE" 2>/dev/null; then
|
||||
skip "inotify watches already configured in $SYSCTL_FILE."
|
||||
else
|
||||
echo "$INOTIFY_LINE" | sudo tee "$SYSCTL_FILE" >/dev/null
|
||||
sudo sysctl --system >/dev/null
|
||||
ok "fs.inotify.max_user_watches → 524288."
|
||||
fi
|
||||
|
||||
# 2) Hostname — only prompt if it looks generic.
|
||||
current_hostname="$(hostname)"
|
||||
case "$current_hostname" in
|
||||
debian|debian13|localhost|trixie|debian12)
|
||||
read -r -p "Current hostname '$current_hostname' looks generic. New hostname (blank to keep): " new_hostname
|
||||
if [[ -n "$new_hostname" ]]; then
|
||||
sudo hostnamectl set-hostname "$new_hostname"
|
||||
ok "Hostname set to '$new_hostname'. Will reflect on next login."
|
||||
else
|
||||
skip "Hostname kept as '$current_hostname'."
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
skip "Hostname '$current_hostname' looks intentional — keeping."
|
||||
;;
|
||||
esac
|
||||
|
||||
# 3) Swap.
|
||||
if swapon --show | grep -q .; then
|
||||
skip "Swap already active: $(swapon --show --noheadings | head -1 | awk '{print $1, $3}')."
|
||||
else
|
||||
if confirm "No swap detected. Add a 4 GB swapfile at /swapfile?"; then
|
||||
sudo fallocate -l 4G /swapfile
|
||||
sudo chmod 600 /swapfile
|
||||
sudo mkswap /swapfile
|
||||
sudo swapon /swapfile
|
||||
ensure_line_sudo "/swapfile none swap sw 0 0" /etc/fstab
|
||||
ok "4 GB swapfile active."
|
||||
else
|
||||
skip "No swap configured (user choice)."
|
||||
fi
|
||||
fi
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
# Best-effort VM hardening — probes current state, only applies what's missing.
|
||||
# Skips cleanly when infra has already configured a step.
|
||||
# - UFW (allow SSH only)
|
||||
# - unattended-upgrades (security channel)
|
||||
# - fail2ban
|
||||
# - sshd: disable root login + password auth (always validates first)
|
||||
set -euo pipefail
|
||||
# shellcheck source=./lib.sh
|
||||
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
|
||||
require_debian
|
||||
require_not_root
|
||||
|
||||
log "Hardening pass — each section probes and skips if already done."
|
||||
|
||||
# ─── 1. UFW ────────────────────────────────────────────────────────────
|
||||
if ! command -v ufw >/dev/null; then
|
||||
apt_install ufw
|
||||
fi
|
||||
if sudo ufw status | head -1 | grep -q 'Status: active'; then
|
||||
skip "UFW already active. Current rules:"
|
||||
sudo ufw status numbered | sed 's/^/ /'
|
||||
else
|
||||
if confirm "Enable UFW with 'deny incoming, allow SSH'?"; then
|
||||
sudo ufw default deny incoming >/dev/null
|
||||
sudo ufw default allow outgoing >/dev/null
|
||||
sudo ufw allow OpenSSH >/dev/null
|
||||
sudo ufw --force enable
|
||||
ok "UFW enabled (SSH-only ingress)."
|
||||
else
|
||||
skip "UFW left disabled."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── 2. unattended-upgrades ────────────────────────────────────────────
|
||||
apt_install unattended-upgrades
|
||||
AUTO_UP="/etc/apt/apt.conf.d/20auto-upgrades"
|
||||
if [[ -f "$AUTO_UP" ]] \
|
||||
&& sudo grep -q 'APT::Periodic::Update-Package-Lists "1"' "$AUTO_UP" \
|
||||
&& sudo grep -q 'APT::Periodic::Unattended-Upgrade "1"' "$AUTO_UP"; then
|
||||
skip "unattended-upgrades already enabled."
|
||||
else
|
||||
cat <<'EOF' | sudo tee "$AUTO_UP" >/dev/null
|
||||
APT::Periodic::Update-Package-Lists "1";
|
||||
APT::Periodic::Unattended-Upgrade "1";
|
||||
EOF
|
||||
ok "unattended-upgrades enabled."
|
||||
fi
|
||||
|
||||
# ─── 3. fail2ban ───────────────────────────────────────────────────────
|
||||
apt_install fail2ban
|
||||
if systemctl is-active fail2ban.service >/dev/null 2>&1; then
|
||||
skip "fail2ban already running."
|
||||
else
|
||||
sudo systemctl enable --now fail2ban.service
|
||||
ok "fail2ban started + enabled at boot."
|
||||
fi
|
||||
|
||||
# ─── 4. sshd hardening ────────────────────────────────────────────────
|
||||
# Drop-in file rather than editing /etc/ssh/sshd_config directly.
|
||||
# We always validate with `sshd -t` before reloading the daemon.
|
||||
SSHD_DROPIN="/etc/ssh/sshd_config.d/99-apf-portal-hardening.conf"
|
||||
if sudo test -f "$SSHD_DROPIN"; then
|
||||
skip "sshd hardening drop-in already at $SSHD_DROPIN."
|
||||
else
|
||||
warn "About to disable password auth + root login on SSH."
|
||||
warn "Make sure your SSH key already works from your workstation."
|
||||
if confirm "Continue?"; then
|
||||
cat <<'EOF' | sudo tee "$SSHD_DROPIN" >/dev/null
|
||||
# Managed by docs/setup/scripts/70-hardening.sh.
|
||||
# To override or extend, drop another file in /etc/ssh/sshd_config.d/.
|
||||
PermitRootLogin no
|
||||
PasswordAuthentication no
|
||||
ChallengeResponseAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
UsePAM yes
|
||||
AllowAgentForwarding yes
|
||||
EOF
|
||||
if sudo sshd -t 2>/dev/null; then
|
||||
sudo systemctl reload ssh.service 2>/dev/null || sudo systemctl reload sshd.service
|
||||
ok "sshd reloaded with hardened config."
|
||||
else
|
||||
err "sshd config validation failed — removing $SSHD_DROPIN."
|
||||
sudo rm -f "$SSHD_DROPIN"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
skip "sshd hardening declined."
|
||||
fi
|
||||
fi
|
||||
|
||||
ok "Hardening pass complete."
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wire up the project-specific dotfiles:
|
||||
# - symlink notes/aliases.zsh → ~/.oh-my-zsh/custom/aliases.zsh
|
||||
# - copy notes/gitconfig.txt to ~/.gitconfig and overlay your identity
|
||||
set -euo pipefail
|
||||
# shellcheck source=./lib.sh
|
||||
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
|
||||
require_debian
|
||||
require_not_root
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [[ -z "$REPO_ROOT" ]]; then
|
||||
err "Run from inside a git checkout of apf_portal."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ─── 1. Aliases ───────────────────────────────────────────────────────
|
||||
SOURCE_ALIASES="$REPO_ROOT/notes/aliases.zsh"
|
||||
if [[ ! -f "$SOURCE_ALIASES" ]]; then
|
||||
err "$SOURCE_ALIASES not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OMZ_CUSTOM="${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}"
|
||||
if [[ ! -d "$OMZ_CUSTOM" ]]; then
|
||||
err "$OMZ_CUSTOM not found — run 20-zsh.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET_ALIASES="$OMZ_CUSTOM/aliases.zsh"
|
||||
if [[ -L "$TARGET_ALIASES" \
|
||||
&& "$(readlink -f "$TARGET_ALIASES")" == "$(readlink -f "$SOURCE_ALIASES")" ]]; then
|
||||
skip "aliases.zsh symlink already correct."
|
||||
else
|
||||
if [[ -e "$TARGET_ALIASES" || -L "$TARGET_ALIASES" ]]; then
|
||||
backup="$TARGET_ALIASES.bak.$(date +%s)"
|
||||
mv "$TARGET_ALIASES" "$backup"
|
||||
warn "Existing $TARGET_ALIASES backed up to $backup."
|
||||
fi
|
||||
ln -s "$SOURCE_ALIASES" "$TARGET_ALIASES"
|
||||
ok "Linked $TARGET_ALIASES → $SOURCE_ALIASES."
|
||||
fi
|
||||
|
||||
# ─── 2. gitconfig — copy + identity overlay ───────────────────────────
|
||||
SOURCE_GITCONFIG="$REPO_ROOT/notes/gitconfig.txt"
|
||||
if [[ ! -f "$SOURCE_GITCONFIG" ]]; then
|
||||
err "$SOURCE_GITCONFIG not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET_GITCONFIG="$HOME/.gitconfig"
|
||||
if [[ -f "$TARGET_GITCONFIG" ]] && ! confirm "$TARGET_GITCONFIG exists — overwrite (existing backed up)?"; then
|
||||
skip "Keeping existing $TARGET_GITCONFIG."
|
||||
else
|
||||
if [[ -f "$TARGET_GITCONFIG" ]]; then
|
||||
cp "$TARGET_GITCONFIG" "$TARGET_GITCONFIG.bak.$(date +%s)"
|
||||
fi
|
||||
|
||||
# Prompt for identity. Defaults: pre-existing config values, else blank.
|
||||
current_name="$(git config --global user.name 2>/dev/null || true)"
|
||||
current_email="$(git config --global user.email 2>/dev/null || true)"
|
||||
read -r -p "Git user.name [${current_name:-<required>}]: " new_name
|
||||
new_name="${new_name:-$current_name}"
|
||||
read -r -p "Git user.email [${current_email:-<required>}]: " new_email
|
||||
new_email="${new_email:-$current_email}"
|
||||
if [[ -z "$new_name" || -z "$new_email" ]]; then
|
||||
err "Both user.name and user.email are required."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$SOURCE_GITCONFIG" "$TARGET_GITCONFIG"
|
||||
# `git config --global` finds and rewrites the [user] section
|
||||
# cleanly even when the source carries a different identity.
|
||||
git config --global user.name "$new_name"
|
||||
git config --global user.email "$new_email"
|
||||
|
||||
ok "Wrote $TARGET_GITCONFIG with identity $new_name <$new_email>."
|
||||
fi
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrator — runs every numbered setup script in order with a confirmation prompt.
|
||||
# Each script is also runnable individually:
|
||||
# ./docs/setup/scripts/40-node.sh
|
||||
# See docs/setup/01-dev-debian-vm-setup.md for the step-by-step doc.
|
||||
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=./lib.sh
|
||||
source "$HERE/lib.sh"
|
||||
|
||||
require_debian
|
||||
require_not_root
|
||||
|
||||
SCRIPTS=(
|
||||
10-base-packages.sh
|
||||
20-zsh.sh
|
||||
30-cli-tools.sh
|
||||
40-node.sh
|
||||
50-docker.sh
|
||||
60-tuning.sh
|
||||
70-hardening.sh
|
||||
80-dotfiles.sh
|
||||
)
|
||||
|
||||
log "Bootstrap will run, in order:"
|
||||
for s in "${SCRIPTS[@]}"; do
|
||||
printf ' - %s\n' "$s"
|
||||
done
|
||||
echo
|
||||
|
||||
if ! confirm "Continue?"; then
|
||||
log "Aborted before any change."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for s in "${SCRIPTS[@]}"; do
|
||||
echo
|
||||
log "═══ $s ═══"
|
||||
if confirm "Run $s now?"; then
|
||||
"$HERE/$s"
|
||||
ok "$s done"
|
||||
else
|
||||
skip "$s"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
ok "Bootstrap complete."
|
||||
log "Next steps:"
|
||||
printf ' - exec zsh -l # reload shell to pick up zsh + PATH\n'
|
||||
printf ' - p10k configure # only if you skipped during 20-zsh.sh\n'
|
||||
printf ' - cp infra/local/.env.example infra/local/.env && $EDITOR infra/local/.env\n'
|
||||
printf ' - ./infra/local/dev.sh up\n'
|
||||
printf ' - pnpm install\n'
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared helpers for docs/setup/scripts/*.sh — sourced from each script.
|
||||
# Idempotency utilities + nice logging.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
C_GREEN=$'\033[1;32m'
|
||||
C_YELLOW=$'\033[1;33m'
|
||||
C_RED=$'\033[1;31m'
|
||||
C_BLUE=$'\033[1;34m'
|
||||
C_DIM=$'\033[2m'
|
||||
C_RESET=$'\033[0m'
|
||||
else
|
||||
C_GREEN= C_YELLOW= C_RED= C_BLUE= C_DIM= C_RESET=
|
||||
fi
|
||||
|
||||
log() { printf '%s[%s]%s %s\n' "$C_BLUE" "$(date +%H:%M:%S)" "$C_RESET" "$*"; }
|
||||
ok() { printf '%s✓%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
|
||||
warn() { printf '%s⚠%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; }
|
||||
err() { printf '%s✗%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; }
|
||||
skip() { printf '%s↪ skip%s %s\n' "$C_DIM" "$C_RESET" "$*"; }
|
||||
|
||||
require_debian() {
|
||||
if ! grep -q '^ID=debian' /etc/os-release 2>/dev/null; then
|
||||
err "This script targets Debian (read from /etc/os-release)."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_not_root() {
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
err "Run as your regular user (sudo is invoked where needed)."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
confirm() {
|
||||
local prompt="${1:-Proceed?}"
|
||||
read -r -p "$prompt [y/N] " ans
|
||||
[[ "$ans" =~ ^[yY]$ ]]
|
||||
}
|
||||
|
||||
# Install apt packages, skipping those already present. Idempotent.
|
||||
apt_install() {
|
||||
local missing=()
|
||||
for pkg in "$@"; do
|
||||
if ! dpkg -s "$pkg" >/dev/null 2>&1; then
|
||||
missing+=("$pkg")
|
||||
fi
|
||||
done
|
||||
if (( ${#missing[@]} == 0 )); then
|
||||
skip "All packages already present: $*"
|
||||
return 0
|
||||
fi
|
||||
log "apt install: ${missing[*]}"
|
||||
sudo apt-get install -y "${missing[@]}"
|
||||
ok "Installed: ${missing[*]}"
|
||||
}
|
||||
|
||||
# Append a line to a file iff not already present (exact match). Idempotent.
|
||||
ensure_line() {
|
||||
local line="$1"
|
||||
local file="$2"
|
||||
if grep -qxF -- "$line" "$file" 2>/dev/null; then
|
||||
skip "Already in $(basename "$file"): $line"
|
||||
else
|
||||
echo "$line" >> "$file"
|
||||
ok "Appended to $file: $line"
|
||||
fi
|
||||
}
|
||||
|
||||
# Same but with sudo (system files).
|
||||
ensure_line_sudo() {
|
||||
local line="$1"
|
||||
local file="$2"
|
||||
if sudo grep -qxF -- "$line" "$file" 2>/dev/null; then
|
||||
skip "Already in $(basename "$file"): $line"
|
||||
else
|
||||
echo "$line" | sudo tee -a "$file" >/dev/null
|
||||
ok "Appended to $file: $line"
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# Auto-starts the apf-portal local-dev infrastructure stack at boot.
|
||||
#
|
||||
# This is a TEMPLATE unit (note the trailing `@`). The instance name is the
|
||||
# Linux username of the dev who owns the clone — the unit reads
|
||||
# `/home/<user>/Works/apf_portal/infra/local/dev.compose.yml`.
|
||||
#
|
||||
# Install:
|
||||
# sudo cp docs/setup/systemd/apf-portal-infra@.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable --now apf-portal-infra@$USER.service
|
||||
#
|
||||
# Override the working directory if your clone lives elsewhere:
|
||||
# sudo systemctl edit apf-portal-infra@$USER.service
|
||||
# # Add: [Service]
|
||||
# # Environment="REPO=/custom/path/to/apf_portal"
|
||||
# # WorkingDirectory=
|
||||
# # WorkingDirectory=${REPO}
|
||||
# # ExecStart=
|
||||
# # ExecStart=${REPO}/infra/local/dev.sh up
|
||||
# # ExecStop=
|
||||
# # ExecStop=${REPO}/infra/local/dev.sh down
|
||||
|
||||
[Unit]
|
||||
Description=apf-portal local infrastructure (postgres + redis + otel) for %i
|
||||
Requires=docker.service
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
User=%i
|
||||
Group=%i
|
||||
WorkingDirectory=/home/%i/Works/apf_portal
|
||||
ExecStart=/home/%i/Works/apf_portal/infra/local/dev.sh up
|
||||
ExecStop=/home/%i/Works/apf_portal/infra/local/dev.sh down
|
||||
TimeoutStartSec=180
|
||||
TimeoutStopSec=60
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user