This is the first post I’m writing here, and I want to start from the very beginning.
Not from “here’s a clever trick I learned last week.” From: what does a working machine actually look like before any project even gets opened? What’s the foundation that everything else sits on?
For me it starts with version management. I work across Java, Python, and Node.js every day. Each project has its own runtime requirement. Some are pinned to old LTS versions, some need the latest. Without a clean way to switch between them, we end up fighting our tools constantly and lose hours that don’t come back.
This post covers how I set up SDKMAN (Java), pyenv (Python), and nvm (Node.js) on a Mac, and how they fit together into a workflow that just works.
The problem with system runtimes
macOS ships with some runtimes pre-installed, but those aren’t meant for development. They’re tied to the OS version, they’re often outdated, and touching them requires sudo. They also give us exactly one version. The moment two projects disagree on the runtime, we’re stuck.
The answer is the same for every language: a version manager. Install it once, use it to install and switch between any version of the runtime we need.
Java — SDKMAN
SDKMAN manages JDK versions, plus Gradle, Maven, Kotlin, and the Spring Boot CLI. It’s the standard tool for this job on Mac and Linux.
Install
curl -s "https://get.sdkman.io" | bash
Restart the terminal (or source ~/.zshrc), then verify:
sdk version
Install a JDK
List available Java distributions:
sdk list java
The list includes Temurin (Eclipse Adoptium), Corretto (Amazon), Zulu (Azul), GraalVM, and others. For most projects I go with Temurin: it’s the community reference build, no vendor strings attached. I’ve never had a reason to reach for anything else unless a project specifically required a particular JVM. Install a specific version:
# Install Java 21 (LTS) — Temurin distribution
sdk install java 21.0.3-tem
# Install Java 17 (LTS) — also Temurin
sdk install java 17.0.11-tem
Switch versions
# Use 21 in the current shell session only
sdk use java 21.0.3-tem
# Set 21 as the global default
sdk default java 21.0.3-tem
# Check what's active
java -version
Per-project pinning
Drop a .sdkmanrc file in the project root:
# .sdkmanrc
java=17.0.11-tem
Then run sdk env inside that directory to activate it. With sdkman_auto_env=true in ~/.sdkman/etc/config, SDKMAN switches automatically on cd.
Python — pyenv
pyenv is the standard version manager for Python. It intercepts the python and python3 commands and routes them to whichever version is currently active.
Install
The cleanest way on Mac is through Homebrew:
brew install pyenv
Then add this to ~/.zshrc:
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
Restart the terminal, then verify:
pyenv --version
Install a Python version
# See all available versions
pyenv install --list
# Install specific versions
pyenv install 3.12.3
pyenv install 3.11.9
Switch versions
# Set global default
pyenv global 3.12.3
# Set for the current shell only
pyenv shell 3.11.9
# Verify
python --version
Per-project pinning
# Inside the project directory
pyenv local 3.11.9
This creates a .python-version file in the directory. Every cd into that folder picks it up automatically. No extra step.
# .python-version
3.11.9
Node.js — nvm
nvm does for Node.js exactly what pyenv does for Python. It manages multiple Node installations and lets us switch between them per-project or per-session.
Install
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
The installer adds the initialization to ~/.zshrc automatically. Restart the terminal, then verify:
nvm --version
Install a Node version
# List available versions (LTS only)
nvm ls-remote --lts
# Install specific versions
nvm install 20 # installs latest 20.x (LTS Iron)
nvm install 18 # installs latest 18.x (LTS Hydrogen)
nvm install --lts # installs the latest active LTS
Switch versions
# Use a version in the current session
nvm use 20
# Set the default for all new shells
nvm alias default 20
# Verify
node --version
npm --version
Per-project pinning
Create a .nvmrc file in the project root:
# .nvmrc
20
Running nvm use with no arguments inside the project reads the file. To make it automatic on cd, add this to ~/.zshrc:
autoload -U add-zsh-hook
load-nvmrc() {
local nvmrc_path
nvmrc_path="$(nvm_find_nvmrc)"
if [ -n "$nvmrc_path" ]; then
local nvmrc_node_version
nvmrc_node_version=$(nvm version "$(cat "${nvmrc_path}")")
if [ "$nvmrc_node_version" = "N/A" ]; then
nvm install
elif [ "$nvmrc_node_version" != "$(nvm version)" ]; then
nvm use
fi
elif [ -n "$(PWD=$OLDPWD nvm_find_nvmrc)" ] && [ "$(nvm version)" != "$(nvm version default)" ]; then
echo "Reverting to nvm default version"
nvm use default
fi
}
add-zsh-hook chpwd load-nvmrc
load-nvmrc
How the three tools fit together
Once everything is set up, the workflow for any project looks like this:
- Clone the repo
cdinto it- If it has
.sdkmanrc,.python-version, and.nvmrc, the runtimes switch automatically - If it doesn’t, set the version once and commit those files
The version files are small, should be committed, and tell anyone cloning the repo exactly what runtime to use. They’re also read by most CI systems. GitHub Actions, in particular, has native support for .nvmrc and .python-version.
Quick reference
sdk list javapyenv versionsnvm lssdk install java <ver>pyenv install <ver>nvm install <ver>sdk default java <ver>pyenv global <ver>nvm alias default <ver>sdk use java <ver>pyenv shell <ver>nvm use <ver>.sdkmanrc.python-version.nvmrcOnce this is in place, runtime switching becomes invisible. We cd into a project, the right version loads, and we stop thinking about it. That’s the whole goal.
Next: the rest of the shell. Prompt, aliases, and the tools that make a terminal actually usable.