Docker Tutorial 2026: Dockerfile, Compose + Production Setup
technology

Docker Tutorial 2026: Dockerfile, Compose + Production Setup

Production Docker for Node.js — Dockerfile caching, Compose named networks and volumes, and the footguns that break every first attempt.

2026-06-21
12 min read
Docker Tutorial 2026: Dockerfile, Compose + Production Setup

Three things break in every first Docker setup: builds that run 10+ minutes even when you changed one line, containers that log ECONNREFUSED 127.0.0.1:5432 while the database is clearly alive, and Postgres data that vanishes on every compose down.

I hit all three containerizing a Next.js + Supabase stack for Build a Full-Stack App with Next.js and Supabase in 20 Minutes. The fix wasn't fancier tooling — it was three patterns: a .dockerignore, a multi-stage Dockerfile, and a docker-compose.yml with named networks and named volumes. Each one is small; together they make the difference between a stack you fight and one you forget is there.

If you already know why containers lose data and just want the config, skip to Docker Compose.

Assumes Node 20+, Docker 24+, and the V2 Compose plugin (docker compose, with a space). The legacy docker-compose Python tool is deprecated and won't be covered.

Why Docker dev environments break#

The root cause of all three failures above is the same: treating the container as a black box instead of a process with its own filesystem, network namespace, and PID namespace.

When docker build is slow, you usually have COPY . . before npm ci, which invalidates the dependency layer on every source change. When two services can't reach each other, the app is connecting to localhost — which inside a container is the container itself, not the host or other containers. When data disappears, you used an anonymous volume that Docker is happy to recreate empty on the next up.

The mental model that fixes everything is short. Filesystem isolation means anything written inside a container is gone on rm unless it's in a named volume. Network isolation means containers only see each other if they're attached to the same Docker network. Layer caching means Dockerfile order matters — instructions that change rarely must come first, instructions that change every commit must come last. Every pattern in this guide is a concrete expression of one of those three invariants.

Prerequisites — Install Docker on Your System#

macOS#

Download Docker Desktop from docker.com/products/docker-desktop/, drag it to Applications, and launch it once to grant the privileged helper. Verify:

bash
docker --version
docker compose version
docker context ls

You should see Docker version 24.x (or newer) and Docker Compose version v2.x.x. The default context should point at desktop-linux.

Windows 11#

Docker Desktop requires WSL 2. Enable it from PowerShell as Administrator, then reboot:

bash
dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart
wsl --set-default-version 2
wsl --update

Install Docker Desktop from the same installer. The first launch takes a minute while it provisions the WSL 2 distro. After that, run the same three docker and docker compose commands above to confirm.

Linux (Ubuntu 24.04+)#

Use Docker's official apt repo rather than the docker.io package, which lags by months:

bash
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
  | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
  | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER

Log out and back in for the docker group change to take effect, then verify with the same three commands as macOS.

Dockerfile Basics — Build Your First Image#

A Dockerfile is a recipe. Every instruction creates a layer; layers are cached by their content hash. The single most important rule: put things that change rarely at the top, things that change every commit at the bottom.

Here is a minimal Dockerfile for a Node app:

dockerfile
# syntax=docker/dockerfile:1
FROM node:20-alpine
 
WORKDIR /app
 
COPY package.json package-lock.json ./
RUN npm ci
 
COPY . .
RUN npm run build
 
ENV NODE_ENV=production
ENV PORT=3000
 
EXPOSE 3000
 
CMD ["node", "dist/server.js"]

Two things to notice. First, package.json and package-lock.json are copied before the source — so npm ci runs only when those files change, not on every source edit. Second, the syntax directive at the top enables BuildKit features on any Docker version, which we rely on later.

The matching .dockerignore is just as important:

plaintext
node_modules
.git
.gitignore
.env
.env.*
Dockerfile
docker-compose*.yml
README.md
coverage
dist
.next
.vscode
.idea

If you skip .dockerignore, two bad things happen. COPY . . pulls in node_modules from the host, which can be the wrong architecture and will explode inside the container. And the build context sent to the Docker daemon includes your entire .git history, .env secrets, and the previous build's dist folder — making builds measurably slower.

Build and run it:

bash
docker build -t myapp:dev .
docker run --rm -p 3000:3000 --name myapp myapp:dev
curl http://localhost:3000

You should see your app's HTML. The image will be around 180 MB on Alpine. For a Next.js app specifically, next start works in the container but next dev does not, because in-container file watching breaks on bind-mounted volumes — covered below.

Multi-Stage Builds — Optimize for Production#

A single-stage Dockerfile leaves dev tooling, build tools, and source files in the final image. A multi-stage build trims that down to the runtime only.

dockerfile
# syntax=docker/dockerfile:1
 
# ---- deps ----
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
 
# ---- build ----
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
 
# ---- runtime ----
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
 
COPY package.json package-lock.json ./
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/public ./public
 
RUN addgroup -S app && adduser -S app -G app
USER app
 
EXPOSE 3000
CMD ["node", "dist/server.js"]

The named stages (deps, build, runtime) let later stages COPY --from= only what they need. runtime never sees source files, never sees typescript, never sees next build output that shouldn't ship. The USER app directive drops root, which matters for any image that talks to the network — many scanners flag root-running images as critical.

Build only the final stage:

bash
docker build --target runtime -t myapp:1.0.0 .
docker images myapp

The final image typically lands between 120 and 160 MB depending on what you copy. If you're shipping to a SaaS platform the way I describe in Complete Guide to Building SaaS with Next.js and Supabase, that's small enough to cold-start in a few seconds on a free tier.

Docker Compose — Orchestrate Multi-Service Apps#

Container orchestration is the automated coordination of container lifecycles: starting them in order, replacing failed ones, scaling replicas up and down, and routing traffic to the healthy instances. Docker Compose is an orchestrator — just a small one, scoped to a single host. It reads a single declarative YAML file and starts every service in dependency order, which is exactly what local dev and small single-host deployments need. The V2 plugin syntax (docker compose, with a space) is what we use here.

yaml
# docker-compose.yml
services:
  app:
    build:
      context: .
      target: runtime
    image: myapp:dev
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/app
      - NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
      - NODE_ENV=development
    volumes:
      - ./src:/app/src:ro
    depends_on:
      db:
        condition: service_healthy
 
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER must be set in .env}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env}
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d app"]
      interval: 5s
      timeout: 5s
      retries: 10
 
volumes:
  pgdata:

Two things to notice. First, the app's DATABASE_URL points at db, not localhostdb is the service name, which Compose registers as a DNS entry on the shared network. Second, pgdata is a named volume declared at the bottom, so it survives docker compose down. Postgres uses /var/lib/postgresql/data as its data directory; that's the path we mount. The healthcheck block on db plus condition: service_healthy on app means the app doesn't try to connect before Postgres accepts connections. The ${POSTGRES_USER} and ${POSTGRES_PASSWORD} placeholders are read from a project-level .env file (Compose picks it up automatically); the :?... suffix makes Compose fail fast with a readable error if either variable is missing, so a forgotten .env never silently falls back to a default.

Bring it up:

bash
docker compose up -d
docker compose ps
docker compose logs -f app

When you outgrow a single host, the same Compose spec is portable to larger orchestrators like Kubernetes, Docker Swarm, or managed platforms such as Fly.io, Render, and AWS ECS. The image you built above does not change — orchestration is a runtime concern, not an image concern.

Networking — Connect Containers Securely#

Docker networking is the layer that gives each container its own network namespace and decides which other containers, hosts, and external networks it can talk to. Every container gets at least one virtual interface on a Docker-managed network; if you do nothing, that network is the default bridge and it only reaches the outside world through a NAT on the host. Two containers on the same user-defined network can talk to each other by name; two containers on different networks cannot. Almost every "my service can't connect" bug is a misconfiguration of this layer.

Compose creates a default network and attaches every service to it. You don't need to declare it explicitly — but you should, because explicit networks give you control over isolation when you grow.

yaml
# docker-compose.yml (excerpt)
networks:
  frontend:
  backend:
 
services:
  app:
    networks:
      - frontend
      - backend
    ports:
      - "3000:3000"
 
  db:
    networks:
      - backend
    # no ports — db is not exposed to the host
 
  redis:
    networks:
      - backend
    image: redis:7-alpine

app sits on both networks because it needs to be reached from the host (port 3000) and reach the data tier. db and redis are only on backend and have no ports block, so they aren't reachable from the host at all — only from app. This is the simplest security boundary Compose gives you for free.

To inspect the network at runtime:

bash
docker network ls
docker network inspect <project>_backend

The inspect output lists every connected container, the subnet, and the gateway. If a service can't reach another, this is the first place to look — ContainerConfig should show the right container IDs attached.

Service discovery — DNS on the Compose network#

Service discovery is the mechanism that lets a container find its peers by name instead of by hardcoded IP. On every user-defined Docker network, Docker runs an embedded DNS server that registers each container's service name as an A record pointing at its current IP. When app resolves db, the request never leaves the Docker host; the embedded resolver returns the live address of the db container, and the connection is forwarded across the bridge.

This is why db works in DATABASE_URL but localhost does not. localhost resolves to the container itself; db is registered as an A record by the embedded DNS server. There is no links: block, no /etc/hosts editing, and no need to run Consul or etcd for a local dev stack. Scale a service to multiple replicas and the same hostname round-robins across them.

To see the records at runtime:

bash
docker compose exec app nslookup db
docker compose exec app getent hosts redis

If a hostname fails to resolve, the two services are almost certainly on different networks. Check with docker network inspect <project>_backend and move the service onto the right one, or add an explicit networks: block so the dependency is visible in the file rather than implicit.

Volumes & Environment Variables — Persist Data and Config#

Docker volumes are the storage primitive that lets a container's filesystem outlive the container itself. A container's writable layer is torn down on rm; a volume is a directory managed by Docker (on the host, in /var/lib/docker/volumes/...) that one or more containers can mount. Volumes are the only safe way to keep database files, uploaded assets, and other state across rebuilds, restarts, and image upgrades.

There are three volume shapes and they behave very differently.

| Type | Syntax | Survives down? | Use for | | --- | --- | --- | --- | | Named | pgdata:/var/lib/postgresql/data | Yes | Database data, anything persistent | | Bind | ./src:/app/src | Yes (host-owned) | Live source code, hot reload | | Anonymous | /var/lib/postgresql/data (no name) | No | Almost never — avoid |

Anonymous volumes look like a short convenience, but Docker gives them a random hash and drops them when the container goes. That's how I lost a Postgres database in 2017 and learned this lesson.

For environment variables, three patterns work in Compose. A literal block (fine for dev), an .env file (better for secrets), and the host shell (best for CI). For real secrets in production, prefer Docker secrets on Swarm or the orchestrator's secret store — never bake .env into the image.

yaml
# docker-compose.override.yml
services:
  app:
    env_file:
      - .env.local
    volumes:
      - ./src:/app/src:ro

The docker-compose.override.yml file is auto-merged with docker-compose.yml when you run docker compose up. Use it for dev-only overrides (bind mounts, dev env vars) and keep the base file clean.

Best Practices — Efficient Images and Caching#

The Dockerfile patterns that buy you the most:

  1. Order by change frequency. package.json and lockfile go before source. Source before build. Build output last.
  2. Use --mount=type=cache in BuildKit for package manager caches. For npm, replace RUN npm ci with:
dockerfile
# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.npm \
    --mount=type=bind,source=package-lock.json,target=package-lock.json \
    --mount=type=bind,source=package.json,target=package.json \
    npm ci
  1. Pin base images by digest. node:20-alpine@sha256:... is reproducible. node:latest is not.
  2. One process per container. Compose can run multiple, but a single container with multiple daemons makes healthchecks and restarts harder.
  3. Use .dockerignore aggressively. Smaller build context = faster docker build, smaller attack surface.

Two more that I learned the hard way. Don't run apt-get upgrade in the Dockerfile — it pulls in unrelated updates and breaks reproducibility. Don't use ADD for local files — it has surprising URL and tar-extraction behavior; COPY is explicit and predictable.

Security — Scan Images and Harden Containers#

A 2026-era checklist before pushing an image anywhere public:

  • Run a vulnerability scanner. Docker Scout ships with Docker Desktop and the docker scout CLI.
  • Run as non-root via USER in the final stage.
  • Drop capabilities you don't need.
  • Pin base images by digest.
  • Keep secrets out of layers (.env, ARG, baked files).
  • Use a minimal base — alpine, distroless, or scratch where possible.

Quick scan:

bash
docker scout cves myapp:1.0.0
docker scout recommendations myapp:1.0.0

Quick hardening diff for the runtime stage:

dockerfile
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 3000
CMD ["node", "dist/server.js"]

If docker scout reports a critical CVE in node:20-alpine, the fix is to bump the base tag (or pin to a newer digest). Don't apt-get upgrade inside the Dockerfile to patch it — rebuild from a patched base image.

Troubleshooting — Common Issues and Fixes#

bind: address already in use#

Another process on the host is using the port you mapped. Find and kill it, or change the host-side mapping in Compose ("8080:3000" instead of "3000:3000").

bash
lsof -i :3000
# or on Windows:
netstat -ano | findstr :3000

ECONNREFUSED 127.0.0.1:5432#

The app is connecting to localhost, which inside the container is itself. Change the connection string to the service name (db), and make sure both services are on the same Compose network. This exact trap catches everyone running Next.js + Postgres for the first time — I cover the wider Supabase flow in Complete Guide to Building SaaS with Next.js and Supabase.

no space left on device#

Docker's storage driver ran out of room. Prune:

bash
docker system df
docker system prune -a --volumes

--volumes removes named volumes too — only run it if you're sure you don't need them.

next dev is slow or hangs inside the container#

Bind-mount the source so file changes reach the container, but exclude node_modules so the host's node_modules don't shadow the container's:

yaml
volumes:
  - ./src:/app/src:ro
  - /app/node_modules

The anonymous volume /app/node_modules is intentional — it shadows the bind mount at that path, keeping the in-container node_modules.

Image rebuilds every time even though only one file changed#

A file outside .dockerignore (or no .dockerignore at all) is invalidating the layer. Add it and rebuild — the next build should hit the cache for every layer above the changed one.

Sanity-check#

After docker compose up -d, run this from the host:

bash
docker compose ps
docker compose logs --tail=50 app
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:3000
docker stats --no-stream

Expected output:

plaintext
NAME      SERVICE   STATUS          PORTS
myapp-app-1   app       Up 2 minutes (healthy)   0.0.0.0:3000->3000/tcp
myapp-db-1    db        Up 2 minutes (healthy)   5432/tcp

And curl should print 200. docker stats should show two containers with predictable CPU and memory numbers — if app is at 100% CPU constantly, you probably have a hot-reload loop or a missing dependency that triggers restart.

If you're using Next.js and the image optimization endpoint complains about a hostname not being configured, that's a Next.js-level concern unrelated to Docker — the fix is in Fix next/image Hostname Not Configured in Next.js. Likewise, when the App Router tells you a page couldn't be rendered statically because it uses dynamic server data, the answer isn't Docker — it's Fix Dynamic Server Usage Error in Next.js App Router.

FAQ#

Do I need Docker Desktop or can I use the CLI alone? On macOS and Windows, Docker Desktop gives you the daemon, Compose, BuildKit, and Docker Scout in one install. On Linux, the CLI + Compose plugin + BuildKit from the apt repo is enough. Docker Desktop is optional on Linux but adds a GUI and Kubernetes.

Should I learn Kubernetes if I'm just starting with Docker? No. Compose is the right tool until you actually have multiple hosts or need auto-scaling. I covered that trade-off in the SaaS guide linked earlier — Compose ships the same artifact to Fly.io, Render, and Railway without rewriting.

Can I use the same docker-compose.yml in CI? Yes. docker compose build works in CI runners, and the resulting images push straight to a registry. For tests, swap the bind mount for an in-memory fixture and use docker compose run --rm app npm test.

How do I keep my dev stack from drifting from production? Pin base images by digest, run the same Dockerfile everywhere, and run docker compose up in CI against the same docker-compose.yml. Drift is almost always a missing file in .dockerignore or an unpinned base image.

With a reproducible image, a versioned Compose file, named networks with embedded DNS, and named volumes in place, the stack is ready to push to any registry and redeploy on any platform without rewriting the patterns covered above.

Frequently Asked Questions

|

Have more questions? Contact us

Written by

Mahdi Br
Mahdi Br

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.