Saltar al contenido

Automatizando entornos de desarrollo con Dev Containers y Nix

Acaba con el «en mi máquina funciona» combinando Dev Containers para el IDE y Nix para dependencias reproducibles: entornos versionados y portables.

4 min de lectura
Un pipeline de entorno de desarrollo que muestra las entradas de un flake de Nix fluyendo hacia una configuración de Dev Container con VS Code conectado

El impuesto de la incorporación

Cada nuevo miembro del equipo pasa horas—a veces días—instalando la versión correcta de Node, el driver de base de datos correcto, las dependencias del sistema correctas. Las instrucciones del README quedan obsoletas. Alguien tiene Python 3.11 mientras el proyecto necesita 3.12. El servidor de CI usa una versión de Postgres distinta a la del desarrollo local. Estos son problemas solucionables, y la solución son entornos de desarrollo automatizados y reproducibles.

Dev Containers para la integración con el IDE

Dev Containers define un entorno Docker al que VS Code (o cualquier IDE compatible) se conecta directamente. Todo el entorno de desarrollo—runtime, herramientas, extensiones, configuración—está versionado junto al código.

jsonjson
// .devcontainer/devcontainer.json
{
  "name": "Project Dev Environment",
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/workspace",
  "features": {
    "ghcr.io/devcontainers/features/node:1": {
      "version": "20"
    },
    "ghcr.io/devcontainers/features/git:1": {}
  },
  "customizations": {
    "vscode": {
      "extensions": [
        "dbaeumer.vscode-eslint",
        "esbenp.prettier-vscode",
        "prisma.prisma"
      ],
      "settings": {
        "editor.formatOnSave": true,
        "editor.defaultFormatter": "esbenp.prettier-vscode"
      }
    }
  },
  "postCreateCommand": "npm ci",
  "forwardPorts": [3000, 5432, 6379]
}
ymlyaml
# .devcontainer/docker-compose.yml
services:
  app:
    build:
      context: ..
      dockerfile: .devcontainer/Dockerfile
    volumes:
      - ..:/workspace:cached
    command: sleep infinity
 
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: app_dev
      POSTGRES_USER: dev
      POSTGRES_PASSWORD: dev
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"
 
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
 
volumes:
  pgdata:

Nix para dependencias reproducibles

Docker te da aislamiento a nivel de sistema operativo. Nix te da reproducibilidad a nivel de paquete. Cada dependencia está fijada a una versión exacta mediante un almacén direccionado por contenido, lo que significa que dos desarrolladores con el mismo archivo flake.lock tienen toolchains idénticos byte a byte.

nixnix
# flake.nix
{
  description = "Project development environment";
 
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
    flake-utils.url = "github:numtide/flake-utils";
  };
 
  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = nixpkgs.legacyPackages.${system};
      in {
        devShells.default = pkgs.mkShell {
          buildInputs = with pkgs; [
            nodejs_20
            nodePackages.pnpm
            postgresql_16
            redis
            openssl
            pkg-config
          ];
 
          shellHook = ''
            echo "Dev environment loaded"
            echo "Node: $(node --version)"
            echo "pnpm: $(pnpm --version)"
            export DATABASE_URL="postgresql://dev:dev@localhost:5432/app_dev"
          '';
        };
      }
    );
}
tstypescript
// ❌ README-based setup — drifts constantly
// "Install Node 20, install pnpm, install Postgres 16,
//  set these env vars, run these 12 commands..."
 
// ✅ One command — identical environment every time
// nix develop
// or
// devcontainer up --workspace-folder .
 
interface EnvironmentSpec {
  runtime: { name: string; version: string };
  tools: Array<{ name: string; version: string }>;
  services: Array<{ name: string; version: string; port: number }>;
  envVars: Record<string, string>;
}
 
function verifyEnvironment(spec: EnvironmentSpec): {
  valid: boolean;
  mismatches: string[];
} {
  const mismatches: string[] = [];
 
  // Check runtime version
  const nodeVersion = process.version;
  if (!nodeVersion.startsWith(`v${spec.runtime.version}`)) {
    mismatches.push(
      `Node version mismatch: expected ${spec.runtime.version}, got ${nodeVersion}`
    );
  }
 
  // Check required env vars
  for (const [key, expected] of Object.entries(spec.envVars)) {
    if (!process.env[key]) {
      mismatches.push(`Missing environment variable: ${key}`);
    }
  }
 
  return { valid: mismatches.length === 0, mismatches };
}

