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 look like before you start any project? What’s the foundation that everything else sits on?
The answer, for me, starts with version management. I work across Java, Python, and Node.js daily. Each project has its own runtime requirement. Some are pinned to old LTS versions. Some need the latest. If you can’t switch between them without friction, you’re going to fight your tools constantly — and you’ll lose time you can’t get 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 you shouldn’t use them for development. They’re tied to the OS version, they’re often outdated, and modifying them requires sudo. More importantly, they give you exactly one version. The moment you work on two projects that disagree on the runtime version, you’re stuck.
The solution is the same for every language: a version manager. Install it once, use it to install and switch between any version of the runtime you need.
Java — SDKMAN
SDKMAN manages JDK versions (and much more — Gradle, Maven, Kotlin, Spring Boot CLI). It’s the standard tool for this job on Mac and Linux.
Install
curl -s "https://get.sdkman.io" | bash
Restart your terminal (or source ~/.zshrc), then verify:
sdk version
Install a JDK
List available Java distributions:
sdk list java
You’ll see distributions from Temurin (Eclipse Adoptium), Corretto (Amazon), Zulu (Azul), GraalVM, and others. For most projects, Temurin is the right choice. 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 your project root:
# .sdkmanrc
java=17.0.11-tem
Then run sdk env inside that directory to activate it. If you add sdkman_auto_env=true to ~/.sdkman/etc/config, SDKMAN switches automatically when you cd into the project.
Python — pyenv
pyenv is the standard version manager for Python. It intercepts the python and python3 commands and routes them to whichever version you’ve configured.
Install
The cleanest way on Mac is through Homebrew:
brew install pyenv
Then add the following to your ~/.zshrc:
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
Restart your 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 your project directory
pyenv local 3.11.9
This creates a .python-version file in the directory. Every time you cd in, pyenv picks it up automatically — no extra step required.
# .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 you 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 your ~/.zshrc automatically. Restart your 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 your project root:
# .nvmrc
20
Then run nvm use inside the project (with no arguments) and nvm will read the file. To make this automatic on cd, add this to your ~/.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— your runtimes switch automatically - If it doesn’t, you set the version once and commit those files
The version files are small, should be committed, and tell anyone who clones 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.nvmrcThis is the foundation. From here, every language-specific post I write will assume this is already in place — a machine where you can switch runtimes without thinking, and where the right version activates automatically when you enter a project.
Next: setting up the rest of the shell environment — prompt, aliases, and the tools I reach for every day.