Multi-Language Development Environments

Updated

September 12, 2026

Multi-Language Development Environments

The desk API is Go, the admin UI is Node, a report is Python, and one crate talks to OpenSSL. The boring default is: one mkShell listing every compiler and C library the repo actually uses — not asdf + nvm + pyenv + libssl-dev.

Mental model

Version managers fight over PATH. Nix does not: each package is a store path. Two Pythons can exist; the shell only prepends the ones you listed.

Need Nix
Go / Node / Python interpreters pkgs.go, pkgs.nodejs, python3.withPackages
C headers / .so buildInputs (openssl, zlib, postgresql)
pkg-config, cmake nativeBuildInputs
Per-subdir tools Named devShells.backend / frontend (direnv chapter)

Do not dump gcc+node+python into every shell “just in case.” Closures grow; eval slows. Split named shells when the frontend never needs go.

In mkShell, packages is the PATH you type (go, node). nativeBuildInputs is setup-hook tools (pkg-config, cmake). buildInputs is libraries those hooks see (openssl). Mixing “everything in packages” skips setup hooks — pkg-config --libs openssl then fails even though openssl is “installed.”

Worked examples

Case 1: One polyglot shell

Save as polyglot.nix:

# polyglot.nix
{ pkgs ? import <nixpkgs> { } }:

pkgs.mkShell {
  packages = [
    pkgs.go
    pkgs.nodejs
    (pkgs.python3.withPackages (ps: [ ps.requests ps.pydantic ]))
    pkgs.sqlite
  ];
  shellHook = ''
    echo "Polyglot: $(go env GOVERSION) node $(node -v) $(python3 --version)"
  '';
}
nix-shell polyglot.nix --run 'go env GOVERSION; node -v; python3 --version'

Output (shape — versions follow 26.05):

go1.24.x
v22.x.x
Python 3.12.x

Use pkgs.nodejs (channel default on 26.05), not a random nodejs_22 unless you have a reason to pin a major. pkgs.go is the channel Go — buildGoModule in CI must use the same major or go.mod’s toolchain line fights GOTOOLCHAIN=local.

nix-shell polyglot.nix --run 'which go; which node; which python3'

All three must be /nix/store/…. A mix of /usr/bin/python3 and store go is two version managers again. PATH order in the shell is Nix first.

Case 2: OpenSSL via pkg-config

Save as native_c.nix:

# native_c.nix
{ pkgs ? import <nixpkgs> { } }:

pkgs.mkShell {
  nativeBuildInputs = [ pkgs.pkg-config ];
  buildInputs = [ pkgs.openssl pkgs.zlib ];
  shellHook = ''
    pkg-config --cflags openssl
  '';
}
nix-shell native_c.nix --run 'pkg-config --libs openssl'

Output (shape):

-L/nix/store/…-openssl-…/lib -lssl -lcrypto

Headers are not /usr/include/openssl. PKG_CONFIG_PATH is set by the setup hook because pkg-config is in nativeBuildInputs. Put openssl only in packages and the hook never runs.

Case 3: Named shells in a flake

# flake.nix fragment
devShells.x86_64-linux = {
  default = self.devShells.x86_64-linux.backend;
  backend = pkgs.mkShell { packages = [ pkgs.go pkgs.gopls ]; };
  frontend = pkgs.mkShell { packages = [ pkgs.nodejs pkgs.pnpm ]; };
};
nix develop .#frontend --command which node
nix develop .#backend --command which go

direnv: use flake .#frontend in web/.envrc.

Case 4: Python packages vs a second venv

packages = [
  (pkgs.python3.withPackages (ps: [ ps.requests ]))
];

python3 -c "import requests" works. pip install requests inside this shell writes to a user venv and fights Nix. If you need a lockfile-heavy app, package it (buildPythonApplication) instead of pip in the shell.

Case 5: LD_LIBRARY_PATH is a last resort

Nix wrappers set RPATH. If a vendor binary still cannot find libssl.so, use nix-ld / FHS (later chapter), not:

export LD_LIBRARY_PATH=$(nix eval --raw nixpkgs#openssl.out)/lib

That leaks into every child process.

The trap

The trap is buildInputs = [ pkgs.go pkgs.nodejs pkgs.python3 pkgs.gcc pkgs.cmake pkgs.openssl … ] copied from another team. The shell takes a minute to eval and nobody uses cmake. List what README says you run.

The other trap is host /usr/include for openssl while Node’s node-gyp uses Nix python. Mix = “works on Ubuntu, dies on macOS.”

A third: packages = [ pkgs.openssl pkgs.pkg-config ] and wondering why pkg-config sees nothing — use nativeBuildInputs / buildInputs. A fourth: pip install / npm i -g inside nix develop “just this once.”

The boring rule

  • One shell per job (backend / frontend), not one kitchen sink.
  • Interpreters in packages. C libs in buildInputs. cmake/pkg-config in nativeBuildInputs.
  • python3.withPackages instead of pip. Same Go major as buildGoModule.
  • Pin majors (nodejs_22) only when the default is wrong.
  • No host headers. No global LD_LIBRARY_PATH.

Try this

  1. Add pkgs.cargo to Case 1; nix-shell --run 'cargo --version'.
  2. pkg-config --cflags openssl in Case 2; confirm /nix/store.
  3. Split Case 3; which node must fail in .#backend if node is not listed.
  4. nix-shell --pure --run 'python3 -c "import ssl; print(ssl.OPENSSL_VERSION)"' with openssl in buildInputs.
  5. Move pkg-config from packages to nativeBuildInputs and confirm pkg-config --libs openssl starts working.