Connect

Blog / Guides

One Tool, Every Language: Declarative Dev Environments

Steve Swoyer

Ever tried to build or debug software only to find that the compiler, runtime, SDK, or plugin you need is missing… or hopelessly outdated? Ever struggled to support a team that works in a polyglot monorepo… where every language, service, and package depends on different combinations of toolchains?

Ever needed just the right historical version of a tool or dependency at just the right moment… but weren’t able to track it down? If any of these problems feels familiar, I’ve got a modest proposal for you.

Imagine creating declarative, reproducible environments for the toolchains and dependencies that you (or the engineers you support) use in their workflows. Imagine declaring compilers, debuggers, dev libraries, and other dependencies as code. Imagine giving engineers the ability imperatively to install new tools (or override versions of existing ones) when necessary. Declarative patterns make this as convenient as:

$ nix run github:team-foo/polyglot-playground#cxx

Or:

$ flox activate -r team-foo/cxx

This article introduces a hardened set of 16 language-specific declarative dev environments. You can use them individually or compose them in different combinations to create polyglot language-toolchain stacks.

You could even compose all 16 into a single gigantic stack. The declarative, stack-as-code patterns outlined here give you convenience, strong reproducibility guarantees across space and time, and cross-platform portability. To take one example: A declarative Nix or Flox C/C++ language-toolchain runs across Linux and macOS, on ARM and x86… without virtualization. Users on macOS get Clang, LLDB, and Apple system frameworks like (CoreFoundation); users on Linux get GCC, GDB, and Valgrind.

With this as prelude, let’s see how the declarative sausage gets made.

Defining an All-in-One Polyglot Toolchain Environment

Creating a declarative language-toolchain stack is as simple as authoring a Nix flake or Flox manifest to compose the language dependencies (C/C++, Rust, Python, Node, Go, and others) your project needs.

The code block below defines a basic Flox environment for C/C++, Rust, Python, Node, and Go:

[install]
gcc.pkg-path = "gcc"
rust.pkg-path = "rustc"
cargo.pkg-path = "cargo"
python.pkg-path = "python3"
node.pkg-path = "nodejs"
go.pkg-path = "go"

The TOML above tells Flox to resolve a compatible dependency graph for gcc and g++; rustc and cargo; python3; node and npm; and the go runtime.

If need be, you can tell Flox’s resolver to pin each package to a specific version:

[install]
gcc.pkg-path = "gcc"
gcc.version = "15.2.0"
rust.pkg-path = "rustc"
rust.version = "1.96.1"
cargo.pkg-path = "cargo"
cargo.version = "1.96.1"
python.pkg-path = "python3"
python.version = "3.14.6"
node.pkg-path = "nodejs"
node.version = "24.18.0"
go.pkg-path = "go"
go.version = "1.26.4"

That’s it. Do flox activate and each of these tools becomes accessible in your Flox environment. To copy and paste this code into a Flox manifest is to recreate the environment.

Composing Language-Specific Environments

Many teams opt to create language-specific leaf environments and compose them using a shared parent environment. This makes it easier to version, test, update, and reuse individual leaf environments, as well as compose them in different combinations to create polyglot language-toolchain stacks.

The TOML below composes separate C/C++, Node.js, Python, Rust, and Go environments. Pasting it into a Flox manifest gives you a polyglot language-toolchain stack that works across macOS and Linux:

schema-version = "1.13.0"
 
[include]
environments = [
    { remote = "flox-labs/cxx" },         # C / C++
    { remote = "flox-labs/node" },        # Node.js + TypeScript
    { remote = "flox-labs/python3" },     # Python
    { remote = "flox-labs/rust-dev" },    # Rust
    { remote = "flox-labs/go" },          # Go
]

Each [include]-ed environment defines a complete language-specific toolchain. Copy and paste the TOML above into a Flox manifest, save your changes, activate the environment… and you’ll pull in all the packages defined by each of the leaf environments. The C/C++ environment’s inventory is below:

[install]
## compilers
clang.pkg-path = "clang"
clang.systems = ["aarch64-darwin", "x86_64-darwin"]
clang.pkg-group = "compilers-darwin"
gcc.pkg-path = "gcc"
gcc.systems = ["x86_64-linux", "aarch64-linux"]
gcc.pkg-group = "compilers-linux"
gcc-unwrapped.pkg-path = "gcc-unwrapped"
gcc-unwrapped.systems = ["x86_64-linux", "aarch64-linux"]
gcc-unwrapped.pkg-group = "compilers-linux"
gcc-unwrapped.priority = 6
 
## build systems — cxx-specific dev toolchain (`flox upgrade cxx`)
cmake.pkg-path = "cmake"
cmake.pkg-group = "cxx"
ninja.pkg-path = "ninja"
ninja.pkg-group = "cxx"
gnumake.pkg-path = "gnumake"
gnumake.pkg-group = "shared"
## autotools (uncomment for projects that use ./configure)
# autoconf.pkg-path = "autoconf"
# automake.pkg-path = "automake"
# libtool.pkg-path = "libtool"
 
## build acceleration and code quality
ccache.pkg-path = "ccache"
ccache.pkg-group = "cxx"
clang-tools.pkg-path = "clang-tools"
clang-tools.pkg-group = "cxx"
 
## native build tools — shared plumbing common to every toolchain env
pkg-config.pkg-path = "pkg-config"
pkg-config.pkg-group = "shared"
openssl.pkg-path = "openssl"
openssl.pkg-group = "shared"
zlib.pkg-path = "zlib"
zlib.pkg-group = "shared"
cacert.pkg-path = "cacert"
cacert.pkg-group = "shared"
git.pkg-path = "git"
git.pkg-group = "shared"
gh.pkg-path = "gh"
gh.pkg-group = "shared"
 
## debugging — cxx-specific
gdb.pkg-path = "gdb"
gdb.systems = ["x86_64-linux", "aarch64-linux"]
gdb.pkg-group = "cxx"
lldb.pkg-path = "lldb"
lldb.systems = ["aarch64-darwin", "x86_64-darwin"]
lldb.pkg-group = "cxx"
valgrind.pkg-path = "valgrind"
valgrind.systems = ["x86_64-linux", "aarch64-linux"]
valgrind.pkg-group = "cxx"

This environment declares both C/C++ packages and the supporting packages that engineers need when they’re building, debugging, and testing C/C++ code. Putting them into separate package groups does two things:

  • You can run flox upgrade <package_group> to separately update each package set;
  • You can isolate historical packages from new packages. This lets you resolve current versions.

When you declare dev environments as code, their dependencies, hooks, and overrides become legible, reviewable, and much easier to test, update, and roll back. This gives platform teams, especially, a reliable, scalable pattern for publishing versioned, authoritative environments. They can update a composed environment’s declared configuration via targeted, auditablse diffs, rather than rebuilding and redistributing VM or container images… or imperatively upgrading packages across bare-metal systems.

Creating the Mother of All Composed Environments

This last example is an intentional tour-de-force: a polyglot declarative environment that composes 16 distinct language-specific environments. Few orgs would use or even need something like this. It’s a proof-of-concept demonstrating how declarative patterns can scale to support large polyglot language-toolchain stacks.

[include]
environments = [
  { remote = "flox-labs/cxx" },       { remote = "flox-labs/erlang" },
  { remote = "flox-labs/node" },      { remote = "flox-labs/python3" },
  { remote = "flox-labs/ruby" },      { remote = "flox-labs/rust-dev" },
  { remote = "flox-labs/zig" },       { remote = "flox-labs/go" },
  { remote = "flox-labs/kotlin" },    { remote = "flox-labs/lua" },
  { remote = "flox-labs/php" },       { remote = "flox-labs/haskell" },
  { remote = "flox-labs/datascience" },{ remote = "flox-labs/clojure" },
  { remote = "flox-labs/scala" },     { remote = "flox-labs/perl" },
]

With non-declarative methods, a polyglot stack is typically encoded as procedural setup scripts, or baked into VM or OCI images. In most cases, changing just one dependency in the stack requires rerunning scripts, rebuilding artifacts, and revalidating the resultant environment. Platform teams usually need to support multiple ecosystem-specific package managers, refactor (or create new) build logic, regenerate images, rerun qualifications, and then roll the results out across every consuming team.

With a declarative stack, changes get expressed as a small, reviewable update to the environment: a diff.

About Flox’s Polyglot Playground

The complete Flox polyglot-playground stack defines:

  • A set of single-language toolchain environments. One each for C/C++, Rust, Go, Zig, Node/TypeScript, Python, Ruby, PHP, Perl, Lua, Erlang/Elixir, Haskell, Kotlin, Clojure, Scala, and R/Julia. Each is designed as a standalone environment, usable on its own;
  • A shared plumbing layer. This includes packages like git, gh, openssl, cacert, pkg-config, zlib , and other cross-cutting dependencies;
  • A platform-split compiler layer. GCC on Linux, Clang on macOS, and other platform-specific tools.

You can pull and run the whole stack just by doing flox activate -r flox-labs/polyglot-playground; alternatively, you can pull this GitHub repo to get the same stack locally. First git clone and cd into the repo, then run flox activate: this will put you inside the polyglot-playground environment. (It doesn’t matter if one or more of these tools are already installed on your local machine: the Flox-provided versions are available first on PATH.)

You can modify these environments to suit your own needs. Each can be used on its own or composed as part of the polyglot-playground stack. Need an extra tool for an ad hoc requirement? Just add it imperatively with flox install ripgrep. Need to install a specific historical version of a package? Just do: flox install nodejs@22.21.1. You can also comment-out environments under [include] in to selectively disable toolchains. Do what you will with it: The world is yours.

Advanced users might opt to use Flox to build and publish a single meta-package per toolchain. This basically rollls up compilers, linters, debuggers, language servers, development libraries, and other language-specific dependencies into a single package. That package can ship its own activation hook under etc/profile.d, setting language-specific cache, package, SDK, or workspace locations; exposing commands on PATH; and defining any environment variables required by the toolchain. Flox sources this hook automatically when the package is activated.

This might look like:

[install]
cxx-toolchain.pkg-path = "flox-user/cxx-toolchain"
node-toolchain.pkg-path = "flox-user/node-toolchain"
python3-toolchain.pkg-path = "flox-user/python3-toolchain"
rust-toolchain.pkg-path = "flox-user/rust-toolchain"
go-toolchain.pkg-path = "flox-user/go-toolchain"

Start with the Reference Environments

The environments in this article are starting points. FloxHub hosts the complete set of language-specific toolchains used by the polyglot-playground, along with scads of other reusable environments for software development, software builds, testing, debugging, data science, and related workflows.

You can activate these environments directly from FloxHub, include them in your own stacks, or opportunistically layer them on top of projects on an ad hoc basis: e.g., when you need just the right tool but don’t have the time to build your own composable, reproducible environment. To learn more about Flox, check out our 'Flox in Five Minutes guide; if you've seen enough and are ready to pull and run your own environments, you can download Flox here!