Zum Inhalt springen

Entwicklerumgebungen automatisieren mit Dev Containers und Nix

Schluss mit „works on my machine“: Dev Containers für die IDE-Integration plus Nix für reproduzierbare Dependencies – versioniert, portabel, identisch.

4 Min. Lesezeit
Eine Entwicklerumgebungs-Pipeline, die zeigt, wie Nix-Flake-Inputs in eine Dev-Container-Konfiguration mit angeschlossenem VS Code fließen

Die Onboarding-Steuer

Jedes neue Teammitglied verbringt Stunden – manchmal Tage – damit, die richtige Node-Version, den richtigen Datenbanktreiber und die richtigen Systemabhängigkeiten zu installieren. README-Anleitungen veralten. Jemand hat Python 3.11, während das Projekt 3.12 braucht. Der CI-Server nutzt eine andere Postgres-Version als die lokale Entwicklung. Das sind lösbare Probleme, und die Lösung sind automatisierte, reproduzierbare Entwicklerumgebungen.

Dev Containers für die IDE-Integration

Dev Containers definieren eine Docker-Umgebung, mit der sich VS Code (oder jede kompatible IDE) direkt verbindet. Die gesamte Entwicklungsumgebung – Runtime, Tools, Erweiterungen, Einstellungen – wird zusammen mit dem Code versioniert.

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 für reproduzierbare Abhängigkeiten

Docker bietet Isolation auf Betriebssystemebene. Nix bietet Reproduzierbarkeit auf Paketebene. Jede Abhängigkeit ist über einen content-addressed Store auf eine exakte Version gepinnt – zwei Entwickler mit derselben flake.lock-Datei haben damit byte-identische Toolchains.

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 };
}

Dev Containers und Nix kombinieren

Der robusteste Ansatz nutzt beides: Dev Containers für die IDE-Erfahrung und die Service-Orchestrierung, Nix innerhalb des Containers für präzise Tool-Versionen. Das gibt dir Dockers Isolation mit Nix' Reproduzierbarkeit.

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}`);
  }
}

Secrets in Entwicklungsumgebungen verwalten

Entwicklungsumgebungen brauchen Zugangsdaten für Datenbanken, APIs und Services. Diese dürfen niemals ins Versionskontrollsystem gelangen – auch nicht in der Entwicklung.

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");
}

Die wichtigsten Erkenntnisse

Automatisierte Entwicklerumgebungen beseitigen Onboarding-Reibung und „works on my machine“-Bugs. Dev Containers liefern IDE-Integration, Service-Orchestrierung und Erweiterungsmanagement – alles versioniert. Nix liefert Reproduzierbarkeit auf Byte-Ebene für jedes Tool und jede Abhängigkeit und stellt sicher, dass zwei Entwickler nie unterschiedliche Compiler-Versionen haben.

Kombiniere beides für das stärkste Setup: Docker für die Service-Isolation, Nix im Container für Tool-Präzision. Führe ein Umgebungs-Verifikationsskript als Post-Create-Befehl aus, um Konfigurationsdrift sofort zu erkennen. Halte Secrets mit generierten .env-Dateien und Vault-Integration aus der Versionskontrolle heraus.

Der Aufwand sind ein paar Konfigurationsdateien, die zusammen mit dem Code committet werden. Der Gewinn: Jeder Entwickler hat ab dem ersten Tag eine funktionierende Umgebung, die identisch mit CI und mit der jedes anderen Teammitglieds ist – keine Setup-Doku, keine Versionskonflikte, keine verschwendeten Tage.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX