Miao, Wei (缪玮)
  • Home
  • Research
  • Teaching
  • Blog

On this page

  • 1 Why this guide
  • 2 Big picture
  • 3 Prerequisites
    • 3.1 Network connectivity to the remote Mac (both machines)
    • 3.2 Xcode Command Line Tools (remote Mac)
    • 3.3 Homebrew (remote Mac)
    • 3.4 nvm + Node (remote Mac)
    • 3.5 git and python3 (remote Mac)
    • 3.6 SSH alias for the remote Mac (laptop)
  • 4 Clone the Positron source
  • 5 Find the matching commit and channel from your client
  • 6 The rebuild script
  • 7 Running the build remotely
  • 8 Connecting from the client
  • 9 Pitfalls
  • 10 When the client updates
  • 11 Fallback: VS Code Remote-SSH

Moving to Positron for Data Science Workflow: Building Positron-Server on a Mac

Coding
Tutorial
Positron
Remote Work
How to build Positron-server from source on an Intel Mac and connect to it remotely over SSH (Tailscale, VPN, or any other route), including the channel-mismatch pitfall that breaks extension resources.
Author

Wei Miao

Published

May 7, 2026

Modified

May 7, 2026

1 Why this guide

Positron is Posit’s next-generation IDE for data science. Think of it as VS Code, but with first-class R, Python, and Quarto support. If you have a powerful Mac at the office and a lighter laptop on the road, you would naturally want to run Positron on the laptop but execute on the Mac at the office, the same way VS Code Remote-SSH lets you do.

There is one problem: Posit ships no official macOS Positron-server binary. Linux servers are released; macOS servers are not. This is a known and openly tracked gap, see posit-dev/positron#8669. Until that issue is closed, if your remote machine is a Mac you have two choices:

  1. Fall back to VS Code Remote-SSH (which ships a real macOS server) and lose all the Positron-specific panes and integrations.
  2. Build Positron-server from source on the remote Mac.

This post documents my experience of implementing option 2 end-to-end. The only network requirement is that you can SSH into the remote Mac from your laptop. How you get that connection is up to you: Tailscale is what I use day-to-day for its zero-config NAT traversal, but an institutional VPN, a public IP with port forwarding, or a plain LAN connection all work just as well. The post also documents a subtle channel-mismatch bug that silently breaks extension icons and webviews. It is the kind of thing that wastes an afternoon if you have not seen it before.

NoteHow the connection actually works

Positron desktop launches the remote server on demand over your SSH connection, exactly like VS Code Remote-SSH. There is no daemon listening on a public port, no auth token, no launchd plist. When the client connects, it spawns the server; when you disconnect, the server eventually exits. This is why the post does not include an auto-start recipe: the server’s lifecycle is the SSH session’s lifecycle.

2 Big picture

Before diving into the details, here is the full flow you will follow:

flowchart TD
    A["<b>1. Prerequisites</b><br/>SSH connectivity (Tailscale, VPN, etc.)<br/>+ Xcode CLT + Homebrew<br/>+ nvm/Node + SSH alias"]
    B["<b>2. Clone Positron source</b><br/>git clone → ~/dev/positron"]
    C["<b>3. Read client commit + quality</b><br/>from /Applications/Positron.app/<br/>Contents/Resources/app/product.json"]
    D["<b>4. Run the rebuild script</b><br/>git checkout → npm install → gulp build<br/>→ install to ~/.positron-server/bin/&lt;commit&gt;"]
    E["<b>5. Connect from the laptop</b><br/>Positron desktop → Remote-SSH<br/>→ remote-mac"]

    A --> B --> C --> D --> E
    E -.->|"each time the client updates"| C

    classDef oneShot fill:#e8f4fc,stroke:#3b82f6,color:#1e3a8a
    classDef recurring fill:#fef3c7,stroke:#d97706,color:#7c2d12
    class A,B oneShot
    class C,D,E recurring

A one-line summary of each step:

  1. Prerequisites: install the toolchain on the remote Mac (one-time). Whatever gives you SSH connectivity gets you in; the rest is the C/JS build environment that compiles Positron.
  2. Clone: pull the Positron source onto the remote Mac (one-time, into ~/dev/positron).
  3. Read commit + quality from your client. Every Positron desktop install pins a specific source commit and a release channel (releases or dailies). The remote server you build must match both, or extension resources silently 404.
  4. Run the rebuild script: checks out the right commit, runs npm install and gulp to build the server, and installs the result to ~/.positron-server/bin/<commit>/. This is the slow step (~20 min).
  5. Connect from the laptop: open Positron desktop, pick the SSH alias, done. Positron spawns the freshly built server over the SSH session.

The blue boxes (steps 1–2) you do once when you first set this up. The orange boxes (steps 3–5) you repeat every time you update Positron desktop on your laptop.

3 Prerequisites

Two machines, two different lists. The laptop is the lightweight end (just Positron and a way to reach the remote); almost everything else lives on the remote Mac because that is where the build runs.

On your laptop (the client):

  • Positron desktop (download here)
  • SSH client (built into macOS, nothing to install)
  • A connectivity tool if your chosen route needs one on this end (e.g., the Tailscale or VPN client agent)
  • An SSH alias entry in ~/.ssh/config

On the remote Mac (the server):

  • SSH server enabled (System Settings → General → Sharing → Remote Login on)
  • A connectivity tool if your route needs one on this end (e.g., Tailscale agent; not needed for a publicly-reachable Mac or for client-only VPNs)
  • Xcode Command Line Tools
  • Homebrew
  • nvm + Node
  • git, python3 (these ship with the Xcode CLT install above)

The detailed sections below cover the non-trivial installs and are tagged with which machine each one applies to.

3.1 Network connectivity to the remote Mac (both machines)

You need a way to SSH from your laptop into the remote Mac. Anything that gives you a routable address works. Pick whichever fits your situation:

  • Tailscale (what I use). Zero-config mesh VPN that punches through NAT and gives every machine a stable 100.x.x.x address and a DNS-style name. Install it on both ends, sign them into the same tailnet, confirm they can ping each other by name. The full walkthrough is in Remote Work Setup: Tailscale.
  • Institutional / corporate VPN. Many universities and companies provide a VPN that puts your laptop on the same network as your office Mac. Connect the VPN, then use the office Mac’s local IP or hostname.
  • Public IP with SSH port forwarding. If your remote Mac has a routable public IP (or you can forward port 22 from your home router to it), you can SSH straight in over the open internet. Combine with a dynamic-DNS provider if your IP changes. Use a strong key, disable password auth, and consider a non-standard SSH port to cut log noise.
  • Same LAN. If your laptop and the remote Mac are on the same Wi-Fi or Ethernet, just use the Mac’s .local Bonjour name or its LAN IP. No extra tooling required.

Whichever option you choose, the rest of this post is identical. The only thing that changes is what you put in the HostName line of the SSH alias below.

3.2 Xcode Command Line Tools (remote Mac)

xcode-select --install

This gives you the necessary build tools that come with MacOS. You may also need to open Xcode once and accept the license agreement, or run the command line below to accept the license:

sudo xcodebuild -license accept

3.3 Homebrew (remote Mac)

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

brew is the to-go package manager for macOS. We will use it to install the remaining dependencies.

3.4 nvm + Node (remote Mac)

brew install nvm
mkdir -p ~/.nvm

Then add to ~/.zshrc:

export NVM_DIR="$HOME/.nvm"
[ -s "$(brew --prefix nvm)/nvm.sh" ] && \. "$(brew --prefix nvm)/nvm.sh"

Reload your shell, then:

nvm install --lts
nvm use --lts
Warningnvm only loads in interactive shells

This default matters in §6 below. If you launch the build through nohup ssh '...', your remote shell is non-interactive, your ~/.zshrc is not sourced, and npm will not be on PATH. We will work around this with zsh -ic.

3.5 git and python3 (remote Mac)

Both ship with the Xcode CLT install above. Verify on the remote Mac:

git --version
python3 --version

3.6 SSH alias for the remote Mac (laptop)

In ~/.ssh/config on your laptop, add:

Host remote-mac
    HostName your-mac-pro
    User yourusername
    ServerAliveInterval 60
    ServerAliveCountMax 30

Replace your-mac-pro with whatever the remote is reachable at: a Tailscale machine name, a Tailscale 100.x.x.x address, a VPN-internal IP, a public DNS name, a .local Bonjour name, or a LAN IP. The alias is what Positron desktop will pick up in the “Connect to Remote” dialog, so the choice of route is fully encapsulated here. If your route changes later (e.g., you switch from VPN to Tailscale), update only the HostName line and nothing else in this post needs to change.

4 Clone the Positron source

On the remote Mac:

mkdir -p ~/dev
git clone https://github.com/posit-dev/positron ~/dev/positron

This is a large repo; the initial clone takes a few minutes.

5 Find the matching commit and channel from your client

This is the most-skipped-but-critical step. The remote server’s commit and quality channel must both match the local Positron desktop, or things will silently break in confusing ways (see Pitfalls).

On your laptop, read the values straight out of the installed Positron app bundle:

python3 -c "import json; p=json.load(open('/Applications/Positron.app/Contents/Resources/app/product.json')); print(p['commit'], p['quality'])"

You will see something like:

80db96acd1bc6f7e7ff5ba4cbc509af8d40af1dd releases

The first value is the commit you need to check out on the remote Mac; the second is the channel (typically releases or dailies depending on which Positron build you installed). Note both, since they feed into the rebuild script next.

6 The rebuild script

Save the following on the remote Mac as ~/bin/positron-rebuild-server, then chmod +x it. The script automates the full pipeline so you can re-run it every time the desktop client updates.

#!/usr/bin/env zsh
set -euo pipefail

# usage: positron-rebuild-server <commit-or-tag>
TARGET_REF="${1:?need a commit or tag}"

SRC="$HOME/dev/positron"
PLATFORM_ARCH="darwin-x64"   # change to darwin-arm64 if your remote is Apple Silicon
BUILD_OUTPUT_DIR="$HOME/dev/vscode-reh-${PLATFORM_ARCH}"
CHANNEL="releases"           # must match your client's product.json `quality`

echo ">> [1/5] sync source"
cd "$SRC"
git fetch --all --tags --prune
git checkout "$TARGET_REF"
RESOLVED_COMMIT=$(git rev-parse HEAD)
echo ">> target commit: $RESOLVED_COMMIT"

echo ">> [2/5] npm install (~4 min)"
npm install

echo ">> [3/5] gulp build (this is the slow part, 10-30 min)"
POSITRON_RELEASE_CHANNEL="$CHANNEL" \
  npx gulp "vscode-reh-${PLATFORM_ARCH}" 2>&1 | tee "$HOME/dev/reh-build.log"

# sanity-check that the built product.json has the channel we asked for
QUALITY=$(python3 -c "import json; print(json.load(open('$BUILD_OUTPUT_DIR/product.json'))['quality'])")
[ "$QUALITY" = "$CHANNEL" ] || { echo "!! quality is '$QUALITY', expected '$CHANNEL'"; exit 1; }

echo ">> [4/5] install to ~/.positron-server/bin/$RESOLVED_COMMIT"
INSTALL_DIR="$HOME/.positron-server/bin/$RESOLVED_COMMIT"
if [ -d "$INSTALL_DIR" ]; then
    mv "$INSTALL_DIR" "$INSTALL_DIR.replaced-$(date +%Y%m%d-%H%M%S)"
fi
mkdir -p "$(dirname "$INSTALL_DIR")"
cp -R "$BUILD_OUTPUT_DIR" "$INSTALL_DIR"

echo ">> [5/5] kick any running server so the next client connect respawns"
pkill -f "positron-server" 2>/dev/null || true

echo "=== DONE ==="

A few things worth highlighting:

  • POSITRON_RELEASE_CHANNEL is the env var that propagates into the gulp build step that writes product.json. The default in source is dailies; if your client is on releases, you must set this or the bug in §8 will bite you.
  • ~/.positron-server/bin/<commit>/ is the install root. Keeping installs keyed by commit means an old install survives the build of a new one until the very last step, so you can roll back.
  • Your ~/.positron-server/extensions/ (separate directory) is left untouched by rebuilds, so your installed extensions persist across server upgrades.

7 Running the build remotely

From your laptop, kick off the build over SSH:

ssh remote-mac 'nohup zsh -ic \
  "NODE_OPTIONS=--max-old-space-size=16384 ~/bin/positron-rebuild-server <commit>" \
  >> ~/dev/reh-rebuild-stdout.log 2>&1 & disown'

