Notebook / Tooling / 001
note entry no. 001 · May 01, 2026

Dev environment from scratch: managing Java, Python, and Node versions on Mac

Before writing a single line of code, we need to be able to switch between language versions without thinking. This is how I set that up on a Mac.

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:

  1. Clone the repo
  2. cd into it
  3. If it has .sdkmanrc, .python-version, and .nvmrc, the runtimes switch automatically
  4. 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

SDKMAN Java
config file .sdkmanrc
auto-switch sdk env · or sdkman_auto_env=true
pyenv Python
config file .python-version
auto-switch automatic on cd
nvm Node.js
config file .nvmrc
auto-switch requires zsh hook
operation
SDKMAN
pyenv
nvm
list installed
sdk list java
pyenv versions
nvm ls
install version
sdk install java <ver>
pyenv install <ver>
nvm install <ver>
switch global
sdk default java <ver>
pyenv global <ver>
nvm alias default <ver>
switch session
sdk use java <ver>
pyenv shell <ver>
nvm use <ver>
pin to project
.sdkmanrc
.python-version
.nvmrc

Once 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.

VM

V. M. Casale

backend / cloud / things that go bump in the night

I keep an engineering notebook of the small fixes, environment tricks, and infrastructure patterns that quietly make my work-week better.

Read next.