Nix Flakes: Visión Funcional

Los Nix Flakes suelen describirse con terminología abstracta que oscurece su valor práctico. Un enfoque más concreto es el siguiente.

Un Nix Flake es un archivo flake.nix en tu repo que declara, con cero ambigüedad:

  1. Exactamente qué versiones de cada herramienta necesita tu proyecto
  2. Exactamente cómo compilar tu proyecto
  3. Exactamente cómo se ve el entorno de desarrollo

Y "exactamente" se refiere a pineado-por-hash-a-un-commit-específico-de-nixpkgs exactamente. No "Node 20.x". No "lo que te dé Homebrew." El build específico de Node.js, compilado con flags específicos, linkeado contra librerías específicas.

Acá está el flake.nix de un servicio de producción representativo, una API en Go con dependencia de PostgreSQL y algunas herramientas de generación de código:

{
 description = "Order service API";

 inputs = {
 nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
 flake-utils.url = "github:numtide/flake-utils";
 gomod2nix = {
 url = "github:nix-community/gomod2nix";
 inputs.nixpkgs.follows = "nixpkgs";
 };
 };

 outputs = { self, nixpkgs, flake-utils, gomod2nix }:
 flake-utils.lib.eachDefaultSystem (system:
 let
 pkgs = import nixpkgs {
 inherit system;
 overlays = [ gomod2nix.overlays.default ];
 };
 in {
 devShells.default = pkgs.mkShell {
 buildInputs = with pkgs; [
 go_1_23
 gopls
 gotools
 go-mockery
 sqlc
 postgresql_16
 pgformatter
 protobuf
 protoc-gen-go
 protoc-gen-go-grpc
 buf
 just
 docker-compose
 ];

 shellHook = ''
 export GOPATH="$PWD/.go"
 export PATH="$GOPATH/bin:$PATH"
 export PGDATA="$PWD/.pgdata"
 export PGHOST="$PWD/.pgdata"
 echo "Order service dev shell listo"
 echo "Go $(go version | cut -d' ' -f3)"
 echo "PostgreSQL $(pg_config --version)"
 '';
 };

 packages.default = pkgs.buildGoApplication {
 pname = "order-service";
 version = "0.1.0";
 src = ./.;
 modules = ./gomod2nix.toml;
 };
 });
}

Cuando alguien ejecuta nix develop, obtiene un shell con cada herramienta en la versión exacta especificada. Go 1.23. PostgreSQL 16. protoc, buf, sqlc, todo. En Linux, en macOS, en Intel, en ARM. Las mismas versiones. Siempre.

La Anatomía de flake.nix (Las Partes Que Importan)

A continuación se desglosa la estructura, ya que la documentación de Nix lo hace más difícil de lo necesario:

inputs

inputs = {
 nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
 flake-utils.url = "github:numtide/flake-utils";
};

Estas son tus dependencias. nixpkgs es el repositorio de paquetes (más de 100,000 paquetes). La URL pinea a un branch específico. Cuando ejecutás nix flake update, actualiza flake.lock con el hash exacto del commit, como un package-lock.json pero para todo el toolchain de tu sistema.

outputs

Acá es donde se define lo que tu flake provee. Los outputs más comunes para un proyecto de desarrollo:

  • devShells.default, el entorno de desarrollo (lo que usa nix develop)
  • packages.default, el artefacto construido (lo que usa nix build)
  • checks, suites de tests (lo que ejecuta nix flake check)

devShells (La Feature que Vale Oro)

devShells.default = pkgs.mkShell {
 buildInputs = [ ... ]; # Herramientas disponibles en el shell
 shellHook = ''...''; # Se ejecuta al entrar al shell
 env = { ... }; # Variables de entorno
};

mkShell crea un entorno, no un container. No hay overhead de aislamiento, no hay montaje de volúmenes, no hay bridge de red. Tu editor, tu terminal, tu config de git, todo funciona normal. Simplemente teners herramientas diferentes en tu PATH.

Por Qué Le Gana a Docker para Desarrollo Local

La precisión importa aquí porque es donde la gente se pone a la defensiva. Docker es excelente para deployment. Nix no está reemplazando tus containers de producción. Lo que Nix reemplaza es el Dockerfile.dev y el docker-compose.dev.yml que manteners separados de tu setup de producción.

Velocidad: nix develop con cache caliente toma 0.3 segundos. Levantar un Docker Compose de dev con 4 servicios: 12-20 segundos. A lo largo de un día de trabajo, eso se suma.

Uso de recursos: Un devShell de Nix usa cero RAM adicional más allá de las herramientas en sí. Docker Desktop en macOS reserva una VM con 4-8GB de RAM por defecto.

Performance del file system: Este es el factor crítico en macOS. El compartido de file system de Docker (incluso con VirtioFS) agrega latencia medible a cada operación de archivo. Para un go test ./... en un servicio típico: 4.1s nativo, 7.8s en Docker. Para npm install en un proyecto mediano: 12s nativo, 34s en Docker.

Debugging: Cuando algo en tu shell de Nix no funciona, es posible inspeccionarlo. which go te muestra el path exacto del Nix store. nix why-depends traza la cadena de dependencias. En Docker, se está haciendo SSH a un container y adivinando.

Una Comparación Real

Checklist de migración, lo que se reemplaza:

Basado en Docker Basado en Nix
Dockerfile.dev (47 líneas) flake.nix (43 líneas)
docker-compose.dev.yml (89 líneas) , (servicios corren nativamente)
.env.docker , (shellHook setea env vars)
scripts/setup-dev.sh (112 líneas) ,
scripts/fix-permissions.sh ,
docs/SETUP.md (47 pasos) docs/SETUP.md (3 pasos)

Los tres pasos: 1) Instalar Nix, 2) Ejecutá nix develop, 3) Ejecutá just dev.

Cross-Compilación: El Arma Secreta de Nix

Esta es una feature genuinamente sorprendente. La historia de cross-compilación de Nix es absurdamente buena:

packages = {
 default = pkgs.buildGoApplication {
 pname = "order-service";
 version = "0.1.0";
 src = ./.;
 modules = ./gomod2nix.toml;
 };

 # Cross-compilar para ARM64 Linux (para deploy en Graviton)
 arm64-linux = pkgs.pkgsCross.aarch64-multiplatform.buildGoApplication {
 pname = "order-service";
 version = "0.1.0";
 src = ./.;
 modules = ./gomod2nix.toml;
 };

 # Construir una imagen Docker sin Docker
 dockerImage = pkgs.dockerTools.buildLayeredImage {
 name = "order-service";
 tag = "latest";
 contents = [ self.packages.${system}.default ];
 config = {
 Cmd = [ "/bin/order-service" ];
 ExposedPorts = { "8080/tcp" = {}; };
 };
 };
};

Ese output dockerImage es hermoso. Construye una imagen de container OCI-compliant sin necesitar Docker instalado. La imagen resultante típicamente pesa 10-30MB porque solo incluye exactamente lo que la aplicación necesita, sin bloat de imagen base, sin herramientas de build sobrantes.

Las imágenes ARM64 Linux se pueden construir en máquinas de desarrollo macOS para testing. Sin QEMU, sin emulación. Nix simplemente cross-compila.

Migración: Un Relato Honesto

Este proceso no siempre es suave. Acá va una línea de tiempo representativa de migrar un equipo de 6:

Semana 1: Escribir el flake.nix y hacerlo funcionar en una máquina. Esto toma unos 3 días por la pelea con el lenguaje Nix. Los mensajes de error son... no son geniales. error: attribute 'go_1_23' missing cuando en realidad tenía un typo en el overlay. Qué útil.

Semana 2: Dos senior desarrolladores instalan Nix y prueban el flake. Problema inmediato: la VPN corporativa de un dev bloquea el binary cache de Nix (cache.nixos.org). Levantar un cache interno en S3 usando Attic toma un día entero.

Semana 3: Lanzamiento a todo el equipo. Tres problemas típicos:

  1. Un dev en una Mac Intel vieja tenía un string de arquitectura diferente. Se arregló asegurando que flake-utils.lib.eachDefaultSystem se usara (ya se usaba).
  2. Nuestros pre-commit hooks asumían herramientas instaladas globalmente. Se arregló ejecutándolos dentro de nix develop.
  3. La extensión de Go de VS Code no encontraba gopls hasta que agregamos la integración con direnv.

Semana 4: Todos en Nix. El setup de Docker para desarrollo se borra. Sin quejas.

Integración con direnv: La Pieza Faltante

nix develop a secas requiere que entres manualmente al shell cada vez que abrís una terminal. direnv con nix-direnv automatiza esto:

# .envrc
use flake

Ese es el archivo completo. Al ejecutar cd en el directorio del proyecto, direnv automáticamente carga el devShell de Nix. Tu editor levanta las herramientas. Tu terminal tiene el PATH correcto. Es invisible una vez configurado.

Instalalo una vez:

# En tu config global de Nix o home-manager
nix profile install nixpkgs#direnv nixpkgs#nix-direnv

Agregar a tu shell rc:

eval "$(direnv hook bash)" # o zsh

Múltiples devShells para Diferentes Flujos de Trabajo

Un flake.nix puede proveer múltiples shells:

devShells = {
 default = pkgs.mkShell {
 buildInputs = with pkgs; [ go_1_23 gopls postgresql_16 ];
 };

 ci = pkgs.mkShell {
 buildInputs = with pkgs; [ go_1_23 postgresql_16 golangci-lint ];
 };

 docs = pkgs.mkShell {
 buildInputs = with pkgs; [ mdbook graphviz plantuml ];
 };
};

nix develop entra al shell default. nix develop .#ci entra al shell de CI. nix develop .#docs entra al shell de documentación. Cada uno es hermético y reproducible.

El Binary Cache: Por Qué los Builds Siguientes Son Rápidos

El primer nix develop puede ser lento, minutos si los paquetes necesitan compilarse desde source. Pero Nix tiene un binary cache (cache.nixos.org) que provee binarios pre-compilados para la mayoría de los paquetes. Después de tu primera ejecución, las llamadas subsiguientes a nix develop son casi instantáneas porque todo está cacheado en el Nix store local.

Para equipos, se recomienda fuertemente levantar un cache compartido. Attic sobre un bucket de S3 funciona bien:

# En tu nix.conf o nixConfig del flake
extra-substituters = https://tu-cache-equipo.s3.amazonaws.com
extra-trusted-public-keys = tu-cache-equipo:AAAA...==

Los builds de CI llenan el cache. Las máquinas de desarrollo consumen de él. Un nix develop completo con cache caliente toma menos de un segundo.

Flake Checks: El Mejor Amigo de Tu CI

checks.${system} = {
 build = self.packages.${system}.default;

 lint = pkgs.runCommand "lint" {
 buildInputs = [ pkgs.golangci-lint ];
 } ''
 cd ${self}
 golangci-lint run ./...
 touch $out
 '';

 test = pkgs.runCommand "test" {
 buildInputs = [ pkgs.go_1_23 pkgs.postgresql_16 ];
 } ''
 cd ${self}
 go test -race -count=1 ./...
 touch $out
 '';
};

nix flake check ejecuta todos los checks. En CI, esto garantiza las mismas versiones exactas de herramientas que en desarrollo local. No más "pero en mi máquina funciona", literalmente no puede diferir.

Evaluación Honesta

Nix Flakes tiene desventajas reales:

  • La curva de aprendizaje es empinada. El lenguaje Nix es raro. La documentación está dispersa. Los mensajes de error muchas veces no ayudan.
  • El ecosistema es más chico. No se va a encontrar una respuesta en Stack Overflow para cada problema.
  • El soporte de macOS es de segunda clase. Funciona, pero se va a encontrar edge cases que los usuarios de Linux nunca ven.
  • El uso de disco del Nix store crece. Ejecutá nix store gc periódicamente o se va a perder 50GB antes de darte cuenta.

La conclusión: el tiempo invertido en aprender Nix se paga dentro del primer mes. Cada nuevo hire desde entonces es productivo desde el día uno. El CI se vuelve determinístico. Los builds son reproducibles entre plataformas. Y se borran 250 líneas de shell scripts que nadie entendía y todos tenían miedo de tocar.

Eso vale un lenguaje de programación raro.