Two non-obvious wrappers, both load-bearing:

  • zsh -ic forces an interactive shell so ~/.zshrc is sourced and nvm puts npm on PATH. Without this, the build dies immediately with npm: command not found.
  • NODE_OPTIONS=--max-old-space-size=16384 raises the Node heap from its 4 GB default to 16 GB. Without this, gulp OOMs partway through with FATAL ERROR: Ineffective mark-compacts near heap limit. Pick a value generous for your machine’s RAM.

You can tail the log to watch progress:

ssh remote-mac 'tail -f ~/dev/reh-rebuild-stdout.log'

Typical end-to-end timing on a Mac Pro is about 20 minutes (≈4 min npm install, ≈14 min gulp, plus the install copy).

8 Connecting from the client

In Positron desktop on your laptop:

  1. Open the Command Palette (Cmd-Shift-P) → Remote-SSH: Connect to Host.
  2. Pick remote-mac (the alias from §2).
  3. Positron negotiates with the remote, finds ~/.positron-server/bin/<commit>/, and spawns the server. The R, Python, and Quarto panes should all behave exactly as they do locally.

If a future client update installs a different commit, simply re-run the script with the new commit hash; the existing install gets archived as <old-commit>.replaced-<timestamp>, and the new client picks up the new install on next connect.

9 Pitfalls

These are the actual issues I hit. Skim them before your first build.

WarningChannel mismatch breaks extension resources, silently

If your script’s POSITRON_RELEASE_CHANNEL does not match the client’s quality, every extension resource (icons, fonts, webview assets) returns 404, but extensions still activate and the server log shows nothing wrong. Symptom: blank/transparent activity-bar icons, broken webviews, and a vague feeling that “extensions are not loading right.” Fix: rebuild with the right channel, or hot-patch one install in place:

sed -i '' 's/"quality": "dailies"/"quality": "releases"/' \
  ~/.positron-server/bin/<commit>/product.json
pkill -f "positron-server.*<commit>"

Then reconnect. Always verify the channel matches what your local Positron.app/Contents/Resources/app/product.json says.

Warningnpm: command not found under nohup ssh

nvm only loads in interactive zsh. Wrap your build invocation in zsh -ic "..." (as shown in §6). A bare ssh remote 'npm install' will fail.

WarningGulp out-of-memory

The default 4 GB Node heap is not enough for a Positron-server build. Set NODE_OPTIONS=--max-old-space-size=16384 (or higher if your remote has the RAM).

Notegit fetch aborts on rewritten tags

Under set -euo pipefail, git fetch --all --tags --prune returns non-zero when an upstream tag has been rewritten (e.g., a moving latest-daily). Pre-delete the offending local tag before invoking the script:

ssh remote-mac 'cd ~/dev/positron && git tag -d latest-daily 2>/dev/null || true'
NoteWrong-architecture extensions

If you have both an Apple Silicon laptop and an Intel remote Mac, the client may try to push darwin-arm64 extensions to your darwin-x64 remote. Things misbehave in odd ways. Clean stale extension dirs under ~/.positron-server/extensions/ and let the client reinstall the correct architecture.

10 When the client updates

Every time you update Positron desktop on the laptop, re-run the script with the new commit:

# on the laptop, get the new commit + quality
python3 -c "import json; p=json.load(open('/Applications/Positron.app/Contents/Resources/app/product.json')); print(p['commit'], p['quality'])"

# if quality flipped, patch the script in place; one sed covers it
ssh remote-mac "sed -i '' 's/CHANNEL=\"dailies\"/CHANNEL=\"releases\"/' ~/bin/positron-rebuild-server"

# then rebuild
ssh remote-mac 'nohup zsh -ic \
  "NODE_OPTIONS=--max-old-space-size=16384 ~/bin/positron-rebuild-server <new-commit>" \
  >> ~/dev/reh-rebuild-stdout.log 2>&1 & disown'

Always check the client product.json first. If you assume the channel is unchanged and it has flipped, you are heading straight back into the §8 trap.

11 Fallback: VS Code Remote-SSH

If the source build breaks on your machine and you do not want to debug it, VS Code Remote-SSH ships a real, official macOS server and “just works” with the same SSH alias you set up above. You will lose the Positron-specific R/Python/Quarto integrations, but you keep a fully functional remote editor. It is a perfectly reasonable Plan B while you wait for Posit to ship official macOS server binaries.

Back to top
 

Copyright 2025, Wei Miao