<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Docker-Compose – Serverküche</title><link>https://serverkueche.de/tags/docker-compose/</link><description>Docker-Compose – Neueste Beiträge von Serverküche</description><generator>Hugo</generator><language>de-DE</language><managingEditor>feedback@serverkueche.de (Serverküche)</managingEditor><webMaster>feedback@serverkueche.de (Serverküche)</webMaster><copyright>2026 Serverküche</copyright><lastBuildDate>Fri, 17 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://serverkueche.de/tags/docker-compose/index.xml" rel="self" type="application/rss+xml"/><item><title>Docker Compose verstehen: Services, Volumes, Netzwerke</title><link>https://serverkueche.de/tutorials/docker-compose-grundlagen/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/tutorials/docker-compose-grundlagen/</guid><description>Die compose.yaml entschlüsselt: Services, Ports, Volumes, Netzwerke und Umgebungsvariablen – das Vokabular, auf dem jedes App-Rezept der Serverküche aufbaut.</description><content:encoded><![CDATA[<p>Im <a href="/tutorials/docker-installieren/">Docker-Tutorial</a> hast du das Compose-Plugin
installiert – erklärt haben wir es noch nicht. Das holen wir jetzt nach, denn fast
jedes App-Rezept hier beschreibt eine Anwendung als <strong><code>compose.yaml</code></strong>. Wer diese
Datei liest wie einen Einkaufszettel, kann jedes folgende Tutorial anpassen, statt
nur zu kopieren.</p>
<h2 id="was-bauen-wir">Was bauen wir?</h2>
<p>Wir bauen Schritt für Schritt einen kleinen Stack auf und lernen dabei die fünf
Bausteine kennen, aus denen praktisch jede <code>compose.yaml</code> besteht: <strong>Services</strong>
(die Container), <strong>Ports</strong> (Erreichbarkeit von außen), <strong>Volumes</strong> (persistente
Daten), <strong>Netzwerke</strong> (Container reden miteinander) und <strong>Umgebungsvariablen</strong>
(Konfiguration). Am Ende verstehst du, warum deine Daten einen <code>down</code>-Befehl
überleben – und wann nicht.</p>
<p>Getestet mit <strong>Docker Compose v5.2</strong> (das <code>docker compose</code>-Plugin ohne Bindestrich –
nicht das alte <code>docker-compose</code> v1 mit Bindestrich).</p>
<h2 id="voraussetzungen">Voraussetzungen</h2>
<ul>
<li>Ein <a href="/tutorials/ssh-absichern/">abgesicherter Server</a> mit <a href="/tutorials/docker-installieren/">installiertem
Docker</a> und Compose-Plugin</li>
<li>Der Benutzer ist in der <code>docker</code>-Gruppe (dann brauchst du kein <code>sudo</code> vor
<code>docker</code>)</li>
</ul>
<h2 id="schritt-für-schritt">Schritt für Schritt</h2>
<h3 id="schritt-1-die-erste-composeyaml">Schritt 1: Die erste compose.yaml</h3>
<p>Eine <code>compose.yaml</code> beschreibt <strong>deklarativ</strong>, welche Container laufen sollen – du
sagst <em>was</em> du willst, nicht <em>wie</em>. Lege einen Projektordner an; der Ordnername
wird später zum Präfix aller Container:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p ~/compose-demo/site <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/compose-demo</span></span></code></pre></div>
</div>
<p>Lege eine kleine HTML-Seite an, die wir gleich ausliefern:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;&lt;h1&gt;Hallo aus der Serverküche&lt;/h1&gt;&#39;</span> &gt; site/index.html</span></span></code></pre></div>
</div>
<p>Und jetzt die zentrale Datei <code>compose.yaml</code>:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">web</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">nginx:1.31</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;8080:80&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./site:/usr/share/nginx/html:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span></span></span></code></pre></div>
</div>
<p>Zeile für Zeile:</p>
<ul>
<li><strong><code>services:</code></strong> – die oberste Ebene. Jeder Eintrag darunter ist ein Container.</li>
<li><strong><code>web:</code></strong> – ein frei wählbarer <strong>Service-Name</strong>. Merke ihn dir, er wird später
auch zum Hostnamen im internen Netzwerk (Schritt 3).</li>
<li><strong><code>image: nginx:1.31</code></strong> – das Container-Image mit <strong>festem Tag</strong>. Nie <code>latest</code>
verwenden: <code>latest</code> ändert sich unter dir und macht Fehler unreproduzierbar.</li>
<li><strong><code>ports: - &quot;8080:80&quot;</code></strong> – Format <code>HOST:CONTAINER</code>. Port <strong>80 im Container</strong> wird
auf <strong>8080 des Servers</strong> gelegt. Links steht immer der Server.</li>
<li><strong><code>volumes: - ./site:...:ro</code></strong> – der lokale Ordner <code>site</code> wird ins Web-Root
gehängt, <code>:ro</code> = read-only. Mehr dazu in Schritt 2.</li>
<li><strong><code>restart: unless-stopped</code></strong> – der Container startet nach einem Reboot oder
Absturz automatisch neu, außer du hast ihn selbst gestoppt. Für Server-Dienste
der sinnvolle Standard. Die Alternativen: <code>no</code> (nie automatisch – der Default),
<code>always</code> (startet selbst nach einem manuellen Stopp wieder, selten gewollt) und
<code>on-failure</code> (nur nach einem Absturz mit Fehlercode). Für die allermeisten Dienste
ist <code>unless-stopped</code> genau richtig.</li>
</ul>
<p>Starte den Stack:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d</span></span></code></pre></div>
</div>
<p><code>-d</code> bedeutet <strong>detached</strong> (im Hintergrund). Beim ersten Mal lädt Docker das Image;
danach siehst du am Ende:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"> Network compose-demo_default  Created
</span></span><span class="line"><span class="cl"> Container compose-demo-web-1  Started</span></span></code></pre></div>
</div>
<p>Prüfe den Status:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose ps</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">NAME                 IMAGE        COMMAND                  SERVICE   CREATED          STATUS          PORTS
</span></span><span class="line"><span class="cl">compose-demo-web-1   nginx:1.31   &#34;/docker-entrypoint.…&#34;   web       10 seconds ago   Up 9 seconds    0.0.0.0:8080-&gt;80/tcp, [::]:8080-&gt;80/tcp</span></span></code></pre></div>
</div>
<p>Der Container heißt <code>compose-demo-web-1</code> – <strong>Projektordner + Service + Nummer</strong>. Test:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl localhost:8080</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">HTML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">h1</span><span class="p">&gt;</span>Hallo aus der Serverküche<span class="p">&lt;/</span><span class="nt">h1</span><span class="p">&gt;</span></span></span></code></pre></div>
</div>
<p>Läuft. Logs eines Dienstes siehst du mit <code>docker compose logs web</code> (oder <code>-f</code> zum
Mitlaufen).</p>
<h3 id="schritt-2-volumes--wo-deine-daten-wirklich-liegen">Schritt 2: Volumes – wo deine Daten wirklich liegen</h3>
<p>Container sind <strong>vergänglich</strong>: Löschst du einen Container, ist alles weg, was
<em>im</em> Container geschrieben wurde. Damit Daten das überleben, gibt es zwei Arten von
Volumes:</p>
<ul>
<li><strong>Bind-Mount</strong> (<code>./site:/usr/share/nginx/html</code>): ein <strong>Ordner von deinem Server</strong>
wird in den Container gehängt. Ideal für Config-Dateien, die du selbst bearbeitest.</li>
<li><strong>Named Volume</strong> (<code>webdata:/var/lib/...</code>): ein von <strong>Docker verwalteter</strong>
Speicher. Ideal für Datenbank-Daten – performant und sauber getrennt vom Host.</li>
</ul>
<p>In Schritt 1 haben wir ein Bind-Mount genutzt. Für datenbankartige Dienste sieht
es so aus – ändere <code>compose.yaml</code> testweise:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">web</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">nginx:1.31</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">webdata:/usr/share/nginx/html</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">webdata:</span></span></span></code></pre></div>
</div>
<p>Named Volumes müssen <strong>zusätzlich</strong> auf oberster Ebene unter <code>volumes:</code> deklariert
werden. Nach <code>docker compose up -d</code> taucht das Volume auf:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker volume ls</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">DRIVER    VOLUME NAME
</span></span><span class="line"><span class="cl">local     compose-demo_webdata</span></span></code></pre></div>
</div>
<p>Der entscheidende Punkt kommt jetzt:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose down
</span></span><span class="line"><span class="cl">docker volume ls</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"> Container compose-demo-web-1  Removed
</span></span><span class="line"><span class="cl"> Network compose-demo_default  Removed
</span></span><span class="line"><span class="cl">DRIVER    VOLUME NAME
</span></span><span class="line"><span class="cl">local     compose-demo_webdata</span></span></code></pre></div>
</div>
<p><code>docker compose down</code> entfernt Container und Netzwerk – <strong>das Volume bleibt</strong>. Genau
deshalb überleben deine Datenbank-Inhalte ein Update. Merke dir das Gegenstück:</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-paprika-400 bg-paprika-50 dark:border-paprika-700 dark:bg-paprika-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🔥</span>down -v löscht Daten
  </p>
  <div class="prose-kitchen text-sm"><code>docker compose down -v</code> löscht <strong>auch die Named Volumes</strong> – also alle persistenten
Daten des Stacks. Tippe das <code>-v</code> nie aus Reflex mit. Für ein reines Neustarten
reicht <code>docker compose down</code> (ohne <code>-v</code>).</div>
</div>
<h3 id="schritt-3-netzwerke--container-reden-miteinander">Schritt 3: Netzwerke – Container reden miteinander</h3>
<p>Compose legt automatisch <strong>ein Netzwerk pro Projekt</strong> an (oben gesehen:
<code>compose-demo_default</code>). Alle Services darin erreichen sich <strong>über ihren
Service-Namen</strong> als Hostname – kein IP-Gefummel nötig. Das ist der Grund, warum in
App-Tutorials die Anwendung ihre Datenbank einfach unter <code>db</code> erreicht.</p>
<p>Zum Beweis ein zweiter Service, der den ersten anspricht:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">web</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">nginx:1.31</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./site:/usr/share/nginx/html:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">ping</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">curlimages/curl:8.21.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">web</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;curl&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-s&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;http://web&#34;</span><span class="p">]</span></span></span></code></pre></div>
</div>
<ul>
<li><strong><code>depends_on: - web</code></strong> – Compose startet <code>web</code> <strong>vor</strong> <code>ping</code>. (Achtung: das
wartet nur auf den <em>Start</em>, nicht auf „fertig hochgefahren&quot; – dafür gibt es
Healthchecks, Schritt 4.)</li>
<li><strong><code>command:</code></strong> – überschreibt den Standardbefehl des Images. <code>ping</code> ruft
<code>http://web</code> auf – <strong><code>web</code> ist der Service-Name aus derselben Datei</strong>.</li>
</ul>
<p>Führe nur den <code>ping</code>-Service einmalig aus:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose run --rm ping</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">HTML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">h1</span><span class="p">&gt;</span>Hallo aus der Serverküche<span class="p">&lt;/</span><span class="nt">h1</span><span class="p">&gt;</span></span></span></code></pre></div>
</div>
<p><code>ping</code> hat <code>web</code> allein über den Namen erreicht – ganz ohne Port-Mapping. <strong>Merke:
<code>ports:</code> brauchst du nur, um einen Dienst von <em>außen</em> (dem Internet) erreichbar zu
machen.</strong> Container untereinander reden über das interne Netzwerk – später lassen
wir deshalb Datenbanken bewusst <em>ohne</em> <code>ports:</code> laufen.</p>
<p>Bisher lebt jedes Netzwerk <strong>innerhalb</strong> eines Compose-Projekts. Manchmal sollen aber
Container aus <strong>verschiedenen</strong> Projekten miteinander reden – das klassische Beispiel ist
ein <strong>Reverse Proxy</strong>, der vor vielen unabhängigen App-Stacks sitzt. Dafür gibt es das
<strong>externe Netzwerk</strong>: eines, das du <strong>einmalig von Hand</strong> anlegst und das danach mehrere
Compose-Projekte gemeinsam nutzen:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker network create proxy</span></span></code></pre></div>
</div>
<p>In der <code>compose.yaml</code> legst du es dann nicht neu an, sondern verweist mit <code>external: true</code>
auf das bereits bestehende Netzwerk:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">web</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">nginx:1.31</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">proxy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p><code>external: true</code> sagt Compose: „Dieses Netzwerk existiert schon – leg es <strong>nicht</strong> an und
lösch es beim <code>down</code> <strong>nicht</strong>.&quot; Fehlt das Netzwerk, bricht <code>up</code> mit <code>network proxy declared as external, but could not be found</code> ab – dann hast du das <code>docker network create</code>
vergessen. Genau dieses Muster – ein gemeinsames <code>proxy</code>-Netz plus <code>external: true</code> – ist
die Grundlage des <a href="/tutorials/reverse-proxy-traefik/">Traefik-Tutorials</a>, mit dem jede App
später ihre Domain und ihr HTTPS bekommt.</p>
<h3 id="schritt-4-konfiguration--umgebungsvariablen-env-und-healthchecks">Schritt 4: Konfiguration – Umgebungsvariablen, .env und Healthchecks</h3>
<p>Fast jede Anwendung wird über <strong>Umgebungsvariablen</strong> konfiguriert. Zwei Wege:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">beispiel/app:1.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TZ=Europe/Berlin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">APP_PORT=3000</span></span></span></code></pre></div>
</div>
<p>Geheimnisse (Passwörter, Tokens) gehören <strong>nicht</strong> in die <code>compose.yaml</code>, sondern in
eine <code>.env</code>-Datei im selben Ordner. Compose liest sie automatisch:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;DB_PASSWORD=EIN_LANGES_ZUFALLSPASSWORT&#34;</span> &gt; .env</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:18</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_PASSWORD=${DB_PASSWORD}</span></span></span></code></pre></div>
</div>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>`.env` nie ins Backup-Repo pushen
  </p>
  <div class="prose-kitchen text-sm">Die <code>.env</code> enthält Klartext-Geheimnisse. Nimm sie in eine <code>.gitignore</code> auf, falls du
deine Compose-Dateien versionierst, und sichere sie getrennt (verschlüsselt).</div>
</div>
<p>So prüfst du, ob Compose deine Datei versteht <strong>und</strong> die <code>.env</code>-Werte richtig
einsetzt – ohne etwas zu starten:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose config</span></span></code></pre></div>
</div>
<p><code>config</code> löst alle Variablen auf und gibt die fertige, normalisierte Konfiguration
aus:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">compose-demo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">EIN_LANGES_ZUFALLSPASSWORT</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:18</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">default</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span></span></span></code></pre></div>
</div>
<p>Steht dort dein <strong>echter</strong> Wert statt <code>${DB_PASSWORD}</code>, greift die <code>.env</code>. Brauchst
du nur einen schnellen Syntax-Check ohne die ganze Ausgabe, nimm
<code>docker compose config --quiet</code> – kommt nichts zurück (Exit-Code <code>0</code>), ist die Datei
gültig. Genau das ist auch dein erster Griff bei YAML-Fehlern (siehe unten).</p>
<p>Ein <strong>Healthcheck</strong> sagt Docker, wann ein Dienst wirklich bereit ist – die Basis
dafür, dass abhängige Dienste erst dann starten:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:18</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_PASSWORD=${DB_PASSWORD}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD-SHELL&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;pg_isready -U postgres&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">beispiel/app:1.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span></span></span></code></pre></div>
</div>
<p>Mit <code>condition: service_healthy</code> startet <code>app</code> erst, wenn der Healthcheck von <code>db</code>
grün ist – das häufigste „warum verbindet sich meine App nicht zur Datenbank?&quot;
verschwindet damit. Die vier Healthcheck-Felder bedeuten: <strong><code>test</code></strong> ist der Befehl,
der im Container läuft (Exit-Code <code>0</code> = gesund), <strong><code>interval</code></strong> der Abstand zwischen
den Prüfungen, <strong><code>timeout</code></strong> wie lange eine Prüfung dauern darf, und <strong><code>retries</code></strong>
wie viele Fehlversuche in Folge nötig sind, bevor der Container als <code>unhealthy</code> gilt.
Den aktuellen Zustand zeigt die <code>STATUS</code>-Spalte von <code>docker compose ps</code> als
<code>(healthy)</code> bzw. <code>(unhealthy)</code>.</p>
<h3 id="schritt-5-der-betriebs-werkzeugkasten">Schritt 5: Der Betriebs-Werkzeugkasten</h3>
<p>Diese Befehle brauchst du täglich – immer <strong>im Projektordner</strong> ausführen:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d        <span class="c1"># starten / Änderungen anwenden</span>
</span></span><span class="line"><span class="cl">docker compose ps           <span class="c1"># Status der Services</span>
</span></span><span class="line"><span class="cl">docker compose logs -f web  <span class="c1"># Logs live mitlesen (Strg+C beendet nur das Ansehen)</span>
</span></span><span class="line"><span class="cl">docker compose <span class="nb">exec</span> web sh  <span class="c1"># Shell im laufenden Container</span>
</span></span><span class="line"><span class="cl">docker compose restart web  <span class="c1"># einen einzelnen Dienst neu starten</span>
</span></span><span class="line"><span class="cl">docker compose stop         <span class="c1"># anhalten, ohne Container/Netzwerk zu entfernen</span>
</span></span><span class="line"><span class="cl">docker compose pull         <span class="c1"># neue Image-Versionen holen</span>
</span></span><span class="line"><span class="cl">docker compose down         <span class="c1"># Stack stoppen und entfernen (Volumes bleiben)</span></span></span></code></pre></div>
</div>
<p>Fast alle Befehle lassen sich auf <strong>einen</strong> Service einschränken, indem du seinen
Namen anhängst (<code>docker compose logs -f web</code>, <code>docker compose restart web</code>) – ohne
Namen gelten sie für den ganzen Stack. Der Unterschied zwischen <code>stop</code> und <code>down</code>:
<code>stop</code> hält die Container nur an (mit <code>start</code> geht&rsquo;s weiter), <code>down</code> entfernt sie
samt Netzwerk (die Named Volumes bleiben in beiden Fällen).</p>
<p>Ein Update läuft fast immer nach demselben Muster: Tag in der <code>compose.yaml</code>
hochsetzen → <code>docker compose pull</code> → <code>docker compose up -d</code>. Compose ersetzt nur die
Container, deren Image sich geändert hat.</p>
<h3 id="schritt-6-alles-zusammen--ein-realistischer-app-stack">Schritt 6: Alles zusammen – ein realistischer App-Stack</h3>
<p>So sieht das Muster aus, das dir in den App-Tutorials immer wieder begegnet: eine
Anwendung plus ihre Datenbank. Diese Datei bündelt alles aus den Schritten 1–4 –
lies sie einmal komplett, dann hast du 90 % jeder späteren <code>compose.yaml</code>
verstanden:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">beispiel/app:1.4         </span><span class="w"> </span><span class="c"># feste Version, kein latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;8080:3000&#34;</span><span class="w">                  </span><span class="c"># nur die App ist von außen erreichbar</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TZ=Europe/Berlin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">DATABASE_URL=postgres://app:${DB_PASSWORD}@db:5432/app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">appdata:/data               </span><span class="w"> </span><span class="c"># persistente App-Daten (Named Volume)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy  </span><span class="w"> </span><span class="c"># startet erst, wenn db bereit ist</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:18</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_USER=app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_PASSWORD=${DB_PASSWORD}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_DB=app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">dbdata:/var/lib/postgresql/data  </span><span class="w"> </span><span class="c"># die eigentlichen Datenbank-Dateien</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD-SHELL&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;pg_isready -U app&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># kein ports: – die Datenbank ist NUR intern über den Namen &#34;db&#34; erreichbar</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">appdata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">dbdata:</span></span></span></code></pre></div>
</div>
<p>Drei Design-Entscheidungen, die du dir merken solltest:</p>
<ol>
<li><strong>Nur <code>app</code> hat <code>ports:</code>.</strong> Die Datenbank braucht keinen offenen Host-Port – die
App erreicht sie intern über den Hostnamen <code>db</code> (die <code>DATABASE_URL</code> zeigt genau
dorthin). Ein nicht veröffentlichter Port ist ein Port, den niemand aus dem
Internet angreifen kann.</li>
<li><strong>Zwei getrennte Named Volumes.</strong> App-Daten und Datenbank-Dateien liegen sauber
getrennt – das macht spätere Backups und Restores nachvollziehbar.</li>
<li><strong>Passwort nur als <code>${DB_PASSWORD}</code>.</strong> Der echte Wert steht in der <code>.env</code>, nicht
in dieser Datei. Dieselbe <code>compose.yaml</code> kann so gefahrlos geteilt werden.</li>
</ol>
<p>Genau dieses Grundgerüst – App nach außen, Datenbank nur intern, Daten in Named
Volumes, Secrets in der <code>.env</code> – wiederholt sich in Nextcloud, Vaultwarden,
Paperless und den meisten anderen Rezepten.</p>
<h2 id="wenn-es-nicht-funktioniert">Wenn es nicht funktioniert</h2>
<p><strong>Symptom:</strong> <code>yaml: line 7: did not find expected key</code> (oder ähnliche YAML-Fehler)</p>
<p><strong>Ursache &amp; Lösung:</strong> YAML ist <strong>einrückungssensibel</strong> – ausschließlich Leerzeichen,
<strong>niemals Tabs</strong>, und pro Ebene konsistent (üblich: 2 Leerzeichen). Prüfe die Datei
ohne sie zu starten: <code>docker compose config</code> löst alles auf und meckert genau die
falsche Zeile an.</p>
<p><strong>Symptom:</strong> <code>Error ... address already in use</code> beim <code>up</code></p>
<p><strong>Ursache &amp; Lösung:</strong> Der Host-Port (links in <code>8080:80</code>) ist schon belegt. Finde den
Beleger mit <code>sudo ss -tlnp | grep 8080</code> oder wähle einen anderen Host-Port. Zwei
Container dürfen sich denselben Host-Port nicht teilen.</p>
<p><strong>Symptom:</strong> Eine App findet ihre Datenbank nicht (<code>could not translate host name</code>).</p>
<p><strong>Ursache &amp; Lösung:</strong> Als Hostname muss der <strong>Service-Name</strong> stehen (z. B. <code>db</code>),
nicht <code>localhost</code>. Innerhalb eines Containers ist <code>localhost</code> der Container selbst,
nicht der Nachbar-Service. Und: Beide Services müssen im selben Compose-Projekt
(derselben Datei) liegen.</p>
<p><strong>Symptom:</strong> Nach <code>docker compose down</code> sind alle Daten weg.</p>
<p><strong>Ursache &amp; Lösung:</strong> Entweder lag das Volume nicht als <strong>Named Volume</strong> unter
<code>volumes:</code> vor (dann war es nur der vergängliche Container-Speicher), oder es wurde
<code>down -v</code> verwendet. Persistente Dienste immer mit deklariertem Named Volume fahren.</p>
<p><strong>Symptom:</strong> <code>docker-compose: command not found</code></p>
<p><strong>Ursache &amp; Lösung:</strong> Das ist das alte Compose v1 (mit Bindestrich). Aktuell ist
<code>docker compose</code> (mit Leerzeichen, Plugin). Falls es fehlt:
<code>sudo apt install docker-compose-plugin</code> (siehe <a href="/tutorials/docker-installieren/">Docker-Tutorial</a>).</p>
<h2 id="wartung--backups">Wartung &amp; Backups</h2>
<ul>
<li><strong>Image-Tags pflegen:</strong> Feste Tags (<code>nginx:1.31</code>) bedeuten, dass du Updates
<strong>bewusst</strong> durch Hochsetzen des Tags einspielst. Das ist gewollt – so entscheidest
du, wann ein Update kommt, statt überrascht zu werden. Rechne mit <strong>monatlichem</strong>
Draufschauen auf die Release-Notes deiner Dienste.</li>
<li><strong>Was gehört ins Backup?</strong> Nicht die Container – die sind aus der <code>compose.yaml</code>
jederzeit neu baubar. Sichern musst du <strong>die Named Volumes</strong> (bzw. Bind-Mount-
Ordner), die <code>compose.yaml</code> und die <code>.env</code>. Ein durchdachtes Off-Site-Backup dieser
Daten bauen wir im Tutorial zu verschlüsselten Restic-Backups (folgt in der Serie).</li>
<li><strong>Aufräumen:</strong> <code>docker compose down</code> beim Abbau eines Stacks; ungenutzte Images
räumst du mit <code>docker image prune</code> weg. Named Volumes werden <strong>nie</strong> automatisch
gelöscht – das ist Absicht.</li>
</ul>
<p>Damit kennst du das Vokabular jeder <code>compose.yaml</code>. Der nächste Schritt ist der
wichtigste Baustein der Serverküche: ein <strong>Reverse Proxy mit Traefik</strong>, der deine
Dienste unter echten Domains mit automatischem HTTPS erreichbar macht.</p>
]]></content:encoded></item></channel></rss>