Parts 1 through 4 built a foundation. Hardware decisions came first, then macOS system defaults, then Homebrew, then a shell and terminal environment worth actually using. If you've followed along, your Mac is no longer a consumer device running developer tools as an afterthought. It's a configured system with intentional defaults. That matters before any of this next part makes sense.
Part 5 is where the development stack goes in. Editors, runtime managers, languages, containers, project structure. The tools you actually write code with. The systems that keep your Node version in Part A from destroying your Python environment in Part B. This is the layer most tutorials skip straight to, which is exactly why so many developers end up with broken global installs and mysterious PATH conflicts six months into a project.
Where We Are in the Series
Parts 1 through 4 covered the ground that makes everything else stable. Hardware selection and the tradeoffs between Apple Silicon chips. macOS system defaults that fight developers less. Homebrew as the package management backbone. Shell configuration and a terminal setup that's actually pleasant to use. Part 4 in particular got into the details of shell environment and prompt configuration, which matters here because runtime managers depend heavily on how your shell initializes.
Part 5 adds the development stack on top of that foundation. Editors and their CLI integration. Runtime managers that keep language versions isolated and reproducible. Core language installs for Node.js and Python. Optional tooling for Python project management and a quick orientation for developers working in Go, Rust, Java, Ruby, or PHP.
This part is language-agnostic by design. Node.js and Python are the primary examples because they're the most common sources of environment problems, not because other languages matter less.
If you already have an editor you're happy with, skip that section. If you've already got mise installed and your runtimes configured, jump to the containers and project structure content. Nothing here requires reading in strict sequence.
New to the series?
Start with Part 1 before continuing here. The shell and PATH configuration from Parts 3 and 4 affects how runtime managers behave, and skipping that setup creates problems that are annoying to debug.
Choosing Your Editor
The editor debates that consume enormous amounts of developer energy online mostly miss the point. What matters isn't which editor is objectively best. What matters is that your editor integrates cleanly with your terminal, opens projects from the command line without friction, and doesn't require a context switch every time you want to run something. Consistency and CLI integration are the actual variables. Everything else is preference.
VS Code: The Safe Default
Visual Studio Code is the right default recommendation for most developers, and not because it's the most exciting option. It has a massive extension ecosystem, strong remote development support via SSH and containers, and it's free. More practically: it's what most tutorials, documentation screenshots, and team environments assume. The muscle memory you build in VS Code transfers across machines and operating systems without friction.
Alternatives Worth Knowing
Cursor is a VS Code fork with AI pair programming features built into the core interface rather than bolted on as an extension. If AI-assisted coding is a priority in your workflow, Cursor gives you that without the extension configuration overhead. It stays close enough to VS Code that the transition is minimal.
Zed is worth knowing about if you find VS Code feels heavy. It's written in Rust, starts fast, and has a deliberately minimal surface area. The extension ecosystem is smaller, but for developers who find VS Code's feature density distracting, Zed is a serious option.
Neovim is terminal-native and infinitely configurable. The learning curve is real and steep. Developers who commit to it tend to stay committed, and the keyboard-driven workflow becomes genuinely fast. It's not the right starting point for most people, but it's not a joke either.
JetBrains IDEs, including IntelliJ, PyCharm, and WebStorm, offer language-specific depth that VS Code extensions don't always match. They're heavier on system resources and they're paid products (with free tiers for some use cases), but for Java, Kotlin, or complex Python projects, the built-in tooling is excellent.
The recommendation stands regardless of which one you choose: pick one, learn it past the surface level, and stop switching every time a new option appears on Hacker News.
Installing VS Code and Wiring Up the CLI
Install via Homebrew
brew install --cask visual-studio-codeInstalling through Homebrew instead of downloading a DMG directly has one practical advantage: it stays in sync with your other managed software. When you run brew upgrade --greedy, VS Code updates alongside everything else. No separate auto-updater running in the background, no manual download cycle.
Verify the Command-Line Tool
code --versionVS Code ships a code CLI command that needs to be available in your PATH for terminal-first workflows to work correctly. If the command isn't found after installation, open VS Code manually, open the command palette with Cmd+Shift+P, and run Shell Command: Install 'code' command in PATH. That writes the symlink. You shouldn't need to do this if you installed via Homebrew, but it's worth verifying before you depend on it.
Other editors have equivalent CLI tools
Cursor installs a cursor command. Zed installs zed. Neovim is invoked as nvim. JetBrains IDEs install their CLI tools through JetBrains Toolbox. The pattern is the same regardless of which editor you chose: verify the CLI command exists and is in your PATH before building habits around it.
Open a Project from the Terminal
This is the workflow that matters. Navigate to your projects directory, create a workspace, and open it directly from the terminal:
1cd ~/Projects
2mkdir project-workspace
3cd project-workspace
4code .The dot in code . is doing specific work. It tells VS Code to open the current directory as a workspace, not just a single file. VS Code treats the directory as the root, which affects how the file explorer, search, and terminal integration behave. Opening individual files with code filename.js works, but opening the directory is almost always what you want.
Why Runtime Management Matters
The Problem with System Python and System Node
macOS ships with a system Python. It's owned by the operating system, it's used by system tools, and it should not be touched. This is not a suggestion. Modifying the system Python with pip install at the global level is one of the most reliable ways to spend an afternoon debugging a broken macOS tool that has nothing to do with the package you installed. The system Python exists for macOS. It doesn't exist for your projects.
Node.js has a similar problem if you install it directly from the official website without a version manager. You end up with a single global Node install that every project on your machine shares, and the first time two projects require different Node versions, you're stuck.
Global Runtimes vs Per-Project Runtimes
The distinction that matters here is between a global default runtime and a per-project runtime. A global default is what runs when you type node or python in a directory that hasn't specified anything. A per-project runtime is pinned in a configuration file that lives inside the project directory.
mise uses a file called mise.toml (or the compatible .tool-versions format from asdf) as the source of truth for what a project needs. When you cd into a directory that contains one of those files, mise activates the correct runtime versions automatically. When you leave the directory, it deactivates them. No manual switching, no environment variables to set by hand.
Reproducibility as a First-Class Concern
Reproducibility means any developer on any machine can run mise install inside a project directory and get the exact same runtime versions. No verbal instructions. No README section that says "make sure you're using Node 20." The config file is the instruction.
Teams that pin runtime versions in config files eliminate an entire category of onboarding friction. New developer joins, clones the repo, runs mise install, and the environment matches. That's the outcome worth building toward.
Don't install multiple runtime managers simultaneously
Running nvm alongside asdf alongside mise creates PATH chaos that's genuinely difficult to untangle. Pick mise and use it for everything. If you have nvm or pyenv installed from a previous setup, remove them before continuing. The mise documentation has clean uninstall instructions for both.
Installing mise and Your Core Runtimes
Install mise
brew install miseVerify it installed correctly:
mise --versionmise is a polyglot runtime manager. One tool that replaces nvm, pyenv, rbenv, and asdf. It handles Node.js, Python, Ruby, Go, Java, and dozens of other runtimes through a consistent interface. The core mechanism is shims and shell hooks: mise intercepts calls to node, python, and similar commands, checks the current directory for a mise.toml or .tool-versions file, and routes the call to the correct version.
Add the mise activation hook to your shell config. For zsh, that's ~/.zshrc:
echo 'eval "$(mise activate zsh)"' >> ~/.zshrc
source ~/.zshrcInstall Node.js LTS
mise use -g node@ltsVerify:
node --version
npm --versionThe @lts alias is doing something useful here. It doesn't pin you to a specific version number. It tracks the current Long-Term Support release, so when a new LTS version becomes available, you can upgrade with the same command and the alias follows. For production work, LTS is the right target.
Using an LTS release for project work means you're not chasing Node updates every few months. Thirty months of active support, followed by a maintenance window, gives you a predictable upgrade timeline you can actually plan around.
Install Python
mise use -g python@latestVerify:
python --versionlatest here means the latest stable Python release. This becomes your global default, the version that runs when you type python in a directory that hasn't specified otherwise. Per-project pinning via mise.toml overrides this global default, so individual projects can target Python 3.11 or 3.12 or whatever their dependencies require without affecting anything else on your system.
Per-project pinning example
Inside a project directory, run mise use [email protected] to pin that project to a specific version. mise creates or updates mise.toml in the current directory. Commit that file to version control and every developer on the project gets the same Python version automatically.
Optional Tooling: uv and Other Languages
Python Project Tooling with uv
brew install uv
uv --versionuv is a Python package and project manager written in Rust, developed by Astral. It handles virtual environments, dependency locking, and project scaffolding in one tool. That's a different scope than pip, which only installs packages and leaves virtual environment management to you.
The relationship between mise and uv is complementary, not redundant. mise manages which Python version is active. uv manages what's installed inside a project using that Python version. mise answers "which Python?" and uv answers "which packages, locked to which versions?"
That speed difference is noticeable in practice, especially on projects with large dependency trees. A fresh install that takes 45 seconds with pip often completes in under 3 seconds with uv.
Notes on Go, Rust, Java, Ruby, and PHP
Go installs cleanly through mise:
mise use -g go@latestGo modules handle dependency management natively. No additional tooling is required for most projects. The go.mod and go.sum files in a project directory serve the same reproducibility function as mise.toml does for runtimes.
Rust is the exception to the mise-for-everything pattern. Use rustup, the official Rust toolchain manager:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shrustup handles stable, beta, and nightly channels, cross-compilation targets, and component management. It's purpose-built for Rust's release structure in a way that generic runtime managers aren't. Don't fight this one.
Java version management matters more than most developers expect, particularly if Android development is on the roadmap. mise supports Java through a JDK plugin and handles multiple versions reasonably well. JetBrains Toolbox is the alternative if you're already in the JetBrains ecosystem and want version management tied to your IDE.
Ruby is well-handled by mise. If you have rbenv installed from a previous setup, remove it before adding Ruby through mise. The redundancy creates PATH conflicts that surface at unpredictable moments.
PHP is available through Homebrew's formula or through mise. If you're working primarily with Laravel, Laravel Herd is worth considering as an all-in-one option for PHP development on macOS. It handles PHP versions, local site management, and common services without requiring manual configuration of each component.
Part 6 picks up where this leaves off, moving into Docker, container workflows, and how to structure a project directory so that your development environment is reproducible not just across machines but across time.
Structuring Project Environments
Part 4 covered the terminal, shell configuration, and the foundational tools that make a Mac feel like a development machine. Now the work gets more specific. Tools matter, but the structure you build around them matters just as much. A project with a great editor and a fast runtime manager can still be a nightmare to onboard, reproduce, or hand off if the environment itself is undocumented and inconsistent.
Essential Files Every Project Should Have
Every project, regardless of language or size, needs a small set of files that describe how it works and what it needs to run. These aren't optional polish. They're the difference between a project you can return to in six months and one you have to reverse-engineer from scratch.
.env stores local environment variables: API keys, database URLs, feature flags, anything that changes between environments or shouldn't be public. It never gets committed to version control.
.env.example is the committed counterpart. It contains the same keys with placeholder values, no real secrets. It documents exactly which variables a new contributor needs to supply before the project will run.
.gitignore should, at minimum, exclude .env, node_modules, __pycache__, .DS_Store, and any editor-specific folders like .vscode or .idea. Start from a language-appropriate template and extend it.
package.json is the Node project manifest. It defines scripts, dependencies, and the engines field, which pins the required Node version so contributors aren't guessing.
pyproject.toml is the modern Python equivalent. It replaces both setup.py and requirements.txt for most projects and centralizes everything in one place.
README.md should document how to set up the project locally: runtime versions, install steps, how to run tests, and any non-obvious configuration. If it takes you more than ten minutes to remember how to start the project, the README isn't doing its job.
A mise.toml or .tool-versions file in the project root pins the runtime version for every contributor. When someone clones the repo and runs mise install, they get exactly the same Node or Python version you used.
One More Thing About mise
If you're working across multiple projects with different Node versions, mise reads the mise.toml from the current directory automatically. No manual switching. You change directories and the runtime follows.
Keeping Secrets Out of Version Control
Committing a .env file is one of the most common and most costly mistakes in early development. API keys pushed to a public repository get scraped by bots within minutes. The damage isn't theoretical.
The safest approach is enforcement, not intention. Add a pre-commit hook that blocks commits containing .env files, or install git-secrets to scan for credential patterns before anything reaches the index. Relying on memory to avoid committing secrets is a policy that fails eventually. Automation doesn't.
Containers: Docker Desktop and OrbStack
Installing Docker Desktop
Install Docker Desktop with:
brew install --cask dockerAfter the cask installs, launch Docker Desktop from Applications and wait for the whale icon in the menu bar to stop animating. Then verify the CLI is available:
docker --versionThis is worth emphasizing: Docker Desktop requires the GUI application to be running for the Docker daemon to be available. The docker CLI is just a client. If the app isn't running, every docker command returns a connection error. Add Docker Desktop to your login items if you use containers regularly.
OrbStack as a Lighter Alternative
OrbStack is a drop-in replacement for Docker Desktop. Same docker CLI, same Compose files, but faster startup, lower memory usage, and tighter macOS integration. It's free to try and paid after the free tier, with pricing aimed at individual developers and small teams.
If Docker Desktop feels heavy on your machine, or if you notice it consuming significant memory in the background, OrbStack is worth evaluating. The migration is straightforward because the CLI interface is identical.
Apple Silicon Note
Containers on M-series Macs require arm64 images or Rosetta emulation. Most official images now ship as multi-arch, so docker pull postgres just works. If you're pulling an older or unofficial image and see architecture warnings, check whether an arm64 tag exists before assuming something is broken.
Why Containers Matter for Local Development
The core value is isolation without installation. You can run a Postgres database, a Redis instance, and a third-party service with an official image without touching your system's global state. When you're done, you stop the containers. Nothing lingers.
The deeper value is reproducibility. A docker-compose.yml checked into the repository means every developer on the project gets the same service versions, the same configuration, and the same startup commands. "It works on my machine" stops being a conversation because the environment is defined in code.
Dev containers extend this idea further, running the editor server itself inside the container. That pattern gets its own section next.
Dev Containers: When They Help and When They Don't
What Dev Containers Are
A dev container is a VS Code feature built on an open standard. Instead of running your editor natively and connecting to external services via Docker, the entire development environment runs inside a container. The editor server runs in the container. Your extensions run in the container. The file system is the container's file system.
Configuration lives in .devcontainer/devcontainer.json. That file specifies the base image, which VS Code extensions to install, and any post-create commands to run after the container starts, things like installing dependencies or running database migrations.
When to Use Them
Dev containers make the most sense in specific situations. Onboarding new team members to a complex stack is the clearest case: clone the repo, open in container, and the environment builds itself. They're also valuable when you need CI and local environments to be identical, or when you're working on multiple projects with conflicting dependencies that can't coexist cleanly on a single machine.
GitHub Codespaces is the cloud-hosted version of the same pattern. If local container performance is a genuine blocker, Codespaces moves the compute off your machine entirely.
When They Are Overkill
Solo projects with simple stacks don't need dev containers. If you're the only developer, mise already pins your runtime versions and a virtual environment handles Python dependencies. Adding a dev container introduces container startup time and file system overhead without solving a problem you actually have.
The file system sharing between macOS and Docker has historically been slower than native access. OrbStack mitigates this significantly, but it's still a real consideration. For projects where fast file watching and quick test runs matter, native development with mise and a virtual environment is often the faster daily experience.
Common Mistakes to Avoid
Most setup problems aren't caused by missing tools. They're caused by conflicting tools, undocumented assumptions, and shortcuts that seem fine until someone else tries to run the project.
Installing too many runtime managers is the most common one. Having nvm, pyenv, asdf, and mise all installed simultaneously creates PATH conflicts and unpredictable behavior. The shell resolves binaries in order, and when four managers are competing to provide node, the one that wins depends on how your shell was configured last. Pick one manager and remove the others completely.
Mixing system Python with project Python causes subtle breakage. Running pip install without an active virtual environment or runtime manager modifies the global Python state. The next project that assumes a clean environment inherits whatever you installed.
Forgetting to document runtime versions is a tax paid by future contributors, including yourself. A project without a .tool-versions, mise.toml, or engines field in package.json forces everyone to guess. Guessing produces inconsistency.
Treating containers as magic is a mindset problem. docker run doesn't automatically solve environment problems if the application code itself has environment-specific assumptions baked in. Containers reproduce environments, they don't fix bad code.
Two Checks Worth Running Every Time
After any install or configuration change, run node --version, python --version, and docker --version to confirm the correct binary is on PATH. One minute of verification prevents an hour of debugging.
Using sudo with npm or pip is a sign that global install paths are misconfigured. The fix is correcting the PATH, not forcing permissions. Installs that require sudo will cause permission problems downstream.
Skipping .env.example is a gift you fail to give your future self. Six months from now, you won't remember which environment variables the project requires. The file takes two minutes to create and saves thirty minutes of confusion.
Part 5 Setup Checklist
Part 6 picks up where the local environment ends. You have a working machine, a runtime manager, and a container runtime. The next step is connecting all of it to version control properly: SSH key setup, Git configuration, signing commits, and the habits that keep a repository history clean from the first commit forward.