Image for MacBook to Mission Control Part 5: Languages, Editors, Containers, and Project Environments
Technology Jun 26, 2026 • 15 min read

MacBook to Mission Control Part 5: Languages, Editors, Containers, and Project Environments

Set up VS Code, mise, Node.js, Python, and Docker on macOS for a flexible, reproducible developer environment across any project.

Share:
Lee Foropoulos

Lee Foropoulos

15 min read

Continue where you left off?
Text size:

Contents

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.

Data dashboard on multiple monitors in a development environment
A well-structured development environment is invisible when it works and catastrophic when it doesn't.

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.

Code on a monitor with colorful syntax highlighting
The editor you use matters less than how well you know it.
Pick one editor. Commit to it. Learn it deeply before you let anyone talk you into switching.

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

bash
brew install --cask visual-studio-code

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

Terminal window with green text on dark background
Installing through Homebrew keeps your editor in the same upgrade pipeline as the rest of your tooling.

Verify the Command-Line Tool

bash
code --version

VS 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:

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

The goal isn't managing runtimes. The goal is never thinking about runtimes again.

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.

73%
of 'works on my machine' incidents in surveyed teams traced back to runtime version mismatches

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

bash
brew install mise

Verify it installed correctly:

bash
mise --version

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

Server room with blue lighting and organized cable management
One runtime manager handling all your languages is significantly less chaotic than three separate tools with overlapping PATH entries.

Add the mise activation hook to your shell config. For zsh, that's ~/.zshrc:

bash
echo 'eval "$(mise activate zsh)"' >> ~/.zshrc
source ~/.zshrc

Install Node.js LTS

bash
mise use -g node@lts

Verify:

bash
node --version
npm --version

The @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.

30 months
of active support for each Node.js LTS release, reducing forced upgrade cycles

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

bash
mise use -g python@latest

Verify:

bash
python --version

latest 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

bash
brew install uv
uv --version

uv 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?"

10–100x
faster dependency resolution with uv compared to pip in common benchmark scenarios

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.

mise manages the runtime. uv manages the project. They don't overlap, and they don't fight.

Notes on Go, Rust, Java, Ruby, and PHP

Go installs cleanly through mise:

bash
mise use -g go@latest

Go 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:

bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

rustup 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:

bash
brew install --cask docker

After 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:

bash
docker --version

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

MacBook on a desk with development environment on screen
A container runtime turns your Mac into a local platform that can run databases, queues, and third-party services without installing them natively.

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.

The environment isn't something you set up. It's something you check out.

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 5 Setup Checklist 0/11

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.

How was this article?

Share

Link copied to clipboard!

You Might Also Like

Lee Foropoulos

Lee Foropoulos

Business Development Lead at Lookatmedia, fractional executive, and founder of gotHABITS.

🔔

Never Miss a Post

Get notified when new articles are published. No email required.

You will see a banner on the site when a new post is published, plus a browser notification if you allow it.

Browser notifications only. No spam, no email.

0 / 0