Bootstrapping my dev environment in WSL2 Ubuntu (WebDev)

This documents the full setup sequence for a professional development environment inside WSL2 Ubuntu, starting from a bare shell. It covers shell customization, Claude Code and its rule configuration, Git and the GitHub CLI, and the language and infrastructure toolchain.

Updated 2026-08-18: this walkthrough was revised after rebuilding the environment on a fresh machine. The Terraform install, AWS SSO configuration, pyenv toolchain, and jq were added or corrected to reflect what the setup actually requires.

The work happened in several distinct phases:

  1. Shell environment (ZSH + oh-my-zsh)
  2. System dependencies
  3. Claude Code installation
  4. Claude Code configuration and rules
  5. Git global config and GitHub CLI
  6. Version-controlling the .claude configuration
  7. Toolchain additions (Terraform, Node.js, markdownlint)
  8. AWS CLI v2 and multi-org SSO
  9. Python toolchain (pyenv)

Phase 1 - Shell Environment (ZSH + oh-my-zsh)

The first thing to do on a fresh WSL Ubuntu install is replace bash with ZSH and layer oh-my-zsh on top for productivity. Syntax highlighting, git-aware prompts, plugin support, and tab completion that bash can’t match for daily work.

Install ZSH:

sudo apt update && sudo apt upgrade -y
sudo apt install zsh -y

Install oh-my-zsh:

sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"

Set ZSH as the default shell:

chsh -s $(which zsh)

After running chsh, close and reopen your WSL terminal for the change to take effect. From this point forward, all shell configuration lives in ~/.zshrc.


Phase 2 - System Dependencies

Install build dependencies:

sudo apt update
sudo apt install -y make build-essential libssl-dev zlib1g-dev \
libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm \
libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev

Install jq, which this list missed:

sudo apt-get install -y jq

jq went unnoticed until Claude Code’s UserPromptSubmit hooks began printing jq: command not found on every single prompt. Anything that pipes JSON through a shell hook assumes jq is on PATH, and a bare WSL image does not ship it. The failure was non-blocking, which is why it took a while to chase down rather than announcing itself immediately.


Phase 3 - Claude Code Installation

Claude Code is Anthropic’s agentic coding CLI. It runs in the terminal and can read, write, and reason about code in your project with full filesystem context. The install is a single curl command, but in a WSL environment there can be path and shell sourcing quirks that require a reload after install.

Install Claude Code:

curl -fsSL https://claude.ai/install.sh | bash

If claude is not found immediately after install, the installer added it to your PATH in ~/.zshrc but the current session hasn’t picked it up yet.

Reload your shell and verify:

source ~/.zshrc
which claude
claude

Phase 4 - Claude Code Configuration and Rules

Out of the box, Claude Code works, but its real power comes from custom rules - markdown files in ~/.claude/rules/ that provide persistent behavioral context: code standards, IAM review checklists, Git workflow expectations, WSL-specific notes, and more. These are global rules that apply across all projects.

Because this WSL environment is a secondary machine (the primary .claude config lives on the Windows side), the existing config was copied from the Windows profile into the WSL home directory rather than starting from scratch.

Copy existing .claude config from Windows profile into WSL:

cp -r /mnt/c/Users/<windows-user>/.claude ~/

Inspect the directory layout:

tree ~/.claude

Create and manage rule files:

# IAM review checklist
vim ~/.claude/rules/iam-review.md

# Copyright / licensing reminder
vim ~/.claude/rules/copywrite.md

# Code review standards
vim ~/.claude/rules/code-review.md

# WSL-specific bridge notes (Windows paths, interop behavior, etc.)
vim ~/.claude/rules/wsl-bridge.md

Housekeeping - rename and remove rules:

# Remove redundant Windows-specific rule now covered by wsl-bridge.md
rm ~/.claude/rules/windows.md

# Normalize filenames
mv ~/.claude/rules/git-workflow.md ~/.claude/rules/git.md
mv ~/.claude/rules/code-quality.md ~/.claude/rules/engineering.md

Move a project-local rule from global to repo-local:

mv ~/.claude/rules/code-review.md .claude/rules/

Verify configuration:

claude config list
claude mcp list

Phase 5 - Git Global Config and GitHub CLI

Before committing anything, Git needs a global identity. The GitHub CLI (gh) enables authenticated GitHub operations - repo creation, secret management, auth status - without managing personal access tokens manually.

Configure Git identity:

git config --global user.name "Brad Duhon"
git config --global user.email "your@email.com"

Install GitHub CLI (gh):

sudo apt update && sudo apt install curl gpg -y
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
  | sudo gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg

echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] \
https://cli.github.com/packages stable main" \
  | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null

sudo apt update && sudo apt install gh -y

Authenticate:

gh auth login
gh auth status

Phase 6 - Version-Control the .claude Configuration

Claude Code rules and config are valuable - they encode your workflow, standards, and context preferences. Versioning ~/.claude in GitHub means it’s portable, recoverable, and auditable. A .gitignore was set up first to ensure no credentials, tokens, or sensitive auth files are accidentally committed.

Initialize the repo:

cd ~/.claude
git init
git branch -M main

Create a security-conscious .gitignore:

cat > ~/.claude/.gitignore << 'EOF'
# Credentials and secrets
*credentials*
*secret*
*token*
*.key
*.pem
*.env
.env*

# Any JSON that might be auth-related
*auth*.json
*service-account*.json

# OS noise
.DS_Store
Thumbs.db
EOF

Add the GitHub remote and sync:

git remote add origin https://github.com/<your-username>/.claude.git
git branch --set-upstream-to=origin/main main
git pull --rebase origin main

Security note: A config repo is a tempting place for a stray token to hide. Keep the .gitignore ahead of the first commit rather than adding it later, and treat anything that slipped in before it as exposed.


Phase 7 - Toolchain: Terraform, Node.js, markdownlint

Three additions: Terraform for infrastructure-as-code work, Node.js for JavaScript tooling and static-site builds, and markdownlint-cli to enforce markdown quality in documentation and Claude rules.

Terraform did not come from the HashiCorp apt source in the end. The repo pins its version in .terraform-version, so the install pulls that exact release and the local toolchain matches the pin instead of whatever apt happens to ship.

Create a user-local bin directory and put it on PATH:

mkdir -p ~/.local/bin
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc

Download the pinned release:

curl -fsSLo /tmp/tf.zip https://releases.hashicorp.com/terraform/1.14.8/terraform_1.14.8_linux_amd64.zip

Extract it without unzip:

python3 -m zipfile -e /tmp/tf.zip ~/.local/bin/
chmod +x ~/.local/bin/terraform

A bare Ubuntu WSL image ships without unzip, and Python’s zipfile module extracts the archive with no package install and no sudo. It does not preserve the executable bit, so the chmod +x is required rather than optional.

Verify the binary matches the pin:

terraform version
cat .terraform-version

Install Node.js 22.x via NodeSource (avoids the outdated apt default):

curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
node --version

Install pnpm (workspace-aware package manager):

sudo npm install -g pnpm

Install markdownlint-cli:

sudo npm install -g markdownlint-cli
markdownlint --version

Phase 8 - AWS CLI v2 and Multi-Org SSO

Two AWS Organizations get reached from this shell, one personal and one corporate, and each runs its own IAM Identity Center. That shapes the whole config: the sso-session block is the organization, and profiles hang off it.

Install unzip, the one step that needs sudo:

sudo apt-get install -y unzip

The python3 -m zipfile fallback from the Terraform install does not work here. The AWS CLI archive carries symlinks and executable bits that Python’s zipfile drops, so unzip is genuinely required rather than a convenience.

Download and extract the installer:

curl -fsSLo /tmp/awscliv2.zip https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip
unzip -q /tmp/awscliv2.zip -d /tmp

Install it user-local, no sudo:

/tmp/aws/install --install-dir ~/.local/aws-cli --bin-dir ~/.local/bin
aws --version

~/.local/bin went on PATH during the Terraform install, so aws resolves with no further shell changes.

Give each organization its own session block in ~/.aws/config:

[sso-session <org-a>]
sso_start_url = https://<org-a-subdomain>.awsapps.com/start
sso_region = <region>
sso_registration_scopes = sso:account:access

[sso-session <org-b>]
sso_start_url = https://<org-b-subdomain>.awsapps.com/start
sso_region = <region>
sso_registration_scopes = sso:account:access

[profile <org-a>-admin]
sso_session = <org-a>
sso_account_id = <ACCOUNT_ID>
sso_role_name = <PermissionSetName>
region = <region>
output = json

[profile <org-b>-admin]
sso_session = <org-b>
sso_account_id = <ACCOUNT_ID>
sso_role_name = <PermissionSetName>
region = <region>
output = json

Sessions are the organization switch: each Identity Center issues its own token, and the profile picks the account and role inside that organization. Add one profile block per account-and-role pair you actually use.

A start URL subdomain is chosen once and does not follow a later company rename, so an outdated name sitting in the corporate URL is expected rather than a mistake.

Where each value comes from:

Log in to one organization:

aws sso login --sso-session <org-a>

The command prints a device-authorization URL and waits. WSL has no browser of its own, so that URL gets pasted into the Windows browser and approved there. The CLI notices the approval and caches the token under ~/.aws/sso/cache.

Verify which identity a profile resolves to:

aws sts get-caller-identity --profile <org-a>-admin

Each session logs in separately and expires separately. A live token for one organization says nothing about the other, so aws sso login gets run once per sso-session name.


Phase 9 - Python Toolchain: pyenv

Ubuntu’s system Python is externally managed under PEP 668, so pip install against it is refused outright. Rather than fight that with --break-system-packages, pyenv supplies a user-owned interpreter where tooling dependencies install freely, and it pins a Python version per project on the way.

Install the build dependencies, the one step that needs sudo:

sudo apt-get install -y build-essential libssl-dev zlib1g-dev libbz2-dev \
libreadline-dev libsqlite3-dev libncursesw5-dev xz-utils tk-dev \
libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev git curl

pyenv compiles CPython from source rather than downloading a binary, which is where the header packages come from. Phase 2 already installed most of this list, so on this machine the command was mostly a no-op.

Install pyenv:

curl -fsSL https://pyenv.run | bash

That clones pyenv and the virtualenv plugin under ~/.pyenv. It does not touch the shell config, which is the next step.

Wire it into ~/.zshrc:

printf '\nexport PYENV_ROOT="$HOME/.pyenv"\nexport PATH="$PYENV_ROOT/bin:$PATH"\neval "$(pyenv init - zsh)"\neval "$(pyenv virtualenv-init -)"\n' >> ~/.zshrc
tail -5 ~/.zshrc
exec zsh

A heredoc is the natural tool for appending a block like this, and it failed twice here. A <<'EOF' terminator has to sit at column 0, and paste handling in an interactive zsh (bracketed paste, prompt frameworks) can indent it, which leaves the shell sitting at a heredoc> prompt waiting for a terminator that never arrives. The printf one-liner has no terminator to break, and tail -5 shows what actually landed before the shell restarts.

Install a Python, cut a working virtualenv, and make it the default:

pyenv install 3.14
pyenv virtualenv 3.14 aws-dev
pyenv global aws-dev

A bare 3.14 resolves to the newest 3.14 patch release pyenv knows about, so the exact patch comes from the version list rather than the command. Setting aws-dev as the global default means every new shell opens inside it already.

Install a package, no sudo and no managed-environment fight:

pip install boto3
python --version

The base 3.14 install stays clean. Project and tooling dependencies land in the aws-dev environment instead. pyenv activate aws-dev is the explicit form for when you have switched away from the default, and pyenv local aws-dev inside a project directory overrides the global for that directory and everything under it. boto3 is what the SSO profile generator needs.


ZSH maintenance

During setup, stale ZSH completion cache files caused warnings. These can be safely removed:

rm ~/.zcompdump-<hostname>-<version>.zwc
rm ~/.zcompdump-<hostname>-<version>
exec zsh

Tools installed

ToolInstall methodPurpose
ZSHaptDefault shell
oh-my-zshcurl installerShell framework
Claude Codeclaude.ai installerAgentic coding CLI
GitHub CLI (gh)apt (GitHub source)GitHub auth + repo operations
Terraformpinned zip (releases.hashicorp.com)Infrastructure as Code
Node.js 22.xNodeSourceJS runtime for front-end tooling
pnpmnpm globalWorkspace package manager
markdownlint-clinpm globalMarkdown linting
AWS CLI v2official zip installer (user-local)AWS API access and SSO login
unzipaptRequired by the AWS CLI installer
pyenvcurl installerPython version manager
jqaptJSON processor for shell hooks
treeaptDirectory visualization

At this point the environment is ready to work in: a configured shell, an agentic CLI carrying its own versioned rules, authenticated Git and GitHub access, and the language and IaC toolchains on PATH. Everything past here is project work, which starts from a clean checkout rather than from a fresh machine.