Flutter and Nix: When Your SDK Thinks It Owns the Filesystem

6 min read

So this started, as these things usually do, with a build that wouldn’t build. I was setting up a Flutter project inside a Nix flake — nothing exotic, just wanting reproducible dev environments like a civilised person — and iOS builds were failing in ways that made no sense until they made too much sense.

What followed was a few hours of digging through Xcode build phases, Gradle internals, Apple code signing documentation, and Flutter issue trackers. I came out the other side with a working — if impure — set of workarounds and a much clearer picture of why Flutter and Nix are fundamentally at odds. This is that write-up.

The symptoms

The immediate failures were straightforward enough. Builds would die because codesign couldn’t write to framework files that lived in the Nix store. rsync would fail copying frameworks because the source was read-only and the tooling expected to be able to modify what it copied. On the Android side, Gradle would try to create build output directories inside the Flutter SDK path itself — which, being in /nix/store/, wasn’t going to happen.

The error messages all pointed the same direction: Flutter expected to write to places that Nix had made read-only. Not build output directories — the SDK’s own installation.

What I found: the iOS side

When you build a Flutter iOS app, Xcode invokes xcode_backend.sh during its Build Phases. This script copies Flutter.framework from the engine artifacts cache — under bin/cache/artifacts/engine/ios in the SDK tree — into the project and then into the built products directory. Older Flutter versions dumped it into ios/Flutter/Flutter.framework, newer ones target BUILT_PRODUCTS_DIR (see flutter/flutter#70224).

These copies need to be writable because Apple’s codesign embeds signature data directly into the Mach-O binary. It physically modifies the file. There’s no detached signing option for embedded frameworks — the binary itself gets rewritten with the developer’s identity. That’s an Apple platform constraint, not something Flutter invented.

The thing is, the build output directory is already writable — that’s the whole point of a build directory. The correct approach is to read from the immutable SDK, copy into the mutable build output, and sign there. Flutter’s pipeline mostly does this now, but the assumption of SDK-level writability is still baked into enough places that it breaks on read-only filesystems.

What I found: the Android/Gradle side

The Flutter Gradle plugin tries to build under $FLUTTER_SDK/flutter_tools/gradle. That path is inside the SDK distribution. When $FLUTTER_SDK lives in the Nix store, Gradle fails with Failed to create parent directory errors pointing at /nix/store/....

This is documented in NixOS/nixpkgs#260278 and #289936. The workaround is to patch build.gradle.kts to redirect buildDir via an environment variable (FLUTTER_GRADLE_PLUGIN_BUILDDIR). It works, but it’s a patch against a design that shouldn’t need patching.

What I found: the SDK itself

Beyond the platform-specific issues, Flutter’s SDK is architecturally hostile to immutability. It caches downloaded engine artifacts under bin/cache/. It runs self-update checks. Its version detection expects a .git directory to be present and writable. It was designed as a self-managing tool — think rustup or nvm — that owns its entire directory tree.

There’s a key issue on the Flutter tracker that captures the whole problem: #118162, titled “Immutable and Offline Flutter SDK mode”. The filing states it plainly — Flutter requires both write access to itself and internet access during initialisation. This makes it impossible to ship as a system package in RPM, DEB, or Flatpak format. The end user doesn’t have write access to /usr/lib/flutter, and a Flatpak SDK extension doesn’t get internet during builds.

The issue was closed as a duplicate. As far as I can tell, the underlying problem hasn’t been addressed.

What the Nix community has done about it

There’s a whole ecosystem of workarounds at this point. The nixpkgs Flutter package creates a flutter-wrapped symlink forest around the read-only store path. Developers write patch scripts that intercept tools like codesign and rsync to make files writable before the real binaries run. Others patch Gradle build files, symlink .git directories from wrapped to unwrapped SDK paths, and various other creative horrors.

Manuel Plavsic documented a comprehensive set of steps in his guide. The NixOS Discourse has threads going back years of people hitting the same wall. Every workaround exists for the same reason: Flutter doesn’t separate its distribution from its build state.

What I ended up doing

I wrote an apply-ios-fixes script for the flake. It installs wrapper scripts for codesign and rsync that chmod files writable before the real tools touch them, and it patches project.pbxproj to prepend a scripts directory to PATH in the xcode_backend.sh build phases:

apply-ios-fixes =
  pkgs.writeShellScriptBin "apply-ios-fixes" ''
    set -euo pipefail

    root="$(${pkgs.git}/bin/git rev-parse --show-toplevel 2>/dev/null || pwd)"
    nix_scripts="$root/nix/ios-scripts"
    ios_scripts="$root/ios/scripts"
    pbxproj="$root/ios/Runner.xcodeproj/project.pbxproj"

    if [ ! -f "$pbxproj" ]; then
      echo "error: project.pbxproj not found" >&2
      exit 1
    fi

    # Install wrapper scripts
    mkdir -p "$ios_scripts"
    cp "$nix_scripts/codesign" "$ios_scripts/codesign"
    cp "$nix_scripts/rsync"    "$ios_scripts/rsync"
    chmod +x "$ios_scripts/codesign" "$ios_scripts/rsync"
    echo "installed ios/scripts/{codesign,rsync}"

    # Patch project.pbxproj – prepend PATH to
    # xcode_backend.sh build phases
    if grep -q 'PROJECT_DIR}/scripts' "$pbxproj"; then
      echo "project.pbxproj already patched"
    else
      ${pkgs.gnused}/bin/sed -i \
        '/xcode_backend\.sh\\" embed_and_thin/s|shellScript = "/bin/sh|shellScript = "export PATH=\\"''${PROJECT_DIR}/scripts:''${PATH}\\"\\n/bin/sh|' \
        "$pbxproj"

      ${pkgs.gnused}/bin/sed -i \
        '/xcode_backend\.sh\\" build"/s|shellScript = "/bin/sh|shellScript = "# Nix codesign fix: wrapper makes files writable before signing\\nexport PATH=\\"''${PROJECT_DIR}/scripts:''${PATH}\\"\\n/bin/sh|' \
        "$pbxproj"

      echo "patched project.pbxproj"
    fi

    echo "done – iOS Nix fixes applied"
  '';

It’s impure. It’s the least of all evils. It gets the job done.

The takeaway

The root cause here isn’t technical complexity — it’s a design philosophy. Flutter treats its SDK directory as a mutable workspace. The SDK is simultaneously the distribution, the build cache, the artifact store, and the update mechanism. Everything co-located, everything writable.

Compare this to how other toolchains handle it. Rust’s cargo never writes back to the toolchain directory. Go’s GOROOT is read-only by design — GOPATH and the module cache live elsewhere. Even Node.js keeps node_modules in the project tree, not inside the runtime installation. The pattern of separating your distribution from your build state is well-established. Flutter just doesn’t follow it.

I’d call this horrendous citizenship on the internet. The “SDK is the world” mentality — where one tool absorbs dependency management, version control, artifact caching, and the build itself into a single mutable blob — is convenient for getting started. flutter run works on your laptop, in your home directory, with internet access. But it means your SDK can’t participate in Nix, can’t be distributed as a system package, can’t run in air-gapped CI, and can’t be shared read-only across users on a build server.

More focus should be put into doing things right, not just what’s easy. Flutter, with all of Google’s resources behind it, has no excuse for not having figured this out by now.