<?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/en/tags/docker-compose/</link><description>Docker-Compose – Neueste Beiträge von Serverküche</description><generator>Hugo</generator><language>en-US</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/en/tags/docker-compose/index.xml" rel="self" type="application/rss+xml"/><item><title>Understanding Docker Compose: services, volumes, networks</title><link>https://serverkueche.de/en/tutorials/docker-compose-basics/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/docker-compose-basics/</guid><description>compose.yaml explained: services, volumes, networks and variables – the Docker vocabulary every app recipe in the Serverküche builds on.</description><content:encoded><![CDATA[<p>In the <a href="/en/tutorials/install-docker/">Docker tutorial</a> you installed the Compose
plugin – but we didn&rsquo;t explain it yet. We&rsquo;re catching up on that now, because almost
every app recipe here describes an application as a <strong><code>compose.yaml</code></strong>. Anyone who
reads this file like a shopping list can adapt every following tutorial instead of
just copying it.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>We build a small stack step by step and, along the way, get to know the five
building blocks that make up practically every <code>compose.yaml</code>: <strong>services</strong> (the
containers), <strong>ports</strong> (reachability from outside), <strong>volumes</strong> (persistent data),
<strong>networks</strong> (containers talking to each other) and <strong>environment variables</strong>
(configuration). By the end you&rsquo;ll understand why your data survives a <code>down</code>
command – and when it doesn&rsquo;t.</p>
<p>Tested with <strong>Docker Compose v5.3</strong> (the <code>docker compose</code> plugin without a hyphen –
not the old <code>docker-compose</code> v1 with a hyphen).</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A <a href="/en/tutorials/harden-ssh/">hardened server</a> with <a href="/en/tutorials/install-docker/">Docker
installed</a> and the Compose plugin</li>
<li>The user is in the <code>docker</code> group (then you don&rsquo;t need <code>sudo</code> before <code>docker</code>)</li>
</ul>
<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-the-first-composeyaml">Step 1: The first compose.yaml</h3>
<p>A <code>compose.yaml</code> describes <strong>declaratively</strong> which containers should run – you say
<em>what</em> you want, not <em>how</em>. Create a project folder; the folder name later becomes
the prefix of all containers:</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>Create a small HTML page that we&rsquo;ll serve in a moment:</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>And now the central file <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>Line by line:</p>
<ul>
<li><strong><code>services:</code></strong> – the top level. Every entry below it is a container.</li>
<li><strong><code>web:</code></strong> – a freely chosen <strong>service name</strong>. Remember it, it later also becomes
the hostname on the internal network (step 3).</li>
<li><strong><code>image: nginx:1.31</code></strong> – the container image with a <strong>fixed tag</strong>. Never use
<code>latest</code>: <code>latest</code> changes under you and makes errors unreproducible.</li>
<li><strong><code>ports: - &quot;8080:80&quot;</code></strong> – format <code>HOST:CONTAINER</code>. Port <strong>80 in the container</strong>
is mapped to <strong>8080 on the server</strong>. The server is always on the left.</li>
<li><strong><code>volumes: - ./site:...:ro</code></strong> – the local <code>site</code> folder is mounted into the web
root, <code>:ro</code> = read-only. More on this in step 2.</li>
<li><strong><code>restart: unless-stopped</code></strong> – the container restarts automatically after a
reboot or crash, unless you stopped it yourself. The sensible default for server
services. The alternatives: <code>no</code> (never automatically – the default), <code>always</code>
(restarts even after a manual stop, rarely wanted) and <code>on-failure</code> (only after a
crash with an error code). For the vast majority of services, <code>unless-stopped</code> is
exactly right.</li>
</ul>
<p>Start the 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> means <strong>detached</strong> (in the background). The first time, Docker downloads the
image; after that you see at the end:</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>Check the 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>The container is called <code>compose-demo-web-1</code> – <strong>project folder + service + number</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">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&lt;h1&gt;Hallo aus der Serverküche&lt;/h1&gt;</span></span></code></pre></div>
</div>
<p>Works. You see a service&rsquo;s logs with <code>docker compose logs web</code> (or <code>-f</code> to follow).</p>
<h3 id="step-2-volumes--where-your-data-really-lives">Step 2: Volumes – where your data really lives</h3>
<p>Containers are <strong>ephemeral</strong>: if you delete a container, everything written <em>inside</em>
the container is gone. So that data survives this, there are two kinds of volumes:</p>
<ul>
<li><strong>Bind mount</strong> (<code>./site:/usr/share/nginx/html</code>): a <strong>folder from your server</strong> is
mounted into the container. Ideal for config files you edit yourself.</li>
<li><strong>Named volume</strong> (<code>webdata:/var/lib/...</code>): storage <strong>managed by Docker</strong>. Ideal for
database data – performant and cleanly separated from the host.</li>
</ul>
<p>In step 1 we used a bind mount. For database-like services it looks like this –
change <code>compose.yaml</code> as a test:</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 must <strong>additionally</strong> be declared at the top level under <code>volumes:</code>.
After <code>docker compose up -d</code> the volume appears:</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>Now comes the crucial point:</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> removes the container and network – <strong>the volume stays</strong>. This
is exactly why your database contents survive an update. Remember the counterpart:</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 deletes data
  </p>
  <div class="prose-kitchen text-sm"><code>docker compose down -v</code> also deletes the <strong>named volumes</strong> – i.e. all the stack&rsquo;s
persistent data. Never type the <code>-v</code> out of reflex. For a plain restart,
<code>docker compose down</code> (without <code>-v</code>) is enough.</div>
</div>
<h3 id="step-3-networks--containers-talking-to-each-other">Step 3: Networks – containers talking to each other</h3>
<p>Compose automatically creates <strong>one network per project</strong> (seen above:
<code>compose-demo_default</code>). All services in it reach each other <strong>via their service
name</strong> as the hostname – no fiddling with IPs. That&rsquo;s the reason why, in app
tutorials, the application simply reaches its database under <code>db</code>.</p>
<p>As proof, a second service that talks to the first:</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 starts <code>web</code> <strong>before</strong> <code>ping</code>. (Careful: this
only waits for the <em>start</em>, not for &ldquo;fully booted&rdquo; – that&rsquo;s what healthchecks are
for, step 4.)</li>
<li><strong><code>command:</code></strong> – overrides the image&rsquo;s default command. <code>ping</code> calls <code>http://web</code>
– <strong><code>web</code> is the service name from the same file</strong>.</li>
</ul>
<p>Run only the <code>ping</code> service once:</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">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&lt;h1&gt;Hallo aus der Serverküche&lt;/h1&gt;</span></span></code></pre></div>
</div>
<p><code>ping</code> reached <code>web</code> purely by name – without any port mapping. <strong>Remember: you only
need <code>ports:</code> to make a service reachable from <em>outside</em> (the internet).</strong> Containers
talk to each other over the internal network – that&rsquo;s why we later deliberately run
databases <em>without</em> <code>ports:</code>.</p>
<p>So far, every network lives <strong>inside</strong> a Compose project. But sometimes containers
from <strong>different</strong> projects should talk to each other – the classic example is a
<strong>reverse proxy</strong> sitting in front of many independent app stacks. For that there&rsquo;s
the <strong>external network</strong>: one you create <strong>once by hand</strong> and that several Compose
projects then share:</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 the <code>compose.yaml</code> you then don&rsquo;t create it again, but reference the already
existing network with <code>external: true</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">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> tells Compose: &ldquo;This network already exists – do <strong>not</strong> create it
and do <strong>not</strong> delete it on <code>down</code>.&rdquo; If the network is missing, <code>up</code> aborts with
<code>network proxy declared as external, but could not be found</code> – then you forgot the
<code>docker network create</code>. This very pattern – a shared <code>proxy</code> network plus
<code>external: true</code> – is the basis of the <a href="/en/tutorials/traefik-reverse-proxy/">Traefik
tutorial</a>, with which every app later gets its
domain and its HTTPS.</p>
<h3 id="step-4-configuration--environment-variables-env-and-healthchecks">Step 4: Configuration – environment variables, .env and healthchecks</h3>
<p>Almost every application is configured via <strong>environment variables</strong>. Two ways:</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>Secrets (passwords, tokens) do <strong>not</strong> belong in the <code>compose.yaml</code>, but in a <code>.env</code>
file in the same folder. Compose reads it automatically:</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>Never push `.env` to a backup repo
  </p>
  <div class="prose-kitchen text-sm">The <code>.env</code> contains plaintext secrets. Add it to a <code>.gitignore</code> if you version your
Compose files, and back it up separately (encrypted).</div>
</div>
<p>This is how you check whether Compose understands your file <strong>and</strong> inserts the
<code>.env</code> values correctly – without starting anything:</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> resolves all variables and prints the finished, normalized configuration:</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>If your <strong>actual</strong> value appears there instead of <code>${DB_PASSWORD}</code>, the <code>.env</code> is
working. If you only need a quick syntax check without the whole output, use
<code>docker compose config --quiet</code> – if nothing comes back (exit code <code>0</code>), the file is
valid. That&rsquo;s also your first move for YAML errors (see below).</p>
<p>A <strong>healthcheck</strong> tells Docker when a service is really ready – the basis for
dependent services only starting then:</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>With <code>condition: service_healthy</code>, <code>app</code> only starts once the healthcheck of <code>db</code> is
green – the most common &ldquo;why won&rsquo;t my app connect to the database?&rdquo; disappears with
it. The four healthcheck fields mean: <strong><code>test</code></strong> is the command that runs in the
container (exit code <code>0</code> = healthy), <strong><code>interval</code></strong> the spacing between checks,
<strong><code>timeout</code></strong> how long a check may take, and <strong><code>retries</code></strong> how many consecutive
failures are needed before the container counts as <code>unhealthy</code>. The current state is
shown by the <code>STATUS</code> column of <code>docker compose ps</code> as <code>(healthy)</code> or <code>(unhealthy)</code>.</p>
<h3 id="step-5-the-operations-toolbox">Step 5: The operations toolbox</h3>
<p>You need these commands daily – always run them <strong>in the project folder</strong>:</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"># start / apply changes</span>
</span></span><span class="line"><span class="cl">docker compose ps           <span class="c1"># status of the services</span>
</span></span><span class="line"><span class="cl">docker compose logs -f web  <span class="c1"># follow logs live (Ctrl+C only ends the viewing)</span>
</span></span><span class="line"><span class="cl">docker compose <span class="nb">exec</span> web sh  <span class="c1"># shell in the running container</span>
</span></span><span class="line"><span class="cl">docker compose restart web  <span class="c1"># restart a single service</span>
</span></span><span class="line"><span class="cl">docker compose stop         <span class="c1"># halt without removing container/network</span>
</span></span><span class="line"><span class="cl">docker compose pull         <span class="c1"># fetch new image versions</span>
</span></span><span class="line"><span class="cl">docker compose down         <span class="c1"># stop and remove the stack (volumes stay)</span></span></span></code></pre></div>
</div>
<p>Almost all commands can be restricted to <strong>one</strong> service by appending its name
(<code>docker compose logs -f web</code>, <code>docker compose restart web</code>) – without a name they
apply to the whole stack. The difference between <code>stop</code> and <code>down</code>: <code>stop</code> only halts
the containers (<code>start</code> continues), <code>down</code> removes them along with the network (the
named volumes stay in both cases).</p>
<p>An update almost always follows the same pattern: bump the tag in the <code>compose.yaml</code>
→ <code>docker compose pull</code> → <code>docker compose up -d</code>. Compose only replaces the
containers whose image has changed.</p>
<h3 id="step-6-all-together--a-realistic-app-stack">Step 6: All together – a realistic app stack</h3>
<p>This is the pattern you&rsquo;ll encounter again and again in the app tutorials: an
application plus its database. This file bundles everything from steps 1–4 – read it
once in full, and you&rsquo;ll have understood 90% of every later <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">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"># fixed version, no 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"># only the app is reachable from outside</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"># persistent app data (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"># starts only once db is ready</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"># the actual database files</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"># no ports: – the database is reachable ONLY internally via the name &#34;db&#34;</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>Three design decisions worth remembering:</p>
<ol>
<li><strong>Only <code>app</code> has <code>ports:</code>.</strong> The database needs no open host port – the app
reaches it internally via the hostname <code>db</code> (the <code>DATABASE_URL</code> points exactly
there). A port that isn&rsquo;t published is a port nobody from the internet can attack.</li>
<li><strong>Two separate named volumes.</strong> App data and database files live cleanly separated
– that makes later backups and restores traceable.</li>
<li><strong>Password only as <code>${DB_PASSWORD}</code>.</strong> The actual value is in the <code>.env</code>, not in
this file. The same <code>compose.yaml</code> can thus be shared safely.</li>
</ol>
<p>This very skeleton – app facing outward, database internal only, data in named
volumes, secrets in the <code>.env</code> – repeats in Nextcloud, Vaultwarden, Paperless and
most other recipes.</p>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong><code>yaml: line 7: did not find expected key</code> (or similar YAML errors).</strong> YAML is
<strong>indentation-sensitive</strong> – spaces only, <strong>never tabs</strong>, and consistent per level
(common: 2 spaces). Check the file without starting it: <code>docker compose config</code>
resolves everything and complains about exactly the wrong line.</p>
<p><strong><code>Error ... address already in use</code> on <code>up</code>.</strong> The host port (left in <code>8080:80</code>) is
already taken. Find the occupant with <code>sudo ss -tlnp | grep 8080</code> or choose a
different host port. Two containers cannot share the same host port.</p>
<p><strong>An app can&rsquo;t find its database (<code>could not translate host name</code>).</strong> The hostname
must be the <strong>service name</strong> (e.g. <code>db</code>), not <code>localhost</code>. Inside a container,
<code>localhost</code> is the container itself, not the neighboring service. And: both services
must be in the same Compose project (the same file).</p>
<p><strong>After <code>docker compose down</code> all data is gone.</strong> Either the volume wasn&rsquo;t declared
as a <strong>named volume</strong> under <code>volumes:</code> (then it was only the ephemeral container
storage), or <code>down -v</code> was used. Always run persistent services with a declared named
volume.</p>
<p><strong><code>docker-compose: command not found</code>.</strong> That&rsquo;s the old Compose v1 (with a hyphen).
The current one is <code>docker compose</code> (with a space, plugin). If it&rsquo;s missing: <code>sudo apt install docker-compose-plugin</code> (see <a href="/en/tutorials/install-docker/">Docker
tutorial</a>).</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>Maintain image tags:</strong> Fixed tags (<code>nginx:1.31</code>) mean you apply updates
<strong>deliberately</strong> by bumping the tag. That&rsquo;s intended – this way you decide when an
update comes, instead of being surprised. Expect a <strong>monthly</strong> look at your
services&rsquo; release notes.</li>
<li><strong>What belongs in the backup?</strong> Not the containers – they can be rebuilt from the
<code>compose.yaml</code> at any time. What you must back up are <strong>the named volumes</strong> (or
bind-mount folders), the <code>compose.yaml</code> and the <code>.env</code>. We build a well-thought-out
off-site backup of this data with <a href="/en/tutorials/restic-backups/">encrypted Restic
backups</a>.</li>
<li><strong>Cleanup:</strong> <code>docker compose down</code> when tearing down a stack; you remove unused
images with <code>docker image prune</code>. Named volumes are <strong>never</strong> deleted automatically
– that&rsquo;s by design.</li>
</ul>
]]></content:encoded></item></channel></rss>