Node.js und Redis mit Docker Compose
Wie ich in Projekten eine Zwei-Container-App aus Node.js und Redis mit Docker Compose beschreibe: eine YAML für den ganzen Stack, Service Discovery über den Servicenamen, Persistenz per Volume – und warum depends_on nicht auf Betriebsbereitschaft wartet.
Ein zweiter Container verdoppelt die Arbeit nicht, er verändert sie. Bei einem einzelnen docker run geht es um ein Image, einen Port, vielleicht zwei Umgebungsvariablen. Sobald ein Node-Prozess auf Redis angewiesen ist, kommen Fragen dazu, die sich mit Kommandozeilen-Akrobatik nur noch mühsam beantworten lassen: In welchem Netz liegen beide Container? Unter welcher Adresse findet der eine den anderen? Wer startet zuerst – und wo bleiben die Daten, wenn ein Container ersetzt wird?
Docker Compose beantwortet diese Fragen an einer einzigen Stelle. Das Wissen, wie der Stack hochfährt, steht danach in einer versionierten Datei statt in Köpfen und einer Setup-Doku: git clone, docker-compose up, läuft. Ein Neuzugang im Team verliert damit keinen halben Tag an Node-Versionen, lokaler Redis-Installation und belegten Ports. Bezahlt wird das mit einer Datei, die gepflegt werden will – ein Handel, der sich schnell rechnet.
Den technischen Weg gehe ich vollständig: von der docker-compose.yml über Service Discovery und Persistenz bis zu dem Fallstrick, der beim ersten Mal leicht eine halbe Stunde kostet. Stand ist Sommer 2018, also Compose file v3 und die Bindestrich-CLI docker-compose.
Redaktionelle Einordnung (Juli 2026): Aktuelles Docker Compose kann bei der Langform von depends_on mit condition: service_healthy auf einen erfolgreichen Healthcheck warten. Die Anwendung sollte Verbindungsabbrüche und Neustarts trotzdem selbst verkraften; ein Healthcheck ersetzt keine Retry-Strategie. Der Haupttext belässt die Einschränkung des damaligen Compose-v3-Formats.
Warum überhaupt Compose
Ein einzelner Container ist schnell erklärt. Sobald aber zwei Container zusammenspielen sollen, wird die imperative Variante mühsam: Netzwerk anlegen, beide Container darin starten, Ports publizieren, Umgebungsvariablen setzen, Startreihenfolge beachten. Das sind viele Kommandos, die niemand fehlerfrei aus dem Kopf reproduziert – schon gar nicht der Kollege, der das Projekt morgen zum ersten Mal klont.
Compose dreht das um. Statt einer Kommandofolge schreibe ich eine deklarative Beschreibung des Zielzustands. Die Datei sagt, welche Services es gibt, welche Images sie nutzen, welche Ports offen sind und wie sie zusammenhängen. Der Startbefehl ist danach immer derselbe. Wer schon einmal mit Docker gearbeitet hat, kennt das Prinzip aus dem Dockerfile – Compose hebt es nur eine Ebene höher, von einem Image auf eine ganze Anwendung.
Das Ganze folgt einer einfachen Idee: reproduzierbare, gekapselte Umgebungen, die auf jedem Rechner gleich hochfahren.
Die docker-compose.yml
Hier ist die Datei, mit der ich starte. Zwei Services, minimal gehalten, damit jede Zeile eine erkennbare Aufgabe hat.
version: '3'
services:
web:
build: .
environment:
NODE_ENV: production
REDIS_HOST: 'redis'
REDIS_PORT: '6379'
ports:
- '8080:8080'
depends_on:
- 'redis'
redis:
image: redis:4.0.11
Die erste Zeile ist 2018 Pflicht: version: '3'. Ohne diesen Key weigert sich Compose, die Datei zu lesen. Darunter kommen die Top-Level-Keys, die uns hier interessieren – services: auf jeden Fall, volumes: und networks: gleich noch.
Zwei Zeilen lohnt es, gezielt anzuschauen. Beim web-Service steht build: . – Compose baut das Image lokal aus dem Dockerfile im aktuellen Verzeichnis. Beim redis-Service steht image: redis:4.0.11 – hier wird ein fertiges Image aus der Registry gezogen. Das ist der zentrale Gegensatz:
build:nutze ich für meinen eigenen Code, den es als fertiges Image noch nicht gibt. Compose ruft im Hintergrund einendocker buildauf.image:nutze ich für alles, was ich unverändert übernehme – Datenbanken, Caches, Message-Broker. Redis pinne ich dabei auf eine konkrete Version (4.0.11) statt auflatest, damit die Umgebung reproduzierbar bleibt.
Für den Fall, dass mein Webservice bereits gebaut und in eine Registry geschoben wurde, tausche ich build: . einfach gegen image: mikebild/compose-nodejs-redis:1.0.0. Dieselbe Datei, ein anderer Bezugsweg – im Deploy-Kontext genau das, was man will.
Auffällig ist auch, was beim redis-Service fehlt: kein ports. Das ist Absicht. Nur web publiziert mit '8080:8080' einen Port auf den Host, weil nur der Webservice von außen erreichbar sein soll. Redis bleibt containerintern und braucht deshalb keine Host-Veröffentlichung. Auf diesen Isolationspunkt komme ich weiter unten zurück, er hat nämlich eine handfeste Sicherheitsseite.
Service Discovery: Redis heißt einfach "redis"
Bleibt die Frage, woher der Node-Prozess weiß, unter welcher Adresse er Redis findet. Es steht ja nirgends eine IP.
Compose legt beim Start automatisch ein Default-Netzwerk an, benannt nach dem Projekt, etwa <projekt>_default. Jeder Service hängt in diesem Netz und ist darin unter seinem Servicenamen per eingebautem DNS erreichbar. Anders gesagt: Jeder Service erreicht jeden anderen Service unter dessen Servicenamen. Der Name redis aus der YAML wird zum Hostnamen im Netzwerk.
Deshalb setze ich in der Datei REDIS_HOST: 'redis'. Der Node-Client verbindet sich zu redis:6379 – keine hartkodierte IP, keine --link-Akrobatik. Wenn Compose morgen dem Container eine andere interne Adresse gibt, ist das egal, weil über den Namen aufgelöst wird.
graph LR
Client["Client / Browser"] -->|"Host-Port 8080"| Web
subgraph net["Compose default network"]
Web["web-Container"] -->|"DNS: redis:6379"| Redis["redis-Container"]
end
Redis --> Vol[("volume<br/>redis-data (/data)")]
Von außen führt genau ein Weg herein, über den publizierten Host-Port 8080 auf web. Intern spricht web mit redis über den Servicenamen. Und die Redis-Daten liegen in einem Volume, um das es gleich geht.
Wer das Default-Netz sichtbar machen will, kann es auch explizit hinschreiben. Ich mache das in einem neuen Projekt gern einmal, damit klar wird, dass hier kein Zauber im Spiel ist:
version: '3'
services:
web:
build: .
environment:
REDIS_HOST: 'redis'
REDIS_PORT: '6379'
ports:
- '8080:8080'
networks:
- app-net
depends_on:
- 'redis'
redis:
image: redis:4.0.11
networks:
- app-net
networks:
app-net:
Funktional ändert das gegenüber der ersten Datei nichts – Compose hätte ohnehin ein Netz gebaut. Aber der Mechanismus steht damit sichtbar in der Datei, und das Netz ist kein impliziter Nebeneffekt mehr.
Der Node-Client
Damit die Verbindung nicht abstrakt bleibt, hier der relevante Ausschnitt des Servers. Es ist ein schlanker Express-Server, der node_redis in der 2018 gängigen Version ^2.8 nutzt – also die Callback-API, noch nicht die späteren Promises.
const SERVICE_PORT = process.env.SERVICE_PORT || 8080;
const REDIS_HOST = process.env.REDIS_HOST || 'localhost';
const REDIS_PORT = process.env.REDIS_PORT || '6379';
const express = require('express');
const redis = require('redis');
const app = express();
const client = redis.createClient(REDIS_PORT, REDIS_HOST);
client.on('error', err => {
console.error(`Redis connection error.\n${err.message}`);
process.exit(1);
});
app.get('/redis', (req, res, next) => {
client.get('foo', (error, result) => {
if (error) return next(error);
res.send(result);
});
});
app.post('/redis', (req, res, next) => {
client.set('foo', 'bar', (error, result) => {
if (error) return next(error);
res.status(201).send('done');
});
});
app.listen(SERVICE_PORT, () => console.log(`Listen on ${SERVICE_PORT}`));
Die entscheidende Zeile ist redis.createClient(REDIS_PORT, REDIS_HOST). Host und Port kommen aus der Umgebung, und die Umgebung füllt Compose über den environment-Block. So bleibt der Code frei von Adressen. Auf meinem Laptop läuft er gegen localhost, im Compose-Netz gegen redis – ohne dass eine Zeile Anwendungscode sich ändert.
Ein Detail behalte ich im Hinterkopf: Der client.on('error', …)-Handler beendet den Prozess mit process.exit(1), sobald die Redis-Verbindung scheitert. Das ist bewusst hart und führt direkt zum wichtigsten Fallstrick.
Der Fallstrick: depends_on wartet nicht auf "ready"
depends_on: ['redis'] liest sich, als würde Compose warten, bis Redis einsatzbereit ist, bevor es web startet. Das tut es nicht. depends_on steuert ausschließlich die Start-Reihenfolge: Compose startet den Redis-Container vor dem Web-Container. Es wartet aber nicht, bis Redis Verbindungen annimmt – nur, bis der Container gestartet wurde.
Der Unterschied klingt akademisch, bis der harte Error-Handler oben zuschlägt. Redis braucht einen Moment, bis der Server im Container tatsächlich lauscht. Startet web in diesem Fenster und versucht sofort zu verbinden, feuert client.on('error'), der Prozess beendet sich mit Exit-Code 1 – und der frisch gestartete Stack ist schon wieder halb tot. Auf langsameren Rechnern passiert das reproduzierbar.
Die saubere Lösung liegt nicht in der YAML, sondern in der Anwendung. Sie muss Verbindungsabbrüche vertragen und einen Reconnect mit Retry versuchen, statt beim ersten Fehler auszusteigen. Wo ich den Anwendungscode nicht anfassen will, schiebe ich in 2018 ein kleines Wartescript vor den Start – wait-for-it.sh, dockerize oder wait-for prüft, ob redis:6379 erreichbar ist, bevor der Node-Prozess startet.
Eine Sache gehört hier ausdrücklich verneint: Die Langform depends_on: {redis: {condition: service_healthy}}, die auf einen Healthcheck wartet, gibt es in Compose v3 im Jahr 2018 nicht. Wer sie in ein v3-File schreibt, bekommt sie schlicht nicht anerkannt. Die Wartelogik gehört also in Anwendung oder Wartescript, Punkt.
Persistenz: Redis braucht ein Volume
Der zweite Fallstrick ist stiller, dafür ärgerlicher. In der Startdatei hat Redis kein Volume. Solange der Container läuft, ist alles gut – die Daten liegen im Container-Dateisystem unter /data. Doch sobald der Container ersetzt wird, etwa nach docker-compose down und einem erneuten up, ist der Cache leer. Der Container ist eben flüchtig, und mit ihm sein Dateisystem.
Wer Redis nicht nur als Wegwerf-Cache nutzt, mountet ein benanntes Volume auf /data. Das redis-Image legt seine RDB-Snapshots genau dort ab.
version: '3'
services:
web:
build: .
environment:
REDIS_HOST: 'redis'
REDIS_PORT: '6379'
ports:
- '8080:8080'
depends_on:
- 'redis'
redis:
image: redis:4.0.11
volumes:
- 'redis-data:/data'
volumes:
redis-data:
Das benannte Volume redis-data überlebt den Lebenszyklus des Containers. Ich kann web neu bauen, den Stack herunterfahren und wieder hochziehen – die Redis-Daten sind noch da. Nur ein explizites docker-compose down -v räumt auch die Volumes ab.
Damit habe ich die vier Bausteine beisammen, die eine Compose-Datei ausmachen:
servicessind die Container der Anwendung, hierwebundredis.portslegt fest, welche Container-Ports auf den Host veröffentlicht werden; nurwebbekommt einen.volumeshält Daten in benannten Volumes, die den Container überleben sollen, hierredis-data.networksbeschreibt das meist automatische Netz, das Service Discovery per Servicename ermöglicht.
Der Zyklus: up, logs, down
Im Betrieb sind es nur eine Handvoll Kommandos, und die bleiben über Projekte hinweg gleich. Ich starte den Stack im Hintergrund, schaue in die Logs und fahre am Ende alles wieder herunter.
docker-compose up -d
docker-compose logs web
curl -X POST http://localhost:8080/redis
curl http://localhost:8080/redis
docker-compose down
Das up -d startet beide Container detached. logs web zeigt gezielt die Ausgabe des Webservice, was beim Debuggen des Startfensters oben hilft. Die beiden curl-Aufrufe schreiben zuerst den Wert nach Redis und lesen ihn dann zurück – der Beweis, dass web seinen Nachbarn unter dem Namen redis tatsächlich erreicht. down stoppt und entfernt Container und das Default-Netz; die benannten Volumes bleiben, sofern ich nicht -v ergänze.
Wer Docker Toolbox nutzt statt Docker for Mac oder Windows, erreicht den Stack übrigens nicht unter localhost, sondern unter der IP der docker-machine, in der Standardkonfiguration 192.168.99.100. Das ist 2018 auf vielen Rechnern noch die Realität und kostet beim ersten Mal ein paar ratlose Minuten.
Der eigentliche Gewinn: Zeit bis zum ersten Commit
Die YAML ist dabei nur das Mittel. Das Ergebnis ist eine Umgebung, die jeder im Team mit denselben zwei Befehlen bekommt. Wer heute ins Projekt kommt, klont das Repository, tippt docker-compose up und hat wenig später einen laufenden Stack aus Node und Redis vor sich – ohne die richtige Node-Version manuell zu suchen, ohne Redis lokal zu installieren, ohne die stille Annahme, dass Port 6379 frei ist. Die Umgebung steht in der Datei, nicht in einer Wiki-Seite, die niemand pflegt.
Das ist der Punkt, an dem sich Compose betriebswirtschaftlich rechnet. Onboarding ist teuer, und der teuerste Teil ist selten das Verstehen des Codes – es ist das Herstellen einer Umgebung, in der der Code überhaupt läuft. Genau diesen Teil verschiebt Compose von jedem einzelnen Entwickler in eine versionierte Datei. Die Zeit bis zum ersten Commit, die sonst zwischen einem halben und einem ganzen Tag liegen kann, schrumpft auf die Dauer eines Image-Pulls.
Kostenlos ist das nicht. Ich handele mir eine zusätzliche Abstraktion ein, die das Team verstehen muss, und eine Datei, die gepflegt werden will. Versions-Pinning ist Disziplin: Ein redis:latest untergräbt genau die Reproduzierbarkeit, für die ich Compose überhaupt einsetze. Und Compose deckt Entwicklung und lokale Integration ab, nicht den Produktionsbetrieb mit seinen Fragen nach Ausfallsicherheit, Skalierung und Secrets.
Die Entscheidung fällt deshalb an einer klaren Grenze. Sobald mehr als ein Prozess zusammenspielt und mehr als eine Person das Projekt anfasst, trägt Compose seinen Aufwand schnell wieder herein – jede vermiedene Setup-Stunde pro Neuzugang ist die Datei bereits wert. Bei einem einzelnen Tool ohne Nachbarn bleibt docker run die ehrlichere Antwort. Ausschlaggebend ist dabei weniger die Technik als die Reibung, die sie erspart.
Ein Wort zur Sicherheit
Der fehlende ports-Eintrag bei Redis ist Absicht und der eigentliche Kern der Isolation. ports publiziert auf allen Host-Interfaces. Würde ich Redis mit '6379:6379' nach außen legen, stünde eine offene, standardmäßig ungesicherte Redis-Instanz im Netz – und Redis ist genau dafür immer wieder negativ aufgefallen. Innerhalb des Compose-Netzes erreicht web seinen Cache ohnehin über den Servicenamen. Von außen soll ihn niemand erreichen. Also lasse ich Redis unpubliziert. Das Prinzip bleibt schlicht: Nur Dienste, die vom Host erreichbar sein müssen, bekommen einen veröffentlichten Port.
Am Rande: Der links-Eintrag, der einem in älteren Beispielen begegnet, ist Legacy. In v3 macht ihn das Default-Netz mit seinem DNS überflüssig. Wer ihn noch stehen hat, kann ihn streichen.
Fazit
Compose macht aus einer Handvoll docker run-Kommandos eine einzige, lesbare Datei, die den ganzen Stack beschreibt. image gegen build trennt übernommene von selbstgebauten Diensten. Service Discovery über den Servicenamen räumt hartkodierte IPs komplett aus dem Weg – REDIS_HOST: 'redis' genügt, und der Node-Client verbindet sich zu redis:6379. Ein benanntes Volume auf /data rettet die Redis-Daten über den Container-Lebenszyklus hinweg. Und ports publiziert nur, was wirklich nach außen soll.
Der eine Punkt, der mir am wichtigsten ist: depends_on ordnet den Start, garantiert aber keine Betriebsbereitschaft. Wer das nicht weiß, jagt einem Race-Condition-Fehler hinterher, den keine YAML-Zeile behebt – die Wartelogik gehört in die Anwendung oder in ein kleines Wartescript. Wer es weiß, baut den Stack von Anfang an widerstandsfähig.
Wichtiger als die einzelne YAML-Zeile ist aber, was sie dem Team spart. Eine reproduzierbare Umgebung verkürzt die Schleife vom Klonen zum ersten Commit, nimmt die Reibung aus dem Onboarding und macht "läuft bei mir" zu einer überprüfbaren Aussage statt einer Hoffnung. Das hebt nebenbei die Qualität, weil alle gegen denselben Zustand entwickeln und testen. Die Grenze bleibt klar: Compose ist ein Werkzeug für Entwicklung und lokale Integration, kein Produktions-Orchestrator. Innerhalb dieser Grenze fühlt sich docker-compose up jedes Mal wie ein einziger, verlässlicher Handgriff an – und genau das ist der Gewinn.
Weiterführende Quellen
-
Foliensatz zu diesem Thema: https://supabase.mikebild.dev/storage/v1/object/public/slides/introduction-docker.html
Wie fandest du diesen Beitrag?
Kommentare