Zum Inhalt springen

Automatisierung der Einrichtung von Entwicklungsumgebungen

Von zwei Tagen Onboarding auf zwanzig Minuten – mit Dev Containers, Shell-Skripten und Infrastructure as Code für Umgebungen per einzelnem Befehl.

5 Min. Lesezeit
Terminal, das ein automatisiertes Skript zur Einrichtung der Entwicklungsumgebung mit Fortschrittsanzeigen ausführt

Das schlimmste Onboarding-Erlebnis ist ein README, das sagt: „Installiere diese 15 Dinge, führe diese 8 Befehle aus, und wenn du in Schritt 6 einen Fehler bekommst, frag Dave." Dave hat das Unternehmen vor drei Monaten verlassen. Das README wurde ein Jahr davor zuletzt aktualisiert.

Eine reproduzierbare Entwicklungsumgebung sollte ein einziger Befehl sein. Repo klonen, Setup-Skript ausführen und anfangen zu programmieren. Alles andere ist Reibung, die sich mit jeder Neueinstellung, jeder Neuinstallation des Betriebssystems und jedem Teammitglied summiert, das einen Tag an verrottete Umgebungen verliert.

Das Setup-Skript-Muster

Der einfachste Ansatz ist ein gut strukturiertes Shell-Skript, das automatisiert, was man sonst manuell machen würde. Es sollte idempotent sein — ein zweimaliges Ausführen darf nichts kaputt machen.

shbash
#!/bin/bash
set -euo pipefail
 
# setup.sh — One-command developer environment setup
 
echo "==> Checking prerequisites..."
 
# Check for required system tools
check_command() {
  if ! command -v "$1" &> /dev/null; then
    echo "❌ $1 is not installed. $2"
    exit 1
  fi
  echo "✅ $1 found"
}
 
check_command "node" "Install from https://nodejs.org"
check_command "docker" "Install from https://docker.com"
check_command "git" "Install from https://git-scm.com"
 
# Check minimum versions
NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
if [ "$NODE_VERSION" -lt 18 ]; then
  echo "❌ Node.js 18+ required, found v$NODE_VERSION"
  exit 1
fi
echo "✅ Node.js v$NODE_VERSION"
 
echo ""
echo "==> Installing dependencies..."
npm ci
 
echo ""
echo "==> Setting up local environment..."
if [ ! -f .env.local ]; then
  cp .env.example .env.local
  echo "✅ Created .env.local from template"
else
  echo "⏭️  .env.local already exists, skipping"
fi
 
echo ""
echo "==> Starting infrastructure..."
docker compose up -d postgres redis
echo "Waiting for PostgreSQL to be ready..."
until docker compose exec -T postgres pg_isready -U app &> /dev/null; do
  sleep 1
done
echo "✅ PostgreSQL is ready"
 
echo ""
echo "==> Running database migrations..."
npm run db:migrate
 
echo ""
echo "==> Seeding development data..."
npm run db:seed
 
echo ""
echo "==> Setup complete! Run 'npm run dev' to start the application."
shbash
# ❌ Manual setup instructions that drift from reality
# README.md:
# 1. Install Node.js 18+
# 2. Install Docker
# 3. Run npm install
# 4. Copy .env.example to .env.local
# 5. Start PostgreSQL: docker run -d ...
# 6. Run migrations: npm run db:migrate
# Note: if migration fails, check that pg_hba.conf allows...
# (nobody reads past step 4)
 
# ✅ Automated setup that IS the documentation
git clone git@github.com:team/project.git
cd project
./setup.sh
# Done. Every step is verified programmatically.

Dev Containers für vollständige Reproduzierbarkeit

Shell-Skripte kümmern sich um die Installation von Werkzeugen, können aber keine identischen Versionen über Betriebssysteme hinweg garantieren. Dev Containers lösen das, indem sie die gesamte Entwicklungsumgebung als Docker-Image definieren.

jsonjson
// .devcontainer/devcontainer.json
{
  "name": "Project Dev Environment",
  "dockerComposeFile": "../docker-compose.dev.yml",
  "service": "app",
  "workspaceFolder": "/workspace",
 
  "features": {
    "ghcr.io/devcontainers/features/node:1": {
      "version": "20"
    },
    "ghcr.io/devcontainers/features/docker-in-docker:2": {},
    "ghcr.io/devcontainers/features/github-cli:1": {}
  },
 
  "customizations": {
    "vscode": {
      "extensions": [
        "dbaeumer.vscode-eslint",
        "esbenp.prettier-vscode",
        "prisma.prisma"
      ],
      "settings": {
        "editor.formatOnSave": true,
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "typescript.tsdk": "node_modules/typescript/lib"
      }
    }
  },
 
  "postCreateCommand": "npm ci && npm run db:migrate && npm run db:seed",
  "forwardPorts": [3000, 5432, 6379],
 
  "remoteUser": "node"
}
ymlyaml
# docker-compose.dev.yml
services:
  app:
    build:
      context: .
      dockerfile: .devcontainer/Dockerfile
    volumes:
      - .:/workspace:cached
      - node_modules:/workspace/node_modules
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
    environment:
      DATABASE_URL: postgresql://app:devpass@postgres:5432/appdb
      REDIS_URL: redis://redis:6379
 
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: devpass
      POSTGRES_DB: appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 5s
      retries: 5
 
  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 5
 
volumes:
  node_modules:
  pgdata:

Makefile als universelle Schnittstelle

Ein Makefile bietet eine konsistente Befehlsschnittstelle, unabhängig von den darunterliegenden Werkzeugen. Jeder Entwickler führt die gleichen Befehle aus — make setup, make dev, make test — selbst wenn sich die Implementierung ändert.