Combinando Dev Containers y Nix

El enfoque más robusto usa ambos: Dev Containers para la experiencia en el IDE y la orquestación de servicios, Nix dentro del contenedor para versiones precisas de herramientas. Esto te da el aislamiento de Docker con la reproducibilidad de Nix.

dockerfiledockerfile
# .devcontainer/Dockerfile
FROM nixos/nix:latest AS dev
 
# Enable flakes
RUN echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf
 
WORKDIR /workspace
 
# Copy just the Nix files first for caching
COPY flake.nix flake.lock ./
RUN nix develop --command echo "Dependencies cached"
 
# The dev shell is now pre-built in the image
ENTRYPOINT ["nix", "develop", "--command"]
CMD ["bash"]
tstypescript
// scripts/check-env.ts
// Run as postCreateCommand to verify environment
async function checkEnvironment(): Promise<void> {
  const checks = [
    { name: "Node.js", command: "node --version", expected: /^v20/ },
    { name: "pnpm", command: "pnpm --version", expected: /^9/ },
    { name: "PostgreSQL", command: "psql --version", expected: /16/ },
  ];
 
  const results = await Promise.all(
    checks.map(async (check) => {
      try {
        const { stdout } = await exec(check.command);
        const matches = check.expected.test(stdout.trim());
        return { ...check, stdout: stdout.trim(), pass: matches };
      } catch {
        return { ...check, stdout: "not found", pass: false };
      }
    })
  );
 
  const failures = results.filter((r) => !r.pass);
 
  if (failures.length > 0) {
    console.error("Environment check failed:");
    for (const f of failures) {
      console.error(`  ${f.name}: expected ${f.expected}, got "${f.stdout}"`);
    }
    process.exit(1);
  }
 
  console.log("All environment checks passed");
  for (const r of results) {
    console.log(`  ${r.name}: ${r.stdout}`);
  }
}

Gestionando secretos en entornos de desarrollo

Los entornos de desarrollo necesitan credenciales para bases de datos, APIs y servicios. Estas nunca deben subirse al control de versiones, ni siquiera en desarrollo.

tstypescript
// .devcontainer/init-secrets.sh generates a .env from a template
// .env.template is committed; .env is gitignored
 
interface SecretConfig {
  name: string;
  source: "vault" | "env" | "generated";
  generateFn?: () => string;
}
 
const devSecrets: SecretConfig[] = [
  {
    name: "DATABASE_URL",
    source: "generated",
    generateFn: () =>
      "postgresql://dev:dev@postgres:5432/app_dev",
  },
  {
    name: "REDIS_URL",
    source: "generated",
    generateFn: () => "redis://redis:6379",
  },
  {
    name: "JWT_SECRET",
    source: "generated",
    generateFn: () => {
      const bytes = new Uint8Array(32);
      crypto.getRandomValues(bytes);
      return Buffer.from(bytes).toString("base64");
    },
  },
  {
    name: "EXTERNAL_API_KEY",
    source: "vault", // Fetched from secrets manager
  },
];
 
function generateEnvFile(secrets: SecretConfig[]): string {
  return secrets
    .map((s) => {
      if (s.source === "generated" && s.generateFn) {
        return `${s.name}=${s.generateFn()}`;
      }
      return `${s.name}=# Set manually or fetch from vault`;
    })
    .join("\n");
}

Conclusiones clave

Los entornos de desarrollo automatizados eliminan la fricción de la incorporación y los bugs del «en mi máquina funciona». Dev Containers proporciona integración con el IDE, orquestación de servicios y gestión de extensiones—todo versionado. Nix proporciona reproducibilidad a nivel de byte para cada herramienta y dependencia, garantizando que dos desarrolladores nunca tengan versiones de compilador distintas.

Combina ambos para la configuración más sólida: Docker para el aislamiento de servicios, Nix dentro del contenedor para la precisión de herramientas. Ejecuta un script de verificación del entorno como comando post-creación para detectar la deriva de configuración de inmediato. Mantén los secretos fuera del control de versiones con archivos .env generados e integración con un vault.

La inversión son unos pocos archivos de configuración subidos junto al código. El retorno es que cada desarrollador, desde el primer día, tiene un entorno funcional idéntico al de CI y al de cualquier otro miembro del equipo—sin documentación de instalación, sin discrepancias de versiones, sin días perdidos.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX