Devbox logo

TL;DR

  • Devbox stellt lokale Entwicklungsumgebungen ohne Container bereit, aber mit der Möglichkeit für Projekt-spezifische Versionen (z.B. Node.js 18, Go 1.20, PHP 8.1, usw.).
  • Anders als Microsoft Dev Box geht es also nicht darum die Entwicklungsumgebung in einen Container oder eine Cloud-Instanz zu verlagern, sondern Tools und Runtimes zuverlässig lokal zu verwenden.
  • Datenbanken, Services und Watcher können über process-compose gestartet werden, so komfortabel wie Docker Compose, aber ohne die Nachteile von Containern.
  • Es werden keine System-Tools oder -Bibliotheken verwendet, sondern nur die in Devbox installierten Versionen, somit ist das Entwicklungs-Setup portierbar und reproduzierbar.
  • Dabei setzt Devbox auf Nix auf, jedoch ohne die Notwendigkeit, die Sprache und Eigenheiten von Nix zu lernen.
  • Nix ist ein Package-Manager, Build-System und funktionale Sprache für Linux und macOS, der reproduzierbare Builds von Software ermöglicht (cool, aber am Anfang auch häufig verwirrend).
  • Die meisten Pakete für Devbox kommen von nixpkgs, der offiziellen Paket-Sammlung von Nix, die von der NixOS-Community gepflegt wird - es können aber auch eigene Packages oder Varianten (sogenannte Flakes) installiert werden.

Warum lokal lieber keine Container?

Docker hat sicherlich viel im Betrieb (Ops) und in der Entwicklung (Dev) revolutioniert und Container sind im DevOps-Bereich nicht mehr wegzudenken. Auch unser Hosting ist mittlerweile zum Großteil auf Container in Kubernetes umgestellt, da sie dort viele Vorteile bieten und unsere Software sauber verpackt und isoliert voneinander ausgeführt werden kann.

In der lokalen Entwicklung ist das aber nicht immer der Fall: häufig sind Tools und Runtimes von Sprachen auch lokal notwendig, damit z.B. Code-Completion und Debugging funktionieren. Auch ist der Wechsel zwischen lokalen und Container-basierten Umgebungen nicht immer einfach, da die Tools und Runtimes in den Containern dann meistens nicht mit denen auf dem Host übereinstimmen. Es passiert somit auch häufig, dass ein Befehl wie yarn install oder composer install dann eben nicht lokal sondern im Container erfolgen muss - was dann wiederum die lokale Entwicklung erschwert oder für Fehler sorgt. Auch ist der eigentlich angepriesene Vorteil der gleichen Umgebung in Dev- und Prod-Umgebungen häufig nicht gegeben: denn Container für die Entwicklung brauchen meistens viel mehr Tools und Bibliotheken als für den Betrieb der eigentlichen Anwendung und basieren somit häufig auf anderen Images.

Von diesen Reibungsverlusten gibt es in der Praxis recht viele, aber einer ist uns bisher besonders aufgefallen: die schlechtere Performance.

Gerade unter macOS gibt es mit Containern ein gravierendes Problem: die Performance - vor allem beim Zugriff auf Dateien, die mit dem Host geteilt werden - ist deutlich schlechter als auf Linux. Denn Container unter Linux sind keine virtuelle Maschinen, sondern nur isolierte Prozesse und somit recht leichtgewichtig. Unter macOS hingegen werden die Container z.B. von Docker for Mac in einer virtuellen Maschine ausgeführt, die dann wiederum die Dateien mit dem Host synchronisieren muss. Hier liegt neben den anderen Nachteilen von Containern auch der Hauptgrund, warum wir dies bisher nur z.B. für Datenbanken und andere spezielle Services verwendet haben, jedoch nicht für die eigentlichen Tools der Entwicklung (also Node.js, PHP, Go usw.).

Über Homebrew zu asdf zu Devbox

In Vergangenheit haben wir für die lokale Entwicklungsumgebung unter macOS hauptsächlich auf Homebrew gesetzt. Dort sind zwar viele Pakete verfügbar, aber meistens nur in den aktuellsten Versionen. Auch gibt es bis heute keine gute Möglichkeit, verschiedene Versionen von Paketen gleichzeitig zu installieren (zumindest nicht wirklich reproduzierbar, auch wenn es z.B. php und php@8.1 gibt). Auch müssen alle Pakete global installiert werden und somit ein Mechanismus gefunden werden, um die Versionen zu wechseln (z.B. nvm für Node.js). Services werden bei Homebrew global installiert und gestartet, so dass beim Wechsel von Projekten mit unterschiedlichen Versionen von z.B. MySQL, PostgreSQL oder Redis immer wieder die passenden Services neu gestartet werden müssen (wenn diese nicht in Container laufen).

Mit asdf haben wir dann eine Möglichkeit gefunden, verschiedene Versionen von Paketen zu installieren und zu verwenden. Für einige Sprachen und Tools funktioniert dies auch ganz gut. Aber gerade PHP unter asdf benötigt sehr viele Bibliotheken, die über Homebrew global installiert werden müssen. Die Builds sind auch nicht immer stabil und es gibt häufig Probleme nach Updates der Bibliotheken in Homebrew (da diese ja meistens nur in der aktuellsten Version vorliegen).

Es ist zwar möglich mit diesem Ansatz in Projekten zu arbeiten, aber das Ziel einer reproduzierbaren und isolierten Umgebung ist damit nicht zu erreichen: häufig müssen kleinere Probleme gelöst werden beim Einrichten von Projekten und nach Updates von Systemen und Homebrew kann es auch passieren, dass die Umgebung nicht mehr funktioniert.

Devbox: Reproduzierbare und isolierte Entwicklungsumgebungen

Somit sind wir schon vor einiger Zeit auf Nix gestoßen, was in der Theorie viele dieser Probleme lösen kann. In der Praxis und am Anfang ist es aber nicht ganz einfach, Nix zu verstehen und zu verwenden. Denn es gibt recht viele Wege und Nix ist eben sehr vieles: ein Package-Manager, ein Build-System, eine funktionale Sprache, eine Package-Sammlung und mit NixOS sogar ein komplettes Betriebssystem. Auch die besseren Anleitungen wie z.B. Zero to Nix erfordern immer noch einiges an Einarbeitung und sind natürlich genereller gehalten als das Problem von lokalen Entwicklungsumgebungen.

Mit Devbox haben wir dann eine Lösung von jetpack.io gefunden, die auf Nix aufsetzt, aber die Komplexität von Nix weitestgehend verbirgt. Das schöne ist, dass dort der Fokus auf der lokalen Entwicklung liegt und viele Aspekte wie Datenbanken und verschiedene Stacks (PHP, MySQL, PostgreSQL, Node.js usw.) direkt bedacht sind.

Quickstart

Über einen Shell-Befehl kann Devbox installiert werden:

curl -fsSL https://get.jetpack.io/devbox | bash

Ist Nix noch nicht lokal installiert, wird dies bei der ersten Verwendung von Devbox automatisch nachgeholt (natürlich mit einer Nachfrage).

Über ein devbox init kann ein Projekt initialisiert und eine devbox.json angelegt werden: dort werden die gewünschten Pakete und einige Einstellungen der Umgebung wie Env-Vars und Init-Hooks konfiguriert.

Möchte ich nun z.B. ein Projekt mit Go, Node.js und PostgreSQL entwickeln, kann ich dies mit den folgenden Befehlen tun:

devbox add go@1.21 nodejs@18 postgresql@15

Dies legt auch eine Datei devbox.lock an, in der die aufgelöste, exakte Version der Pakete festgehalten wird - wichtig für die Reproduzierbarkeit.

Die passenden Versionen eines Tools kann man dabei einfach über z.B. devbox search nodejs herausfinden. Dabei passiert einiges im Hintergrund was mit purem Nix relativ kompliziert ist: nämlich die Suche nach Paketen in einer exakten Version - und das auch für ältere Versionen. Unter https://www.nixhub.io/ kann man das ganze auch ohne Devbox durchsuchen - die Ersteller von Devbox haben diesen Hub als Index über die Nix Pakete gebaut.

A result from Nixhub for the Go package

Das fantastische ist, dass dies auch in Zukunft stabil funktionieren wird, denn bei dem Paket in einer Version wird im Hintergrund der exakte Commit-Hash der Nix-Packages referenziert und somit ist die Reproduzierbarkeit gegeben. Ein kleinerer Nachteil ist dabei, dass eventuelle Bibliotheken und andere Abhängigkeiten mehrfach installiert werden - diese stören sich zwar durch den besonderen Aufbau von Nix nicht, aber es wird natürlich etwas mehr Platz auf der lokalen Disk benötigt. Wie immer kann man halt nicht alles gleichzeitig optimieren: hier ist die Reproduzierbarkeit und eine strikte Isolation wichtiger als möglich wenig Speicherplatz zu verwenden.

Nach der Installation kann mit devbox shell die Umgebung aktiviert werden und die Tools stehen in der entsprechenden Version im PATH zur Verfügung. Via direnv kann dieses Aktivieren bequemer gestaltet werden - dazu später noch mehr.

Praktische Verwendung in Projekten

Wir haben nach einer Phase mit Tests in verschiedenen Projekten mit unterschiedlichen Stacks den Entschluss gefasst alle unsere Projekte und Entwicklungsumgebungen darauf umzustellen. In Zukunft müssen wir dafür dann auf den Rechnern primär nur noch Devbox+Nix bereitstellen und das lokale Aufsetzen und Wechseln von Projekten sollte deutlich einfacher werden.

Als wichtiges Ziel haben wir dabei festgelegt, dass nach dem Klonen aus Git ein einziger Befehl reichen sollte, um ein Projekt zum Laufen zu bringen:

devbox services up

Dabei sollen via process-compose folgende Dinge passieren:

  • Installieren von Packages z.B. mit yarn install oder composer install
  • Starten von Services wie Datenbanken, Redis, Elasticsearch, etc.
  • Erstellen von Datenbanken und Ausführen von Migrations
  • Import von Testdaten und Anlegen von lokalen Accounts etc.
  • Starten von Watchern und lokalen Servern

Damit können viele projektspezifische Anweisungen, die sonst in einer Readme Platz finden und aktuell gehalten werden müssen, automatisiert werden.

Zudem vermeiden wir sämtliche globale Installationen von Services wie z.B. Datenbanken, diese werden per Devbox nur in den jeweiligen Projekten installiert und gestartet - was durch entsprechende Plugins recht einfach ist und auch alle Daten und Konfigurationen lokal im Projektverzeichnis ablegt. Die Konfigurationen committen wir dabei mit ins Git - diese liegen im Verzeichnis devbox.d. Das Verzeichnis .devbox wiederum ist in der .gitignore und beinhaltet Symlinks von Nix auf das aktive Profil und Arbeitsverzeichnisse von Diensten - es ist quasi ein projekt-spezifisches /usr/local.

Beispiel einer Webapp mit Next.js, Go und PostgreSQL

Hier ist ein umfangreicheres Beispiel aus einer individuell entwickelten Webanwendung, welche als Stack Next.js, Go und PostgreSQL verwendet.

Das ist die Devbox Konfiguration mit Paketen, Umgebungsvariablen und Shell Scripts:

devbox.json
{
  "packages": [
    "go@1.21",
    "nodejs@16",
    "path:devbox.d/flakes/yarn-overlay#yarn",
    "imgproxy@3",
    "minio@latest",
    "postgresql@14",
    "redis@6.2",
    "stripe-cli@latest"
  ],
  "include": [
    "github:networkteam/devbox-plugins?dir=postgresql"
  ],
  "shell": {
    "scripts": {
      "backend:setup": [
        "devbox run backend:migrate"
      ],
      "backend:lint": [
        "cd backend",
        "golangci-lint run --out-format tab"
      ],
      "backend:migrate": [
        "cd backend",
        "go run ./cli/ctl migrate up"
      ],
      "backend:fixtures": [
        "cd backend",
        "go run ./cli/ctl fixtures import --confirm",
      ]
    }
  },
  "env": {
    "BACKEND_ENV":        "development",
    "DATABASE_NAME":      "webapp-dev",
    "TEST_DATABASE_NAME": "webapp-test"
  }
}

Wie man sieht können auch Tools wie MinIO, imgproxy und sogar die Stripe CLI einfach als Nix Package in einer definierten Version installiert werden. Einiges davon hatten wir vorher in Docker Container ausgelagert, da Homebrew die entsprechenden Pakete nicht alle bereitstellt.

Für die Datenbank haben wir ein eigenes Plugin für PostgreSQL erstellt, da sich Devbox nicht direkt um das Initialisieren und Anlegen der Datenbanken kümmert und auch einen Healthcheck für die Verwendung von Abhängigkeiten hinzufügt. Das Schreiben von Plugins ist zum Glück nicht kompliziert und wie auch der Rest von Devbox recht gut dokumentiert.

Und hier haben wir die Definition der Prozesse, die für den Start und die Initialisierung der Webanwendung notwendig sind:

process-compose.yaml
version: "0.5"

processes:
  backend:dev:
    command: go run github.com/networkteam/refresh
    working_dir: backend
    depends_on:
      backend:setup:
        condition: process_completed_successfully
    availability:
      restart: "always"

  backend:setup:
    working_dir: backend
    command: devbox run backend:setup
    depends_on:
      postgresql:createdb:
        condition: process_completed_successfully

  frontend:dev:
    command: yarn dev
    working_dir: frontend
    depends_on:
      yarn:install:
        condition: process_completed_successfully
    availability:
      restart: "always"

  minio:
    command: minio server
    environment:
      - "MINIO_ADDRESS=localhost:9000"
      - "MINIO_VOLUMES=.devbox/virtenv/minio/data"
      - "MINIO_ROOT_USER=webapp"
      - "MINIO_ROOT_PASSWORD=not-a-secret"
    availability:
      restart: "always"

  imgproxy:
    command: imgproxy
    ports:
      - "9090:8080"
    environment:
      - "IMGPROXY_BIND=localhost:9090"
      - "IMGPROXY_ENABLE_WEBP_DETECTION=1"
      - "IMGPROXY_KEY=736563726574"
      - "IMGPROXY_SALT=68656C6C6F"
      - "IMGPROXY_USE_S3=true"
      - "IMGPROXY_S3_ENDPOINT=http://localhost:9000"
      - "AWS_ACCESS_KEY_ID=webapp"
      - "AWS_SECRET_ACCESS_KEY=not-a-secret"

  yarn:install:
    command: yarn install

Wenn dann die Anwendung mit devbox services up gestartet wird, werden alle Dienste gestartet, die Datenbanken angelegt und ich erhalte nach kurzer Zeit ein lauffähiges System:

The output of devbox services up
Die Ausgabe von Process Compose zeigt alle laufenden Services mit ihren Logs.

Beispiel für ein Next.js / Neos CMS Projekt mit Zebra

Wer das ganze direkt selber ausprobieren möchte, kann sich auch unsere Zebra Demo anschauen. Hier wird für ein Next.js und Neos CMS Projekt der Stack mit Node.js, PHP, Nginx und PostgreSQL definiert:

devbox.json
{
  "packages": [
    "nodejs@18",
    "github:NixOS/nixpkgs/dd5621df6dcb90122b50da5ec31c411a0de3e538#nodejs_18.pkgs.yarn",
    "php@8.1",
    "php81Extensions.gd@latest",
    "php81Extensions.pdo@latest",
    "php81Packages.composer@latest",
    "nginx@latest",
    "postgresql@13",
    "path:devbox.d/flakes/grazer"
  ],
  "include": ["github:networkteam/devbox-plugins?dir=postgresql"],
  "env": {
    "FLOW_CONTEXT": "Development/Devbox",
    "NGINX_WEB_PORT": "8000",
    "NGINX_WEB_ROOT": "../../../neos/Web",
    "NEOS_BASE_URL": "http://localhost:8000",
    "DATABASE_NAME": "neos"
  }
}

Für Grazer - einen kleinen Service für die Revalidierung in Go - haben wir eine lokale Flake ergänzt, die das Tool als Nix Package installieren kann.

Die Services für Process Compose sind in diesem Fall sogar noch etwas übersichtlicher:

process-compose.yaml
version: "0.5"

processes:
  yarn:install:
    command: yarn install

  neos:setup:
    working_dir: neos
    command: composer install && ./flow doctrine:migrate && ./flow site:import --package-key Zebra.Site
    depends_on:
      postgresql:createdb:
        condition: process_completed_successfully

  next:dev:
    working_dir: next
    command: yarn dev
    depends_on:
      yarn:install:
        condition: process_completed_successfully
    availability:
      restart: "always"

  grazer:
    command: grazer
    environment:
      - GZ_REVALIDATE_TOKEN=a-secret-token
      - GZ_NEXT_REVALIDATE_URL=http://localhost:3000/api/revalidate
      - GZ_NEOS_BASE_URL=$NEOS_BASE_URL
      - GZ_VERBOSE=true

Wie auch schon im Beispiel zuvor reicht der Befehl devbox services up aus, um ein lauffähiges System zu starten:

The output of devbox services up for a Zebra project
Nach dem Start ist Neos bereits eingerichtet und das Frontend auf http://localhost:3000/ erreichbar.

Tipps für die Verwendung von Devbox

Beim Einsatz von Devbox haben sich bei uns bereits einige Tipps und Tricks herauskristallisiert, die ich hier kurz vorstellen möchte:

Automatisches Aktivieren von Umgebungen mit direnv

Wir haben gute Erfahrungen mit direnv gemacht, da dies die Umgebung eines Projektes beim Wechsel in das Verzeichnis automatisch aktiviert. Mit devbox generate direnv wird dabei direkt die notwendige .envrc generiert, die dann mit im Repository abgelegt werden kann.

Weitere Informationen finden sich in der Devbox Dokumentation zu direnv.

Die Möglichkeiten von process-compose ausschöpfen

Mit process-compose können viele Dinge automatisiert werden, die sonst in einer Readme stehen würden. Dabei können auch Abhängigkeiten zwischen Prozessen definiert werden, so dass z.B. ein yarn install ausgeführt wird, bevor ein yarn dev Prozess gestartet wird oder Migrationen ausgeführt werden, bevor der Server gestartet wird.

In der Devbox Dokumentation zu Services gibt es einen Überblick. Die Dokumentation von Process Compose, welches von Devbox eingebettet wird, zeigt dabei alle Möglichkeiten inklusive Abhängigkeiten, Readyness, Liveness und vielen mehr.

Die passenden Tools in IDEs konfigurieren

Für PHP und Go kann es notwendig sein einer IDE wie PhpStorm oder GoLand mitzuteilen, dass die projekt-spezifischen Versionen verwendet werden sollen.

Dafür kann z.B. der GOROOT auf den Symlink des Nix-Profils gesetzt werden. Per UI ist das in GoLand (noch?) nicht einfach möglich, aber die XML-Datei kann per Hand ergänzt werden:

<component name="GOROOT" url="file://$PROJECT_DIR$/.devbox/nix/profile/default/share/go" />

Versionen von Node.js und Yarn synchronisieren

Vor allem bei Node.js und Yarn gibt es eine kleinere Hürde, welche etwas verwirrend sein kann: da die Pakete laut Nix Philosophie immer komplett eigenständig und isoliert sind, verwendet das Yarn Package im Hintergrund mitunter eine andere Version von Node.js als eine in der devbox.json angegebene Version (wie z.B. nodejs@16).

Über eine spezifische Version mit Commit-Hash oder eine lokale Nix Flake lässt sich das aber lösen. Im Devbox Issue dazu habe ich ein Beispiel aus unseren Projekten gepostet.

Mit devbox global auch globale Tools verwalten

Nachdem sich nun viele Tools und Sprachen in den Projekten nutzen lassen ist es auch global möglich die Nix Pakete zu verwenden. Dafür bietet devbox global nach Installation eines kleinen Shell-Hooks die Möglichkeit die Pakete auch global zu installieren. Das kann für reproduzierbare Versionen globaler Tools wie git, curl, jq usw. sinnvoll sein. Denn diese möchte man ja nicht in jedem Projekt neu installieren (sofern zumindest das Projekt diese nicht direkt in Scripts benötigt).

Noch weiter gehen kann man dann mit Fleek, was komplette Konfigurationen über den Nix Home Manager ermöglicht.

Fazit

Nach unseren Erfahrungen hat Devbox ein gutes Level an Abstraktion basierend auf Nix gefunden und liefert auf pragmatische Art so ziemlich alles, was wir uns für die lokale Entwicklung vorstellen und wünschen. Im konkreten Fall ist natürlich bei jedem Projekt etwas Arbeit für die Umstellung und Einrichtung angesagt, aber dank Nix sollte dies deutlich reproduzierbarer und portierbarer sein als bisher. Und hat man für einen konkreten Stack einmal ein gutes Setup gefunden, so ist es auch schnell auf neue Projekte oder eine Base-Setup / Boilerplate übertragbar. Für Node.js (gerade auch in älteren Versionen) ist jedoch manchmal noch etwas Anpassung zusammen mit Yarn notwendig, so dass hier die Versionen zueinander passen.

Ist Devbox für ein Projekt einmal definiert, funktioniert das Setup performant, stabil und auf Anhieb auch bei anderen Entwicklern. Auch wenn noch kein Devbox / Nix installiert war ist nach wenigen Minuten ein lauffähiges lokales System vorhanden.

Alternativ lohnt sich vielleicht auch ein Blick auf devenv, denn dort ist in den letzten Monaten auch viel passiert und es werden mittlerweile viele Sprachen unterstützt. Der Ansatz ist aber deutlich näher an Nix (was ja durchaus gewünscht sein kann) und bietet weniger Abstraktion.

Wir sind auf jeden Fall gespannt, wie sich Devbox weiterentwickelt und werden weiterhin unsere aktuellen Projekte darauf umstellen und schrittweise weiter in das Nix Universum eintauchen.