makefilemakefile
# Makefile — the universal interface to your project
 
.PHONY: setup dev test lint clean db-migrate db-seed help
 
# Default target: show available commands
help: ## Show this help message
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
		sort | awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-15s\033[0m %s\n", $$1, $$2}'
 
setup: ## Set up development environment from scratch
	@echo "==> Running setup..."
	@./setup.sh
 
dev: ## Start development server with hot reload
	@docker compose up -d postgres redis
	@npm run dev
 
test: ## Run all tests
	@npm run test
 
test-watch: ## Run tests in watch mode
	@npm run test -- --watch
 
lint: ## Run linter and type checker
	@npm run lint
	@npx tsc --noEmit
 
db-migrate: ## Run database migrations
	@npm run db:migrate
 
db-seed: ## Seed development database
	@npm run db:seed
 
db-reset: ## Reset database (destroy and recreate)
	@docker compose down -v postgres
	@docker compose up -d postgres
	@sleep 3
	@npm run db:migrate
	@npm run db:seed
 
clean: ## Remove build artifacts and dependencies
	@rm -rf node_modules .next dist
	@docker compose down -v
	@echo "Cleaned."
tstypescript
// ❌ Different commands for different developers
// Alice: "I use yarn"
// Bob: "I use pnpm"
// Carol: "I run the database differently on my Mac"
// Result: "works on my machine" syndrome
 
// ✅ Everyone uses the same Makefile interface
// make setup  → Identical environment for everyone
// make dev    → Same dev server command
// make test   → Same test runner
// The Makefile abstracts away implementation details

Verwaltung von Umgebungsvariablen

Entwicklungsumgebungen brauchen Konfiguration — API-Schlüssel, Datenbank-URLs, Feature-Flags. Die Datei .env.example dokumentiert jede benötigte Variable, ohne echte Werte preiszugeben.

tstypescript
// scripts/check-env.ts — Validate environment variables at startup
import { readFileSync, existsSync } from 'fs';
 
interface EnvVar {
  name: string;
  required: boolean;
  default?: string;
  description: string;
}
 
function parseEnvExample(path: string): EnvVar[] {
  const content = readFileSync(path, 'utf-8');
  const vars: EnvVar[] = [];
 
  for (const line of content.split('\n')) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith('#')) continue;
 
    const [name, ...valueParts] = trimmed.split('=');
    const value = valueParts.join('=');
 
    // Comments above the variable describe it
    vars.push({
      name: name.trim(),
      required: !value.includes('optional'),
      default: value || undefined,
      description: '',
    });
  }
 
  return vars;
}
 
function validateEnvironment(): void {
  if (!existsSync('.env.example')) {
    console.warn('No .env.example found — skipping env validation');
    return;
  }
 
  const expected = parseEnvExample('.env.example');
  const missing: string[] = [];
 
  for (const envVar of expected) {
    if (envVar.required && !process.env[envVar.name]) {
      missing.push(envVar.name);
    }
  }
 
  if (missing.length > 0) {
    console.error('Missing required environment variables:');
    for (const name of missing) {
      console.error(`  - ${name}`);
    }
    console.error('\nCopy .env.example to .env.local and fill in the values.');
    process.exit(1);
  }
 
  console.log('✅ All required environment variables are set');
}
 
validateEnvironment();

Überprüfen, ob das Setup funktioniert

Das Setup-Skript sollte einen Verifizierungsschritt enthalten, der bestätigt, dass alles funktioniert. Führe einen schnellen Smoke-Test aus — kann die Anwendung starten, sich mit der Datenbank verbinden und einen Health-Check ausliefern?

tstypescript
// scripts/verify-setup.ts
async function verifySetup(): Promise<void> {
  const checks = [
    { name: 'Node modules', check: () => existsSync('node_modules') },
    { name: 'Environment file', check: () => existsSync('.env.local') },
    { name: 'Database connection', check: checkDatabaseConnection },
    { name: 'Redis connection', check: checkRedisConnection },
    { name: 'TypeScript compilation', check: checkTypeScriptCompiles },
  ];
 
  let allPassed = true;
 
  for (const { name, check } of checks) {
    try {
      const result = await check();
      console.log(result ? `✅ ${name}` : `❌ ${name}`);
      if (!result) allPassed = false;
    } catch (error) {
      console.log(`❌ ${name}: ${(error as Error).message}`);
      allPassed = false;
    }
  }
 
  if (!allPassed) {
    console.error('\n⚠️  Some checks failed. Run ./setup.sh to fix.');
    process.exit(1);
  }
 
  console.log('\n🎉 Development environment is ready!');
}

Die wichtigsten Erkenntnisse

  1. Automatisiere alles in einem einzigen Befehl — ./setup.sh oder make setup sollte einen Entwickler in Minuten von null auf betriebsbereit bringen
  2. Mache Skripte idempotent — ein zweimaliges Ausführen des Setups sollte dasselbe Ergebnis liefern wie ein einmaliges
  3. Nutze Dev Containers für vollständige Reproduzierbarkeit — Docker-basierte Umgebungen beseitigen das „works on my machine"-Problem über Betriebssysteme hinweg
  4. Stelle ein Makefile als universelle Schnittstelle bereit — konsistente Befehle für Setup, Dev, Test und Lint, unabhängig von den darunterliegenden Werkzeugen
  5. Validiere Umgebungsvariablen beim Start — erkenne fehlende Konfiguration sofort, statt später mit kryptischen Fehlern zu scheitern
  6. Baue Verifizierung ein — ein Smoke-Test am Ende des Setups bestätigt, dass tatsächlich alles funktioniert
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX