<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Shubham Sharma</title>
    <description>The latest articles on DEV Community by Shubham Sharma (@shubham_sharma_94).</description>
    <link>https://dev.to/shubham_sharma_94</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4102464%2F909cc49e-8052-49a0-aa02-87ca32df931c.png</url>
      <title>DEV Community: Shubham Sharma</title>
      <link>https://dev.to/shubham_sharma_94</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shubham_sharma_94"/>
    <language>en</language>
    <item>
      <title>Install Docker on macOS, Windows (WSL2), and Linux: One Guide, Three Paths</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Sat, 10 Oct 2026 14:00:02 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/install-docker-on-macos-windows-wsl2-and-linux-one-guide-three-paths-4ej7</link>
      <guid>https://dev.to/shubham_sharma_94/install-docker-on-macos-windows-wsl2-and-linux-one-guide-three-paths-4ej7</guid>
      <description>&lt;p&gt;Almost every guide on this site starts the same way: "make sure Docker is installed." This is the post that earns that line. One install, three operating systems, and a single command at the end that proves it worked. No prior containers knowledge needed, and nothing here assumes you have used Docker before.&lt;/p&gt;

&lt;p&gt;The end state is the same on every OS: a working Docker Engine and a green &lt;code&gt;docker run hello-world&lt;/code&gt;. How you get there differs. On macOS and Windows you install Docker Desktop, an app that runs the engine inside a small Linux VM for you. On Linux you install the engine natively, straight onto the machine. Pick your section below and skip the other two.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;macOS and Windows: install &lt;strong&gt;Docker Desktop&lt;/strong&gt;. It bundles the engine, the CLI, and Compose behind one app.&lt;/li&gt;
&lt;li&gt;Windows specifically: use the &lt;strong&gt;WSL2 backend&lt;/strong&gt;, and run your &lt;code&gt;docker&lt;/code&gt; commands from inside a WSL2 Linux distro.&lt;/li&gt;
&lt;li&gt;Linux: install &lt;strong&gt;Docker Engine&lt;/strong&gt; from Docker's own apt repository, not the old &lt;code&gt;docker.io&lt;/code&gt; package.&lt;/li&gt;
&lt;li&gt;On Linux, add yourself to the &lt;code&gt;docker&lt;/code&gt; group so you can drop &lt;code&gt;sudo&lt;/code&gt;. That group is root-equivalent, so treat it with respect.&lt;/li&gt;
&lt;li&gt;You are done when &lt;code&gt;docker run hello-world&lt;/code&gt; prints "Hello from Docker!" That output looks the same on all three, apart from one line that names your CPU.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A machine you can install software on, with admin or &lt;code&gt;sudo&lt;/code&gt; rights.&lt;/li&gt;
&lt;li&gt;A few GB of free disk and RAM (Docker Desktop asks for at least 4 GB on macOS, 8 GB on Windows).&lt;/li&gt;
&lt;li&gt;A terminal. On macOS that is Terminal or iTerm, on Windows it is PowerShell plus your WSL2 shell, on Linux it is your normal shell.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  macOS: Docker Desktop
&lt;/h2&gt;

&lt;p&gt;On an Apple Silicon or Intel Mac, Docker Desktop is the path. It runs the Linux engine inside a lightweight VM and gives you the &lt;code&gt;docker&lt;/code&gt; CLI, Compose, and a menu-bar app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Check requirements.&lt;/strong&gt; You need a currently supported macOS (the current release and the two before it) and at least 4 GB of RAM. On Apple Silicon, Rosetta 2 is recommended but no longer strictly required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Download the right disk image.&lt;/strong&gt; From &lt;a href="https://docs.docker.com/desktop/setup/install/mac-install/" rel="noopener noreferrer"&gt;Docker's official macOS install page&lt;/a&gt;, grab the build that matches your chip: the Apple Silicon (ARM64) &lt;code&gt;Docker.dmg&lt;/code&gt; for M1 and later, or the Intel (AMD64) one for older Macs. Installing the wrong architecture is the most common first mistake.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Install it.&lt;/strong&gt; Open the &lt;code&gt;.dmg&lt;/code&gt;, drag the Docker icon into your Applications folder, then launch Docker from Applications. Accept the Docker Subscription Service Agreement, keep the recommended settings, and enter your password when prompted so it can finish setting up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Wait for the engine.&lt;/strong&gt; A whale icon appears in your menu bar. While it is animating, the engine is still starting. Once it settles and the Docker menu says it is running, you are ready.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Verify.&lt;/strong&gt; Open a terminal and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nt"&gt;--version&lt;/span&gt;
docker run hello-world
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see a version line followed by the "Hello from Docker!" message shown later in this guide. If the whale icon is not steady yet, give it a few more seconds and try again.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker Desktop is not the only option on macOS.&lt;/strong&gt; Alternatives like OrbStack and Colima also give you a real Docker Engine and the same &lt;code&gt;docker&lt;/code&gt; CLI. Every command in this series works the same against them. If you are new, start with Docker Desktop; it is the path the official docs assume.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Windows: Docker Desktop with the WSL2 backend
&lt;/h2&gt;

&lt;p&gt;On Windows, Docker Desktop runs on top of &lt;a href="https://learn.microsoft.com/windows/wsl/install" rel="noopener noreferrer"&gt;WSL2&lt;/a&gt; (the Windows Subsystem for Linux, version 2). This is the modern, faster backend, and it is what you want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Check requirements.&lt;/strong&gt; Docker lists 64-bit Windows 10 (22H2, build 19045) or Windows 11 (23H2, build 22631) or newer, a 64-bit CPU with SLAT, 8 GB of RAM, and hardware virtualization enabled in your BIOS or UEFI. If virtualization is off, containers will not start, so enable it now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Install WSL2.&lt;/strong&gt; Open PowerShell as Administrator and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--install&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That command installs WSL2 and a default Ubuntu distribution. If WSL is already present, update it and confirm the version instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--update&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--version&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want WSL version 2.1.5 or later. Reboot if it asks you to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Download and run Docker Desktop.&lt;/strong&gt; Get &lt;code&gt;Docker Desktop Installer.exe&lt;/code&gt; for your architecture (x86_64 for most machines) from &lt;a href="https://docs.docker.com/desktop/setup/install/windows-install/" rel="noopener noreferrer"&gt;Docker's official Windows install page&lt;/a&gt;. Run it, and on the configuration screen make sure &lt;strong&gt;"Use WSL 2 instead of Hyper-V"&lt;/strong&gt; is selected. Finish the wizard and let it authorize.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Start it.&lt;/strong&gt; Search for Docker in the Start menu, open Docker Desktop, and accept the agreement. Give the engine a moment to come up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Verify from inside WSL2.&lt;/strong&gt; This is the step people miss. Open your WSL2 Linux shell (for example, launch "Ubuntu" from the Start menu), not plain PowerShell, and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nt"&gt;--version&lt;/span&gt;
docker run hello-world
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run &lt;code&gt;docker&lt;/code&gt; from inside your WSL2 distro, not from PowerShell.&lt;/strong&gt; With the WSL2 backend, Docker Desktop wires the &lt;code&gt;docker&lt;/code&gt; command into your Linux distributions. Working from the WSL2 shell is where your project files, paths, and performance all line up. Running from PowerShell can work, but the Linux shell is the intended home.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Linux: Docker Engine (the native path)
&lt;/h2&gt;

&lt;p&gt;On Linux there is no Desktop VM in the middle. You install Docker Engine directly, and it is the leanest, fastest option. The steps below are the classic &lt;code&gt;.list&lt;/code&gt; form of &lt;a href="https://docs.docker.com/engine/install/ubuntu/" rel="noopener noreferrer"&gt;Docker's official apt-repository method&lt;/a&gt; on Ubuntu 24.04 LTS (the current docs also show a newer deb822 &lt;code&gt;.sources&lt;/code&gt; variant; both install the same engine). Every command and its output here was captured on a real Ubuntu 24.04.5 LTS system (a fresh VM).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not &lt;code&gt;apt install docker.io&lt;/code&gt;.&lt;/strong&gt; Ubuntu's built-in &lt;code&gt;docker.io&lt;/code&gt; package is older and misses Compose v2 and buildx. Use Docker's own repository, below, to get the current engine and plugins.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;1. Add Docker's official apt repository.&lt;/strong&gt; First the prerequisites and the signing key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; ca-certificates curl
&lt;span class="nb"&gt;sudo install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 0755 &lt;span class="nt"&gt;-d&lt;/span&gt; /etc/apt/keyrings
&lt;span class="nb"&gt;sudo &lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://download.docker.com/linux/ubuntu/gpg &lt;span class="nt"&gt;-o&lt;/span&gt; /etc/apt/keyrings/docker.asc
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;a+r /etc/apt/keyrings/docker.asc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add the repository. This one-liner fills in your architecture and Ubuntu codename automatically, so it is correct on both amd64 servers and arm64 machines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"deb [arch=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;dpkg &lt;span class="nt"&gt;--print-architecture&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; /etc/os-release &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VERSION_CODENAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; stable"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/docker.list &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the test machine that wrote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deb [arch=arm64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu noble stable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Install the engine and plugins.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Confirm the versions and that the service is running.&lt;/strong&gt; Docker Engine installs as a systemd service that starts on boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nt"&gt;--version&lt;/span&gt;
docker compose version
systemctl is-enabled docker
systemctl is-active docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Captured output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Docker version 29.8.0, build 88096ef
Docker Compose version v5.5.1
enabled
active
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Verify.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;docker run hello-world
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will see the "Hello from Docker!" message below. Note the &lt;code&gt;sudo&lt;/code&gt;: right after install, only root can talk to the Docker socket. The next section fixes that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run Docker without sudo (Linux post-install)
&lt;/h2&gt;

&lt;p&gt;Typing &lt;code&gt;sudo&lt;/code&gt; before every &lt;code&gt;docker&lt;/code&gt; command gets old fast. Add your user to the &lt;code&gt;docker&lt;/code&gt; group so the CLI can reach the daemon socket directly. This is one of Docker's official &lt;a href="https://docs.docker.com/engine/install/linux-postinstall/" rel="noopener noreferrer"&gt;Linux post-install steps&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;groupadd docker          &lt;span class="c"&gt;# usually already exists&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Group membership is only picked up in a new login session. Either log out and back in, or start a fresh group session in place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;newgrp docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;docker&lt;/code&gt; works without &lt;code&gt;sudo&lt;/code&gt;. On the test machine, running hello-world as the normal user succeeded, printing the usual message, including:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Share images, automate workflows, and more with a free Docker ID:
 https://hub.docker.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;docker&lt;/code&gt; group is root-equivalent.&lt;/strong&gt; Anyone in it can start a container that mounts the whole host filesystem, which is effectively root access. Only add trusted users, and on a shared server consider rootless Docker (next) instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Optional: rootless Docker (Linux)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.docker.com/engine/security/rootless/" rel="noopener noreferrer"&gt;Rootless mode&lt;/a&gt; runs the daemon as your user, not root, which shrinks the blast radius if a container is compromised. Install the extras and run Docker's setup tool as your normal user (not with sudo):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; uidmap docker-ce-rootless-extras
dockerd-rootless-setuptool.sh &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two real gotchas showed up during testing, both worth knowing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rootless refuses to install while rootful Docker is running.&lt;/strong&gt; The tool aborts with "rootful Docker is running and accessible" if the system daemon still holds &lt;code&gt;/var/run/docker.sock&lt;/code&gt;. Stop it first with &lt;code&gt;sudo systemctl disable --now docker.service docker.socket&lt;/code&gt;, then run the setup tool again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It also needs subuid/subgid ranges.&lt;/strong&gt; If the tool complains about missing system requirements, your user has no entry in &lt;code&gt;/etc/subuid&lt;/code&gt; and &lt;code&gt;/etc/subgid&lt;/code&gt;. A normally created Ubuntu user already has these; if yours does not, add them (&lt;code&gt;echo "$USER:100000:65536" | sudo tee -a /etc/subuid /etc/subgid&lt;/code&gt;) and rerun.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once it finishes it creates a &lt;code&gt;rootless&lt;/code&gt; CLI context and prints two environment lines to add to your shell profile. After starting the user service, &lt;code&gt;docker run hello-world&lt;/code&gt; runs entirely as your user, and &lt;code&gt;docker info&lt;/code&gt; reports &lt;code&gt;rootless&lt;/code&gt; under its security options.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a working install looks like
&lt;/h2&gt;

&lt;p&gt;On every OS, success looks essentially like this. This is the real output of &lt;code&gt;docker run hello-world&lt;/code&gt;, trimmed after the four numbered steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
    (arm64v8)
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;(arm64v8)&lt;/code&gt; line just names the host CPU architecture. On an Intel or amd64 machine it reads &lt;code&gt;(amd64)&lt;/code&gt; instead, and the rest is the same.&lt;/p&gt;

&lt;p&gt;Those four lines are worth reading once, because they describe the whole system in miniature: a client (the &lt;code&gt;docker&lt;/code&gt; command), a daemon (the engine), an image pulled from a registry (&lt;a href="https://hub.docker.com/" rel="noopener noreferrer"&gt;Docker Hub&lt;/a&gt;), and a container created from that image. That is the mental model the rest of this series builds on.&lt;/p&gt;

&lt;p&gt;That registry is also the fun part. &lt;a href="https://hub.docker.com/" rel="noopener noreferrer"&gt;Docker Hub&lt;/a&gt; is the default public one, and it holds thousands of ready to run images: databases like Postgres, Redis, and MySQL, tools like nginx and Grafana, and whole self-hosted apps. Most of them are a single &lt;code&gt;docker run&lt;/code&gt; away, which is what makes Docker so useful once it is installed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common gotchas, by OS
&lt;/h2&gt;

&lt;h3&gt;
  
  
  macOS: "Cannot connect to the Docker daemon"
&lt;/h3&gt;

&lt;p&gt;The engine is not up yet. Check the whale icon in the menu bar; if it is still animating, wait for it to settle, then retry. If it never starts, quit and reopen Docker Desktop.&lt;/p&gt;

&lt;h3&gt;
  
  
  Windows: containers will not start
&lt;/h3&gt;

&lt;p&gt;Almost always virtualization is disabled in BIOS/UEFI, or WSL2 is out of date. Enable virtualization, then run &lt;code&gt;wsl --update&lt;/code&gt; and reboot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Windows: &lt;code&gt;docker&lt;/code&gt; not found in PowerShell
&lt;/h3&gt;

&lt;p&gt;Run it from your WSL2 Linux shell instead. That is where the WSL2 backend exposes the command.&lt;/p&gt;

&lt;h3&gt;
  
  
  Linux: "permission denied while trying to connect to the Docker daemon socket"
&lt;/h3&gt;

&lt;p&gt;You are not in the &lt;code&gt;docker&lt;/code&gt; group yet, or you have not started a new session. Run &lt;code&gt;newgrp docker&lt;/code&gt; or log out and back in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Linux: apt cannot find &lt;code&gt;docker-ce&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The Docker repository was not added correctly. Recheck the keyring and the &lt;code&gt;docker.list&lt;/code&gt; file from the Linux section, then run &lt;code&gt;sudo apt-get update&lt;/code&gt; again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;You have a working engine. The natural next step is to actually drive it: start containers, look inside them, and clean them up.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next in this series:&lt;/strong&gt; Run Your First Containers, where we use &lt;code&gt;docker run&lt;/code&gt;, &lt;code&gt;ps&lt;/code&gt;, &lt;code&gt;logs&lt;/code&gt;, &lt;code&gt;exec&lt;/code&gt;, &lt;code&gt;stop&lt;/code&gt;, and &lt;code&gt;rm&lt;/code&gt; to build the real mental model of images versus containers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Verified on 2026-09-10. The Linux path was run end to end on a real Ubuntu 24.04.5 LTS arm64 system (a fresh VM): the classic apt-repository install, the captured versions (Docker Engine 29.8.0, Compose v5.5.1, containerd 2.3.5), the systemd service showing enabled and active, &lt;code&gt;docker run hello-world&lt;/code&gt;, the non-root &lt;code&gt;docker&lt;/code&gt; group step, and rootless mode (including the two gotchas above). The macOS verification output was confirmed against the OrbStack engine on Apple Silicon (the same Docker Engine that Docker Desktop ships, wrapped in a different app), not Docker Desktop itself; the Docker Desktop screens follow Docker's official macOS docs. The Windows and WSL2 steps follow Docker's official Windows docs and were not captured on Windows hardware in this run.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>windows</category>
      <category>macos</category>
      <category>docker</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>Run Your First Containers: docker run, ps, logs, exec, stop, rm</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Wed, 07 Oct 2026 14:00:05 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/run-your-first-containers-docker-run-ps-logs-exec-stop-rm-4550</link>
      <guid>https://dev.to/shubham_sharma_94/run-your-first-containers-docker-run-ps-logs-exec-stop-rm-4550</guid>
      <description>&lt;p&gt;You have Docker installed and &lt;code&gt;hello-world&lt;/code&gt; printed its greeting. That container did its one job and exited immediately, which is not very interesting. Real containers stay up, serve traffic, and need starting, inspecting, and cleaning up. This post walks the everyday container lifecycle on a single command you will use constantly: a web server you can actually open.&lt;/p&gt;

&lt;p&gt;By the end you will be able to start a container, see it running, talk to it, look inside it, stop and restart it, and remove it, all with a handful of &lt;code&gt;docker&lt;/code&gt; commands. Every command and its output below was captured on a real Ubuntu 24.04 machine running Docker Engine 29.8.0.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;image&lt;/strong&gt; is the read-only template. A &lt;strong&gt;container&lt;/strong&gt; is a running instance of it. One image, many containers.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker run&lt;/code&gt; creates and starts a container. &lt;code&gt;-d&lt;/code&gt; runs it in the background, &lt;code&gt;-p&lt;/code&gt; publishes a port, &lt;code&gt;--name&lt;/code&gt; gives it a friendly name.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker ps&lt;/code&gt; lists running containers, &lt;code&gt;docker ps -a&lt;/code&gt; includes stopped ones.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker logs&lt;/code&gt;, &lt;code&gt;docker exec&lt;/code&gt;, &lt;code&gt;docker stop&lt;/code&gt;, &lt;code&gt;docker start&lt;/code&gt;, and &lt;code&gt;docker rm&lt;/code&gt; are the rest of the daily toolkit.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--rm&lt;/code&gt; auto-deletes a container when it exits, which keeps throwaway runs from piling up.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Docker installed and working. If &lt;code&gt;docker run hello-world&lt;/code&gt; prints "Hello from Docker!", you are set. If not, start with &lt;a href="https://www.techdevmantra.com/guides/install-docker-macos-windows-wsl2-linux" rel="noopener noreferrer"&gt;Install Docker on macOS, Windows (WSL2), and Linux&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;On Linux, either be in the &lt;code&gt;docker&lt;/code&gt; group or prefix these commands with &lt;code&gt;sudo&lt;/code&gt;. The install guide covers the group step.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Images versus containers, in one minute
&lt;/h2&gt;

&lt;p&gt;This is the one idea that makes everything else click. An &lt;strong&gt;image&lt;/strong&gt; is a packaged, read-only snapshot: an application plus everything it needs to run. A &lt;strong&gt;container&lt;/strong&gt; is a live, running copy created from that image, with its own writable layer on top. You can start ten containers from the same image, and each one is independent.&lt;/p&gt;

&lt;p&gt;When you run &lt;code&gt;docker run nginx&lt;/code&gt;, Docker looks for the &lt;code&gt;nginx&lt;/code&gt; image locally, pulls it from &lt;a href="https://hub.docker.com/" rel="noopener noreferrer"&gt;Docker Hub&lt;/a&gt; (the default public registry, home to thousands of ready to run images) if it is missing, then creates and starts a container from it. Let us do exactly that, but with a real web server you can open in a browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start a container
&lt;/h2&gt;

&lt;p&gt;Run the official &lt;code&gt;nginx&lt;/code&gt; web server, in the background, with its port published to your machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:80 &lt;span class="nt"&gt;--name&lt;/span&gt; web nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three flags are doing the work here. &lt;code&gt;-d&lt;/code&gt; (detached) runs the container in the background and hands you back your prompt. &lt;code&gt;-p 8080:80&lt;/code&gt; maps port 8080 on your machine to port 80 inside the container, where nginx listens. &lt;code&gt;--name web&lt;/code&gt; gives the container a name so you do not have to use its ID for every later command.&lt;/p&gt;

&lt;p&gt;On the first run, Docker pulls the image, then prints the new container's full ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Status: Downloaded newer image for nginx:latest
22bc7448b0bc864f0fbe6fce4966fa99a07ca7e017a11d6bcdadf5d738f7cb51
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  See it running
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker ps&lt;/code&gt; lists running containers. Here is the row for the one we just started (columns trimmed to fit):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CONTAINER ID   IMAGE     STATUS         PORTS                    NAMES
22bc7448b0bc   nginx     Up Less than a second   0.0.0.0:8080-&amp;gt;80/tcp     web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;0.0.0.0:8080-&amp;gt;80/tcp&lt;/code&gt; is the port mapping in action. Note the short container ID (&lt;code&gt;22bc7448b0bc&lt;/code&gt;); it is just the first 12 characters of the full ID from the run command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Talk to it
&lt;/h2&gt;

&lt;p&gt;The container is serving nginx on port 8080. Ask it for a page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:8080 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; title
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;title&amp;gt;Welcome to nginx!&amp;lt;/title&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a real HTTP response from software running inside the container. Open &lt;code&gt;http://localhost:8080&lt;/code&gt; in a browser and you will see the nginx welcome page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Look inside
&lt;/h2&gt;

&lt;p&gt;Two commands cover most of what you need to inspect a running container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read its logs&lt;/strong&gt; with &lt;code&gt;docker logs&lt;/code&gt;. nginx writes each request to its access log, so the &lt;code&gt;curl&lt;/code&gt; above shows up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker logs web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;172.17.0.1 - - [10/Sep/2026:10:12:07 +0000] "GET / HTTP/1.1" 200 896 "-" "curl/8.5.0" "-"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Get a shell inside&lt;/strong&gt; with &lt;code&gt;docker exec&lt;/code&gt;. The &lt;code&gt;-it&lt;/code&gt; flags give you an interactive terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; web sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you are inside the container. Poke around, then type &lt;code&gt;exit&lt;/code&gt; to leave. For a quick one-off you can also run a single command without an interactive shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;web &lt;span class="nb"&gt;whoami
&lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;web nginx &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;root
nginx version: nginx/1.31.5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;exec&lt;/code&gt; runs inside a container that is already running.&lt;/strong&gt; It does not start a new one. If the container is stopped, start it first. This is the difference between &lt;code&gt;docker run&lt;/code&gt; (create a new container) and &lt;code&gt;docker exec&lt;/code&gt; (step into an existing one), which trips up a lot of beginners.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Stop, start, and see stopped containers
&lt;/h2&gt;

&lt;p&gt;Stop the container. Docker asks nginx to shut down gracefully and returns the name once it has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker stop web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A stopped container does not vanish. It still exists, just not running. &lt;code&gt;docker ps&lt;/code&gt; alone will not show it, but &lt;code&gt;docker ps -a&lt;/code&gt; will:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker ps &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAMES     STATUS
web       Exited (0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Exited (0)&lt;/code&gt; means it stopped cleanly (exit code 0). Because the container still exists, you can start it again without recreating it, and it keeps its name and configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker start web
docker ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAMES     STATUS         PORTS
web       Up Less than a second   0.0.0.0:8080-&amp;gt;80/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Clean up
&lt;/h2&gt;

&lt;p&gt;When you are done, remove the container. A running container will not be removed unless you force it, so &lt;code&gt;-f&lt;/code&gt; stops and removes in one step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;docker ps -a&lt;/code&gt; no longer lists &lt;code&gt;web&lt;/code&gt;. The container is gone; the &lt;code&gt;nginx&lt;/code&gt; image stays cached locally for next time.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stopped containers pile up.&lt;/strong&gt; Every &lt;code&gt;docker run&lt;/code&gt; without &lt;code&gt;--rm&lt;/code&gt; leaves a container behind after it exits, and they accumulate quietly. Run &lt;code&gt;docker ps -a&lt;/code&gt; now and then to see them, and remove the ones you do not need.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For anything short-lived, skip the cleanup entirely with &lt;code&gt;--rm&lt;/code&gt;, which deletes the container the moment it exits. This runs a tiny Alpine Linux container, prints one line, and removes itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; alpine &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"hello from a throwaway container"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hello from a throwaway container
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, &lt;code&gt;docker ps -a&lt;/code&gt; shows nothing new. The container ran and cleaned up after itself. This is the pattern for one-off commands and scripts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lifecycle at a glance
&lt;/h2&gt;

&lt;p&gt;That is the whole loop you will use every day:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;docker run&lt;/code&gt; creates and starts a container (add &lt;code&gt;-d&lt;/code&gt;, &lt;code&gt;-p&lt;/code&gt;, &lt;code&gt;--name&lt;/code&gt;, &lt;code&gt;--rm&lt;/code&gt; as needed).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker ps&lt;/code&gt; / &lt;code&gt;docker ps -a&lt;/code&gt; list running / all containers.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker logs&lt;/code&gt; reads its output.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker exec -it &amp;lt;name&amp;gt; sh&lt;/code&gt; opens a shell inside it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker stop&lt;/code&gt; / &lt;code&gt;docker start&lt;/code&gt; pause and resume it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker rm -f&lt;/code&gt; removes it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Common gotchas
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "port is already allocated"
&lt;/h3&gt;

&lt;p&gt;Something else is using host port 8080. Either stop that process or publish a different port, for example &lt;code&gt;-p 8081:80&lt;/code&gt;, then browse to 8081.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;docker exec&lt;/code&gt; says the container is not running
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;exec&lt;/code&gt; only works on a running container. Check &lt;code&gt;docker ps -a&lt;/code&gt;; if it shows &lt;code&gt;Exited&lt;/code&gt;, run &lt;code&gt;docker start &amp;lt;name&amp;gt;&lt;/code&gt; first.&lt;/p&gt;

&lt;h3&gt;
  
  
  The name is already in use
&lt;/h3&gt;

&lt;p&gt;Container names are unique. If &lt;code&gt;docker run --name web&lt;/code&gt; fails because &lt;code&gt;web&lt;/code&gt; exists (even stopped), remove the old one with &lt;code&gt;docker rm web&lt;/code&gt; or pick a new name.&lt;/p&gt;

&lt;h3&gt;
  
  
  Changes inside a container disappear
&lt;/h3&gt;

&lt;p&gt;Anything you write inside a container is lost when it is removed. That is by design. Persisting data needs volumes, which is a later topic in this series.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;You can now drive a single container through its whole life. Real applications are rarely one container though; they are an app plus a database plus a cache, wired together. That is what Docker Compose is for.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next in this series:&lt;/strong&gt; Docker Compose, where we bring up a web app, Postgres, and Redis together with one &lt;code&gt;docker compose up&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Verified on 2026-09-10 on a real Ubuntu 24.04.5 LTS system running Docker Engine 29.8.0. Every command in this post was run in order against the official &lt;code&gt;nginx&lt;/code&gt; image (container &lt;code&gt;22bc7448b0bc&lt;/code&gt;, nginx 1.31.5) and the &lt;code&gt;alpine&lt;/code&gt; image: &lt;code&gt;docker run -d -p 8080:80 --name web nginx&lt;/code&gt;, &lt;code&gt;docker ps&lt;/code&gt;, &lt;code&gt;curl localhost:8080&lt;/code&gt; returning the nginx welcome page, &lt;code&gt;docker logs&lt;/code&gt; showing the captured &lt;code&gt;GET / 200&lt;/code&gt;, &lt;code&gt;docker exec&lt;/code&gt; returning &lt;code&gt;root&lt;/code&gt; and the nginx version, &lt;code&gt;docker stop&lt;/code&gt; (Exited 0), &lt;code&gt;docker start&lt;/code&gt;, &lt;code&gt;docker rm -f&lt;/code&gt;, and a &lt;code&gt;--rm&lt;/code&gt; throwaway run.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>selfhosting</category>
      <category>linux</category>
    </item>
    <item>
      <title>Operating Containers: Healthchecks, Resource Limits, Restart Policies, and Env Config</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Sun, 04 Oct 2026 14:00:01 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/operating-containers-healthchecks-resource-limits-restart-policies-and-env-config-367h</link>
      <guid>https://dev.to/shubham_sharma_94/operating-containers-healthchecks-resource-limits-restart-policies-and-env-config-367h</guid>
      <description>&lt;p&gt;A container that runs is not the same as a container that behaves. In development, "it started" is enough. In production you want more: the container should know when it is broken, stay within a memory and CPU budget, come back on its own after a crash, and get its config cleanly without secrets baked into the image. This post adds all four to a real stack, with commands and output captured on a live Docker Engine.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;healthcheck&lt;/strong&gt; lets Docker report a container as healthy or unhealthy, and Compose can hold a dependent back until its dependency is healthy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource limits&lt;/strong&gt; (&lt;code&gt;--memory&lt;/code&gt;, &lt;code&gt;--cpus&lt;/code&gt;, or &lt;code&gt;deploy.resources.limits&lt;/code&gt; in Compose) cap what a container can use. Exceed memory and it is killed with exit code 137.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;restart policy&lt;/strong&gt; (&lt;code&gt;restart: unless-stopped&lt;/code&gt; or &lt;code&gt;on-failure&lt;/code&gt;) brings a crashed container back automatically.&lt;/li&gt;
&lt;li&gt;Load config from an &lt;strong&gt;env file&lt;/strong&gt; so it stays out of the image, and know the precedence: a &lt;code&gt;-e&lt;/code&gt; flag beats &lt;code&gt;--env-file&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Docker installed and running. See &lt;a href="https://www.techdevmantra.com/guides/install-docker-macos-windows-wsl2-linux" rel="noopener noreferrer"&gt;Install Docker on macOS, Windows (WSL2), and Linux&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Comfort with Compose, from &lt;a href="https://www.techdevmantra.com/guides/docker-compose-multi-service-stack" rel="noopener noreferrer"&gt;Docker Compose: Run a Multi-Service Stack&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Get the code.&lt;/strong&gt; The full stack for this post (compose file, health endpoint, env file) is in the &lt;a href="https://github.com/Ssharma94Eie/docker-foundations" rel="noopener noreferrer"&gt;docker-foundations&lt;/a&gt; repo, under &lt;a href="https://github.com/Ssharma94Eie/docker-foundations/tree/main/07-operating" rel="noopener noreferrer"&gt;&lt;code&gt;07-operating/&lt;/code&gt;&lt;/a&gt;. Clone it to follow along.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Healthchecks: does the container actually work?
&lt;/h2&gt;

&lt;p&gt;A running container is not necessarily a working one. A web server can be up while its process is deadlocked. A healthcheck is a command Docker runs inside the container on an interval; if it passes, the container is &lt;code&gt;healthy&lt;/code&gt;, if it fails enough times, &lt;code&gt;unhealthy&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is the smallest demonstration. Run a container whose health depends on a file existing, then create and remove that file to flip its state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; svc &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--health-cmd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"test -f /tmp/ok"&lt;/span&gt; &lt;span class="nt"&gt;--health-interval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2s &lt;span class="nt"&gt;--health-retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="se"&gt;\&lt;/span&gt;
  alpine sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"touch /tmp/ok; sleep 3600"&lt;/span&gt;
docker ps &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"{{.Names}}  {{.Status}}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;svc  Up 5 seconds (healthy)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now break the check by deleting the file, wait for the interval to run, and look again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;svc &lt;span class="nb"&gt;rm&lt;/span&gt; /tmp/ok
docker ps &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"{{.Names}}  {{.Status}}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;svc  Up 12 seconds (unhealthy)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Recreate the file and it recovers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;svc &lt;span class="nb"&gt;touch&lt;/span&gt; /tmp/ok
docker ps &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"{{.Names}}  {{.Status}}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;svc  Up 19 seconds (healthy)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The status column tracks the health state live. In a real image you set this once with a &lt;code&gt;HEALTHCHECK&lt;/code&gt; instruction in the Dockerfile, or a &lt;code&gt;healthcheck:&lt;/code&gt; block in Compose.&lt;/p&gt;

&lt;h3&gt;
  
  
  Health-gated startup in Compose
&lt;/h3&gt;

&lt;p&gt;The real payoff is dependency ordering. In the Compose post we saw that &lt;code&gt;depends_on&lt;/code&gt; waits for a container to start, not to be ready. A healthcheck fixes that: &lt;code&gt;depends_on&lt;/code&gt; with &lt;code&gt;condition: service_healthy&lt;/code&gt; holds a service back until its dependency reports healthy.&lt;/p&gt;

&lt;p&gt;Our stack has a &lt;code&gt;web&lt;/code&gt; service that depends on a Postgres &lt;code&gt;db&lt;/code&gt; with a &lt;code&gt;pg_isready&lt;/code&gt; healthcheck. Bring it up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Container op-demo-db-1   Started
 Container op-demo-db-1   Waiting
 Container op-demo-db-1   Healthy
 Container op-demo-web-1  Starting
 Container op-demo-web-1  Started
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that order: Compose started &lt;code&gt;db&lt;/code&gt;, then &lt;strong&gt;waited&lt;/strong&gt; until it was &lt;strong&gt;healthy&lt;/strong&gt;, and only then started &lt;code&gt;web&lt;/code&gt;. No more racing a database that has not finished booting.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose ps &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"table {{.Service}}&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;{{.Status}}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SERVICE   STATUS
db        Up 7 seconds (healthy)
web       Up 4 seconds (healthy)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Resource limits: stay in budget
&lt;/h2&gt;

&lt;p&gt;By default a container can use all the host's memory and CPU. One runaway process can starve everything else on the box. Limits fix that.&lt;/p&gt;

&lt;h3&gt;
  
  
  Memory
&lt;/h3&gt;

&lt;p&gt;Cap memory with &lt;code&gt;--memory&lt;/code&gt; (and &lt;code&gt;--memory-swap&lt;/code&gt; to also cap swap). When a container tries to exceed its memory limit, the kernel kills it. Watch it happen: this Python container asks for 200MB with a 64MB cap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--name&lt;/span&gt; oom &lt;span class="nt"&gt;--memory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;64m &lt;span class="nt"&gt;--memory-swap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;64m &lt;span class="se"&gt;\&lt;/span&gt;
  python:3-alpine python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"bytearray(200*1024*1024)"&lt;/span&gt;
docker inspect oom &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'OOMKilled={{.State.OOMKilled}}  ExitCode={{.State.ExitCode}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OOMKilled=true  ExitCode=137
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;OOMKilled=true&lt;/code&gt; and exit code &lt;code&gt;137&lt;/code&gt; are the signature of a container killed for exceeding its memory limit. If you ever see a container mysteriously exit with 137, this is almost always why.&lt;/p&gt;

&lt;h3&gt;
  
  
  CPU
&lt;/h3&gt;

&lt;p&gt;Cap CPU with &lt;code&gt;--cpus&lt;/code&gt;. This container runs a busy loop that would otherwise peg a whole core, limited to half a CPU:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; cpuhog &lt;span class="nt"&gt;--cpus&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.5 alpine sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"while true; do :; done"&lt;/span&gt;
docker stats &lt;span class="nt"&gt;--no-stream&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"{{.Name}}  CPU={{.CPUPerc}}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cpuhog  CPU=49.71%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The busy loop is held right at its half-a-CPU ceiling instead of consuming everything.&lt;/p&gt;

&lt;h3&gt;
  
  
  Limits in Compose
&lt;/h3&gt;

&lt;p&gt;In a Compose file you set the same limits per service under &lt;code&gt;deploy.resources.limits&lt;/code&gt;, which &lt;code&gt;docker compose up&lt;/code&gt; applies (you do not need Swarm). After bringing the stack up, you can confirm the limit landed on the container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker inspect op-demo-web-1 &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'memory={{.HostConfig.Memory}}  nanocpus={{.HostConfig.NanoCpus}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;memory=134217728  nanocpus=500000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the 128MB (134217728 bytes) and half-CPU (500000000 nanocpus) budget from the compose file, enforced on the running container.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restart policies: self-healing
&lt;/h2&gt;

&lt;p&gt;Processes crash. A restart policy tells Docker to bring the container back automatically. &lt;code&gt;unless-stopped&lt;/code&gt; restarts it on any exit except a deliberate &lt;code&gt;docker stop&lt;/code&gt;, and &lt;code&gt;on-failure&lt;/code&gt; restarts only on a non-zero exit, optionally up to a retry cap.&lt;/p&gt;

&lt;p&gt;Watch a crashing container recover. This one runs for a few seconds, then exits with an error, with a cap of three retries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; crasher &lt;span class="nt"&gt;--restart&lt;/span&gt; on-failure:3 alpine sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"sleep 3; exit 1"&lt;/span&gt;
docker inspect crasher &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'RestartCount={{.RestartCount}} Status={{.State.Status}}'&lt;/span&gt;
&lt;span class="c"&gt;# ... a few crash-and-restart cycles later ...&lt;/span&gt;
docker inspect crasher &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'RestartCount={{.RestartCount}} Status={{.State.Status}} ExitCode={{.State.ExitCode}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RestartCount=0 Status=running
RestartCount=3 Status=exited ExitCode=1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker restarted the crashing container three times, then stopped because it hit the &lt;code&gt;:3&lt;/code&gt; cap. On a real service you would use &lt;code&gt;restart: unless-stopped&lt;/code&gt; (no cap) so it keeps recovering, which is what our compose file sets on both services.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A restart policy is not a healthcheck.&lt;/strong&gt; A restart policy reacts to the container process exiting. A healthcheck reacts to the app being unresponsive while the process is still up. Production services usually want both: &lt;code&gt;restart: unless-stopped&lt;/code&gt; to recover from crashes, and a &lt;code&gt;healthcheck&lt;/code&gt; so orchestrators and &lt;code&gt;depends_on&lt;/code&gt; know when the app is actually ready.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Env config without secrets in the image
&lt;/h2&gt;

&lt;p&gt;Hardcoding config into an image is a mistake: it bakes environment-specific values (and often secrets) into an artifact you push to a registry. Load them at run time instead. Docker pulls environment values from three places (the image's own &lt;code&gt;ENV&lt;/code&gt;, an &lt;code&gt;--env-file&lt;/code&gt;, and &lt;code&gt;-e&lt;/code&gt; flags), and the precedence matters: a &lt;code&gt;-e&lt;/code&gt; flag beats &lt;code&gt;--env-file&lt;/code&gt;, and both beat a value baked into the image with &lt;code&gt;ENV&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"GREETING=from_env_file&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;ONLY_IN_FILE=yes&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; envfile
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--env-file&lt;/span&gt; envfile &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;GREETING&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;from_flag alpine &lt;span class="nb"&gt;env&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ONLY_IN_FILE=yes
GREETING=from_flag
# (PATH, HOSTNAME, HOME and other standard vars omitted)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GREETING&lt;/code&gt; came out as &lt;code&gt;from_flag&lt;/code&gt;: the explicit &lt;code&gt;-e&lt;/code&gt; won over the file. &lt;code&gt;ONLY_IN_FILE&lt;/code&gt; passed through untouched. The same order holds in Compose: a service's &lt;code&gt;environment:&lt;/code&gt; block overrides its &lt;code&gt;env_file:&lt;/code&gt;. Keep the file (&lt;code&gt;.env&lt;/code&gt;) out of git, commit an &lt;code&gt;.env.example&lt;/code&gt; template, and your secrets never enter the image.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole thing in one Compose file
&lt;/h2&gt;

&lt;p&gt;All four behaviors live together in the stack's &lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;demo"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;256M&lt;/span&gt;
          &lt;span class="na"&gt;cpus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.50"&lt;/span&gt;

  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node:22-alpine&lt;/span&gt;
    &lt;span class="na"&gt;working_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/app&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node server.js&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./app:/app&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:3000"&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;   &lt;span class="c1"&gt;# web starts only once db reports healthy&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wget&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-q&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-O-&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;http://localhost:3000/health&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;||&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exit&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;128M&lt;/span&gt;
          &lt;span class="na"&gt;cpus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.50"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker compose up -d&lt;/code&gt;, and both services come up healthy, budgeted, self-healing, and configured from &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common gotchas
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Container exits with code 137
&lt;/h3&gt;

&lt;p&gt;It was killed for exceeding its memory limit (or received a SIGKILL). Check &lt;code&gt;docker inspect --format '{{.State.OOMKilled}}'&lt;/code&gt;. If true, raise the limit or fix the leak.&lt;/p&gt;

&lt;h3&gt;
  
  
  Healthcheck passes but the app is broken
&lt;/h3&gt;

&lt;p&gt;Your check is too shallow. &lt;code&gt;pg_isready&lt;/code&gt; proves Postgres accepts connections; a check that only pings the port proves less. Point the healthcheck at an endpoint that actually exercises the app, like a &lt;code&gt;/health&lt;/code&gt; route that touches its dependencies.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;depends_on: condition: service_healthy&lt;/code&gt; does nothing
&lt;/h3&gt;

&lt;p&gt;The dependency has no &lt;code&gt;healthcheck&lt;/code&gt;, so it can never report &lt;code&gt;healthy&lt;/code&gt;. Add a &lt;code&gt;healthcheck:&lt;/code&gt; block to the service you are waiting on.&lt;/p&gt;

&lt;h3&gt;
  
  
  The container keeps restarting forever
&lt;/h3&gt;

&lt;p&gt;A restart policy plus a container that crashes instantly is a crash loop. Use &lt;code&gt;on-failure:&amp;lt;n&amp;gt;&lt;/code&gt; to cap retries while you debug, check &lt;code&gt;docker logs&lt;/code&gt;, and fix the underlying crash before switching back to &lt;code&gt;unless-stopped&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  My &lt;code&gt;.env&lt;/code&gt; changes are ignored
&lt;/h3&gt;

&lt;p&gt;Compose reads &lt;code&gt;.env&lt;/code&gt; from the project directory for variable substitution, and &lt;code&gt;env_file:&lt;/code&gt; for what a service sees. Make sure you edited the right one, and recreate the container (&lt;code&gt;docker compose up -d&lt;/code&gt; again) so it picks up the new values.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;Your containers now behave: they report health, respect limits, recover from crashes, and take config cleanly. The next step is making them safe.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next in this series:&lt;/strong&gt; Docker Security Basics, running as non-root, mounting the filesystem read-only, dropping capabilities, and keeping secrets out of images.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Verified on 2026-09-11 on a real Ubuntu 24.04.5 LTS system (arm64) with Docker Engine 29.8.0 and Compose v5.5.1. Captured: healthcheck transitions healthy/unhealthy/healthy; a Compose stack where db went Started to Waiting to Healthy before web started; a memory-limited container OOM-killed with ExitCode 137; a &lt;code&gt;--cpus=0.5&lt;/code&gt; busy loop held at 49.71% in &lt;code&gt;docker stats&lt;/code&gt;; a crashing container restarted to RestartCount 3 under &lt;code&gt;on-failure:3&lt;/code&gt;; &lt;code&gt;-e&lt;/code&gt; overriding &lt;code&gt;--env-file&lt;/code&gt;; and &lt;code&gt;deploy.resources.limits&lt;/code&gt; applied as 134217728 bytes / 500000000 nanocpus on the web container.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>selfhosting</category>
      <category>linux</category>
    </item>
    <item>
      <title>Where Your Data Lives: Docker Volumes, Bind Mounts, and Networks</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Thu, 01 Oct 2026 14:00:09 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/where-your-data-lives-docker-volumes-bind-mounts-and-networks-1e2p</link>
      <guid>https://dev.to/shubham_sharma_94/where-your-data-lives-docker-volumes-bind-mounts-and-networks-1e2p</guid>
      <description>&lt;p&gt;Here is a fact that surprises people the first time it bites them: when you remove a container, everything it wrote is gone. A container's filesystem is a temporary layer that is thrown away with the container. Restart your database container after an upgrade and, if you did nothing special, your data went with it.&lt;/p&gt;

&lt;p&gt;The fix is to keep data outside the container. Docker gives you two ways to do that, volumes and bind mounts, plus a private network so your containers can find each other. This post covers all three, with real commands and output captured on a live Docker Engine.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A container's own filesystem is ephemeral. Anything you want to keep must live in a &lt;strong&gt;volume&lt;/strong&gt; or a &lt;strong&gt;bind mount&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Named volumes&lt;/strong&gt; are managed by Docker and are the right default for databases and app data. They survive &lt;code&gt;docker rm&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bind mounts&lt;/strong&gt; map a specific host folder into the container, ideal for serving or editing live files during development.&lt;/li&gt;
&lt;li&gt;Back up a volume by running a throwaway container that tars its contents. Restore is the same trick in reverse.&lt;/li&gt;
&lt;li&gt;On a &lt;strong&gt;user-defined network&lt;/strong&gt;, containers reach each other by name through Docker's built-in DNS. The default bridge does not do this.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Get the code.&lt;/strong&gt; The compose file, scripts, and net-demo are in the &lt;a href="https://github.com/Ssharma94Eie/docker-foundations" rel="noopener noreferrer"&gt;docker-foundations&lt;/a&gt; repo, under &lt;a href="https://github.com/Ssharma94Eie/docker-foundations/tree/main/06-volumes-networks" rel="noopener noreferrer"&gt;&lt;code&gt;06-volumes-networks/&lt;/code&gt;&lt;/a&gt;. Clone it to follow along.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Docker installed and running. See &lt;a href="https://www.techdevmantra.com/guides/install-docker-macos-windows-wsl2-linux" rel="noopener noreferrer"&gt;Install Docker on macOS, Windows (WSL2), and Linux&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;The basics of starting and removing containers from &lt;a href="https://www.techdevmantra.com/guides/run-your-first-docker-containers" rel="noopener noreferrer"&gt;Run Your First Containers&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Named volumes: data that survives
&lt;/h2&gt;

&lt;p&gt;A named volume is storage that Docker manages for you, separate from any container. You attach it with &lt;code&gt;-v &amp;lt;name&amp;gt;:&amp;lt;path-in-container&amp;gt;&lt;/code&gt;. Let us prove it survives a container being destroyed and recreated.&lt;/p&gt;

&lt;p&gt;Create a volume and start Postgres on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker volume create demo_pgdata
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; pg &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;demo &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;demo &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;demo &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; demo_pgdata:/var/lib/postgresql/data postgres:16-alpine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Give Postgres a few seconds to initialize, then write a row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;pg psql &lt;span class="nt"&gt;-U&lt;/span&gt; demo &lt;span class="nt"&gt;-d&lt;/span&gt; demo &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"CREATE TABLE notes(id serial primary key, body text);
      INSERT INTO notes(body) VALUES ('survives a container rebuild');"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CREATE TABLE
INSERT 0 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now destroy the container completely, then start a brand new one on the same volume and read the row back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; pg
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; pg2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;demo &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;demo &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;demo &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; demo_pgdata:/var/lib/postgresql/data postgres:16-alpine
docker &lt;span class="nb"&gt;exec &lt;/span&gt;pg2 psql &lt;span class="nt"&gt;-U&lt;/span&gt; demo &lt;span class="nt"&gt;-d&lt;/span&gt; demo &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"SELECT * FROM notes;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; id |             body
----+------------------------------
  1 | survives a container rebuild
(1 row)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container was deleted and a new one created, and the data was still there. That is the whole point of a named volume. This is also why, in the Compose post, Postgres used a &lt;code&gt;pgdata&lt;/code&gt; volume: without it, tearing the stack down and bringing it back up would start from an empty database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bind mounts: live files from the host
&lt;/h2&gt;

&lt;p&gt;A bind mount maps a specific folder on your machine into the container. Changes on the host show up instantly inside the container, which is perfect for development. The syntax is the same &lt;code&gt;-v&lt;/code&gt;, but the left side is a host path instead of a volume name.&lt;/p&gt;

&lt;p&gt;Serve a folder with nginx:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;h1&amp;gt;version one&amp;lt;/h1&amp;gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ./site/index.html
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; web &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:80 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;/site"&lt;/span&gt;:/usr/share/nginx/html:ro nginx:alpine
curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;h1&amp;gt;version one&amp;lt;/h1&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now edit the file on the host and request the page again. No restart, no rebuild:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;h1&amp;gt;version two, edited on the host&amp;lt;/h1&amp;gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ./site/index.html
curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;h1&amp;gt;version two, edited on the host&amp;lt;/h1&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container is serving your host files directly. The &lt;code&gt;:ro&lt;/code&gt; on the end mounts them read only, which is a good habit when the container has no business writing back.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which one do I use?&lt;/strong&gt; Use a &lt;strong&gt;named volume&lt;/strong&gt; for data the application owns, like a database, a cache, or uploaded files. You do not care where on disk it lives, only that it persists and is easy to back up. Use a &lt;strong&gt;bind mount&lt;/strong&gt; when you need a specific host folder, most often your source code during development or a config file you edit by hand. Rule of thumb: named volumes for state, bind mounts for code and config.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Back up and restore a volume
&lt;/h2&gt;

&lt;p&gt;Because a named volume is just a directory Docker manages, you can back it up by running a tiny throwaway container that mounts the volume and tars it to a folder on your host.&lt;/p&gt;

&lt;p&gt;Back it up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; demo_pgdata:/data:ro &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;/backup"&lt;/span&gt;:/backup &lt;span class="se"&gt;\&lt;/span&gt;
  alpine &lt;span class="nb"&gt;tar &lt;/span&gt;czf /backup/demo_pgdata.tar.gz &lt;span class="nt"&gt;-C&lt;/span&gt; /data &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; backup/demo_pgdata.tar.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;6.4M    backup/demo_pgdata.tar.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;alpine&lt;/code&gt; container mounts the volume at &lt;code&gt;/data&lt;/code&gt; and your host folder at &lt;code&gt;/backup&lt;/code&gt;, tars one into the other, and exits. Restore is the same move in reverse: create a fresh volume and untar into it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker volume create demo_pgdata_restored
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; demo_pgdata_restored:/data &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;/backup"&lt;/span&gt;:/backup &lt;span class="se"&gt;\&lt;/span&gt;
  alpine sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"tar xzf /backup/demo_pgdata.tar.gz -C /data"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prove the restored copy is real by running Postgres on it and checking the row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; pg3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;demo &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;demo &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;demo &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; demo_pgdata_restored:/var/lib/postgresql/data postgres:16-alpine
docker &lt;span class="nb"&gt;exec &lt;/span&gt;pg3 psql &lt;span class="nt"&gt;-U&lt;/span&gt; demo &lt;span class="nt"&gt;-d&lt;/span&gt; demo &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"SELECT body FROM notes;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;             body
------------------------------
 survives a container rebuild
(1 row)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can wrap these two commands in small &lt;code&gt;backup-volume.sh&lt;/code&gt; and &lt;code&gt;restore-volume.sh&lt;/code&gt; scripts to reuse the pattern on any volume.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For a live database, prefer its own dump tool.&lt;/strong&gt; Tarring the volume is perfect for static data and for moving a volume between machines. For a database that is actively being written to, a consistent backup is safer with the database's own tool (&lt;code&gt;pg_dump&lt;/code&gt; for Postgres) so you do not capture a half-written file. Tar the volume when the container is stopped, or use &lt;code&gt;pg_dump&lt;/code&gt; while it runs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Networks: containers that find each other by name
&lt;/h2&gt;

&lt;p&gt;By default, containers you start with &lt;code&gt;docker run&lt;/code&gt; land on the default bridge network, where they can only reach each other by IP address. Create your own user-defined network and you get something much better: Docker runs an embedded DNS server, so containers resolve each other by name.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker network create appnet
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; web1 &lt;span class="nt"&gt;--network&lt;/span&gt; appnet nginx:alpine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From another container on the same network, look up &lt;code&gt;web1&lt;/code&gt; by name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--network&lt;/span&gt; appnet busybox nslookup web1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Address:    127.0.0.11:53
Non-authoritative answer:
Name:   web1
Address: 172.18.0.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That answer came from Docker's built-in resolver at &lt;code&gt;127.0.0.11&lt;/code&gt;. And because the name resolves, you can just talk to the service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--network&lt;/span&gt; appnet alpine &lt;span class="se"&gt;\&lt;/span&gt;
  sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"wget -qO- http://web1 | grep -i '&amp;lt;title&amp;gt;'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;title&amp;gt;Welcome to nginx!&amp;lt;/title&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker network inspect&lt;/code&gt; shows who is attached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker network inspect appnet &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{range .Containers}}{{.Name}} -&amp;gt; {{.IPv4Address}}{{println}}{{end}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;web1 -&amp;gt; 172.18.0.2/16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is exactly why Compose works the way it does: Compose puts your services on a user-defined network automatically, which is why the API in the Compose post could connect to &lt;code&gt;postgres&lt;/code&gt; by name without ever knowing its IP.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The default bridge has no name resolution.&lt;/strong&gt; Containers started without &lt;code&gt;--network&lt;/code&gt; share the default bridge, where DNS by name does not work; you would have to use IP addresses, which change. Always create a user-defined network (or use Compose, which does it for you) when containers need to talk.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Doing it in Compose
&lt;/h2&gt;

&lt;p&gt;You rarely type these flags by hand for a real app. In Compose, a named volume and a bind mount look like this, and the network is created for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pgdata:/var/lib/postgresql/data&lt;/span&gt;   &lt;span class="c1"&gt;# named volume: managed by Docker, survives rebuilds&lt;/span&gt;

  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:alpine&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:80"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./site:/usr/share/nginx/html:ro&lt;/span&gt;   &lt;span class="c1"&gt;# bind mount: serves live files from the host&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker compose up&lt;/code&gt; creates the &lt;code&gt;pgdata&lt;/code&gt; volume, wires the bind mount, and puts both services on a shared network where &lt;code&gt;web&lt;/code&gt; could reach &lt;code&gt;db&lt;/code&gt; by name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common gotchas
&lt;/h2&gt;

&lt;h3&gt;
  
  
  My data still disappeared
&lt;/h3&gt;

&lt;p&gt;You probably used an anonymous volume or none at all. Check that the &lt;code&gt;-v name:/path&lt;/code&gt; left side is a real volume name, and that the path on the right is where the app actually writes (&lt;code&gt;/var/lib/postgresql/data&lt;/code&gt; for Postgres). &lt;code&gt;docker volume ls&lt;/code&gt; shows your named volumes.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;docker compose down -v&lt;/code&gt; wiped my database
&lt;/h3&gt;

&lt;p&gt;That is what &lt;code&gt;-v&lt;/code&gt; does: it removes named volumes along with the containers. Use plain &lt;code&gt;docker compose down&lt;/code&gt; to keep your data, and reserve &lt;code&gt;-v&lt;/code&gt; for a deliberate clean slate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bind mount shows an empty directory
&lt;/h3&gt;

&lt;p&gt;The host path was wrong or did not exist. Docker creates a missing bind-mount source as an empty directory rather than failing, so a typo silently mounts nothing. Use an absolute path (or &lt;code&gt;$PWD/...&lt;/code&gt;) and confirm the folder exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  Permission denied inside the container
&lt;/h3&gt;

&lt;p&gt;The container process runs as a specific user, and bind-mounted host files keep their host ownership. If the container cannot read or write them, line up the ownership, or use a named volume (which Docker initializes with the right permissions) instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Containers cannot reach each other
&lt;/h3&gt;

&lt;p&gt;They are on the default bridge. Put them on the same user-defined network (&lt;code&gt;docker network create&lt;/code&gt; then &lt;code&gt;--network&lt;/code&gt;, or let Compose do it) so name resolution works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;Your data now outlives your containers and your services can find each other. The series continues with getting images off your machine and operating containers day to day.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next in this series:&lt;/strong&gt; Docker Images and Registries, tagging and pushing an image so you can pull it anywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Verified on 2026-09-10 on a real Ubuntu 24.04.5 LTS system (arm64) with Docker Engine 29.8.0. Captured: a named volume (&lt;code&gt;demo_pgdata&lt;/code&gt;) retaining a Postgres row across a full &lt;code&gt;docker rm -f&lt;/code&gt; and recreate; a bind mount serving &lt;code&gt;version one&lt;/code&gt; then &lt;code&gt;version two&lt;/code&gt; after a host edit with no restart; a volume backed up to a 6.4M tar.gz via an alpine sidecar and restored into a new volume with the row intact; and on a user-defined network, &lt;code&gt;nslookup web1&lt;/code&gt; resolving to 172.18.0.2 via Docker's &lt;code&gt;127.0.0.11&lt;/code&gt; resolver plus &lt;code&gt;wget http://web1&lt;/code&gt; returning the nginx welcome page.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>selfhosting</category>
      <category>linux</category>
    </item>
    <item>
      <title>Lean Docker Images: Multi-Stage Builds and Layer Caching</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Mon, 28 Sep 2026 14:00:02 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/lean-docker-images-multi-stage-builds-and-layer-caching-1gfk</link>
      <guid>https://dev.to/shubham_sharma_94/lean-docker-images-multi-stage-builds-and-layer-caching-1gfk</guid>
      <description>&lt;p&gt;In the last post we ran a Node API with Compose using a stock &lt;code&gt;node&lt;/code&gt; image and a bind mount. That is great for development, but it is not how you ship. To deploy, you build an image: a self-contained artifact with your code and its dependencies baked in. The catch is that the obvious way to write that Dockerfile produces an image that is enormous and slow to rebuild.&lt;/p&gt;

&lt;p&gt;In this post we build the same API two ways. First the naive version that just works, then an optimized multi-stage version, and we measure the difference. Every number below was captured on a real Docker Engine.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A naive &lt;code&gt;FROM node:22&lt;/code&gt; image here was &lt;strong&gt;1.62GB&lt;/strong&gt;. The multi-stage, Alpine-based version was &lt;strong&gt;233MB&lt;/strong&gt;, about 7 times smaller.&lt;/li&gt;
&lt;li&gt;Most of the size is the &lt;strong&gt;base image&lt;/strong&gt;. &lt;code&gt;node:22&lt;/code&gt; is 1.62GB; &lt;code&gt;node:22-alpine&lt;/code&gt; is 227MB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order your Dockerfile for the cache.&lt;/strong&gt; Copy &lt;code&gt;package.json&lt;/code&gt; and the lockfile first, install, then copy the rest. A code change should not reinstall your dependencies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-stage builds&lt;/strong&gt; let you install and build in one stage and ship only the result, so build tools never reach the final image.&lt;/li&gt;
&lt;li&gt;Add a &lt;strong&gt;&lt;code&gt;.dockerignore&lt;/code&gt;&lt;/strong&gt; so junk like &lt;code&gt;node_modules&lt;/code&gt; and &lt;code&gt;.git&lt;/code&gt; never enters the build.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Get the code.&lt;/strong&gt; Both Dockerfiles and the app are in the &lt;a href="https://github.com/Ssharma94Eie/docker-foundations" rel="noopener noreferrer"&gt;docker-foundations&lt;/a&gt; repo, under &lt;a href="https://github.com/Ssharma94Eie/docker-foundations/tree/main/04-dockerfiles" rel="noopener noreferrer"&gt;&lt;code&gt;04-dockerfiles/&lt;/code&gt;&lt;/a&gt;. Clone it to follow along.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Docker installed and running. See &lt;a href="https://www.techdevmantra.com/guides/install-docker-macos-windows-wsl2-linux" rel="noopener noreferrer"&gt;Install Docker on macOS, Windows (WSL2), and Linux&lt;/a&gt; if you need it.&lt;/li&gt;
&lt;li&gt;The app from &lt;a href="https://www.techdevmantra.com/guides/docker-compose-multi-service-stack" rel="noopener noreferrer"&gt;Docker Compose: Run a Multi-Service Stack&lt;/a&gt;. We reuse its &lt;code&gt;server.js&lt;/code&gt; and &lt;code&gt;package.json&lt;/code&gt;. You do not need Postgres or Redis running to build the image; we are packaging the app, not starting it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Attempt 1: the naive Dockerfile
&lt;/h2&gt;

&lt;p&gt;Here is the version almost everyone writes first. It works, and that is the problem: it hides how much room there is to improve.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# The naive way: everything works, nothing is optimized.&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:22&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build it and check the size:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-f&lt;/span&gt; Dockerfile.naive &lt;span class="nt"&gt;-t&lt;/span&gt; demo-api:naive &lt;span class="nb"&gt;.&lt;/span&gt;
docker images demo-api &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"table {{.Repository}}:{{.Tag}}&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;{{.Size}}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;REPOSITORY:TAG   SIZE
demo-api:naive   1.62GB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;1.62GB for a tiny API.&lt;/strong&gt; Two problems are baked in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The base image is huge.&lt;/strong&gt; &lt;code&gt;FROM node:22&lt;/code&gt; pulls the full Debian-based Node image, which is 1.62GB on its own. Our app adds almost nothing on top, so the base is essentially the whole image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The layer order defeats the cache.&lt;/strong&gt; &lt;code&gt;COPY . .&lt;/code&gt; copies your source before &lt;code&gt;npm install&lt;/code&gt; runs. Docker caches each instruction, but a layer's cache is invalid the moment any input changes. Since your source changes constantly, that &lt;code&gt;COPY&lt;/code&gt; busts on every edit, which forces &lt;code&gt;npm install&lt;/code&gt; to run again every single build.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Attempt 2: the optimized multi-stage Dockerfile
&lt;/h2&gt;

&lt;p&gt;Now the version you actually want. It fixes both problems: a small base, and an order that keeps dependencies cached.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# The optimized way: multi-stage build, slim base, production deps only,&lt;/span&gt;
&lt;span class="c"&gt;# and cache-friendly ordering so code changes do not reinstall dependencies.&lt;/span&gt;

&lt;span class="c"&gt;# Stage 1: install only production dependencies.&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;deps&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json package-lock.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev

&lt;span class="c"&gt;# Stage 2: the final runtime image. It carries only the app and its&lt;/span&gt;
&lt;span class="c"&gt;# production node_modules, on a small Alpine base.&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:22-alpine&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NODE_ENV=production&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=deps /app/node_modules ./node_modules&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; server.js ./&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a &lt;code&gt;.dockerignore&lt;/code&gt; so the build context stays clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node_modules
npm-debug.log
.git
.gitignore
.env
Dockerfile*
.dockerignore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build it and compare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-f&lt;/span&gt; Dockerfile &lt;span class="nt"&gt;-t&lt;/span&gt; demo-api:slim &lt;span class="nb"&gt;.&lt;/span&gt;
docker images demo-api &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"table {{.Repository}}:{{.Tag}}&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;{{.Size}}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;REPOSITORY:TAG   SIZE
demo-api:slim    233MB
demo-api:naive   1.62GB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;233MB versus 1.62GB.&lt;/strong&gt; Same app, about one seventh the size. Three changes did the work.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. A smaller base image
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;node:22-alpine&lt;/code&gt; is 227MB against &lt;code&gt;node:22&lt;/code&gt; at 1.62GB. Alpine is a minimal Linux distribution, so you drop a whole Debian userland you were not using. For most Node apps, Alpine is all you need. When you want to go even smaller and more locked down, distroless images are the next step, but Alpine is the easy, safe default.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Multi-stage: build in one stage, ship another
&lt;/h3&gt;

&lt;p&gt;The file has two &lt;code&gt;FROM&lt;/code&gt; lines, so two stages. The &lt;code&gt;deps&lt;/code&gt; stage installs dependencies. The final stage starts fresh from a clean Alpine and copies in only what it needs with &lt;code&gt;COPY --from=deps&lt;/code&gt;. Anything that existed only in the build stage never reaches the final image: caches, temporary files, dev tooling. Our app is simple, but this is the pattern that keeps compilers and build caches out of production images for TypeScript, Go, and everything else.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Production dependencies only
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;npm ci --omit=dev&lt;/code&gt; installs just the runtime dependencies and skips devDependencies like test runners and linters. (This demo only declares two runtime dependencies, so &lt;code&gt;--omit=dev&lt;/code&gt; changes nothing here; it is the habit that pays off the moment you add real devDependencies.) Combined with &lt;code&gt;ENV NODE_ENV=production&lt;/code&gt;, the final image carries only what it needs to run. &lt;code&gt;npm ci&lt;/code&gt; also requires a committed &lt;code&gt;package-lock.json&lt;/code&gt;, which makes the install reproducible: the same versions every time, on every machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove the cache works
&lt;/h2&gt;

&lt;p&gt;Size is the headline, but the day-to-day win is rebuild speed. Make a one-line change and rebuild both images:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"// a small change"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; server.js
docker build &lt;span class="nt"&gt;--progress&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;plain &lt;span class="nt"&gt;-f&lt;/span&gt; Dockerfile.naive &lt;span class="nt"&gt;-t&lt;/span&gt; demo-api:naive &lt;span class="nb"&gt;.&lt;/span&gt;   &lt;span class="c"&gt;# naive&lt;/span&gt;
docker build &lt;span class="nt"&gt;--progress&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;plain &lt;span class="nt"&gt;-f&lt;/span&gt; Dockerfile &lt;span class="nt"&gt;-t&lt;/span&gt; demo-api:slim &lt;span class="nb"&gt;.&lt;/span&gt;           &lt;span class="c"&gt;# optimized&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the &lt;strong&gt;naive&lt;/strong&gt; build, the &lt;code&gt;COPY . .&lt;/code&gt; layer sees the changed file, so its cache is invalid and everything after it re-runs, including &lt;code&gt;npm install&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#7 [3/4] COPY . .
#7 DONE 0.1s
#8 [4/4] RUN npm install
#8 DONE 1.6s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the &lt;strong&gt;optimized&lt;/strong&gt; build, the code change only affects the final &lt;code&gt;COPY server.js&lt;/code&gt;. The dependency stage did not change, so BuildKit reuses it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#6 [deps 3/4] COPY package.json package-lock.json ./
#6 CACHED
#8 [deps 4/4] RUN npm ci --omit=dev
#8 CACHED
#9 [stage-1 3/4] COPY --from=deps /app/node_modules ./node_modules
#9 CACHED
#10 [stage-1 4/4] COPY server.js ./
#10 DONE 0.2s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;CACHED&lt;/code&gt; on &lt;code&gt;npm ci&lt;/code&gt; is the whole point. Your dependencies are installed once and reused until &lt;code&gt;package.json&lt;/code&gt; or the lockfile actually changes.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Be honest about the numbers.&lt;/strong&gt; Because this demo app depends on just two small packages, the rebuild time difference here is only about a second. The payoff scales with your dependency tree: on a real app with hundreds of packages, a cached dependency layer turns a minute-long reinstall into an instant rebuild. The image-size win, on the other hand, is large no matter what.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Look at the layers
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;docker history&lt;/code&gt; shows what each layer contributes to the final image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;history &lt;/span&gt;demo-api:slim
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SIZE     CREATED BY
0B       CMD ["node" "server.js"]
0B       EXPOSE [3000/tcp]
4.1kB    COPY server.js ./
5.71MB   COPY /app/node_modules ./node_modules
0B       ENV NODE_ENV=production
0B       WORKDIR /app
...      (node:22-alpine base layers, ~227MB)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your application is the top few layers: a 4.1kB source file and a 5.71MB &lt;code&gt;node_modules&lt;/code&gt;. Everything else is the Alpine base. There is nothing left to trim without changing the base image itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common gotchas
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;npm ci&lt;/code&gt; fails with "no package-lock.json found"
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;npm ci&lt;/code&gt; requires a committed lockfile. Generate one with &lt;code&gt;npm install&lt;/code&gt; (or &lt;code&gt;npm install --package-lock-only&lt;/code&gt;) and commit &lt;code&gt;package-lock.json&lt;/code&gt;. This is a feature: it is what makes the build reproducible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Alpine build fails on a native module
&lt;/h3&gt;

&lt;p&gt;Some npm packages compile native code and expect the GNU C library, while Alpine uses musl. If a dependency fails to build on Alpine, either add the build toolchain in the deps stage (&lt;code&gt;apk add --no-cache python3 make g++&lt;/code&gt;) or switch the base to &lt;code&gt;node:22-slim&lt;/code&gt;, a smaller Debian image that keeps glibc.&lt;/p&gt;

&lt;h3&gt;
  
  
  The image is still huge
&lt;/h3&gt;

&lt;p&gt;Check three things: are you on an Alpine or slim base, are you running &lt;code&gt;npm ci --omit=dev&lt;/code&gt; rather than a full install, and do you have a &lt;code&gt;.dockerignore&lt;/code&gt; so &lt;code&gt;node_modules&lt;/code&gt; and &lt;code&gt;.git&lt;/code&gt; are not copied in. Missing any one of these puts the weight back.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reordering did not help
&lt;/h3&gt;

&lt;p&gt;Make sure &lt;code&gt;COPY package.json package-lock.json ./&lt;/code&gt; comes before the source &lt;code&gt;COPY&lt;/code&gt;, and that only those two files are in that first copy. If you &lt;code&gt;COPY . .&lt;/code&gt; before installing, you are back to busting the cache on every change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;You now have a lean, cache-friendly image. The next step is getting it off your machine so you (or your CI) can pull it anywhere.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next in this series:&lt;/strong&gt; Docker Images and Registries, where we tag the image and push it to Docker Hub and GitHub Container Registry, then pull it back and inspect what is really inside.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Verified on 2026-09-10 on a real Ubuntu 24.04.5 LTS system (arm64, an OrbStack VM on Apple Silicon; on amd64 the exact byte counts differ slightly) with Docker Engine 29.8.0 (BuildKit). Both images were built from the same app: the naive &lt;code&gt;node:22&lt;/code&gt; image measured 1.62GB and the multi-stage &lt;code&gt;node:22-alpine&lt;/code&gt; image 233MB (&lt;code&gt;docker images&lt;/code&gt;), base images node:22 (1.62GB) and node:22-alpine (227MB). After a one-line change to server.js, the naive rebuild re-ran &lt;code&gt;RUN npm install&lt;/code&gt; while the optimized rebuild showed &lt;code&gt;CACHED&lt;/code&gt; on the deps stage (&lt;code&gt;COPY package.json package-lock.json&lt;/code&gt; and &lt;code&gt;RUN npm ci --omit=dev&lt;/code&gt;), re-running only &lt;code&gt;COPY server.js&lt;/code&gt;. The &lt;code&gt;docker history&lt;/code&gt; layer sizes are from that build.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>selfhosting</category>
      <category>linux</category>
    </item>
    <item>
      <title>Docker Compose: Run a Multi-Service Stack (Web + Postgres + Redis)</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Fri, 25 Sep 2026 14:00:05 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/docker-compose-run-a-multi-service-stack-web-postgres-redis-4fo2</link>
      <guid>https://dev.to/shubham_sharma_94/docker-compose-run-a-multi-service-stack-web-postgres-redis-4fo2</guid>
      <description>&lt;p&gt;Running one container is easy. Real applications are never one container though. A typical web app is a server, a database, and a cache, all running together and talking to each other. Starting each one by hand with the right flags, in the right order, on the right network, gets old immediately.&lt;/p&gt;

&lt;p&gt;Docker Compose fixes that. You describe every service once in a single YAML file, then bring the whole stack up with one command. In this post we build a real three service stack, a Node API backed by Postgres and Redis, and drive it end to end: up, prove the services are talking, then down. Every command and its output below was captured on a real Docker Engine.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Compose describes a multi-service app in one &lt;code&gt;docker-compose.yml&lt;/code&gt;, brought up with &lt;code&gt;docker compose up&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Containers on the same Compose network reach each other by &lt;strong&gt;service name&lt;/strong&gt; (the API connects to &lt;code&gt;postgres:5432&lt;/code&gt; and &lt;code&gt;redis:6379&lt;/code&gt;, no IPs).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish only what the outside world needs.&lt;/strong&gt; Here only the API gets a host port; Postgres and Redis stay private on the Compose network.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;depends_on&lt;/code&gt; controls start order, not readiness. Your app still needs to retry the first connection.&lt;/li&gt;
&lt;li&gt;Keep secrets in a &lt;code&gt;.env&lt;/code&gt; file that you never commit, and reference them from the Compose file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Get the code.&lt;/strong&gt; Every file in this post is in the &lt;a href="https://github.com/Ssharma94Eie/docker-foundations" rel="noopener noreferrer"&gt;docker-foundations&lt;/a&gt; repo, under &lt;a href="https://github.com/Ssharma94Eie/docker-foundations/tree/main/03-compose-stack" rel="noopener noreferrer"&gt;&lt;code&gt;03-compose-stack/&lt;/code&gt;&lt;/a&gt;. Clone it to follow along.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Docker installed and running. If you need it, see &lt;a href="https://www.techdevmantra.com/guides/install-docker-macos-windows-wsl2-linux" rel="noopener noreferrer"&gt;Install Docker on macOS, Windows (WSL2), and Linux&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Comfort with single containers helps. If &lt;code&gt;docker run&lt;/code&gt;, &lt;code&gt;ps&lt;/code&gt;, and &lt;code&gt;logs&lt;/code&gt; are new, start with &lt;a href="https://www.techdevmantra.com/guides/run-your-first-docker-containers" rel="noopener noreferrer"&gt;Run Your First Containers&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Modern Docker ships Compose as the &lt;code&gt;docker compose&lt;/code&gt; subcommand. Check yours with &lt;code&gt;docker compose version&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Compose actually is
&lt;/h2&gt;

&lt;p&gt;Compose is one YAML file plus one command. Instead of a pile of &lt;code&gt;docker run&lt;/code&gt; lines, you declare each service (its image, ports, environment, volumes, and dependencies) in &lt;code&gt;docker-compose.yml&lt;/code&gt;, and Compose creates them together on a shared private network. On that network, every service can reach every other by its service name, which is the piece that makes multi-container apps sane.&lt;/p&gt;

&lt;p&gt;We will build this stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;api&lt;/strong&gt;: a small Node HTTP server on port 3000, published to your machine on 8080.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;postgres&lt;/strong&gt;: a Postgres 16 database, private to the stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redis&lt;/strong&gt;: a Redis 7 cache, private to the stack.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The API writes a row to Postgres and increments a counter in Redis on every request, which proves all three are wired together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The project
&lt;/h2&gt;

&lt;p&gt;Five small files. Here is the layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;compose-stack/
  docker-compose.yml
  .env.example
  app/
    package.json
    server.js
  db/
    init.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  docker-compose.yml
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node:22-alpine&lt;/span&gt;
    &lt;span class="na"&gt;working_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/app&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sh -c "npm install --no-audit --no-fund &amp;amp;&amp;amp; node server.js"&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:3000"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}&lt;/span&gt;
      &lt;span class="na"&gt;REDIS_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis://redis:6379&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./app:/app&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis&lt;/span&gt;

  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_USER}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_DB}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pgdata:/var/lib/postgresql/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro&lt;/span&gt;

  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:7-alpine&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth pointing out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;version:&lt;/code&gt; line.&lt;/strong&gt; Modern Compose treats the old top-level &lt;code&gt;version&lt;/code&gt; field as obsolete and ignores it. If you see it in older tutorials, you can delete it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service-name hostnames.&lt;/strong&gt; The API's &lt;code&gt;DATABASE_URL&lt;/code&gt; points at the host &lt;code&gt;postgres&lt;/code&gt;, and &lt;code&gt;REDIS_URL&lt;/code&gt; at &lt;code&gt;redis&lt;/code&gt;. Those are the service names, and Compose resolves them on the shared network. You never hardcode an IP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only the API publishes a port.&lt;/strong&gt; &lt;code&gt;ports: "8080:3000"&lt;/code&gt; exposes the API to your machine. Postgres and Redis have no &lt;code&gt;ports&lt;/code&gt; entry, so they are reachable only by other services in the stack, not from your host or the internet. That is exactly what you want for a database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;depends_on&lt;/code&gt;&lt;/strong&gt; makes Compose start Postgres and Redis before the API. Read the readiness note below, because this does less than it looks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A named volume, &lt;code&gt;pgdata&lt;/code&gt;,&lt;/strong&gt; keeps the database on disk so data survives &lt;code&gt;down&lt;/code&gt; and restarts. &lt;code&gt;init.sql&lt;/code&gt; is mounted into Postgres's init directory and runs once when the volume is first created.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  app/server.js
&lt;/h3&gt;

&lt;p&gt;The app is deliberately tiny. On each request it bumps a Redis counter and inserts a Postgres row, then returns both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;redis&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REDIS_URL&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;redis error:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// depends_on waits for the container to start, not for the service to be ready,&lt;/span&gt;
&lt;span class="c1"&gt;// so retry the first connection to Postgres and Redis.&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;tries&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`waiting for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tries&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;): &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; not ready after &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tries&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; tries`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withRetry&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT 1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;postgres&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withRetry&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;redis&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connected to postgres and redis&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;visits&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inserted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INSERT INTO hits (path) VALUES ($1) RETURNING id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT COUNT(*)::int AS count FROM hits&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;API is talking to Postgres and Redis over the Compose network&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;redis_visits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;visits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;postgres_hit_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;inserted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;postgres_total_hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="mi"&gt;2&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;api listening on port 3000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;redis.on("error", ...)&lt;/code&gt; line matters more than it looks. In node-redis, the client emits an &lt;code&gt;error&lt;/code&gt; event while it cannot reach the server, and an error event with no listener is thrown and takes the process down. That is exactly the window this app is built to survive, so the handler stays. Each request is wrapped in a &lt;code&gt;try/catch&lt;/code&gt; too, so a transient query failure returns a 500 instead of crashing the server.&lt;/p&gt;

&lt;p&gt;Notice there is no Dockerfile here. The API uses the stock &lt;code&gt;node:22-alpine&lt;/code&gt; image, bind-mounts your code in, and runs &lt;code&gt;npm install&lt;/code&gt; at startup. That is fine for local development and keeps this post focused on Compose. Building a proper image with a Dockerfile is the next post in the series.&lt;/p&gt;

&lt;h3&gt;
  
  
  db/init.sql and .env.example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env.example  (copy to .env, which you never commit)&lt;/span&gt;
&lt;span class="nv"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;demo
&lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;change_me_in_a_real_project
&lt;span class="nv"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compose automatically reads a file named &lt;code&gt;.env&lt;/code&gt; in the project directory and substitutes those values into &lt;code&gt;${POSTGRES_USER}&lt;/code&gt; and friends. Commit &lt;code&gt;.env.example&lt;/code&gt; so people know which variables to set, and keep the real &lt;code&gt;.env&lt;/code&gt; out of git.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bring the stack up
&lt;/h2&gt;

&lt;p&gt;Copy the example env file, then start everything in the background:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compose creates the network and volume, then starts the services in dependency order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Network compose-stack_default   Created
 Volume compose-stack_pgdata     Created
 Container compose-stack-postgres-1  Started
 Container compose-stack-redis-1     Started
 Container compose-stack-api-1       Started
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check what is running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SERVICE    IMAGE                STATUS         PORTS
api        node:22-alpine       Up 23 seconds  0.0.0.0:8080-&amp;gt;3000/tcp
postgres   postgres:16-alpine   Up 23 seconds  5432/tcp
redis      redis:7-alpine       Up 23 seconds  6379/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the PORTS column. Only &lt;code&gt;api&lt;/code&gt; has a &lt;code&gt;0.0.0.0:8080-&amp;gt;3000&lt;/code&gt; mapping, so only the API is reachable from your machine. Postgres and Redis show their internal ports with no host mapping: private to the stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove the services are talking
&lt;/h2&gt;

&lt;p&gt;Hit the API twice, at two different paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:8080/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"API is talking to Postgres and Redis over the Compose network"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"redis_visits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"postgres_hit_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"postgres_total_hits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:8080/hello
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"API is talking to Postgres and Redis over the Compose network"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"redis_visits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"postgres_hit_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"postgres_total_hits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both counters went up, which means each request really did reach both stores. You can confirm the data landed by reading each service directly with &lt;code&gt;docker compose exec&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;redis redis-cli GET visits
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;postgres psql &lt;span class="nt"&gt;-U&lt;/span&gt; demo &lt;span class="nt"&gt;-d&lt;/span&gt; demo &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"SELECT id, path FROM hits ORDER BY id;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; id |  path
----+--------
  1 | /
  2 | /hello
(2 rows)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Redis counter is at 2, and Postgres has both requests recorded, one row per call. Three separate containers, cooperating over a private network, from one YAML file.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the wiring works
&lt;/h2&gt;

&lt;p&gt;The mechanism is the Compose network. When Compose brings the stack up, it puts all services on one user-defined bridge network and registers each service name as a DNS name on it. So inside the API container, &lt;code&gt;postgres&lt;/code&gt; resolves to the Postgres container and &lt;code&gt;redis&lt;/code&gt; to the Redis container. That is why &lt;code&gt;DATABASE_URL&lt;/code&gt; can say &lt;code&gt;@postgres:5432&lt;/code&gt; and just work, with no IP addresses and no links.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;depends_on&lt;/code&gt; is start order, not readiness.&lt;/strong&gt; Compose starts Postgres and Redis before the API, but "started" only means the container process launched, not that Postgres is accepting connections yet. On a cold start the database can need a second or two to come up, and if the API tries to connect first it will fail. That is why the API retries its first connection: if the database is slow to accept connections, you will see a few &lt;code&gt;waiting for postgres&lt;/code&gt; lines in the API logs before &lt;code&gt;connected&lt;/code&gt;. Do not rely on &lt;code&gt;depends_on&lt;/code&gt; alone; make your app tolerant of a not-ready dependency, or add a healthcheck.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once it is up, the API logs confirm it connected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose logs api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;api-1  | connected to postgres and redis
api-1  | api listening on port 3000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Tear it down
&lt;/h2&gt;

&lt;p&gt;Stop and remove the whole stack in one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose down
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Container compose-stack-api-1       Removed
 Container compose-stack-postgres-1  Removed
 Container compose-stack-redis-1     Removed
 Network compose-stack_default       Removed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker compose down&lt;/code&gt; removes the containers and the network but keeps the named volume, so your database survives. When you want a truly clean slate, including the data, add the volume flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose down &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;down -v&lt;/code&gt; deletes your data.&lt;/strong&gt; The &lt;code&gt;-v&lt;/code&gt; flag removes named volumes too, which wipes the Postgres database. Use it when you want a fresh start, not on anything you care about.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Common gotchas
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "port is already allocated" on 8080
&lt;/h3&gt;

&lt;p&gt;Another process is using host port 8080. Change the API's mapping to something free, for example &lt;code&gt;"8081:3000"&lt;/code&gt;, and browse to 8081.&lt;/p&gt;

&lt;h3&gt;
  
  
  The API crashes or logs endless "waiting for postgres"
&lt;/h3&gt;

&lt;p&gt;Usually the credentials do not match. The API's &lt;code&gt;DATABASE_URL&lt;/code&gt; and the Postgres service must use the same &lt;code&gt;POSTGRES_USER&lt;/code&gt;, &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt;, and &lt;code&gt;POSTGRES_DB&lt;/code&gt;. Since both read from &lt;code&gt;.env&lt;/code&gt;, make sure &lt;code&gt;.env&lt;/code&gt; exists (copy it from &lt;code&gt;.env.example&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Changes to init.sql do not take effect
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;init.sql&lt;/code&gt; only runs when the Postgres data volume is first created. If you already ran the stack, the volume exists, so edits are ignored. Recreate it with &lt;code&gt;docker compose down -v&lt;/code&gt; and bring the stack back up.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;version&lt;/code&gt; warning on &lt;code&gt;up&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;If Compose warns that the &lt;code&gt;version&lt;/code&gt; field is obsolete, delete the top-level &lt;code&gt;version:&lt;/code&gt; line. Modern Compose does not use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;You now have a reproducible multi-service stack that starts and stops with one command. The API still installs its dependencies at runtime, which is slow and not how you ship to production.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next in this series:&lt;/strong&gt; Lean Docker Images, where we write a real Dockerfile for the API, then cut its size and build time with multi-stage builds and layer caching.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Verified on 2026-09-10 on a real Ubuntu 24.04.5 LTS system with Docker Engine 29.8.0 and Docker Compose v5.5.1. The full stack (node:22-alpine, postgres:16-alpine, redis:7-alpine) was brought up with &lt;code&gt;docker compose up -d&lt;/code&gt;, and the outputs shown come from that run (trimmed for width, and shown under the &lt;code&gt;compose-stack&lt;/code&gt; project name this post uses): &lt;code&gt;docker compose ps&lt;/code&gt;, the two &lt;code&gt;curl&lt;/code&gt; round-trips (Redis counter and Postgres rows both incrementing), &lt;code&gt;redis-cli GET visits&lt;/code&gt; returning 2, the &lt;code&gt;psql&lt;/code&gt; query returning both rows, the api logs, and &lt;code&gt;docker compose down&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>selfhosting</category>
      <category>linux</category>
    </item>
    <item>
      <title>Docker Security Basics: Non-Root, Read-Only, Image Scanning, and Secrets</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Tue, 22 Sep 2026 14:00:05 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/docker-security-basics-non-root-read-only-image-scanning-and-secrets-57f3</link>
      <guid>https://dev.to/shubham_sharma_94/docker-security-basics-non-root-read-only-image-scanning-and-secrets-57f3</guid>
      <description>&lt;p&gt;A container that runs is not a container that is safe. By default, a container runs as root, with a writable filesystem, the full set of Linux capabilities, and often a secret or two baked into the image. All of it is fixable. This post hardens a container step by step, without breaking it, and measures each change on a real Docker Engine.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scan your image.&lt;/strong&gt; A slim base is not just smaller, it is safer: &lt;code&gt;node:22&lt;/code&gt; carried 533 high or critical OS CVEs, &lt;code&gt;node:22-alpine&lt;/code&gt; carried 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not run as root.&lt;/strong&gt; Add a non-root &lt;code&gt;USER&lt;/code&gt; (or &lt;code&gt;user:&lt;/code&gt; in Compose) so a container breakout is not instant host root.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the root filesystem read-only&lt;/strong&gt; with &lt;code&gt;read_only: true&lt;/code&gt;, and add a &lt;code&gt;tmpfs&lt;/code&gt; for the few paths that must be writable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drop all capabilities&lt;/strong&gt; with &lt;code&gt;cap_drop: ALL&lt;/code&gt;, add back only what you need, and set &lt;code&gt;no-new-privileges&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep secrets out of the image.&lt;/strong&gt; Use Compose &lt;code&gt;secrets:&lt;/code&gt; at run time and &lt;code&gt;RUN --mount=type=secret&lt;/code&gt; at build time. Never &lt;code&gt;ENV&lt;/code&gt; or &lt;code&gt;COPY&lt;/code&gt; a secret.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Docker installed and running. See &lt;a href="https://www.techdevmantra.com/guides/install-docker-macos-windows-wsl2-linux" rel="noopener noreferrer"&gt;Install Docker on macOS, Windows (WSL2), and Linux&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;The lean multi-stage image from &lt;a href="https://www.techdevmantra.com/guides/lean-docker-images-multi-stage-builds" rel="noopener noreferrer"&gt;Lean Docker Images&lt;/a&gt;; we build on that slim base here.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Get the code.&lt;/strong&gt; The hardened Dockerfiles, compose file, and Trivy wrapper for this post are in the &lt;a href="https://github.com/Ssharma94Eie/docker-foundations" rel="noopener noreferrer"&gt;docker-foundations&lt;/a&gt; repo, under &lt;a href="https://github.com/Ssharma94Eie/docker-foundations/tree/main/08-security" rel="noopener noreferrer"&gt;&lt;code&gt;08-security/&lt;/code&gt;&lt;/a&gt;. Clone it to follow along.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Scan first: what is actually in your image
&lt;/h2&gt;

&lt;p&gt;Before hardening anything, look at what you are shipping. Trivy scans an image and reports known CVEs. The easiest way to run it is as a container. Here it scans the full Debian-based &lt;code&gt;node:22&lt;/code&gt;, counting only HIGH and CRITICAL OS-package vulnerabilities:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; /var/run/docker.sock:/var/run/docker.sock &lt;span class="se"&gt;\&lt;/span&gt;
  aquasec/trivy:latest image &lt;span class="nt"&gt;--severity&lt;/span&gt; HIGH,CRITICAL &lt;span class="nt"&gt;--scanners&lt;/span&gt; vuln node:22
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node:22 (debian 12.15)
Total: 533 (HIGH: 501, CRITICAL: 32)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;533 high or critical vulnerabilities, 32 of them critical, before you have added a single line of your own code. Now scan the slim Alpine variant instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node:22-alpine (alpine 3.24.1)
Total: 2 (HIGH: 2, CRITICAL: 0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two. Same Node.js, a fraction of the attack surface, because Alpine ships almost none of the Debian userland those CVEs live in. (Trivy also reports the Node and npm packages bundled in the runtime, 11 either way; those live in the language layer, not the OS, so the base image does not change them.) This is the security half of the argument for a slim base, on top of the size win from the lean-images post. The &lt;code&gt;scan.sh&lt;/code&gt; wrapper in the repo runs exactly this scan on any image.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not run as root
&lt;/h2&gt;

&lt;p&gt;By default, the process inside a container runs as root. If an attacker escapes the container, or if a bind-mounted host path is involved, that root can become host root. The fix is a non-root user. The &lt;code&gt;node&lt;/code&gt; images ship one called &lt;code&gt;node&lt;/code&gt; (uid 1000); switch to it with a single &lt;code&gt;USER&lt;/code&gt; line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Hardened: slim base, and run as the built-in non-root 'node' user.&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:22-alpine&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; server.js ./&lt;/span&gt;
&lt;span class="c"&gt;# node:22-alpine ships a non-root 'node' user (uid 1000)&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; node&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build and check who the container runs as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; secure:hardened &lt;span class="nb"&gt;.&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; secure:hardened &lt;span class="nb"&gt;whoami
&lt;/span&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; secure:hardened &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node
1000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not root. That one line removes the most common and most dangerous default.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not put an inline comment on a &lt;code&gt;USER&lt;/code&gt; line.&lt;/strong&gt; Dockerfiles have no inline comments: &lt;code&gt;USER node   # ...&lt;/code&gt; sets the username to the whole string including the &lt;code&gt;#&lt;/code&gt;, and the container fails to start with "unable to find user". Put comments on their own line.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Make the filesystem read-only
&lt;/h2&gt;

&lt;p&gt;Most containers never need to write to their own filesystem at run time. If yours does not, mount it read-only so an attacker cannot drop a script or tamper with binaries. Compare a normal container with a read-only one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; alpine &lt;span class="nb"&gt;touch&lt;/span&gt; /test.txt
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--read-only&lt;/span&gt; alpine &lt;span class="nb"&gt;touch&lt;/span&gt; /test.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# first command: writes fine
# second command:
touch: /test.txt: Read-only file system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the paths that need to be writable (a cache, &lt;code&gt;/tmp&lt;/code&gt;), add a &lt;code&gt;tmpfs&lt;/code&gt;, which is an in-memory scratch space that never touches the image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--read-only&lt;/span&gt; &lt;span class="nt"&gt;--tmpfs&lt;/span&gt; /tmp alpine &lt;span class="nb"&gt;touch&lt;/span&gt; /tmp/test.txt   &lt;span class="c"&gt;# succeeds&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Drop capabilities
&lt;/h2&gt;

&lt;p&gt;A root process inside a container still holds a set of Linux capabilities, fine-grained powers like changing file ownership or binding low ports. Most apps need none of them. Drop them all and see the difference. &lt;code&gt;chown&lt;/code&gt; needs &lt;code&gt;CAP_CHOWN&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; alpine &lt;span class="nb"&gt;chown &lt;/span&gt;nobody /tmp
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--cap-drop&lt;/span&gt; ALL alpine &lt;span class="nb"&gt;chown &lt;/span&gt;nobody /tmp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# first command: chown succeeds
# second command:
chown: /tmp: Operation not permitted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;cap_drop: ALL&lt;/code&gt; the container cannot perform privileged operations, even as root. Add back only what you actually need with &lt;code&gt;cap_add&lt;/code&gt;. Pair it with &lt;code&gt;no-new-privileges:true&lt;/code&gt;, which stops a process from ever gaining more privileges (for example through a setuid binary).&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep secrets out of the image
&lt;/h2&gt;

&lt;p&gt;This is the one that bites teams hardest, because the mistake is invisible until someone pulls your image. The wrong way is to pass a token as a build arg and store it in the environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# ANTI-PATTERN. Do NOT do this. Shown only to prove the leak.&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; API_TOKEN&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; API_TOKEN=$API_TOKEN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build that and the token is permanently in the image's metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;history&lt;/span&gt; &lt;span class="nt"&gt;--no-trunc&lt;/span&gt; secure:baked | &lt;span class="nb"&gt;grep &lt;/span&gt;API_TOKEN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;API_TOKEN=supersecret-token-value
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anyone who can pull the image can read it. The right way for build-time secrets is BuildKit's &lt;code&gt;--mount=type=secret&lt;/code&gt;, which exposes the secret only during one &lt;code&gt;RUN&lt;/code&gt; and never writes it to a layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# syntax=docker/dockerfile:1&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;secret,id&lt;span class="o"&gt;=&lt;/span&gt;api_token &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /run/secrets/api_token &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"secret was available at build time"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;--secret&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api_token,src&lt;span class="o"&gt;=&lt;/span&gt;api_token.txt &lt;span class="nt"&gt;-f&lt;/span&gt; Dockerfile.buildsecret &lt;span class="nt"&gt;-t&lt;/span&gt; secure:buildsecret &lt;span class="nb"&gt;.&lt;/span&gt;
docker &lt;span class="nb"&gt;history&lt;/span&gt; &lt;span class="nt"&gt;--no-trunc&lt;/span&gt; secure:buildsecret | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"supersecret-token-value"&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; secure:buildsecret &lt;span class="nb"&gt;cat&lt;/span&gt; /run/secrets/api_token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0
cat: can't open '/run/secrets/api_token': No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero occurrences in the history, and the file does not exist in the final image. The secret did its job during the build and vanished. For run-time secrets, Compose has a &lt;code&gt;secrets:&lt;/code&gt; block that mounts a file into the container at &lt;code&gt;/run/secrets/&lt;/code&gt;, without putting it in the environment where &lt;code&gt;docker inspect&lt;/code&gt; or a crash log would expose it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it all together
&lt;/h2&gt;

&lt;p&gt;The stack's &lt;code&gt;docker-compose.yml&lt;/code&gt; applies every one of these at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secure-demo&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:3000"&lt;/span&gt;
    &lt;span class="na"&gt;read_only&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;            &lt;span class="c1"&gt;# the container's root filesystem is read-only&lt;/span&gt;
    &lt;span class="na"&gt;tmpfs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/tmp&lt;/span&gt;                    &lt;span class="c1"&gt;# a small writable scratch space in memory&lt;/span&gt;
    &lt;span class="na"&gt;cap_drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ALL&lt;/span&gt;                     &lt;span class="c1"&gt;# drop every Linux capability&lt;/span&gt;
    &lt;span class="na"&gt;security_opt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;no-new-privileges:true&lt;/span&gt;  &lt;span class="c1"&gt;# process can never gain more privileges&lt;/span&gt;
    &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;api_token&lt;/span&gt;               &lt;span class="c1"&gt;# mounted at /run/secrets/api_token, not in env&lt;/span&gt;

&lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;api_token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./api_token.txt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bring it up and check the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
curl localhost:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;secure demo. running as uid 1000. secret mounted: true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Non-root, and the secret arrived as a mounted file, not an environment variable. Confirm the hardening holds from inside the container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;web &lt;span class="nb"&gt;env&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; token     &lt;span class="c"&gt;# nothing: the token is not in the environment&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;web &lt;span class="nb"&gt;touch&lt;/span&gt; /oops.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;touch: /oops.txt: Read-only file system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token is nowhere in the environment, and the read-only filesystem refuses the write. That is a container an attacker has very little room to work with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hardening checklist
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Success&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For any container you run in production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start from a slim base and scan it (&lt;code&gt;node:22-alpine&lt;/code&gt; had 2 OS CVEs versus 533 for &lt;code&gt;node:22&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Run as a non-root user (&lt;code&gt;USER&lt;/code&gt;, or &lt;code&gt;user:&lt;/code&gt; in Compose).&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;read_only: true&lt;/code&gt; and add a &lt;code&gt;tmpfs&lt;/code&gt; for writable paths.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cap_drop: ALL&lt;/code&gt;, then &lt;code&gt;cap_add&lt;/code&gt; only what you need.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;no-new-privileges:true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Keep secrets out of the image: Compose &lt;code&gt;secrets:&lt;/code&gt; at run time, &lt;code&gt;RUN --mount=type=secret&lt;/code&gt; at build time. Never &lt;code&gt;ENV&lt;/code&gt; or &lt;code&gt;COPY&lt;/code&gt; a secret.&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Common gotchas
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The app breaks under &lt;code&gt;read_only: true&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;It is trying to write somewhere. Find the path (logs, cache, a pid file) and add it as a &lt;code&gt;tmpfs&lt;/code&gt; or a named volume, rather than removing &lt;code&gt;read_only&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The app breaks under &lt;code&gt;cap_drop: ALL&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;It needs a capability. The common one is binding a port below 1024, which needs &lt;code&gt;CAP_NET_BIND_SERVICE&lt;/code&gt;. Add just that back with &lt;code&gt;cap_add&lt;/code&gt;, or publish a high port and map it. Better still, run the app on a high port and let the proxy handle 80 and 443.&lt;/p&gt;

&lt;h3&gt;
  
  
  A non-root container cannot write to a mounted volume
&lt;/h3&gt;

&lt;p&gt;The volume is owned by root on the host. Set the volume's ownership to your container's uid, or use a named volume, which Docker initializes with the right permissions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trivy reports vulnerabilities you cannot fix
&lt;/h3&gt;

&lt;p&gt;Some CVEs have no patched version yet. Focus on HIGH and CRITICAL with a fix available, keep your base image updated, and rescan regularly rather than chasing an empty report.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;Your container is now scanned, non-root, read-only, capability-stripped, and free of baked-in secrets. The last foundational question is which engine to run it with.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next in this series:&lt;/strong&gt; Docker vs Podman, a hands-on comparison of the daemonless, rootless alternative and what actually changes when you migrate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Verified on 2026-09-11 on a real Ubuntu 24.04.5 LTS system (arm64) with Docker Engine 29.8.0 (BuildKit). Captured: Trivy scans of node:22 (533 HIGH/CRITICAL OS CVEs, 32 critical) versus node:22-alpine (2, 0 critical); the hardened image running as uid 1000 (whoami node); &lt;code&gt;--read-only&lt;/code&gt; refusing a write and &lt;code&gt;--tmpfs&lt;/code&gt; allowing one; &lt;code&gt;--cap-drop ALL&lt;/code&gt; turning a working &lt;code&gt;chown&lt;/code&gt; into "Operation not permitted"; a BuildKit &lt;code&gt;--mount=type=secret&lt;/code&gt; leaving zero occurrences of the token in &lt;code&gt;docker history&lt;/code&gt; and no secret file in the image, versus an &lt;code&gt;ARG&lt;/code&gt;/&lt;code&gt;ENV&lt;/code&gt; build that printed &lt;code&gt;API_TOKEN=supersecret-token-value&lt;/code&gt; in history; and the hardened Compose stack serving as uid 1000 with the secret mounted, no token in the environment, and a read-only root filesystem refusing &lt;code&gt;touch /oops.txt&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>selfhosting</category>
      <category>linux</category>
    </item>
    <item>
      <title>Capstone: Dockerize Your Own App End to End</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Sat, 19 Sep 2026 14:00:01 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/capstone-dockerize-your-own-app-end-to-end-eij</link>
      <guid>https://dev.to/shubham_sharma_94/capstone-dockerize-your-own-app-end-to-end-eij</guid>
      <description>&lt;p&gt;This is the capstone of the Docker Foundations series. Instead of one new concept, it pulls the whole track together: you take a real app from source code to a live URL served over HTTPS, built into a lean image, hardened the way you would actually run it, pushed to a registry by CI, and deployed to a server. Every command below was run for real, first locally and then on an actual Ubuntu server.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Get the code: the app, the multi-stage Dockerfile, the production Compose file, the Caddy config, and the CI workflow are in the &lt;a href="https://github.com/Ssharma94Eie/docker-foundations/tree/main/10-capstone" rel="noopener noreferrer"&gt;10-capstone folder&lt;/a&gt; of the companion repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Built and run on Docker Engine 29.8.0 with Compose v5.5.1, then deployed on a separate Ubuntu 24.04 server. The output shown is the genuine result.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The app
&lt;/h2&gt;

&lt;p&gt;The app is deliberately small but real: an Express server backed by Postgres. It serves a page with a visit counter (so it has to read and write a database) and exposes a &lt;code&gt;/healthz&lt;/code&gt; endpoint for health checks. The full source is in the repo; the only thing that matters here is that it is a normal app with a real dependency, not a toy that prints hello.&lt;/p&gt;

&lt;h2&gt;
  
  
  A lean image with a multi-stage build
&lt;/h2&gt;

&lt;p&gt;The Dockerfile builds in two stages. The first installs production dependencies against the lockfile; the second copies just those dependencies and the source into a minimal runtime image that runs as a non-root user and declares a healthcheck:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# ---- deps: install production dependencies against the lockfile ----&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;deps&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; app/package.json app/package-lock.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev

&lt;span class="c"&gt;# ---- runtime: minimal image, non-root, with a healthcheck ----&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NODE_ENV=production&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=deps /app/node_modules ./node_modules&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; app/ ./&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; node&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;
&lt;span class="k"&gt;HEALTHCHECK&lt;/span&gt;&lt;span class="s"&gt; --interval=10s --timeout=3s --start-period=5s --retries=3 \&lt;/span&gt;
  CMD wget -q -O /dev/null http://localhost:3000/healthz || exit 1
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build it and check the size:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; tdm-capstone:local &lt;span class="nb"&gt;.&lt;/span&gt;
docker images tdm-capstone:local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tdm-capstone:local  233MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the build tooling stays in the first stage, the final image carries only the Alpine base, the production &lt;code&gt;node_modules&lt;/code&gt;, and the app. That is the multi-stage payoff from earlier in the series, applied to a real app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The production stack
&lt;/h2&gt;

&lt;p&gt;A single Compose file wires the app to Postgres and puts Caddy in front of it, and it turns on the operating and security practices from the rest of the series at once. The important parts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${APP_IMAGE:-tdm-capstone:local}&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;   &lt;span class="c1"&gt;# do not start until Postgres is ready&lt;/span&gt;
    &lt;span class="na"&gt;read_only&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;                  &lt;span class="c1"&gt;# the app writes nothing to its own filesystem&lt;/span&gt;
    &lt;span class="na"&gt;tmpfs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/tmp&lt;/span&gt;
    &lt;span class="na"&gt;cap_drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ALL&lt;/span&gt;                          &lt;span class="c1"&gt;# it needs no Linux capabilities&lt;/span&gt;
    &lt;span class="na"&gt;security_opt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;no-new-privileges:true&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wget&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-q&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-O&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/dev/null&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;http://localhost:3000/healthz&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;||&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exit&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cpus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.5"&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;128M&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bring it up, and the health gating sequences the whole stack for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.prod.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.prod.yml ps &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"table {{.Service}}&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;{{.Status}}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SERVICE   STATUS
app       Up 23 seconds (healthy)
caddy     Up 18 seconds
db        Up 28 seconds (healthy)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres becomes healthy first, the app waits for it and then becomes healthy itself, and only then does Caddy start. The app answers over HTTPS through Caddy, and the counter proves it is really talking to Postgres:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-k&lt;/span&gt; https://localhost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;!doctype html&amp;gt;...&amp;lt;p&amp;gt;This page has been served &amp;lt;strong&amp;gt;1&amp;lt;/strong&amp;gt; times.&amp;lt;/p&amp;gt;...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hit it again and the count goes to 2. Now confirm the hardening actually took effect, not just that it is written in the file. The container runs as an unprivileged user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.prod.yml &lt;span class="nb"&gt;exec &lt;/span&gt;app &lt;span class="nb"&gt;id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uid=1000(node) gid=1000(node) groups=1000(node)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its root filesystem is read-only, so a compromised process cannot rewrite the app, while the &lt;code&gt;/tmp&lt;/code&gt; tmpfs stays writable for scratch space:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.prod.yml &lt;span class="nb"&gt;exec &lt;/span&gt;app sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"touch /oops.txt"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;touch: /oops.txt: Read-only file system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the limits and dropped capabilities are real, straight from &lt;code&gt;docker inspect&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ReadonlyRootfs=true  Memory=134217728  NanoCpus=500000000  CapDrop=[ALL]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is 128MB of memory, half a CPU, every Linux capability dropped, and a read-only root, on a container that self-reports health. This one stack applies the Compose, volumes, operating, and security posts together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ship it: build in CI, push to GHCR
&lt;/h2&gt;

&lt;p&gt;You do not build production images by hand on your laptop. A small GitHub Actions workflow builds the image on every push and pushes it to the GitHub Container Registry, authenticating with the token GitHub gives the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build-push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/setup-buildx-action@v3&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.actor }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./10-capstone&lt;/span&gt;
          &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/&amp;lt;you&amp;gt;/tdm-capstone:latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pushing this to the repo ran the job green in about half a minute and published the image with its digest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;build-push  in 26s
pushing manifest for ghcr.io/&amp;lt;you&amp;gt;/tdm-capstone:latest@sha256:d6e8d6b6...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your server can now pull a known, immutable image by tag or digest instead of building on the box. New images published this way are private by default, so to pull one on a server you would first run &lt;code&gt;docker login ghcr.io&lt;/code&gt; (or make the package public in your GitHub package settings).&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploy it with automatic HTTPS
&lt;/h2&gt;

&lt;p&gt;The last step is a real server. Copy this folder up, set the environment, and run the same Compose file. The one new idea is the hostname: &lt;a href="https://sslip.io" rel="noopener noreferrer"&gt;sslip.io&lt;/a&gt; is a free wildcard DNS service where a name like &lt;code&gt;203-0-113-5.sslip.io&lt;/code&gt; resolves to &lt;code&gt;203.0.113.5&lt;/code&gt;, which gives you a real hostname for any IP without buying a domain. Caddy uses that hostname to request a certificate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# .env on the server
SITE_ADDRESS=&amp;lt;your-server-ip-with-dashes&amp;gt;.sslip.io
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.prod.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
curl &lt;span class="nt"&gt;-k&lt;/span&gt; https://&amp;lt;your-server-ip-with-dashes&amp;gt;.sslip.io
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;!doctype html&amp;gt;...&amp;lt;p&amp;gt;This page has been served &amp;lt;strong&amp;gt;1&amp;lt;/strong&amp;gt; times.&amp;lt;/p&amp;gt;...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the app, built from the same Dockerfile, running behind Caddy and answering over HTTPS at a real hostname, on a real server.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;About the certificate: the lab server here has a private IP, which Let's Encrypt cannot reach to validate, so this deploy used Caddy's &lt;code&gt;tls internal&lt;/code&gt; option (a local certificate authority) to prove the HTTPS path end to end. On a real VPS with a public IP, you delete the &lt;code&gt;tls internal&lt;/code&gt; line and Caddy fetches a genuine, browser-trusted Let's Encrypt certificate for your sslip.io hostname automatically, with no domain purchase and no manual certbot step.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Tear it down
&lt;/h2&gt;

&lt;p&gt;Everything is disposable, which is the point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.prod.yml down &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That stops and removes the containers, the network, and the named volumes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you built
&lt;/h2&gt;

&lt;p&gt;You took an app with a database and turned it into a lean, non-root, resource-limited, health-checked image; ran it as a hardened stack behind a reverse proxy with HTTPS; had CI build and publish it to a registry; and deployed it to a server reachable over HTTPS with no domain purchase. That is the entire Docker Foundations series in one project.&lt;/p&gt;

&lt;p&gt;If you worked through the whole track, from installing Docker and running your first containers, through Compose, lean images, volumes and networks, operating, and security, you now have every piece it takes to ship a container you built yourself. That is a genuinely production-shaped skill set, and everything here is in the companion repo for you to clone and run.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>selfhosting</category>
      <category>caddy</category>
      <category>containers</category>
    </item>
    <item>
      <title>Docker Images vs Containers, Explained</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Mon, 14 Sep 2026 10:15:47 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/docker-images-vs-containers-explained-2o0</link>
      <guid>https://dev.to/shubham_sharma_94/docker-images-vs-containers-explained-2o0</guid>
      <description>&lt;p&gt;If you are new to Docker, this is probably the first thing that trips you up: people say "image" and "container" like they mean the same thing, and they absolutely do not. Getting this one distinction straight makes almost everything else about Docker click into place.&lt;/p&gt;

&lt;p&gt;Here is the whole idea in one sentence: an &lt;strong&gt;image&lt;/strong&gt; is the read-only template, and a &lt;strong&gt;container&lt;/strong&gt; is a running copy made from it. If you have written any code, it is the same relationship as a class and an object, or a recipe and the meal you cook from it. One image, many containers.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Everything below was run on a real Docker Engine (Server 29.8.0). The image IDs, container IDs, and sizes are the genuine output. The tables are trimmed to the columns that matter here, so what you see on your own machine will have a few more columns.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  One image, many containers
&lt;/h2&gt;

&lt;p&gt;Let's prove the relationship instead of just asserting it. First, pull an image once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker pull nginx:alpine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now start three containers from that single image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; c1 nginx:alpine
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; c2 nginx:alpine
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; c3 nginx:alpine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is still only one image on disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker images nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;REPOSITORY   TAG      IMAGE ID       SIZE
nginx        alpine   72ba65eb42c1   104MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But there are three separate containers, each with its own container ID, all pointing back at that same image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CONTAINER ID   IMAGE          NAMES
f291e8935b87   nginx:alpine   c3
dbbf21ab18e1   nginx:alpine   c2
01da24bab3c5   nginx:alpine   c1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One image (&lt;code&gt;72ba65eb42c1&lt;/code&gt;), three containers (&lt;code&gt;01da...&lt;/code&gt;, &lt;code&gt;dbbf...&lt;/code&gt;, &lt;code&gt;f291...&lt;/code&gt;). That is the core fact: the image is the shared, unchanging original, and each container is an independent running instance of it. This is exactly why Docker is efficient. Ten copies of the same app do not mean ten copies of its files on disk. They share one image.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two quick commands map cleanly onto the two concepts: &lt;code&gt;docker images&lt;/code&gt; lists your images (the templates), and &lt;code&gt;docker ps&lt;/code&gt; lists your running containers (the instances). If you ever lose track of which is which, that pair is the tell.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Each container gets its own writable layer
&lt;/h2&gt;

&lt;p&gt;If they all share one read-only image, how can containers be different from each other? Because when a container starts, Docker adds a thin &lt;strong&gt;writable layer&lt;/strong&gt; on top of the image, just for that container. Anything the container changes goes into its own layer and touches nothing else.&lt;/p&gt;

&lt;p&gt;Watch it happen. Write a file inside &lt;code&gt;c1&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;c1 sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"echo 'written inside c1' &amp;gt; /note.txt"&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;c1 &lt;span class="nb"&gt;cat&lt;/span&gt; /note.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;written inside c1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now look for that same file in &lt;code&gt;c2&lt;/code&gt;, which was started from the identical image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;c2 &lt;span class="nb"&gt;cat&lt;/span&gt; /note.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cat: can't open '/note.txt': No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file exists in &lt;code&gt;c1&lt;/code&gt; and does not exist in &lt;code&gt;c2&lt;/code&gt;, even though both came from the same image. Each container's changes live in its own private writable layer. The image underneath never changed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That writable layer is created and destroyed with the container. Run &lt;code&gt;docker rm c1&lt;/code&gt; and the &lt;code&gt;note.txt&lt;/code&gt; you wrote is gone for good. This is the number one surprise for beginners: data written inside a container is not permanent. When you need data to survive, you use a volume, which is the subject of its own guide in this series.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  So what is an image, really?
&lt;/h2&gt;

&lt;p&gt;An image is not one big blob. It is a stack of read-only layers, each one a set of filesystem changes from the step that built it. You can list them with &lt;code&gt;docker image history&lt;/code&gt;. It prints newest layer first, so here are the top few (the &lt;code&gt;--format&lt;/code&gt; flag just trims it to size and command for readability):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker image &lt;span class="nb"&gt;history &lt;/span&gt;nginx:alpine &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"{{.Size}}&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;{{.CreatedBy}}"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-6&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;51.8MB   RUN /bin/sh -c set -x   &amp;amp;&amp;amp; apkArch="$(cat ...
0B       ENV ACME_VERSION=0.4.1
0B       ENV NJS_RELEASE=1
0B       ENV NJS_VERSION=1.0.1
0B       CMD ["nginx" "-g" "daemon off;"]
0B       STOPSIGNAL SIGQUIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each line is a layer. Some add real files and have a size (the 51.8MB package install), others just set metadata like an environment variable or the default command and cost nothing. This is only the top of the list; the base Alpine layer and the rest of the image sit below these, which is where most of the 104MB actually lives. Every container you run from this image shares all of those read-only layers and simply adds its own empty writable layer on top. That shared design is why the three containers above cost you 104MB of image once, not 104MB three times.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model to keep
&lt;/h2&gt;

&lt;p&gt;Line the two up side by side and the distinction sticks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;image&lt;/strong&gt; is built, versioned, and read-only. You create it with a Dockerfile, tag it, push it to a registry, and pull it. It is the same for everyone who pulls it.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;container&lt;/strong&gt; is run, started, stopped, and thrown away. It is one live instance of an image, with its own ID, its own writable layer, and its own lifecycle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The commands follow the same split. &lt;code&gt;docker build&lt;/code&gt; and &lt;code&gt;docker pull&lt;/code&gt; deal in images. &lt;code&gt;docker run&lt;/code&gt;, &lt;code&gt;docker stop&lt;/code&gt;, and &lt;code&gt;docker rm&lt;/code&gt; deal in containers. And &lt;code&gt;docker run&lt;/code&gt; is simply the bridge between them: it takes an image and produces a running container.&lt;/p&gt;

&lt;p&gt;Once that lands, the rest of Docker is mostly detail. Next in the Docker Foundations series, put it to work by running your first containers hands-on, then move up to Docker Compose to run several at once.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>selfhosting</category>
      <category>containers</category>
    </item>
    <item>
      <title>Fix: Permission Denied on the Docker Daemon Socket</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Mon, 14 Sep 2026 10:15:46 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/fix-permission-denied-on-the-docker-daemon-socket-2jol</link>
      <guid>https://dev.to/shubham_sharma_94/fix-permission-denied-on-the-docker-daemon-socket-2jol</guid>
      <description>&lt;p&gt;You ran a normal &lt;code&gt;docker&lt;/code&gt; command on Linux and got this instead of output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;permission denied while trying to connect to the docker API at unix:///var/run/docker.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On older Docker versions the same problem reads a little differently, but it is the exact same wall:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Got permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is broken. Docker is running fine. Your user account just is not allowed to talk to it yet. This guide shows you why that happens, the one-line fix that gets you working right now, the permanent fix, and the one "fix" you should never copy from a random forum answer.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Everything below was run on a real Docker Engine (Client 29.8.0 / Server 29.8.0) on Ubuntu 24.04.5 LTS. The commands and output are the genuine results, not illustrations.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why this happens
&lt;/h2&gt;

&lt;p&gt;Docker does its real work in a background service (the daemon) that runs as &lt;code&gt;root&lt;/code&gt;. Your &lt;code&gt;docker&lt;/code&gt; command is just a client. It talks to the daemon through a Unix socket, a special file at &lt;code&gt;/var/run/docker.sock&lt;/code&gt;. Look at who owns that file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /var/run/docker.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;srw-rw---- 1 root docker 0 Sep 10 15:41 /var/run/docker.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that line right to left. The socket is owned by user &lt;code&gt;root&lt;/code&gt; and group &lt;code&gt;docker&lt;/code&gt;, and its permissions are &lt;code&gt;rw&lt;/code&gt; for the owner, &lt;code&gt;rw&lt;/code&gt; for the group, and nothing for everyone else. So only &lt;code&gt;root&lt;/code&gt; and members of the &lt;code&gt;docker&lt;/code&gt; group can read from or write to it. A brand-new user is in neither:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uid=1000(tester) gid=1000(tester) groups=1000(tester)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;docker&lt;/code&gt; group in that list, so the connection is refused. That is the whole story. The error is a file-permission error wearing a scary costume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: run it right now with sudo
&lt;/h2&gt;

&lt;p&gt;If you just need the command to work this second, run it as &lt;code&gt;root&lt;/code&gt; with &lt;code&gt;sudo&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;docker ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAMES     STATUS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command connects and returns cleanly (an empty table here just means no containers are running). This is fine for a one-off, but typing &lt;code&gt;sudo&lt;/code&gt; before every &lt;code&gt;docker&lt;/code&gt; command gets old fast, and it means every container you start is being managed as root. For your own machine, set up the permanent fix instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: add your user to the docker group (the real fix)
&lt;/h2&gt;

&lt;p&gt;Add your user to the &lt;code&gt;docker&lt;/code&gt; group once, and you never need &lt;code&gt;sudo&lt;/code&gt; for Docker again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-aG&lt;/code&gt; means "append to this group" (the &lt;code&gt;-a&lt;/code&gt; matters; without it you would replace all your other groups). Confirm it took:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uid=1000(tester) gid=1000(tester) groups=1000(tester),990(docker)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is the &lt;code&gt;docker&lt;/code&gt; group. But if you try &lt;code&gt;docker ps&lt;/code&gt; in the same terminal, you may still get permission denied, and this is the step everyone trips on.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Group membership is only read when a login session starts. Your current shell was started before you joined the group, so it still has your old group list. You need a fresh session for the change to apply.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Get a fresh session in any one of these ways:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# option A: load the new group into the current shell right now&lt;/span&gt;
newgrp docker

&lt;span class="c"&gt;# option B: log out and back in (or close and reopen your terminal)&lt;/span&gt;

&lt;span class="c"&gt;# option C: over SSH, disconnect and reconnect&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, Docker works as your normal user, no &lt;code&gt;sudo&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAMES     STATUS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connected, no permission error, no &lt;code&gt;sudo&lt;/code&gt;. That is the fix you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "fix" to avoid
&lt;/h2&gt;

&lt;p&gt;Search this error and you will find answers telling you to just open up the socket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# do NOT do this&lt;/span&gt;
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;666 /var/run/docker.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It makes the error go away, and it is a genuinely bad idea. &lt;code&gt;666&lt;/code&gt; lets &lt;em&gt;every&lt;/em&gt; user and process on the machine read and write the Docker socket. Anyone who can talk to the Docker daemon can start a container that mounts your entire host filesystem as root, which is effectively handing them root on the box. The &lt;code&gt;docker&lt;/code&gt; group already gives your user that same power, so understand what you are joining.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Error&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Adding a user to the &lt;code&gt;docker&lt;/code&gt; group (or opening the socket) grants root-equivalent access to the whole machine. That is expected and documented, but it means you should only do it for accounts you fully trust. On shared or production servers, prefer rootless Docker or calling Docker through &lt;code&gt;sudo&lt;/code&gt; with a controlled sudoers rule.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# see who owns the socket (root:docker, group-only access)&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /var/run/docker.sock

&lt;span class="c"&gt;# right now, one-off&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;docker ps

&lt;span class="c"&gt;# permanent: join the group, then start a fresh session&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;
newgrp docker      &lt;span class="c"&gt;# or log out and back in&lt;/span&gt;
docker ps          &lt;span class="c"&gt;# works, no sudo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is every legitimate way out of this error. If &lt;code&gt;sudo docker ps&lt;/code&gt; also fails, your problem is different: the daemon itself is not running. Start it with &lt;code&gt;sudo systemctl start docker&lt;/code&gt; and check &lt;code&gt;sudo systemctl status docker&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;New to Docker and want the mental model behind all this? Start with our guide on running your first containers, then work through the Docker Foundations series.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>selfhosting</category>
      <category>linux</category>
    </item>
    <item>
      <title>Docker Foundations: The Complete Hands-On Series (Install to Production)</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Sun, 13 Sep 2026 19:19:28 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/docker-foundations-the-complete-hands-on-series-install-to-production-4c8j</link>
      <guid>https://dev.to/shubham_sharma_94/docker-foundations-the-complete-hands-on-series-install-to-production-4c8j</guid>
      <description>&lt;p&gt;Docker is the front door to modern back-end and self-hosting work, and most tutorials drop you in the middle of it. This series does not. It starts from nothing and takes you all the way to an app you built, hardened, and deployed over HTTPS on a real server. Every guide is hands-on, and every command and number in them was run on real Docker and captured, not paraphrased.&lt;/p&gt;

&lt;p&gt;Use this page as the map. Work straight down it if you are starting out, or jump to the piece you need.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every guide in this series is a real lab: the commands, output, image sizes, and errors are genuine, captured on Docker Engine 29.x. The runnable code for each one lives in the companion repo linked at the bottom.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The path, start to finish
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;a href="https://www.techdevmantra.com/guides/install-docker-macos-windows-wsl2-linux" rel="noopener noreferrer"&gt;Install Docker on macOS, Windows (WSL2), and Linux&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
One guide, three operating systems, ending in a verified &lt;code&gt;docker run hello-world&lt;/code&gt; and the post-install checks each OS actually needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;a href="https://www.techdevmantra.com/guides/run-your-first-docker-containers" rel="noopener noreferrer"&gt;Run Your First Containers&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
The everyday lifecycle on a real nginx: &lt;code&gt;run&lt;/code&gt;, &lt;code&gt;ps&lt;/code&gt;, &lt;code&gt;logs&lt;/code&gt;, &lt;code&gt;exec&lt;/code&gt;, &lt;code&gt;stop&lt;/code&gt;, &lt;code&gt;start&lt;/code&gt;, and &lt;code&gt;rm&lt;/code&gt;, plus the images-versus-containers mental model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;a href="https://www.techdevmantra.com/guides/docker-compose-multi-service-stack" rel="noopener noreferrer"&gt;Docker Compose: Run a Multi-Service Stack&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
Move from single containers to a declarative Web plus Postgres plus Redis stack with one &lt;code&gt;docker compose up&lt;/code&gt;, wired together over a Compose network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. &lt;a href="https://www.techdevmantra.com/guides/lean-docker-images-multi-stage-builds" rel="noopener noreferrer"&gt;Lean Docker Images: Multi-Stage Builds and Layer Caching&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
Cut the same app from a 1.62GB image to 233MB with a multi-stage build, and measure exactly why it shrank and why rebuilds get faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. &lt;a href="https://www.techdevmantra.com/guides/docker-volumes-bind-mounts-networks" rel="noopener noreferrer"&gt;Where Your Data Lives: Volumes, Bind Mounts, and Networks&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
Make data survive restarts, back up and restore a volume, and connect containers by name on a user-defined network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. &lt;a href="https://www.techdevmantra.com/guides/operating-docker-containers" rel="noopener noreferrer"&gt;Operating Containers: Healthchecks, Limits, Restart Policies, and Env Config&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
Turn a working stack into one that behaves in production: health-gated startup, memory and CPU limits, self-healing restarts, and clean env config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. &lt;a href="https://www.techdevmantra.com/guides/docker-security-basics" rel="noopener noreferrer"&gt;Docker Security Basics: Non-Root, Read-Only, Image Scanning, and Secrets&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
Harden a container without breaking it: scan for CVEs, run as non-root, mount the root filesystem read-only, drop capabilities, and keep secrets out of images.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. &lt;a href="https://www.techdevmantra.com/guides/docker-vs-podman" rel="noopener noreferrer"&gt;Docker vs Podman: A Hands-On Comparison and Migration&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
Run the same images and Compose file under both engines to see what daemonless and rootless really change, and what it takes to migrate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. &lt;a href="https://www.techdevmantra.com/guides/dockerize-app-end-to-end" rel="noopener noreferrer"&gt;Capstone: Dockerize Your Own App End to End&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
Put it all together: a lean image, a hardened Compose stack, a CI build that pushes to a registry, and a deploy behind Caddy with automatic HTTPS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answers
&lt;/h2&gt;

&lt;p&gt;Two short reads that clear up the questions almost everyone hits early:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.techdevmantra.com/guides/docker-images-vs-containers-explained" rel="noopener noreferrer"&gt;Docker Images vs Containers, Explained&lt;/a&gt;&lt;/strong&gt; proves the difference with real output: one image, many containers, each with its own writable layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.techdevmantra.com/guides/fix-permission-denied-docker-daemon-socket" rel="noopener noreferrer"&gt;Fix: Permission Denied on the Docker Daemon Socket&lt;/a&gt;&lt;/strong&gt; explains the error every Linux beginner sees and the one permanent fix (plus the insecure one to avoid).&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Get the code: every runnable lab in this series lives in the &lt;a href="https://github.com/Ssharma94Eie/docker-foundations" rel="noopener noreferrer"&gt;docker-foundations repo&lt;/a&gt;, one folder per post. Clone it and follow along.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where to start
&lt;/h2&gt;

&lt;p&gt;If you are new, begin at step 1 and go in order; each post builds on the last. If you already run containers, jump to Compose, security, or the capstone. By the end you will be able to build a lean image, run it as a hardened stack, and ship it to a server over HTTPS, all with tools you understand because you ran every step yourself.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>selfhosting</category>
      <category>containers</category>
      <category>linux</category>
    </item>
    <item>
      <title>LM Studio Guide: Run Local LLMs on Your Mac Fast</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Tue, 01 Sep 2026 13:56:57 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/lm-studio-guide-run-local-llms-on-your-mac-fast-2g70</link>
      <guid>https://dev.to/shubham_sharma_94/lm-studio-guide-run-local-llms-on-your-mac-fast-2g70</guid>
      <description>&lt;p&gt;LLMs are rapidly changing how we interact with technology, and thankfully, you don't need a supercomputer or a cloud subscription to experiment with them. LM Studio is a desktop application that lets you download and run a vast array of large language models right on your Mac. This guide will walk you through everything from understanding why you'd want to run models locally to getting your first AI model up and running.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is LM Studio and Why Run AI Models on Your Mac
&lt;/h2&gt;

&lt;p&gt;LM Studio is a game-changer for anyone curious about AI. It's a beautifully designed, user-friendly desktop application that simplifies the process of downloading, discovering, and running large language models (LLMs) directly on your computer. Think of it as an app store for AI, but instead of games or productivity tools, you're downloading powerful AI models that can generate text, write code, answer questions, and much more.&lt;/p&gt;

&lt;h3&gt;
  
  
  Privacy and Control Without the Cloud
&lt;/h3&gt;

&lt;p&gt;One of the biggest advantages of running AI models locally with LM Studio is the unparalleled privacy and control you gain. When you use cloud-based AI services, your prompts and data are sent to remote servers, where they might be stored, analyzed, or used for training. With LM Studio, everything happens on your machine.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Running models locally means your conversations, your code snippets, and your sensitive data never leave your computer. This is a massive win for privacy-conscious users and developers who handle proprietary information.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This local execution also means you're not reliant on external APIs that can change their terms, pricing, or availability without notice. You have direct access to the AI's capabilities whenever you need them, internet connection or not.&lt;/p&gt;

&lt;h3&gt;
  
  
  What You'll Be Able to Do After Setup
&lt;/h3&gt;

&lt;p&gt;Once LM Studio is up and running, a whole new world of AI-powered possibilities opens up on your Mac. You're not just running a demo; you're interacting with powerful AI that can assist you in numerous ways.&lt;/p&gt;

&lt;p&gt;Here are just a few things you'll be able to do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Text Generation:&lt;/strong&gt; Brainstorm ideas, write blog posts, draft emails, create marketing copy, or even write poetry.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Code Completion:&lt;/strong&gt; Get intelligent code suggestions, help with debugging, and understand complex code snippets, all within your local environment.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Chatbot Creation:&lt;/strong&gt; Build your own personal chatbot for specific tasks or general conversation, trained on the models you download.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Summarization:&lt;/strong&gt; Condense long documents or articles into concise summaries.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Translation:&lt;/strong&gt; Experiment with language translation capabilities.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Creative Writing:&lt;/strong&gt; Develop stories, scripts, or game narratives.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The beauty of LM Studio is its flexibility. You can swap out models easily, experiment with different AI personalities, and tailor the experience to your exact needs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Check Your Mac's Readiness Before Installing
&lt;/h2&gt;

&lt;p&gt;Before you dive into downloading LM Studio, it's crucial to ensure your Mac is up to the task. Running large language models, even locally, can be resource-intensive. Understanding your system's capabilities will help you set realistic expectations and avoid potential performance issues.&lt;/p&gt;

&lt;h3&gt;
  
  
  Minimum vs. Recommended Specs
&lt;/h3&gt;

&lt;p&gt;The performance of AI models heavily depends on your hardware. While LM Studio can technically run on a range of Macs, some configurations will offer a much smoother experience than others.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Minimum Specs (Basic Functionality)&lt;/th&gt;
&lt;th&gt;Recommended Specs (Smooth Experience)&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;8 GB&lt;/td&gt;
&lt;td&gt;16 GB or more&lt;/td&gt;
&lt;td&gt;More RAM allows for larger models and faster processing.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Storage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;50 GB free space&lt;/td&gt;
&lt;td&gt;100 GB+ free space&lt;/td&gt;
&lt;td&gt;Models can be several gigabytes each.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Processor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Intel Core i5 or Apple M1 (base)&lt;/td&gt;
&lt;td&gt;Apple M1 Pro/Max/Ultra, M2, M3 series (or higher Intel)&lt;/td&gt;
&lt;td&gt;Apple Silicon (M-series) chips offer significant performance advantages.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GPU&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Integrated Graphics&lt;/td&gt;
&lt;td&gt;Dedicated GPU (if available) or Apple Silicon GPU&lt;/td&gt;
&lt;td&gt;GPU acceleration dramatically speeds up inference. LM Studio leverages Metal on macOS.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  How to Check Your Mac's Specs
&lt;/h3&gt;

&lt;p&gt;You don't need to be a terminal wizard to find out what kind of Mac you have. Here's a simple way to check your system details:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Click the Apple Menu:&lt;/strong&gt; In the top-left corner of your screen, click the Apple icon ().&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Select "About This Mac":&lt;/strong&gt; This will open a window with a summary of your Mac's hardware and software.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Check Overview Tab:&lt;/strong&gt; Look for information on your "Processor" (e.g., Apple M1, Intel Core i7), "Memory" (your RAM), and "Graphics."&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Check Storage:&lt;/strong&gt; Click on the "Storage" tab to see how much free space you have.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;For More Detail:&lt;/strong&gt; Click "System Report..." for a more in-depth look at your hardware.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your Mac has Apple Silicon (M1, M2, M3 chips), you're in a great position. These chips are highly optimized for AI tasks and will generally provide a much better experience than older Intel Macs with equivalent RAM.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Download and Install LM Studio on Your Mac
&lt;/h2&gt;

&lt;p&gt;Now that you've confirmed your Mac is ready, let's get LM Studio installed. The process is straightforward and designed to be as user-friendly as possible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Getting the Installer from the Official Source
&lt;/h3&gt;

&lt;p&gt;Always download software from official sources to ensure you're getting a legitimate and malware-free copy.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Head over to the official LM Studio website: &lt;a href="https://lmstudio.ai/" rel="noopener noreferrer"&gt;lmstudio.ai&lt;/a&gt;. You'll find download buttons prominently displayed. Choose the version appropriate for your Mac (usually an &lt;code&gt;.dmg&lt;/code&gt; file).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Running the Installation Wizard
&lt;/h3&gt;

&lt;p&gt;Once you've downloaded the &lt;code&gt;.dmg&lt;/code&gt; file, the installation is usually as simple as dragging the application icon to your Applications folder.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Open the &lt;code&gt;.dmg&lt;/code&gt; file:&lt;/strong&gt; Double-click the downloaded file. A Finder window will typically appear.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Drag to Applications:&lt;/strong&gt; Drag the LM Studio application icon into the "Applications" folder shortcut within that window.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Eject the disk image:&lt;/strong&gt; After copying, you can eject the LM Studio disk image by dragging its icon from the Finder sidebar to the Trash.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Launch LM Studio:&lt;/strong&gt; Open your Applications folder and double-click the LM Studio icon. You might see a security warning; if so, click "Open."&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Verify Installation and Launch for the First Time
&lt;/h3&gt;

&lt;p&gt;After launching, LM Studio will present its main interface. For the first launch, it might take a moment to load. You'll see a clean dashboard with options to search for models, chat, and view settings. This confirms that the installation was successful and LM Studio is ready to go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Download Your First AI Model Inside LM Studio
&lt;/h2&gt;

&lt;p&gt;The real magic of LM Studio is its integrated model browser. Instead of hunting for models across the web, you can discover and download them directly within the application.&lt;/p&gt;

&lt;h3&gt;
  
  
  Understanding Model Sizes and What They Mean
&lt;/h3&gt;

&lt;p&gt;You'll see models listed with numbers like "7B," "13B," or "70B." This refers to the number of parameters the model has, which is a rough indicator of its complexity and capability. Larger models are generally more powerful but require more resources (RAM and processing power).&lt;/p&gt;

&lt;p&gt;You'll also encounter terms like "quantization." This is a process that reduces the precision of the model's weights, making it smaller and faster to run, often with a negligible impact on quality. Common quantization formats include GGUF (used by llama.cpp, which LM Studio leverages) with variations like &lt;code&gt;q4_K_M&lt;/code&gt; or &lt;code&gt;q5_K_S&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model Size&lt;/th&gt;
&lt;th&gt;Parameters&lt;/th&gt;
&lt;th&gt;Resource Needs&lt;/th&gt;
&lt;th&gt;Quality&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;7B&lt;/td&gt;
&lt;td&gt;7 Billion&lt;/td&gt;
&lt;td&gt;Low to Moderate&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Beginners, basic tasks, systems with 8-16GB RAM.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13B&lt;/td&gt;
&lt;td&gt;13 Billion&lt;/td&gt;
&lt;td&gt;Moderate to High&lt;/td&gt;
&lt;td&gt;Very Good&lt;/td&gt;
&lt;td&gt;Systems with 16GB+ RAM, more complex tasks.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;70B&lt;/td&gt;
&lt;td&gt;70 Billion&lt;/td&gt;
&lt;td&gt;Very High&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;High-end systems with 32GB+ RAM, demanding tasks.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;For your first model, start small. A 7B or 13B model is usually a safe bet for most modern Macs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Recommended Starter Models for Mac
&lt;/h3&gt;

&lt;p&gt;Here are a few models that are often well-regarded and perform nicely on Mac hardware:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Mistral 7B Instruct:&lt;/strong&gt; A very capable 7B model that balances performance and quality.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Llama 3 8B Instruct:&lt;/strong&gt; Meta's latest offering, known for its strong performance and instruction-following.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;OpenHermes 2.5 Mistral 7B:&lt;/strong&gt; A fine-tuned version of Mistral, often praised for its conversational abilities.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Downloading a Model Step by Step
&lt;/h3&gt;

&lt;p&gt;Let's get your first model downloaded:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Navigate to the Search Tab:&lt;/strong&gt; In LM Studio, click the magnifying glass icon on the left sidebar to go to the model search.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Search for a Model:&lt;/strong&gt; Type the name of a model (e.g., "Mistral 7B Instruct") into the search bar.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Select a Specific Version:&lt;/strong&gt; You'll see a list of available GGUF files. Look for one with a &lt;code&gt;Q&lt;/code&gt; in its name (e.g., &lt;code&gt;mistral-7b-instruct-v0.2.Q4_K_M.gguf&lt;/code&gt;). The &lt;code&gt;Q4_K_M&lt;/code&gt; indicates a good balance of size and quality.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Click Download:&lt;/strong&gt; Click the download button next to the model file you've chosen. You'll see the download progress in the bottom section of the app.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Model downloads can take a while depending on your internet speed and the model size. Be patient!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Load Your Model and Start Chatting
&lt;/h2&gt;

&lt;p&gt;With a model downloaded, the next step is to load it and start interacting. LM Studio makes this incredibly simple.&lt;/p&gt;

&lt;h3&gt;
  
  
  Loading the Model into Memory
&lt;/h3&gt;

&lt;p&gt;Once the download is complete, you need to load the model into LM Studio's inference engine.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Go to the Chat Tab:&lt;/strong&gt; Click the chat bubble icon on the left sidebar.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Select Your Model:&lt;/strong&gt; At the top of the chat interface, you'll see a dropdown menu labeled "Select a model to load." Click it and choose the model you just downloaded.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Wait for Loading:&lt;/strong&gt; LM Studio will now load the model into your Mac's RAM. You'll see a progress indicator. This can take anywhere from a few seconds to a couple of minutes, depending on the model size and your system's speed.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Your First Prompt and Response
&lt;/h3&gt;

&lt;p&gt;Once the model is loaded, the chat interface is ready. Simply type your question or prompt into the message box at the bottom and press Enter. The model will then generate a response.&lt;/p&gt;

&lt;h3&gt;
  
  
  Basic Settings to Tweak (Temperature, Context)
&lt;/h3&gt;

&lt;p&gt;You'll notice a settings panel on the right side of the chat interface. While you can explore these later, two key parameters to be aware of are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Temperature:&lt;/strong&gt; Controls the randomness of the output. Lower temperatures (e.g., 0.2) lead to more focused and deterministic responses, while higher temperatures (e.g., 0.8) produce more creative and varied output.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Context Length:&lt;/strong&gt; This determines how much previous conversation the model remembers. A larger context window allows for longer, more coherent conversations but uses more RAM.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here are some common settings to experiment with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Temperature:&lt;/strong&gt; Start around 0.7 for general chat.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Top-K / Top-P:&lt;/strong&gt; These are sampling strategies that also influence output creativity. Defaults are often fine to start.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Max new tokens:&lt;/strong&gt; Limits the length of the model's response.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Context Length:&lt;/strong&gt; Adjust based on your RAM. For smaller models, you might be able to increase this significantly.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Don't be afraid to experiment! Changing these settings can dramatically alter the model's behavior.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Troubleshooting Common Installation and Setup Issues
&lt;/h2&gt;

&lt;p&gt;Even with user-friendly tools, you might run into a snag or two. Here are solutions to some common problems.&lt;/p&gt;

&lt;h3&gt;
  
  
  App Won't Launch or Crashes on Startup
&lt;/h3&gt;

&lt;p&gt;If LM Studio refuses to open or quits unexpectedly right after launching, it's often due to permissions or installation conflicts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Troubleshooting App Launch Issues&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Re-download LM Studio:&lt;/strong&gt; The installer might have been corrupted during download. Try downloading it again from lmstudio.ai.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Check macOS Version:&lt;/strong&gt; Ensure your macOS is up-to-date. LM Studio has minimum OS requirements.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Delete and Reinstall:&lt;/strong&gt; Drag LM Studio from your Applications folder to the Trash, then empty the Trash. Re-download and install it again.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Permissions:&lt;/strong&gt; While less common for simple drag-and-drop installs, ensure LM Studio has necessary permissions if prompted.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Model Downloads Fail or Run Out of Disk Space
&lt;/h3&gt;

&lt;p&gt;This is usually a straightforward storage issue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Troubleshooting Download Failures&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Check Available Disk Space:&lt;/strong&gt; Go to "About This Mac" -&amp;gt; "Storage" to see how much free space you have. Models can be several gigabytes.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Pause and Resume:&lt;/strong&gt; Sometimes, pausing the download and then resuming it can help.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Clear Cache:&lt;/strong&gt; LM Studio has a cache for downloaded models. You can manage this in the Settings tab to free up space if needed.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Download Smaller Models:&lt;/strong&gt; If space is tight, opt for smaller models or more heavily quantized versions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Responses Are Slow or Model Won't Load
&lt;/h3&gt;

&lt;p&gt;Performance issues are typically related to hardware limitations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Troubleshooting Slow Performance&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;RAM is Key:&lt;/strong&gt; If the model won't load or responses are extremely slow, you might not have enough RAM for that specific model.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Close Other Applications:&lt;/strong&gt; Free up RAM by closing any unnecessary applications running in the background.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Try a Smaller Model:&lt;/strong&gt; A 7B model will always be faster and easier to load than a 70B model.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;GPU Acceleration:&lt;/strong&gt; Ensure GPU acceleration is enabled in the settings if your Mac supports it (which most modern Macs with Apple Silicon do). LM Studio usually handles this automatically.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Q: Why is my model download so slow?&lt;br&gt;
A: Download speed depends heavily on your internet connection and the server load on the model hosting platform. Larger models naturally take longer.&lt;/p&gt;

&lt;p&gt;Q: Can I run multiple models at once?&lt;br&gt;
A: LM Studio is designed to run one model for inference at a time. You can download many models, but only one can be loaded into memory for chatting.&lt;/p&gt;

&lt;p&gt;Q: What's the difference between GGUF and other model formats?&lt;br&gt;
A: GGUF is a file format optimized for running LLMs efficiently on consumer hardware, particularly using libraries like llama.cpp, which LM Studio relies on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Can Build and Do Next with LM Studio
&lt;/h2&gt;

&lt;p&gt;You've got LM Studio installed, a model downloaded, and you've had your first chat. What's next? LM Studio isn't just for casual chatting; it's a powerful tool for developers and creators.&lt;/p&gt;

&lt;h3&gt;
  
  
  Text Generation and Content Creation
&lt;/h3&gt;

&lt;p&gt;Leverage LM Studio for all your writing needs.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Brainstorming:&lt;/strong&gt; Ask for blog post ideas, marketing slogans, or story concepts.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Drafting:&lt;/strong&gt; Generate first drafts of articles, emails, or social media posts.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Editing:&lt;/strong&gt; Get suggestions for improving clarity, tone, or grammar.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Code Completion and Programming Assistance
&lt;/h3&gt;

&lt;p&gt;Developers will find LM Studio invaluable for local coding tasks.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Code Snippets:&lt;/strong&gt; Ask for boilerplate code or examples for specific functions.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Debugging Help:&lt;/strong&gt; Paste error messages or code blocks and ask for potential explanations or fixes.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Learning New Languages:&lt;/strong&gt; Request explanations of syntax or concepts in a programming language you're learning.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Building Chatbots and Conversational Interfaces
&lt;/h3&gt;

&lt;p&gt;LM Studio can act as the backend for your own applications.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Local API:&lt;/strong&gt; LM Studio can expose a local OpenAI-compatible API. This means you can point your existing AI applications or scripts to LM Studio instead of a cloud service.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Custom Tools:&lt;/strong&gt; Build specialized chatbots for customer support, internal knowledge bases, or personal assistants.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Experimenting with Prompt Engineering
&lt;/h3&gt;

&lt;p&gt;The quality of AI output is heavily influenced by how you prompt it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Iterate:&lt;/strong&gt; Try different phrasings, add context, or specify the desired output format.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Few-Shot Learning:&lt;/strong&gt; Provide examples within your prompt to guide the model's response style.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Role-Playing:&lt;/strong&gt; Instruct the model to act as a specific persona for tailored responses.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Success&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;By running these models locally, you're not just experimenting with AI; you're building a foundation for powerful, privacy-preserving applications.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Ready to Go Deeper with LM Studio
&lt;/h2&gt;

&lt;p&gt;You've successfully set up LM Studio and run your first AI model. This is just the beginning of your journey into local AI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Explore More AI Possibilities&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Ready to dive deeper? Explore LM Studio's documentation to learn about advanced settings, custom model configurations, and integrating with other tools.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://lmstudio.ai/docs/" rel="noopener noreferrer"&gt;Discover Advanced Features&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://lmstudio.ai/models" rel="noopener noreferrer"&gt;Start experimenting with different models&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://lmstudio.ai/docs/" rel="noopener noreferrer"&gt;Read the official LM Studio documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://lmstudio.ai/discord" rel="noopener noreferrer"&gt;Join the LM Studio Discord community&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>lmstudio</category>
      <category>localai</category>
      <category>aitools</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
