A concentrate of best practice, commands, troubleshooting, tips and tricks related to docker and docker compose, from a developers' point of view.
- Docker for Developers
How to use this doc
- Skim Quick Commands and Gotchas before coding
- Use Recipes when you need hot-reload, init data, or admin tasks
- All examples target this stack: Node app + Postgres + Redis + Adminer
# List running containers
docker ps
docker ps -a # <--- Will also list stopped containers
docker ps -q # <--- List only container IDs (useful for batch-processing, e.g. docker rm -f $(docker ps -qa) force-removes ALL containers)
# List images
docker image ls
docker image ls -q # <--- Returns all image IDs
# Start / stop / remove container(s)
docker start <name|id>
docker stop <name|id>
docker rm <name|id>
# Shell into a running container
docker exec -it <name|id> bash # Or whichever available shell there is, like sh, or ash
# Logs (live-streaming)
docker logs -f <name|id>
# Inspect (ports, env, mounts, networks)
docker inspect <name|id> | jq . # Requires JQ for working with JSON objects. Can be done away with# Bring stack up (build if needed) / detached
docker compose up --build
docker compose up -d
# Stop & remove containers + network (keeps named volumes)
docker compose down
# Also remove named volumes (‼️ deletes data!)
docker compose down -v
# List services / logs / run a command inside a service
docker compose ps
docker compose logs -f
docker compose exec app sh
# Rebuild images without starting
docker compose build
# Scale a service (e.g., workers)
docker compose up -d --scale app=2# Remove dangling images
docker image prune
# Remove stopped containers, networks, build cache (prompts)
docker system prune
# Include unused volumes too (⚠ data loss for unused volumes)
docker system prune -a --volumesTip: If things get weird:
docker compose down, thendocker compose up --build.
This is the best practices version of the stack here mentioned.
Use a lean multi-stage build. For dev hot-reload we’ll override the command and mount the code (see next section).
# Dockerfile
FROM node:20-alpine AS deps
WORKDIR /app
# Install only production deps by default; dev flow uses override
COPY package*.json ./
RUN npm ci
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# If you have a build step (TypeScript, bundlers), run it here:
# RUN npm run build
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app ./
EXPOSE 3000
CMD ["npm", "start"]If you use TypeScript:
- Add
RUN npm run buildin the build stage- Set
CMD ["node", "dist/index.js"]or keepnpm startif your script runs it
Bind-mount the source and run a watch command (e.g., nodemon or npm run dev). Keep large folders out via .dockerignore.
# docker-compose.override.yml (dev-only overrides)
services:
app:
command: npm run dev
working_dir: /app
volumes:
- ./:/app
# optionally mount a separate, writeable node_modules cache:
# - app-node-modules:/app/node_modules
environment:
- NODE_ENV=development
# volumes:
# app-node-modules:.dockerignore essentials:
.git
node_modules
npm-debug.log*
yarn-error.log*
dist
.build
.tmp
coverage
.DS_Store
Gotcha: On macOS/Windows, huge bind mounts can be slow. Mount only what you need and keep dependencies inside the container.
Create a .env for local dev and a .env.example for the repo.
# .env.example
APP_PORT=3000
NODE_ENV=development
DB_HOST=postgres
DB_PORT=5432
DB_NAME=myapp
DB_USER=postgres
DB_PASSWORD=example
REDIS_HOST=redis
REDIS_PORT=6379Compose auto-loads .env in the same directory. Do not commit real secrets; inject them in CI/CD or keep them local.
-
User-defined bridge networks (recommended) This is what Compose creates by default (e.g.,
<project>_default) and what we explicitly use here (app-network,backend-network). Containers on the same user-defined bridge get automatic DNS: you can reachpostgresandredisby those service names. Isolation is per-network -
Default bridge – legacy The engine ships with a network called
bridge. It does not provide DNS-based service discovery like user-defined bridges do. Prefer Compose’s user-defined bridges for development. (On Linux, thebridgegateway is often172.17.0.1) -
Host network – Linux-only Shares the host’s network namespace. No port publishing (
ports:is ignored), no isolation, and easy port conflicts. Not supported on Docker Desktop for macOS/Windows with Linux containers. Avoid for multi-service dev; use only for niche cases (low-level networking, perf tests) -
none No networking at all (air‑gapped container). Rarely used for app dev
-
Use user-defined bridge networks from Compose. Keep internal services (DB/Redis) on a private network (e.g.,
backend-network). Put the app on bothapp-network(for future frontends) andbackend-network(to reach DB/Redis). Only publish the app’s port to the host; don’t publish DB/Redis -
Service name ≠ localhost. Inside containers, use service names (
postgres:5432,redis:6379). From your host, uselocalhost:<published-port>(e.g.,http://localhost:3000)
- Docker Desktop (macOS/Windows): use
host.docker.internal - Linux: add the host-gateway alias
services:
app:
extra_hosts:
- host.docker.internal:host-gatewayNow anything in the container can talk to services on your host via host.docker.internal (e.g., hitting a local SMTP/dev server).
ports: ["3000:3000"]⇒ host TCP 3000 → container TCP 3000. The left side is the host portports: ["3000"]⇒ publish container TCP 3000 to a random host port (useful for parallel test runs)- Port clashes? Change the host side (
8081:3000) or run with a different project name-p
# List and inspect networks
docker network ls
docker network inspect <project>_backend-network
docker network inspect bridge # the default docker0 bridge (legacy)
# See where app is published and verify
docker compose ps
docker compose port app 3000 # shows mapped host port
curl -I http://localhost:3000 # from the host
# From inside the app container
docker compose exec app sh -lc "getent hosts postgres redis || true; ping -c1 postgres || true"depends_on.condition: service_healthy requires the dependency to have a healthcheck.
- App: simple HTTP health at
/health(already in compose) - Postgres:
pg_isready(already in compose) - Redis:
redis-cli ping(already in compose)
Still racing? Add retry logic in the app’s startup (recommended) or a tiny “wait-for” script.
Put .sql files in ./init-scripts. Postgres runs them on first init.
-- ./init-scripts/001_schema.sql
CREATE TABLE IF NOT EXISTS todos (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT FALSE
);
-- ./init-scripts/010_seed.sql
INSERT INTO todos (title, done) VALUES
('Learn Compose', false),
('Wire up Redis cache', false);# Open psql inside the Postgres container (uses container localhost)
docker compose exec -e PGPASSWORD=$DB_PASSWORD postgres psql -U $DB_USER -d $DB_NAME -h 127.0.0.1 -p 5432
# Dump/restore examples
docker compose exec -e PGPASSWORD=$DB_PASSWORD postgres pg_dump -U $DB_USER -d $DB_NAME > dump.sql
docker compose exec -e PGPASSWORD=$DB_PASSWORD postgres psql -U $DB_USER -d $DB_NAME -f /var/lib/postgresql/dump.sqldocker compose exec redis redis-cli ping
docker compose exec redis redis-cli keys '*'
docker compose exec redis redis-cli flushdb # ⚠ clears current DBEnable tools only on demand.
# example: only run Adminer with a "tools" profile
services:
adminer:
profiles: ["tools"]
# run: docker compose --profile tools up -d# Base + prod overlay
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d- Read the error (build vs runtime).
- Check logs:
docker compose logs -f app
docker compose logs -f postgres
docker compose logs -f redis- Container exits immediately? Run foreground (no
-d) to see output. - Connectivity test from app:
docker compose exec app sh -lc "apk add --no-cache curl netcat-openbsd || true; nc -zv postgres 5432; nc -zv redis 6379"- Network membership:
docker network inspect <stack>_backend-network- Port conflict (e.g., 3000 in use): change the left side of
host:container. - Hot-reload not updating: check the mount path,
npm run dev/nodemonrunning, and.dockerignore. - Volumes stuck: remove containers, keep volumes (
down), or reset volumes cautiously (down -v). - Rebuild from scratch:
docker compose build --no-cache
docker compose down -v && docker compose up --build- Keep images lean (Alpine base,
npm ci, multi-stage builds,.dockerignore) - Mount only what you need for dev. Avoid mounting
node_modulesfrom host on macOS/Windows - If needed, use a named volume for
node_modulesmanaged in-container - Don’t run heavy admin containers unless needed (use profiles)
- Don’t commit real secrets; provide a
.env.example - Be cautious with
docker compose down -vanddocker system prune --volumes(data loss!)
- Pin base images by major/minor (avoid
latest) for reproducibility. Example:node:20-alpine - Multi-stage builds: compile in one stage, copy only runtime artifacts to the final image
- Keep contexts small with a solid
.dockerignore(nonode_modules, build output, VCS, logs) - Deterministic installs: use
npm ciand commitpackage-lock.json - Run as non-root where possible to reduce blast radius security-wise
# Example: non-root + tini (PID 1) for proper signal handling
FROM node:20-alpine
RUN addgroup -S app && adduser -S app -G app \
&& apk add --no-cache tini
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
USER app
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["npm", "start"]Why tini? PID 1 in containers ignores signals by default;
tini(or--initin Compose) forwards signals so your app exits cleanly.
- Never bake secrets into images; use env vars or mounted files. Don’t commit real
.envvalues—ship a.env.example - Limit container privileges when feasible:
- Read-only FS, drop capabilities, no-new-privileges
Avoid mounting the Docker socket into app containers
services:
app:
# For local dev you can enable some of these gradually
read_only: false # set true if your app writes only to writable mounts
security_opt:
- no-new-privileges:true
cap_drop: ["ALL"] # add back only what you need
tmpfs: ["/tmp"]
# init forwards signals like tini would
init: true- Healthchecks for key services (
/health,pg_isready,redis-cli ping). Gate startup withdepends_on.condition: service_healthy - Logs: use structured logs; rotate the default driver to avoid disk bloat
services:
app:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"- Resource limits: if you need limits in production, configure them in the orchestrator (K8s/Swarm). Compose’s
deploy.resourcesis ignored outside Swarm; for local testing prefer OS/Docker Desktop limits or run-time flags - Image scanning in CI with tools like Trivy or Docker Scout; fix CVEs by updating bases and deps
- Keep a private backend network for DB/cache; only expose the app port to the host
- Prefer named volumes for stateful services (Postgres/Redis). Avoid bind-mounting their data directories from your host
- Use profiles to enable optional tools (e.g., Adminer) only when needed
services:
adminer:
profiles: ["tools"]
# run with: docker compose --profile tools up -d- Track LTS Node versions and update regularly
- For hot-reload, prefer bind mounts +
npm run dev; avoid mounting hostnode_modules(slow on macOS/Windows) - If you need native addons, perform builds in a builder stage with required toolchains, then copy only the result to the runtime image
# Builder for native deps
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Runtime
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app ./
ENV NODE_ENV=production
CMD ["node", "server.js"]APP_PORT=3000
NODE_ENV=development
DB_HOST=postgres
DB_PORT=5432
DB_NAME=myapp
DB_USER=postgres
DB_PASSWORD=example
REDIS_HOST=redis
REDIS_PORT=6379healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5sdocker compose ps
docker compose ls
docker network ls
docker volume ls// server.js
import express from "express";
const app = express();
app.get("/health", (req, res) => res.send("ok"));
app.get("/", (req, res) => res.json({ hello: "world" }));
app.listen(3000, () => console.log("API on 3000"));{
"scripts": {
"dev": "nodemon --legacy-watch server.js",
"start": "node server.js"
}
}-
“App can’t reach Postgres.” Use hostname
postgresand port5432inside the Compose network; confirm withnc -zv postgres 5432 -
“Code changes don’t show up.” Use the dev override (bind-mount +
npm run dev), or rebuild withdocker compose up --build -
“I need to inspect DB.” Open Adminer on
http://localhost:8080(System: PostgreSQL, Server:postgres, User:postgres, Password: from.env, DB:myapp) -
“Multiple stacks?” Use separate folders or a project name:
docker compose -p myproj up -d
When stuck: logs first, then networks/ports, then mounts/env, then rebuild.