Multi-platformcontainersTested on real hardware

Your First Docker Compose Stack (nginx hello-world)

A starter Docker Compose stack that serves a local folder with nginx. Demonstrates images, containers, ports, volumes, and the five daily-driver compose commands on Linux, macOS, and Windows.

Distrosubuntu, debian, fedora, opensuse, macos, windows
Shellyaml
Updated
Compose file
yaml
#!/usr/bin/env bash
# First Docker Compose stack — nginx serving a local folder.
# Works on Linux, macOS (Docker Desktop), and WSL2 on Windows.
set -euo pipefail

STACK_DIR="${HOME}/stacks/hello"
mkdir -p "${STACK_DIR}/site"
cd "${STACK_DIR}"

# Write the compose file
cat > docker-compose.yml <<'EOF'
services:
  web:
    image: nginx:latest
    container_name: hello-web
    ports:
      - "8080:80"
    volumes:
      - ./site:/usr/share/nginx/html:ro
    restart: unless-stopped
EOF

# Write a test page
cat > site/index.html <<'EOF'
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>It works</title></head>
<body><h1>It works. I run my own web server now.</h1></body>
</html>
EOF

# Start the stack
docker compose up -d

echo ""
echo "Stack is running. Open: http://localhost:8080"
echo ""
echo "Useful commands (run from ${STACK_DIR}):"
echo "  docker compose ps          — show running containers"
echo "  docker compose logs -f     — live logs"
echo "  docker compose pull        — pull newer images"
echo "  docker compose down        — stop and remove containers"

What this does

Creates a minimal Docker Compose stack that runs nginx to serve a local HTML folder. The intent is to teach the four core concepts — images, containers, ports, volumes — by doing, then leave you with the five commands you’ll use every day for every self-hosted service.

Why this approach

Docker Compose is how the vast majority of self-hosted services are actually deployed. When you look at a service’s documentation for “how to self-host this,” it almost always gives you a docker-compose.yml file to run. Understanding what the file contains — images, ports, volumes, environment variables — is the prerequisite for every service you’ll deploy afterward.

The nginx example in this playbook is deliberately minimal because the goal is to understand the concepts, not to install nginx. A real homelab server won’t run nginx by itself; it runs Plex, Immich, Home Assistant, or a dozen other services. But those services all follow exactly the same pattern as the nginx stack: pull an image, map some ports, mount some volumes, set some environment variables, and run. Once you understand the pattern on a simple example, the Arr stack’s six-service compose file is just more of the same thing.

One concept that trips up beginners is the difference between a named volume (managed by Docker, persists across container replacements) and a bind mount (a path on the host filesystem). This playbook uses a bind mount for the served HTML folder (./html:/usr/share/nginx/html) — which means you can see and edit the files on your host — and shows how to add a named volume for data that needs to persist but doesn’t need to be edited directly. The choice between the two matters for every service: config files often work better as bind mounts (you edit them directly), databases and application data often work better as named volumes (Docker manages their lifecycle).

Prerequisites

The four key concepts in this stack

Concept Where it appears What it means
Image nginx:latest The read-only template downloaded from Docker Hub
Container hello-web The running instance created from the image
Port "8080:80" Host port 8080 → container port 80; how your browser reaches it
Volume ./site:/usr/share/nginx/html:ro A host folder mounted into the container; data lives on the host

The five daily-driver commands

docker compose up -d        # start (or apply changes) — the one you run most
docker compose ps           # what's running and is it healthy?
docker compose logs -f      # live log stream — first stop when something breaks
docker compose pull         # download newer versions of images
docker compose down         # stop and remove containers (volumes are kept)

Edit the page without restarting

The ./site folder is a bind mount — changes on the host are immediately visible inside the container. Edit site/index.html and reload the browser. No compose restart needed.

Change the port

Edit the ports: mapping in docker-compose.yml and re-run docker compose up -d:

ports:
  - "9090:80"   # now accessible on port 9090

Persist data with a named volume

The ./site bind mount is great when you want to edit the files yourself. For app data you never touch directly (databases, config that the app manages), use a named volume:

services:
  db:
    image: postgres:16
    volumes:
      - db-data:/var/lib/postgresql/data

volumes:
  db-data:

Named volumes survive docker compose down and are managed by Docker. List them with docker volume ls.

Stop and clean up

# Stop and remove the containers (keep volumes and images):
docker compose down

# Also remove the named volumes (WARNING: deletes data):
docker compose down -v

# Also remove the downloaded images:
docker compose down --rmi all

macOS notes

  • Docker Desktop for Mac includes the Compose plugin — docker compose works in the macOS Terminal exactly as on Linux.
  • The host path ./site in the volume mount refers to the directory on your Mac, not inside Docker’s VM. It is shared automatically.
  • localhost:8080 resolves to the container; no IP lookup needed.

Windows (WSL2 + Docker Desktop) notes

Run the script from a WSL2 terminal (Ubuntu or Debian distribution). Docker Desktop with the WSL2 backend makes docker and docker compose available inside WSL2 automatically. Access the running stack at http://localhost:8080 from any Windows browser.

Next stacks to try

Once this clicks, every self-hosted guide is a variation on the same four concepts: