# Node.js und Redis mit Docker Compose

URL: https://www.mikebild.dev/de/blog/docker-compose-nodejs-redis/

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.

```yaml
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 einen `docker build` auf.
- `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 auf `latest`, 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.

```mermaid
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:

```yaml
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.

```js
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.

```yaml
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:

- `services` sind die Container der Anwendung, hier `web` und `redis`.
- `ports` legt fest, welche Container-Ports auf den Host veröffentlicht werden; nur `web` bekommt einen.
- `volumes` hält Daten in benannten Volumes, die den Container überleben sollen, hier `redis-data`.
- `networks` beschreibt 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.

```bash
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

- [Docker Compose: Startreihenfolge und `service_healthy`](https://docs.docker.com/compose/how-tos/startup-order/)

- Foliensatz zu diesem Thema: https://supabase.mikebild.dev/storage/v1/object/public/slides/introduction-docker.html
- [Compose file v3 reference](https://docs.docker.com/compose/compose-file/)
- [Networking in Compose (DNS, Default-Netz)](https://docs.docker.com/compose/networking/)
- [Control startup order in Compose](https://docs.docker.com/compose/startup-order/)
- [Compose CLI overview](https://docs.docker.com/compose/reference/overview/)
- [redis Official Image, Tag 4.0.11](https://hub.docker.com/_/redis)
