Set Up a Mac for Development

Here I’m setting up a Mac for development from a fresh macOS installation. Use it as a starting point to customize your own setup and automate it for the future. The instructions were tested on macOS Golden Gate, but they should work on other versions of macOS.

I’m also open sourcing my dotfiles: the configuration files I use for my shell, editor, and system settings, plus the scripts this guide relies on. I’ve been using them for some time and I think they can be useful to others too. In this guide, I explain what they contain and how to customize them to your needs. I’m planning to keep the dotfiles on the GitHub repository up to date with the latest tools and configurations I find useful.

Once you’ve tailored the configuration to your liking, every time you install a brand new macOS you can run one script and have everything installed and configured the way you want it.

The guide covers:

#Getting Started on a New macOS Installation

When you turn on the computer for the first time, the setup assistant will start. Choose your language, time zone, Apple ID, etc. Turn FileVault on so the SSD is encrypted, then update macOS. Restart the computer once the updates are applied.

#Files for the Setup

Start by forking the dotfiles repository to your own GitHub account, so you have a personal copy you can customize. Go to https://github.com/gonafr/dotfiles and click Fork in the top-right corner. Then clone your fork into $HOME/Downloads.

cd $HOME/Downloads

# Step 1: Clone your forked dotfiles repository
# You will be prompted to install the Xcode Command Line Tools that you should accept.
# This will install the necessary tools (git) to run the scripts.
# Replace <yourusername> with your GitHub username.
git clone https://github.com/<yourusername>/dotfiles.git
cd dotfiles

Keep reading to understand and personalize the dotfiles.

#Understand and Personalize Your Own Setup Scripts

#Install Homebrew and New Applications With It

Homebrew is a package manager for macOS and Linux that makes it easy to install, update, and manage software. Install it with:

cd $HOME/Downloads/dotfiles/homebrew
bash homebrew-install.sh

If you want to choose what to install, edit the homebrew-install-applications.sh file as you wish. The rest of this guide assumes you install the applications listed in the homebrew-install-applications.sh file. The ones really necessary are:

  • Git (git)
  • Gnu Stow (stow)
  • iTerm2 (iterm2)
  • Node Version Manager (nvm)
  • pnpm (pnpm)
  • fzf (fzf)
  • pyenv (pyenv)

CLI (Command Line Interface) applications are installed with brew install <name-of-the-application> and GUI (Graphical User Interface) applications with brew install --cask <name-of-the-application>. You can edit the file with the nano text editor.

cd $HOME/Downloads/dotfiles/homebrew
nano homebrew-install-applications.sh

After you edit and save the file, run it to install the applications.

cd $HOME/Downloads/dotfiles/homebrew
bash homebrew-install-applications.sh

Applications installed through Homebrew are easy to update. Here’s how you would update nvm, for example.

brew update
brew upgrade nvm
nvm --version
brew cleanup nvm

I added a brewup alias in the .alias.zsh file (more on that later) that updates all your installed Homebrew applications at once.

#Add Applications to the macOS Startup

Some applications are useful to have running automatically when you start your computer. You can add them under System Settings → General → Login Items & Extensions by clicking the "+" button, but I wrote a script to automate this.

Edit the add-login-items.sh file to choose which applications start when you log in.

cd $HOME/Downloads/dotfiles/macos
bash add-login-items.sh

#Customizing the Zsh Shell

Zsh (Z Shell) is a command-line shell for Unix-like operating systems, and it has been the default shell since macOS Catalina. Its configuration lives in the .zshrc file in your home folder. Files like this, whose names start with a . (dot), are known as dotfiles.

The dotfiles I typically use are:

  • .zshrc: Zsh configuration file.
  • .alias.zsh: File to store aliases.
  • .env.zsh: File to store environment variables.
  • .p10k.zsh: File to store the configuration of a Zsh theme called Powerlevel10k.

You can edit all of these files to your needs before proceeding. For example, to edit the .zshrc file with the nano text editor:

cd $HOME/Downloads/dotfiles/.dotfiles
nano .zshrc

I manage these dotfiles with Gnu Stow, which creates symlinks in your home folder pointing to a folder where the real files are stored. I like to keep them in a folder called .dotfiles in my home directory.

The following commands create a .dotfiles folder in your home directory, copy the .dotfiles from $HOME/Downloads into it, and create the symlinks with Gnu Stow (installed earlier through the homebrew-install-applications.sh script). All the files have comments explaining what each configuration does. Feel free to edit the dotfiles to your needs before running the commands.

mkdir $HOME/.dotfiles
cp -Rn $HOME/Downloads/dotfiles/.dotfiles $HOME
cd $HOME/.dotfiles
stow .
source ~/.zshrc

The first time you open a terminal (or run source ~/.zshrc) with these dotfiles, the .zshrc uses the zinit plugin manager to automatically download Powerlevel10k and a few Zsh plugins (syntax highlighting, autosuggestions, completions, history substring search, and fzf-tab) from GitHub. This only happens once, but the first load needs an internet connection and takes a few extra seconds.

If you edit these files later, run stow . again inside the .dotfiles folder in your home directory to create symlinks for any new files, and source ~/.zshrc to reload the configuration. I also have an alias for the reload: zrc.

#Configure Git Version Control

Git is a distributed version control system (VCS) that tracks changes in code and makes it easy to collaborate on a project.

Git is configured through a .gitconfig file in your home folder. I like to also keep a .gitconfig-personal and a .gitconfig-work file with different identities and options, and switch between them with Git aliases: git use-personal and git use-work. The common configuration is replicated in each file, along with these aliases, so each identity stays isolated and easy to switch.

[alias]
    use-personal = "!cp ~/.gitconfig-personal ~/.gitconfig"
    use-work = "!cp ~/.gitconfig-work ~/.gitconfig"

Each configuration can set a different user name, email, and so on. You can check the currently loaded configuration with:

git config --list

Copy the .gitconfig and .gitignore_global files to a git folder in your home folder. Inside it, copy the .gitconfig file to new .gitconfig-personal and .gitconfig-work files and edit them as you wish. Don’t forget to change the name and email in this part:

[user]
    name = Your Name
    email = [email protected]
mkdir -p $HOME/git
cp -R $HOME/Downloads/dotfiles/git/ $HOME/git/
cd $HOME/git
cp .gitconfig .gitconfig-personal
cp .gitconfig .gitconfig-work
nano .gitconfig-personal
nano .gitconfig-work

Then create the symlinks in your home folder with Gnu Stow.

cd $HOME/git
stow .

The .gitignore_global file ignores files that should never be versioned. You can get inspiration from the macOS.gitignore maintained by GitHub, or use an online .gitignore generator.

#JavaScript Development Tools

#Configure Node Version Manager (nvm) and Install Node.js

Node Version Manager (NVM) is a command-line tool to install, manage, and switch between different versions of Node.js, the JavaScript runtime that runs outside the browser.

After installing nvm via Homebrew, it needs to be set up in the shell profile: ~/.zshrc for Zsh, ~/.bashrc for bash, and so on. This is already done in the provided .zshrc. Instead of loading nvm on every terminal start (which slows down the shell startup), it lazy-loads nvm: nvm is only sourced the first time you run nvm, node, npm, or npx. These are the relevant lines:

# Initialize NVM (Node Version Manager) and set up Node.js and npm commands to use the correct version of Node.js based on the .nvmrc file in the current directory.
# The following code sets the NVM_DIR environment variable to the location where NVM is installed, and defines a function for nvm that ensures the correct version of Node.js is used when running node, npm, or npx commands.
export NVM_DIR="$HOME/.nvm"

# Prepend the NVM default node bin so it is available before lazy-loading kicks in.
# This is necessary for Husky Git hooks and other child processes, which can't see shell functions and rely on the inherited PATH.
# The following code checks if the default Node.js version is set in NVM, resolves any aliases, and prepends the corresponding bin directory to the PATH environment variable.
if [[ -s "$NVM_DIR/alias/default" ]]; then
  _nvm_default="$(cat "$NVM_DIR/alias/default")"
  # Resolve the full alias chain (e.g. default -> lts/* -> lts/krypton -> v24.18.0).
  for _i in 1 2 3 4 5; do
    [[ -s "$NVM_DIR/alias/$_nvm_default" ]] || break
    _nvm_default="$(cat "$NVM_DIR/alias/$_nvm_default")"
  done
  # The "node"/"stable" aliases mean the latest installed version: match any version dir.
  [[ "$_nvm_default" == node || "$_nvm_default" == stable ]] && _nvm_default=""
  # NVM version dirs are always v-prefixed; alias files may omit the v.
  # The trailing glob lets partial versions (e.g. "24") resolve to the latest installed match.
  _nvm_node_bin="$(echo "$NVM_DIR/versions/node/v${_nvm_default#v}"*/bin(NOn[1]))"
  [[ -d "$_nvm_node_bin" ]] && export PATH="$_nvm_node_bin:$PATH"
  unset _nvm_default _nvm_node_bin _i
fi

# Lazy-load nvm when node, npm, or npx is called.
# Calling nvm use 18 directly still works (forwards args).
# npm, node, npx calling bare nvm just lazy-loads it without dumping help text.
nvm() {
  unset -f nvm node npm npx
  if [ -s "$NVM_DIR/nvm.sh" ]; then
    . "$NVM_DIR/nvm.sh"
  elif command -v brew >/dev/null 2>&1 && [ -s "$(brew --prefix nvm 2>/dev/null)/nvm.sh" ]; then
    . "$(brew --prefix nvm 2>/dev/null)/nvm.sh"
  fi
  
  # Makes sure a default Node.js version exists, installing the latest LTS the first time if none does.
  if [[ "$(nvm version default 2>/dev/null)" == "N/A" ]]; then
    nvm install --lts >/dev/null 2>&1
    nvm alias default 'lts/*' >/dev/null 2>&1
  fi

  [[ $# -gt 0 ]] && nvm "$@"
}
node() { nvm; command node "$@"; }
npm() { nvm; command npm "$@"; }
npx() { nvm; command npx "$@"; }

The provided .zshrc also prepends the default Node version’s bin directory to the PATH. Shell functions aren’t passed to child processes, but PATH is, so programs started from your terminal (like Git hooks or scripts with a #!/usr/bin/env node shebang) can find node even before the lazy-loading kicks in. The sourcing inside the function checks both places nvm.sh can live: $NVM_DIR (when nvm is installed with the official install script) and the Homebrew prefix (when nvm is installed with Homebrew, like in this guide), so it works with either installation method.

To install Node.js 20:

nvm install 20

To install the latest long-term support (LTS) version:

nvm install --lts

Then restart the terminal. To have nvm activate the latest installed version of Node.js, run:

nvm use node

To install and use a specific version, replace xx.xx with the version you want:

nvm install xx.xx
nvm use xx.xx
node -v
npm -v

To make a specific version the new default:

nvm alias default xx.xx

npm (Node Package Manager) comes bundled with Node.js, but I prefer pnpm, which is up to 2x faster. The homebrew-install-applications.sh script already installs pnpm, so I can use commands like pnpm install or pnpm run dev.

#Java Development Tools

With each new Java release, juggling multiple JDK versions and configuring the JAVA_HOME path variable gets tedious. jEnv simplifies managing multiple JDK versions. This section also covers setting up Eclipse Temurin, an OpenJDK distribution, and Apache Maven, a build automation tool.

The provided homebrew-install-applications.sh script and ~/.zshrc already configure the following steps with JDK v21 and jEnv. I’m explaining them here so you understand the approach; if you’re using the provided files, you don’t need to run these commands.

#Install the Correct Java JDK

Many vendors offer JDKs, but to avoid licensing and support issues I recommend Eclipse Temurin, an open-source JDK maintained by the Adoptium project. Use the LTS version unless you need the latest features.

Install the JDK through Homebrew:

brew install --cask temurin@21

#Install jEnv to Manage Multiple JDK Installations

jEnv is a command-line tool for managing multiple JDK installations. It’s a lightweight choice if you want more control over where your JDKs come from. It sets JAVA_HOME in the shell, either globally, locally to the current working directory, or per shell, which is useful when different projects require different JDK versions.

Install jEnv:

brew install jenv

Add jEnv to the shell, which puts jEnv and its shims on the PATH and initializes it. Just like nvm, the .zshrc in the dotfiles lazy-loads jEnv, so it only initializes the first time you run jenv and the shell startup stays fast. These are the relevant lines, so you don’t need to add them again if you’re using the provided dotfiles:

# jEnv
export PATH="$HOME/.jenv/bin:$HOME/.jenv/shims:$PATH"
if command -v jenv >/dev/null 2>&1; then
  jenv() {
    unset -f jenv
    eval "$(command jenv init - zsh)"
    jenv "$@"
  }
fi

Enable the export and maven plugins once, so jEnv can manage the JAVA_HOME variable and the JDK version for Maven projects. This lets different Maven projects use different JDK versions and keeps JAVA_HOME correct when you switch versions with jEnv. The bootstrap-dotfiles.sh script already runs these two commands, so you don’t need to run them manually.

jenv enable-plugin export
jenv enable-plugin maven

To install a new JDK version, install it with Homebrew and add it to jEnv:

brew install --cask temurin@17
jenv add /Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home

If you already have Java installed, find its installation path:

/usr/libexec/java_home -V

Then add it to jEnv:

jenv add /Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home

Verify the installation by listing the available JDKs:

jenv versions

You should see something like this:

* system (set by /Users/goncalorodrigues/.jenv/version)
  21
  21.0
  21.0.6
  temurin64-21.0.6

jEnv can set the JDK version at three levels: global, local, and shell.

The global JDK is used whenever you run the java command anywhere on your computer. To set a global version for all projects:

jenv global 21.0

The local JDK is configured for a specific folder. Running java inside that folder uses the local version instead of the global one. To set it, navigate to the project directory and run:

jenv local 17.0

This creates a .java-version file in the current directory containing the version you set, in this case “17.0”.

The shell JDK is used only for the current shell instance and overrides any local and global version already set:

jenv shell 1.8

#Install the Maven Build Tool

Once you have a JDK installed, get the Apache Maven build tool. It is provided by the Maven project itself, not by Temurin or the OpenJDK.

brew install maven

#Python Development Tools

#Install pyenv to Manage Multiple Python Installations

pyenv is a command-line tool for managing multiple Python installations. It lets you switch between Python versions globally, per project, or per shell.

Install pyenv through Homebrew:

brew install pyenv

I like to check the latest stable version of Python 3 and install it with pyenv. To list the available Python 3 versions:

pyenv install --list | grep '^\s*3\.' | tail -20

Then install the version you want and set it as the global default. Replace 3.14.4 with your version:

pyenv install 3.14.4
pyenv global 3.14.4
python --version

pyenv also needs to be set up in the shell profile (~/.zshrc for Zsh, ~/.bashrc for bash, etc.). This is already done in the provided .zshrc, which lazy-loads pyenv just like nvm and jEnv, so it only initializes the first time you run pyenv. These are the relevant lines:

# Make sure pyenv knows where to look for its installation files and stored Python versions.
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"

# Lazy-load pyenv on first use to keep the shell startup fast.
if command -v pyenv &>/dev/null; then
  pyenv() {
    unset -f pyenv
    eval "$(command pyenv init -)"
    pyenv "$@"
  }
fi

After editing the .zshrc file, or to apply the changes immediately, reload the Zsh configuration:

zrc

To use a specific Python version for a specific project, navigate to the project directory and run:

pyenv install 3.14.4
pyenv local 3.14.4

This creates a .python-version file in the current directory containing the version you set, in this case “3.14.4”.

#Secure Shell (SSH)

SSH (Secure Shell) is a cryptographic network protocol for securely accessing devices over an unsecured network, most often to log in to remote servers.

Generate a separate SSH key for each group of systems you access. Logical groupings could be, for example: version control servers, personal servers and work servers. Always protect your keys with a strong passphrase and never share the private keys with anyone. Consider storing the passphrases in a password manager. You’ll be prompted for a passphrase when generating each key; you can leave it empty, but using one is recommended for added security. Use Apple Keychain to store the passphrase so you don’t have to enter it every time you use the key. Replace the placeholders with your name and email address.

ssh-keygen -t ed25519 -C "Your Name (GitHub) <[email protected]>" -f ~/.ssh/id_ed25519_github
ssh-keygen -t ed25519 -C "Your Name (Personal-Servers) <[email protected]>" -f ~/.ssh/id_ed25519_personal_servers

Choose a unique filename for each key, like the ones above, and secure each key with a passphrase for added protection.

These commands generate SSH key pairs (public and private keys) for secure authentication. A breakdown of the options:

  • -t ed25519: the type of key to create. Ed25519 is an elliptic curve algorithm that is more secure and faster than the older RSA.
  • -C: a comment added to the key, usually your email address, to help identify it later.
  • -f: the filename of the key pair.

The SSH agent is a background program that holds your keys in memory for SSH authentication. To use a key, the agent must be running and the key added to it. The --apple-use-keychain option stores the SSH key in the macOS Keychain so you don’t have to enter the passphrase every time you use the key. It requires macOS 12 or later. Start the agent and add your key with:

Replace ~/.ssh/id_ed25519_github with the path to your SSH private key.

eval "$(ssh-agent -s)"
ssh-add --apple-use-keychain ~/.ssh/id_ed25519_github

Copy your public SSH key to the clipboard:

Replace ~/.ssh/id_ed25519_github.pub with the path to your SSH public key.

cat ~/.ssh/id_ed25519_github.pub | pbcopy

Then go to the service where you want to use the key (GitHub, for example) and paste the public key in its SSH key settings. This gives you secure authentication with the service, so you no longer need to enter your username and password for every push or pull.

To use the SSH key on a remote server, copy the public key into the ~/.ssh/authorized_keys file of the user you want to log in as.

On your local machine, copy the public key to your clipboard:

cat ~/.ssh/id_ed25519_personal_servers.pub | pbcopy

Then connect to the remote server over SSH, probably with the username and password. On the server, create the ~/.ssh directory and the authorized_keys file if they don’t exist, and restrict their permissions:

mkdir -p ~/.ssh
touch ~/.ssh/authorized_keys
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys

Add your public key to the authorized_keys file:

echo "your-public-key-here" >> ~/.ssh/authorized_keys

Now you can connect to the remote server with the SSH key without typing the password.

#SSH Connections

You can simplify SSH connections with the SSH config file. If ~/.ssh/config doesn’t exist, create it and restrict its permissions so only the owner can read and write it. SSH requires this to work correctly with the config file.

mkdir -p ~/.ssh
touch ~/.ssh/config
chmod 600 ~/.ssh/config
nano ~/.ssh/config

Add the appropriate content, replacing the variables with your own host information.

For example, say you have a Raspberry Pi at 192.168.10.10 with the user pi, and you’ve already generated an SSH key for your personal servers and added the public key to the Pi’s authorized_keys file. With the following configuration, the command ssh -i ~/.ssh/id_ed25519_personal_servers [email protected] becomes just ssh lab-pt-pi-01.

Host *
  AddKeysToAgent yes
  UseKeychain yes

Host github.com
  HostName github.com
  User git
  IdentityFile ~/.ssh/id_ed25519_github

Host lab-pt-pi-01
  HostName 192.168.10.10
  User pi
  IdentityFile ~/.ssh/id_ed25519_personal_servers

Host * is a wildcard that applies the settings to all hosts.

#Improved macOS Default Settings

I like to change some of Apple’s default settings, so I wrote a script with the ones I change. Edit the file to your needs, then run it:

cd $HOME/Downloads/dotfiles/macos
bash set-defaults.sh

To add the Home folder to the Finder sidebar, press CMD + SHIFT + H in Finder to open the Home folder, then drag it to the sidebar.

#Application Settings

#Microsoft Edge - Chrome Extensions

#iTerm2

iTerm2 doesn’t allow ⌥ + ← and → for word navigation by default. To fix this, change the key mappings: go to Settings -> Profiles -> Keys -> Key Mappings -> Load Preset and select Terminal App Compatibility.

For better visibility, raise the minimum contrast: go to Settings -> Profiles -> Colors -> Minimum Contrast and set it to 30%.

#Automatic Install and Configuration

Once you understand and customize what gets installed and configured, you can take your dotfiles folder to a new macOS machine and run the following to set everything up automatically:

cd $HOME/Downloads/dotfiles
bash bootstrap-macos.sh

Since you cloned your fork, the origin remote already points to your repository. Commit your customizations and push them:

cd $HOME/Downloads/dotfiles

# Step 1: Add all changed files
git add .

# Step 2: Commit the changes
git commit -m "chore: customize dotfiles for my setup"

# Step 3: Push to your fork
git push origin main

# Step 4: Move your dotfiles folder to where you want on your filesystem (I'm moving it to the $HOME/Documents/Development/Projects/Personal folder, but you can move it to wherever you want)
mv $HOME/Downloads/dotfiles $HOME/Documents/Development/Projects/Personal

Your customized dotfiles repository is now saved on your own GitHub.

#Final Thoughts

You now have a macOS setup ready for development and your own customized dotfiles repository.

I wrote this article to share what I know about setting up a Mac for development, and as a reference for myself the next time I set up a new machine. I’ll try to keep my dotfiles repository updated with new tools and configurations I find useful.

If you want to learn more about setting up systems for development and managing dotfiles, check out the unofficial Dotfiles Guide. It has detailed information on dotfiles, best practices for managing them, and example repositories from other developers.

Happy development!

Date: